This commit is contained in:
aardschok 2017-11-07 14:08:05 +01:00
commit dc79affaa8
13 changed files with 369 additions and 98 deletions

View file

@ -1,8 +1,6 @@
# absolute_import is needed to counter the `module has no cmds error` in Maya
from __future__ import absolute_import
import uuid
from maya import cmds
import pyblish.api
@ -197,6 +195,5 @@ class GenerateUUIDsOnInvalidAction(pyblish.api.Action):
asset = instance.data['asset']
asset_id = io.find_one({"name": asset, "type": "asset"},
projection={"_id": True})['_id']
for node in nodes:
lib.set_id(node, asset_id, overwrite=True)
for node, _id in lib.generate_ids(nodes, asset_id=asset_id):
lib.set_id(node, _id, overwrite=True)

46
colorbleed/lib.py Normal file
View file

@ -0,0 +1,46 @@
import avalon.io as io
import avalon.api
def is_latest(representation):
"""Return whether the representation is from latest version
Args:
representation (str or io.ObjectId): The representation id.
Returns:
bool: Whether the representation is of latest version.
"""
rep = io.find_one({"_id": io.ObjectId(representation),
"type": "representation"})
version = io.find_one({"_id": rep['parent']})
# Get highest version under the parent
highest_version = io.find_one({
"type": "version",
"parent": version["parent"]
}, sort=[("name", -1)])
if version['name'] != highest_version['name']:
return True
else:
return False
def any_outdated():
"""Return whether the current scene has any outdated content"""
checked = set()
host = avalon.api.registered_host()
for container in host.ls():
representation = container['representation']
if representation in checked:
continue
if not is_latest(container['representation']):
return True
checked.add(representation)
return False

View file

@ -32,6 +32,7 @@ def install():
log.info("Installing callbacks ... ")
avalon.on("init", on_init)
avalon.on("save", on_save)
avalon.on("open", on_open)
def uninstall():
@ -55,10 +56,29 @@ def on_init(_):
log.warning("Can't load plug-in: "
"{0} - {1}".format(plugin, e))
def safe_deferred(fn):
"""Execute deferred the function in a try-except"""
def _fn():
"""safely call in deferred callback"""
try:
fn()
except Exception as exc:
print(exc)
try:
utils.executeDeferred(_fn)
except Exception as exc:
print(exc)
cmds.loadPlugin("AbcImport", quiet=True)
cmds.loadPlugin("AbcExport", quiet=True)
force_load_deferred("mtoa")
from .customize import override_component_mask_commands
safe_deferred(override_component_mask_commands)
def on_save(_):
"""Automatically add IDs to new nodes
@ -72,3 +92,36 @@ def on_save(_):
nodes = lib.get_id_required_nodes(referenced_nodes=False)
for node, new_id in lib.generate_ids(nodes):
lib.set_id(node, new_id, overwrite=False)
def on_open(_):
"""On scene open let's assume the containers have changed."""
from ..lib import any_outdated
from avalon.vendor.Qt import QtWidgets
from ..widgets import popup
if any_outdated():
log.warning("Scene has outdated content.")
# Find maya main window
top_level_widgets = {w.objectName(): w for w in
QtWidgets.QApplication.topLevelWidgets()}
parent = top_level_widgets.get("MayaWindow", None)
if parent is None:
log.info("Skipping outdated content pop-up "
"because Maya window can't be found.")
else:
# Show outdated pop-up
def _on_show_inventory():
import avalon.tools.cbsceneinventory as tool
tool.show(parent=parent)
dialog = popup.Popup(parent=parent)
dialog.setWindowTitle("Maya scene has outdated content")
dialog.setMessage("There are outdated containers in "
"your Maya scene.")
dialog.on_show.connect(_on_show_inventory)
dialog.show()

View file

@ -0,0 +1,66 @@
"""A set of commands that install overrides to Maya's UI"""
import maya.cmds as mc
import maya.mel as mel
from functools import partial
import logging
log = logging.getLogger(__name__)
COMPONENT_MASK_ORIGINAL = {}
def override_component_mask_commands():
"""Override component mask ctrl+click behavior.
This implements special behavior for Maya's component
mask menu items where a ctrl+click will instantly make
it an isolated behavior disabling all others.
Tested in Maya 2016 and 2018
"""
log.info("Installing override_component_mask_commands..")
# Get all object mask buttons
buttons = mc.formLayout("objectMaskIcons",
query=True,
childArray=True)
# Skip the triangle list item
buttons = [btn for btn in buttons if btn != "objPickMenuLayout"]
def on_changed_callback(raw_command, state):
"""New callback"""
# If "control" is held force the toggled one to on and
# toggle the others based on whether any of the buttons
# was remaining active after the toggle, if not then
# enable all
if mc.getModifiers() == 4: # = CTRL
state = True
active = [mc.iconTextCheckBox(btn, query=True, value=True) for btn
in buttons]
if any(active):
mc.selectType(allObjects=False)
else:
mc.selectType(allObjects=True)
# Replace #1 with the current button state
cmd = raw_command.replace(" #1", " {}".format(int(state)))
mel.eval(cmd)
for btn in buttons:
# Store a reference to the original command so that if
# we rerun this override command it doesn't recursively
# try to implement the fix. (This also allows us to
# "uninstall" the behavior later)
if btn not in COMPONENT_MASK_ORIGINAL:
original = mc.iconTextCheckBox(btn, query=True, cc=True)
COMPONENT_MASK_ORIGINAL[btn] = original
# Assign the special callback
original = COMPONENT_MASK_ORIGINAL[btn]
new_fn = partial(on_changed_callback, original)
mc.iconTextCheckBox(btn, edit=True, cc=new_fn)

View file

@ -447,7 +447,7 @@ def extract_alembic(file,
endFrame (float): End frame of output. Ignored if `frameRange`
provided.
frameRange (tuple or str): Two-tuple with start and end frame or a
frameRange (tuple or str): Two-tuple with start and end frame or a
string formatted as: "startFrame endFrame". This argument
overrides `startFrame` and `endFrame` arguments.
@ -481,7 +481,7 @@ def extract_alembic(file,
an Euler filter. Euler filtering helps resolve irregularities in
rotations especially if X, Y, and Z rotations exceed 360 degrees.
Defaults to True.
"""
# Ensure alembic exporter is loaded
@ -669,28 +669,28 @@ def get_id(node):
def generate_ids(nodes, asset_id=None):
"""Returns new unique ids for the given nodes.
Note: This does not assign the new ids, it only generates the values.
To assign new ids using this method:
>>> nodes = ["a", "b", "c"]
>>> for node, id in generate_ids(nodes):
>>> set_id(node, id)
To also override any existing values (and assign regenerated ids):
>>> nodes = ["a", "b", "c"]
>>> for node, id in generate_ids(nodes):
>>> set_id(node, id, overwrite=True)
Args:
nodes (list): List of nodes.
asset_id (str or bson.ObjectId): The database id for the *asset* to
generate for. When None provided the current asset in the
generate for. When None provided the current asset in the
active session is used.
Returns:
list: A list of (node, id) tuples.
"""
if asset_id is None:
@ -715,14 +715,14 @@ def set_id(node, unique_id, overwrite=False):
Args:
node (str): the node to add the "cbId" on
unique_id (str): The unique node id to assign.
unique_id (str): The unique node id to assign.
This should be generated by `generate_ids`.
overwrite (bool, optional): When True overrides the current value even
overwrite (bool, optional): When True overrides the current value even
if `node` already has an id. Defaults to False.
Returns:
None
"""
attr = "{0}.cbId".format(node)
@ -739,9 +739,9 @@ def set_id(node, unique_id, overwrite=False):
def remove_id(node):
"""Remove the id attribute from the input node.
Args:
node (str): The node name
node (str): The node name
Returns:
bool: Whether an id attribute was deleted
@ -973,20 +973,19 @@ def apply_shaders(relationships, shadernodes, nodes):
shader_data = relationships.get("relationships", {})
shading_engines = cmds.ls(shadernodes, type="objectSet", long=True)
assert len(shading_engines) > 0, ("Error in retrieving objectSets "
"from reference")
assert shading_engines, "Error in retrieving objectSets from reference"
# region compute lookup
ns_nodes_by_id = defaultdict(list)
nodes_by_id = defaultdict(list)
for node in nodes:
ns_nodes_by_id[get_id(node)].append(node)
nodes_by_id[get_id(node)].append(node)
shading_engines_by_id = defaultdict(list)
for shad in shading_engines:
shading_engines_by_id[get_id(shad)].append(shad)
# endregion
# region assign
# region assign shading engines and other sets
for data in shader_data.values():
# collect all unique IDs of the set members
shader_uuid = data["uuid"]
@ -994,21 +993,29 @@ def apply_shaders(relationships, shadernodes, nodes):
filtered_nodes = list()
for uuid in member_uuids:
filtered_nodes.extend(ns_nodes_by_id[uuid])
filtered_nodes.extend(nodes_by_id[uuid])
shading_engine = shading_engines_by_id[shader_uuid]
assert len(shading_engine) == 1, ("Could not find the correct "
"objectSet with cbId "
"'{}'".format(shader_uuid))
id_shading_engines = shading_engines_by_id[shader_uuid]
if not id_shading_engines:
log.error("No shader found with cbId "
"'{}'".format(shader_uuid))
continue
elif len(id_shading_engines) > 1:
log.error("Skipping shader assignment. "
"More than one shader found with cbId "
"'{}'. (found: {})".format(shader_uuid,
id_shading_engines))
continue
if filtered_nodes:
cmds.sets(filtered_nodes, forceElement=shading_engine[0])
else:
if not filtered_nodes:
log.warning("No nodes found for shading engine "
"'{0}'".format(shading_engine[0]))
"'{0}'".format(id_shading_engines[0]))
continue
cmds.sets(filtered_nodes, forceElement=id_shading_engines[0])
# endregion
apply_attributes(attributes, ns_nodes_by_id)
apply_attributes(attributes, nodes_by_id)
# endregion LOOKDEV

View file

@ -71,14 +71,20 @@ class ReferenceLoader(api.Loader):
assert os.path.exists(path), "%s does not exist." % path
cmds.file(path, loadReference=reference_node, type=file_type)
# Fix PLN-40 for older containers created with Avalon that had the
# `.verticesOnlySet` set to True.
if cmds.getAttr(node + ".verticesOnlySet"):
self.log.info("Setting %s.verticesOnlySet to False", node)
cmds.setAttr(node + ".verticesOnlySet", False)
# TODO: Add all new nodes in the reference to the container
# Currently new nodes in an updated reference are not added to the
# container whereas actually they should be!
nodes = cmds.referenceQuery(reference_node, nodes=True, dagPath=True)
cmds.sets(nodes, forceElement=container['objectName'])
cmds.sets(nodes, forceElement=node)
# Update metadata
cmds.setAttr(container["objectName"] + ".representation",
cmds.setAttr(node + ".representation",
str(representation["_id"]),
type="string")

View file

@ -87,10 +87,14 @@ class CollectInstances(pyblish.api.ContextPlugin):
# Collect members
members = cmds.ls(members, long=True) or []
# `maya.cmds.listRelatives(noIntermediate=True)` only works when
# `shapes=True` argument is passed, since we also want to include
# transforms we filter afterwards.
children = cmds.listRelatives(members,
allDescendents=True,
fullPath=True,
noIntermediate=True) or []
fullPath=True) or []
children = cmds.ls(children, noIntermediate=True, long=True)
parents = self.get_all_parents(members)
members_hierarchy = list(set(members + children + parents))

View file

@ -133,6 +133,11 @@ class CollectLook(pyblish.api.InstancePlugin):
self.log.warning("No sets found for the nodes in the instance: "
"%s" % instance[:])
# Ensure unique shader sets
# Add shader sets to the instance for unify ID validation
instance.extend(shader for shader in looksets if shader
not in instance_lookup)
self.log.info("Collected look for %s" % instance)
def collect_sets(self, instance):
@ -181,7 +186,7 @@ class CollectLook(pyblish.api.InstancePlugin):
node_id = lib.get_id(node)
if not node_id:
self.log.error("Node '{}' has no attribute 'cbId'".format(node))
self.log.error("Member '{}' has no attribute 'cbId'".format(node))
return
member_data = {"name": node, "uuid": node_id}

View file

@ -10,6 +10,10 @@ class ValidateLookContents(pyblish.api.InstancePlugin):
* At least one relationship must be collection.
* All relationship object sets at least have an ID value
Tip:
* When no node IDs are found on shadingEngines please save your scene
and try again.
"""
order = colorbleed.api.ValidateContentsOrder
@ -57,12 +61,12 @@ class ValidateLookContents(pyblish.api.InstancePlugin):
invalid = set()
attributes = ["relationships", "attributes"]
keys = ["relationships", "attributes"]
lookdata = instance.data["lookData"]
for attr in attributes:
if attr not in lookdata:
cls.log.error("Look Data has no attribute "
"'{}'".format(attr))
for key in keys:
if key not in lookdata:
cls.log.error("Look Data has no key "
"'{}'".format(key))
invalid.add(instance.name)
# Validate at least one single relationship is collected

View file

@ -5,7 +5,7 @@ import colorbleed.api
class ValidateLookNoDefaultShaders(pyblish.api.InstancePlugin):
"""Validate look contains no default shaders.
"""Validate if any node has a connection to a default shader.
This checks whether the look has any members of:
- lambert1
@ -28,6 +28,9 @@ class ValidateLookNoDefaultShaders(pyblish.api.InstancePlugin):
label = 'Look No Default Shaders'
actions = [colorbleed.api.SelectInvalidAction]
DEFAULT_SHADERS = {"lambert1", "initialShadingGroup",
"initialParticleSE", "particleCloud1"}
def process(self, instance):
"""Process all the nodes in the instance"""
@ -38,44 +41,18 @@ class ValidateLookNoDefaultShaders(pyblish.api.InstancePlugin):
@classmethod
def get_invalid(cls, instance):
disallowed = ["lambert1", "initialShadingGroup",
"initialParticleSE", "particleCloud1"]
disallowed = set(disallowed)
# Check if there are any skinClusters present
# If so ensure nodes which are skinned
intermediate = []
skinclusters = cmds.ls(type="skinCluster")
cls.log.info("Found skinClusters, will skip original shapes")
if skinclusters:
intermediate += cmds.ls(intermediateObjects=True,
shapes=True,
long=True)
invalid = set()
for node in instance:
# Get shading engine connections
shaders = cmds.listConnections(node, type="shadingEngine") or []
# get connection
# listConnections returns a list or None
object_sets = cmds.listConnections(node, type="objectSet") or []
# Ensure the shape in the instances have at least a single shader
# connected if it *can* have a shader, like a `surfaceShape` in
# Maya.
if (cmds.objectType(node, isAType="surfaceShape") and
not cmds.ls(object_sets, type="shadingEngine")):
if node in intermediate:
continue
cls.log.error("Detected shape without shading engine: "
"'{}'".format(node))
invalid.add(node)
# Check for any disallowed connections
if any(s in disallowed for s in object_sets):
# Check for any disallowed connections on *all* nodes
if any(s in cls.DEFAULT_SHADERS for s in shaders):
# Explicitly log each individual "wrong" connection.
for s in object_sets:
if s in disallowed:
for s in shaders:
if s in cls.DEFAULT_SHADERS:
cls.log.error("Node has unallowed connection to "
"'{}': {}".format(s, node))

View file

@ -25,14 +25,14 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin):
"""Process all meshes"""
# Ensure all nodes have a cbId
invalid = self.get_invalid_dict(instance)
invalid = self.get_invalid(instance)
if invalid:
raise RuntimeError("Nodes found with non-unique "
"asset IDs: {0}".format(invalid))
@classmethod
def get_invalid_dict(cls, instance):
"""Return a dictionary mapping of id key to list of member nodes"""
def get_invalid(cls, instance):
"""Return the member nodes that are invalid"""
# Collect each id with their members
ids = defaultdict(list)
@ -42,24 +42,11 @@ class ValidateNodeIdsUnique(pyblish.api.InstancePlugin):
continue
ids[object_id].append(member)
# Skip those without IDs (if everything should have an ID that should
# be another validation)
ids.pop(None, None)
# Take only the ids with more than one member
invalid = dict((_id, members) for _id, members in ids.iteritems() if
len(members) > 1)
return invalid
@classmethod
def get_invalid(cls, instance):
"""Return the member nodes that are invalid"""
invalid_dict = cls.get_invalid_dict(instance)
# Take only the ids with more than one member
invalid = list()
for members in invalid_dict.itervalues():
invalid.extend(members)
for _ids, members in ids.iteritems():
if len(members) > 1:
cls.log.error("ID found on multiple nodes: '%s'" % members)
invalid.extend(members)
return invalid
return invalid

View file

119
colorbleed/widgets/popup.py Normal file
View file

@ -0,0 +1,119 @@
import sys
import logging
import contextlib
from avalon.vendor.Qt import QtCore, QtWidgets, QtGui
log = logging.getLogger(__name__)
class Popup(QtWidgets.QDialog):
on_show = QtCore.Signal()
def __init__(self, parent=None, *args, **kwargs):
super(Popup, self).__init__(parent=parent, *args, **kwargs)
self.setContentsMargins(0, 0, 0, 0)
# Layout
layout = QtWidgets.QHBoxLayout(self)
layout.setContentsMargins(10, 5, 10, 10)
message = QtWidgets.QLabel("")
message.setStyleSheet("""
QLabel {
font-size: 12px;
}
""")
show = QtWidgets.QPushButton("Show")
show.setSizePolicy(QtWidgets.QSizePolicy.Maximum,
QtWidgets.QSizePolicy.Maximum)
show.setStyleSheet("""QPushButton { background-color: #BB0000 }""")
layout.addWidget(message)
layout.addWidget(show)
# Size
self.resize(400, 40)
geometry = self.calculate_window_geometry()
self.setGeometry(geometry)
self.widgets = {
"message": message,
"show": show,
}
# Signals
show.clicked.connect(self._on_show_clicked)
# Set default title
self.setWindowTitle("Popup")
def setMessage(self, message):
self.widgets['message'].setText(message)
def _on_show_clicked(self):
"""Callback for when the 'show' button is clicked.
Raises the parent (if any)
"""
parent = self.parent()
self.close()
# Trigger the signal
self.on_show.emit()
if parent:
parent.raise_()
def calculate_window_geometry(self):
"""Respond to status changes
On creation, align window with screen bottom right.
"""
window = self
width = window.width()
width = max(width, window.minimumWidth())
height = window.height()
height = max(height, window.sizeHint().height())
desktop_geometry = QtWidgets.QDesktopWidget().availableGeometry()
screen_geometry = window.geometry()
screen_width = screen_geometry.width()
screen_height = screen_geometry.height()
# Calculate width and height of system tray
systray_width = screen_geometry.width() - desktop_geometry.width()
systray_height = screen_geometry.height() - desktop_geometry.height()
padding = 10
x = screen_width - width
y = screen_height - height
x -= systray_width + padding
y -= systray_height + padding
return QtCore.QRect(x, y, width, height)
@contextlib.contextmanager
def application():
app = QtWidgets.QApplication(sys.argv)
yield
app.exec_()
if __name__ == "__main__":
with application():
dialog = Popup()
dialog.setMessage("There are outdated containers in your Maya scene.")
dialog.show()