diff --git a/colorbleed/action.py b/colorbleed/action.py index 9b66da0206..a94dc6e921 100644 --- a/colorbleed/action.py +++ b/colorbleed/action.py @@ -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) diff --git a/colorbleed/lib.py b/colorbleed/lib.py new file mode 100644 index 0000000000..e25bbfcafd --- /dev/null +++ b/colorbleed/lib.py @@ -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 \ No newline at end of file diff --git a/colorbleed/maya/__init__.py b/colorbleed/maya/__init__.py index 01929eb9e3..d482f6751a 100644 --- a/colorbleed/maya/__init__.py +++ b/colorbleed/maya/__init__.py @@ -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() diff --git a/colorbleed/maya/customize.py b/colorbleed/maya/customize.py new file mode 100644 index 0000000000..64f33d5aae --- /dev/null +++ b/colorbleed/maya/customize.py @@ -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) diff --git a/colorbleed/maya/lib.py b/colorbleed/maya/lib.py index 4bd6bf6091..09032df09e 100644 --- a/colorbleed/maya/lib.py +++ b/colorbleed/maya/lib.py @@ -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 diff --git a/colorbleed/maya/plugin.py b/colorbleed/maya/plugin.py index 2a7c32d2ac..aecb55611b 100644 --- a/colorbleed/maya/plugin.py +++ b/colorbleed/maya/plugin.py @@ -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") diff --git a/colorbleed/plugins/maya/publish/collect_instances.py b/colorbleed/plugins/maya/publish/collect_instances.py index c25856c12c..08779fe378 100644 --- a/colorbleed/plugins/maya/publish/collect_instances.py +++ b/colorbleed/plugins/maya/publish/collect_instances.py @@ -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)) diff --git a/colorbleed/plugins/maya/publish/collect_look.py b/colorbleed/plugins/maya/publish/collect_look.py index 2df35499bc..4dfd29cadd 100644 --- a/colorbleed/plugins/maya/publish/collect_look.py +++ b/colorbleed/plugins/maya/publish/collect_look.py @@ -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} diff --git a/colorbleed/plugins/maya/publish/validate_look_contents.py b/colorbleed/plugins/maya/publish/validate_look_contents.py index 6e14b3af24..c6ea36f9be 100644 --- a/colorbleed/plugins/maya/publish/validate_look_contents.py +++ b/colorbleed/plugins/maya/publish/validate_look_contents.py @@ -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 diff --git a/colorbleed/plugins/maya/publish/validate_look_no_default_shaders.py b/colorbleed/plugins/maya/publish/validate_look_no_default_shaders.py index 39b4bd6cb8..9315359184 100644 --- a/colorbleed/plugins/maya/publish/validate_look_no_default_shaders.py +++ b/colorbleed/plugins/maya/publish/validate_look_no_default_shaders.py @@ -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)) diff --git a/colorbleed/plugins/maya/publish/validate_node_ids_unique.py b/colorbleed/plugins/maya/publish/validate_node_ids_unique.py index e32eb297a3..f828cac0c3 100644 --- a/colorbleed/plugins/maya/publish/validate_node_ids_unique.py +++ b/colorbleed/plugins/maya/publish/validate_node_ids_unique.py @@ -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 \ No newline at end of file + return invalid diff --git a/colorbleed/widgets/__init__.py b/colorbleed/widgets/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/colorbleed/widgets/popup.py b/colorbleed/widgets/popup.py new file mode 100644 index 0000000000..0ad1e07490 --- /dev/null +++ b/colorbleed/widgets/popup.py @@ -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()