Embed all internal Colorbleed library (cb) code into config

This commit is contained in:
Roy Nieterau 2018-10-09 12:57:50 +02:00
parent a9f822757d
commit 128b285654
11 changed files with 689 additions and 57 deletions

View file

@ -2,6 +2,7 @@ import os
import re
import logging
import importlib
import itertools
from .vendor import pather
from .vendor.pather.error import ParseError
@ -12,6 +13,24 @@ import avalon.api
log = logging.getLogger(__name__)
def pairwise(iterable):
"""s -> (s0,s1), (s2,s3), (s4, s5), ..."""
a = iter(iterable)
return itertools.izip(a, a)
def grouper(iterable, n, fillvalue=None):
"""Collect data into fixed-length chunks or blocks
Examples:
grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
"""
args = [iter(iterable)] * n
return itertools.izip_longest(fillvalue=fillvalue, *args)
def is_latest(representation):
"""Return whether the representation is from latest version

View file

@ -94,6 +94,11 @@ INT_FPS = {15, 24, 25, 30, 48, 50, 60, 44100, 48000}
FLOAT_FPS = {23.976, 29.97, 47.952, 59.94}
def _get_mel_global(name):
"""Return the value of a mel global variable"""
return mel.eval("$%s = $%s;" % (name, name))
def matrix_equals(a, b, tolerance=1e-10):
"""
Compares two matrices with an imperfection tolerance
@ -306,6 +311,33 @@ def attribute_values(attr_values):
cmds.setAttr(attr, value)
@contextlib.contextmanager
def keytangent_default(in_tangent_type='auto',
out_tangent_type='auto'):
"""Set the default keyTangent for new keys during this context"""
original_itt = cmds.keyTangent(query=True, g=True, itt=True)[0]
original_ott = cmds.keyTangent(query=True, g=True, ott=True)[0]
cmds.keyTangent(g=True, itt=in_tangent_type)
cmds.keyTangent(g=True, ott=out_tangent_type)
try:
yield
finally:
cmds.keyTangent(g=True, itt=original_itt)
cmds.keyTangent(g=True, ott=original_ott)
@contextlib.contextmanager
def undo_chunk():
"""Open a undo chunk during context."""
try:
cmds.undoInfo(openChunk=True)
yield
finally:
cmds.undoInfo(closeChunk=True)
@contextlib.contextmanager
def renderlayer(layer):
"""Set the renderlayer during the context"""
@ -339,6 +371,126 @@ def evaluation(mode="off"):
cmds.evaluationManager(mode=original)
@contextlib.contextmanager
def no_refresh():
"""Temporarily disables Maya's UI updates
Note:
This only disabled the main pane and will sometimes still
trigger updates in torn off panels.
"""
pane = _get_mel_global('gMainPane')
state = cmds.paneLayout(pane, query=True, manage=True)
cmds.paneLayout(pane, edit=True, manage=False)
try:
yield
finally:
cmds.paneLayout(pane, edit=True, manage=state)
@contextlib.contextmanager
def empty_sets(sets, force=False):
"""Remove all members of the sets during the context"""
assert isinstance(sets, (list, tuple))
original = dict()
original_connections = []
# Store original state
for obj_set in sets:
members = cmds.sets(obj_set, query=True)
original[obj_set] = members
try:
for obj_set in sets:
cmds.sets(clear=obj_set)
if force:
# Break all connections if force is enabled, this way we
# prevent Maya from exporting any reference nodes which are
# connected with placeHolder[x] attributes
plug = "%s.dagSetMembers" % obj_set
connections = cmds.listConnections(plug,
source=True,
destination=False,
plugs=True,
connections=True) or []
original_connections.extend(connections)
for dest, src in pairwise(connections):
cmds.disconnectAttr(src, dest)
yield
finally:
for dest, src in pairwise(original_connections):
cmds.connectAttr(src, dest)
# Restore original members
for origin_set, members in original.iteritems():
cmds.sets(members, forceElement=origin_set)
@contextlib.contextmanager
def renderlayer(layer):
"""Set the renderlayer during the context
Arguments:
layer (str): Name of layer to switch to.
"""
original = cmds.editRenderLayerGlobals(query=True,
currentRenderLayer=True)
try:
cmds.editRenderLayerGlobals(currentRenderLayer=layer)
yield
finally:
cmds.editRenderLayerGlobals(currentRenderLayer=original)
class delete_after(object):
"""Context Manager that will delete collected nodes after exit.
This allows to ensure the nodes added to the context are deleted
afterwards. This is useful if you want to ensure nodes are deleted
even if an error is raised.
Examples:
with delete_after() as delete_bin:
cube = maya.cmds.polyCube()
delete_bin.extend(cube)
# cube exists
# cube deleted
"""
def __init__(self, nodes=None):
self._nodes = list()
if nodes:
self.extend(nodes)
def append(self, node):
self._nodes.append(node)
def extend(self, nodes):
self._nodes.extend(nodes)
def __iter__(self):
return iter(self._nodes)
def __enter__(self):
return self
def __exit__(self, type, value, traceback):
if self._nodes:
cmds.delete(self._nodes)
def get_renderer(layer):
with renderlayer(layer):
return cmds.getAttr("defaultRenderGlobals.currentRenderer")
@ -367,6 +519,157 @@ def no_undo(flush=False):
cmds.undoInfo(**{keyword: original})
def get_shader_assignments_from_shapes(shapes):
"""Return the shape assignment per related shading engines.
Returns a dictionary where the keys are shadingGroups and the values are
lists of assigned shapes or shape-components.
For the 'shapes' this will return a dictionary like:
{
"shadingEngineX": ["nodeX", "nodeY"],
"shadingEngineY": ["nodeA", "nodeB"]
}
Args:
shapes (list): The shapes to collect the assignments for.
Returns:
dict: The {shadingEngine: shapes} relationships
"""
shapes = cmds.ls(shapes,
long=True,
selection=True,
shapes=True,
objectsOnly=True)
if not shapes:
return {}
# Collect shading engines and their shapes
assignments = defaultdict(list)
for shape in shapes:
# Get unique shading groups for the shape
shading_groups = cmds.listConnections(shape,
type="shadingEngine") or []
shading_groups = list(set(shading_groups))
for shading_group in shading_groups:
assignments[shading_group].add(shape)
return dict(assignments)
@contextlib.contextmanager
def shader(nodes, shadingEngine="initialShadingGroup"):
"""Assign a shader to nodes during the context"""
shapes = cmds.ls(nodes, dag=1, o=1, shapes=1, long=1)
original = get_shader_assignments_from_shapes(shapes)
try:
# Assign override shader
if shapes:
cmds.sets(shapes, edit=True, forceElement=shadingEngine)
yield
finally:
# Assign original shaders
for sg, members in original.items():
if members:
cmds.sets(shapes, edit=True, forceElement=shadingEngine)
@contextlib.contextmanager
def displaySmoothness(nodes,
divisionsU=0,
divisionsV=0,
pointsWire=4,
pointsShaded=1,
polygonObject=1):
"""Set the displaySmoothness during the context"""
# Ensure only non-intermediate shapes
nodes = cmds.ls(nodes,
dag=1,
shapes=1,
long=1,
noIntermediate=True)
def parse(node):
"""Parse the current state of a node"""
state = {}
for key in ["divisionsU",
"divisionsV",
"pointsWire",
"pointsShaded",
"polygonObject"]:
value = cmds.displaySmoothness(node, query=1, **{key: True})
if value is not None:
state[key] = value[0]
return state
originals = dict((node, parse(node)) for node in nodes)
try:
# Apply current state
cmds.displaySmoothness(nodes,
divisionsU=divisionsU,
divisionsV=divisionsV,
pointsWire=pointsWire,
pointsShaded=pointsShaded,
polygonObject=polygonObject)
yield
finally:
# Revert state
for node, state in originals.iteritems():
if state:
cmds.displaySmoothness(node, **state)
@contextlib.contextmanager
def no_display_layers(nodes):
"""Ensure nodes are not in a displayLayer during context.
Arguments:
nodes (list): The nodes to remove from any display layer.
"""
# Ensure long names
nodes = cmds.ls(nodes, long=True)
# Get the original state
lookup = set(nodes)
original = {}
for layer in cmds.ls(type='displayLayer'):
# Skip default layer
if layer == "defaultLayer":
continue
members = cmds.editDisplayLayerMembers(layer,
query=True,
fullNames=True)
if not members:
continue
members = set(members)
included = lookup.intersection(members)
if included:
original[layer] = list(included)
try:
# Add all nodes to default layer
cmds.editDisplayLayerMembers("defaultLayer", nodes, noRecurse=True)
yield
finally:
# Restore original members
for layer, members in original.iteritems():
cmds.editDisplayLayerMembers(layer, members, noRecurse=True)
@contextlib.contextmanager
def namespaced(namespace, new=True):
"""Work inside namespace during context
@ -1534,3 +1837,193 @@ def validate_fps():
return False
return True
def bake(nodes,
frame_range=None,
step=1.0,
simulation=True,
preserve_outside_keys=False,
disable_implicit_control=True,
shape=True):
"""Bake the given nodes over the time range.
This will bake all attributes of the node, including custom attributes.
Args:
nodes (list): Names of transform nodes, eg. camera, light.
frame_range (tuple): frame range with start and end frame.
or if None then takes timeSliderRange
simulation (bool): Whether to perform a full simulation of the
attributes over time.
preserve_outside_keys (bool): Keep keys that are outside of the baked
range.
disable_implicit_control (bool): When True will disable any
constraints to the object.
shape (bool): When True also bake attributes on the children shapes.
step (float): The step size to sample by.
Returns:
None
"""
# Parse inputs
if not nodes:
return
assert isinstance(nodes, (list, tuple)), "Nodes must be a list or tuple"
# If frame range is None fall back to time slider range
if frame_range is None:
frame_range = getTimeSliderRange()
# If frame range is single frame bake one frame more,
# otherwise maya.cmds.bakeResults gets confused
if frame_range[1] == frame_range[0]:
frame_range[1] += 1
# Bake it
with keytangent_default(in_tangent_type='auto',
out_tangent_type='auto'):
cmds.bakeResults(nodes,
simulation=simulation,
preserveOutsideKeys=preserve_outside_keys,
disableImplicitControl=disable_implicit_control,
shape=shape,
sampleBy=step,
time=(frame_range[0], frame_range[1]))
def bake_to_world_space(nodes,
frameRange=None,
simulation=True,
preserveOutsideKeys=False,
disableImplicitControl=True,
shape=True,
step=1.0):
"""Bake the nodes to world space transformation (incl. other attributes)
Bakes the transforms to world space (while maintaining all its animated
attributes and settings) by duplicating the node. Then parents it to world
and constrains to the original.
Other attributes are also baked by connecting all attributes directly.
Baking is then done using Maya's bakeResults command.
See `bake` for the argument documentation.
Returns:
list: The newly created and baked node names.
"""
def _get_attrs(node):
"""Workaround for buggy shape attribute listing with listAttr"""
attrs = cmds.listAttr(node,
write=True,
scalar=True,
settable=True,
connectable=True,
keyable=True,
shortNames=True) or []
valid_attrs = []
for attr in attrs:
node_attr = '{0}.{1}'.format(node, attr)
# Sometimes Maya returns 'non-existent' attributes for shapes
# so we filter those out
if not cmds.attributeQuery(attr, node=node, exists=True):
continue
# We only need those that have a connection, just to be safe
# that it's actually keyable/connectable anyway.
if cmds.connectionInfo(node_attr,
isDestination=True):
valid_attrs.append(attr)
return valid_attrs
transform_attrs = set(["t", "r", "s",
"tx", "ty", "tz",
"rx", "ry", "rz",
"sx", "sy", "sz"])
world_space_nodes = []
with delete_after() as delete_bin:
# Create the duplicate nodes that are in world-space connected to
# the originals
for node in nodes:
# Duplicate the node
short_name = node.rsplit("|", 1)[-1]
new_name = "{0}_baked".format(short_name)
new_node = cmds.duplicate(node,
name=new_name,
renameChildren=True)[0]
# Connect all attributes on the node except for transform
# attributes
attrs = _get_attrs(node)
attrs = set(attrs) - transform_attrs if attrs else []
for attr in attrs:
orig_node_attr = '{0}.{1}'.format(node, attr)
new_node_attr = '{0}.{1}'.format(new_node, attr)
# unlock to avoid connection errors
cmds.setAttr(new_node_attr, lock=False)
cmds.connectAttr(orig_node_attr,
new_node_attr,
force=True)
# If shapes are also baked then connect those keyable attributes
if shape:
children_shapes = cmds.listRelatives(new_node,
children=True,
fullPath=True,
shapes=True)
if children_shapes:
orig_children_shapes = cmds.listRelatives(node,
children=True,
fullPath=True,
shapes=True)
for orig_shape, new_shape in zip(orig_children_shapes,
children_shapes):
attrs = _get_attrs(orig_shape)
for attr in attrs:
orig_node_attr = '{0}.{1}'.format(orig_shape, attr)
new_node_attr = '{0}.{1}'.format(new_shape, attr)
# unlock to avoid connection errors
cmds.setAttr(new_node_attr, lock=False)
cmds.connectAttr(orig_node_attr,
new_node_attr,
force=True)
# Parent to world
if cmds.listRelatives(new_node, parent=True):
new_node = cmds.parent(new_node, world=True)[0]
# Unlock transform attributes so constraint can be created
for attr in transform_attrs:
cmds.setAttr('{0}.{1}'.format(new_node, attr), lock=False)
# Constraints
delete_bin.extend(cmds.parentConstraint(node, new_node, mo=False))
delete_bin.extend(cmds.scaleConstraint(node, new_node, mo=False))
world_space_nodes.append(new_node)
bake(world_space_nodes,
frame_range=frameRange,
step=step,
simulation=simulation,
preserve_outside_keys=preserveOutsideKeys,
disable_implicit_control=disableImplicitControl,
shape=shape)
return world_space_nodes

View file

@ -22,6 +22,6 @@ class CreateCamera(avalon.maya.Creator):
# Bake to world space by default, when this is False it will also
# include the parent hierarchy in the baked results
data['bakeToWorldSpace'] = True
data['bake_to_world_space'] = True
self.data = data

View file

@ -1,7 +1,10 @@
import re
import os
import glob
from maya import cmds
import pyblish.api
import colorbleed.maya.lib as lib
from cb.utils.maya import context, shaders
SHAPE_ATTRS = ["castsShadows",
"receiveShadows",
@ -48,6 +51,139 @@ def get_look_attrs(node):
return result
def node_uses_image_sequence(node):
"""Return whether file node uses an image sequence or single image.
Determine if a node uses an image sequence or just a single image,
not always obvious from its file path alone.
Args:
node (str): Name of the Maya node
Returns:
bool: True if node uses an image sequence
"""
# useFrameExtension indicates an explicit image sequence
node_path = get_file_node_path(node).lower()
# The following tokens imply a sequence
patterns = ["<udim>", "<tile>", "<uvtile>", "u<u>_v<v>", "<frame0"]
return (cmds.getAttr('%s.useFrameExtension' % node) or
any(pattern in node_path for pattern in patterns))
def seq_to_glob(path):
"""Takes an image sequence path and returns it in glob format,
with the frame number replaced by a '*'.
Image sequences may be numerical sequences, e.g. /path/to/file.1001.exr
will return as /path/to/file.*.exr.
Image sequences may also use tokens to denote sequences, e.g.
/path/to/texture.<UDIM>.tif will return as /path/to/texture.*.tif.
Args:
path (str): the image sequence path
Returns:
str: Return glob string that matches the filename pattern.
"""
if path is None:
return path
# If any of the patterns, convert the pattern
patterns = {
"<udim>": "<udim>",
"<tile>": "<tile>",
"<uvtile>": "<uvtile>",
"#": "#",
"u<u>_v<v>": "<u>|<v>",
"<frame0": "<frame0\d+>",
"<f>": "<f>"
}
lower = path.lower()
has_pattern = False
for pattern, regex_pattern in patterns.items():
if pattern in lower:
path = re.sub(regex_pattern, "*", path, flags=re.IGNORECASE)
has_pattern = True
if has_pattern:
return path
base = os.path.basename(path)
matches = list(re.finditer(r'\d+', base))
if matches:
match = matches[-1]
new_base = '{0}*{1}'.format(base[:match.start()],
base[match.end():])
head = os.path.dirname(path)
return os.path.join(head, new_base)
else:
return path
def get_file_node_path(node):
"""Get the file path used by a Maya file node.
Args:
node (str): Name of the Maya file node
Returns:
str: the file path in use
"""
# if the path appears to be sequence, use computedFileTextureNamePattern,
# this preserves the <> tag
if cmds.attributeQuery('computedFileTextureNamePattern',
node=node,
exists=True):
plug = '{0}.computedFileTextureNamePattern'.format(node)
texture_pattern = cmds.getAttr(plug)
patterns = ["<udim>",
"<tile>",
"u<u>_v<v>",
"<f>",
"<frame0",
"<uvtile>"]
lower = texture_pattern.lower()
if any(pattern in lower for pattern in patterns):
return texture_pattern
# otherwise use fileTextureName
return cmds.getAttr('{0}.fileTextureName'.format(node))
def get_file_node_files(node):
"""Return the file paths related to the file node
Note:
Will only return existing files. Returns an empty list
if not valid existing files are linked.
Returns:
list: List of full file paths.
"""
path = get_file_node_path(node)
path = cmds.workspace(expandName=path)
if node_uses_image_sequence(node):
glob_pattern = seq_to_glob(path)
return glob.glob(glob_pattern)
elif os.path.exists(path):
return [path]
else:
return []
class CollectLook(pyblish.api.InstancePlugin):
"""Collect look data for instance.
@ -74,7 +210,7 @@ class CollectLook(pyblish.api.InstancePlugin):
def process(self, instance):
"""Collect the Look in the instance with the correct layer settings"""
with context.renderlayer(instance.data["renderlayer"]):
with lib.renderlayer(instance.data["renderlayer"]):
self.collect(instance)
def collect(self, instance):
@ -268,7 +404,7 @@ class CollectLook(pyblish.api.InstancePlugin):
# paths as the computed patterns
source = source.replace("\\", "/")
files = shaders.get_file_node_files(node)
files = get_file_node_files(node)
if len(files) == 0:
self.log.error("No valid files found from node `%s`" % node)

View file

@ -5,14 +5,14 @@ from maya import cmds
import avalon.maya
import colorbleed.api
import cb.utils.maya.context as context
import colorbleed.maya.lib as lib
class ExtractCameraAlembic(colorbleed.api.Extractor):
"""Extract a Camera as Alembic.
The cameras gets baked to world space by default. Only when the instance's
`bakeToWorldSpace` is set to False it will include its full hierarchy.
`bake_to_world_space` is set to False it will include its full hierarchy.
"""
@ -27,7 +27,7 @@ class ExtractCameraAlembic(colorbleed.api.Extractor):
instance.data.get("endFrame", 1)]
handles = instance.data.get("handles", 0)
step = instance.data.get("step", 1.0)
bake_to_worldspace = instance.data("bakeToWorldSpace", True)
bake_to_worldspace = instance.data("bake_to_world_space", True)
# get cameras
members = instance.data['setMembers']
@ -66,8 +66,8 @@ class ExtractCameraAlembic(colorbleed.api.Extractor):
job_str += ' -file "{0}"'.format(path)
with context.evaluation("off"):
with context.no_refresh():
with lib.evaluation("off"):
with lib.no_refresh():
cmds.AbcExport(j=job_str, verbose=False)
if "files" not in instance.data:

View file

@ -1,13 +1,11 @@
import os
from itertools import izip_longest
from maya import cmds
import avalon.maya
import colorbleed.api
import cb.utils.maya.context as context
from cb.utils.maya.animation import bakeToWorldSpace
from colorbleed.lib import grouper
from colorbleed.maya import lib
def massage_ma_file(path):
@ -36,18 +34,6 @@ def massage_ma_file(path):
f.close()
def grouper(iterable, n, fillvalue=None):
"""Collect data into fixed-length chunks or blocks
Examples:
grouper('ABCDEFG', 3, 'x') --> ABC DEF Gxx
"""
args = [iter(iterable)] * n
return izip_longest(fillvalue=fillvalue, *args)
def unlock(plug):
"""Unlocks attribute and disconnects inputs for a plug.
@ -87,7 +73,7 @@ class ExtractCameraMayaAscii(colorbleed.api.Extractor):
will be published.
The cameras gets baked to world space by default. Only when the instance's
`bakeToWorldSpace` is set to False it will include its full hierarchy.
`bake_to_world_space` is set to False it will include its full hierarchy.
Note:
The extracted Maya ascii file gets "massaged" removing the uuid values
@ -106,12 +92,12 @@ class ExtractCameraMayaAscii(colorbleed.api.Extractor):
instance.data.get("endFrame", 1)]
handles = instance.data.get("handles", 0)
step = instance.data.get("step", 1.0)
bake_to_worldspace = instance.data("bakeToWorldSpace", True)
bake_to_worldspace = instance.data("bake_to_world_space", True)
# TODO: Implement a bake to non-world space
# Currently it will always bake the resulting camera to world-space
# and it does not allow to include the parent hierarchy, even though
# with `bakeToWorldSpace` set to False it should include its hierarchy
# with `bake_to_world_space` set to False it should include its hierarchy
# to be correct with the family implementation.
if not bake_to_worldspace:
self.log.warning("Camera (Maya Ascii) export only supports world"
@ -140,11 +126,13 @@ class ExtractCameraMayaAscii(colorbleed.api.Extractor):
# Perform extraction
self.log.info("Performing camera bakes for: {0}".format(transform))
with avalon.maya.maintained_selection():
with context.evaluation("off"):
with context.no_refresh():
baked = bakeToWorldSpace(transform,
frameRange=range_with_handles,
step=step)
with lib.evaluation("off"):
with lib.no_refresh():
baked = lib.bake_to_worldspace(
transform,
frameRange=range_with_handles,
step=step
)
baked_shapes = cmds.ls(baked,
type="camera",
dag=True,

View file

@ -6,10 +6,9 @@ from maya import cmds
import pyblish.api
import avalon.maya
import colorbleed.api
import colorbleed.maya.lib as maya
from cb.utils.maya import context
import colorbleed.api
import colorbleed.maya.lib as lib
class ExtractLook(colorbleed.api.Extractor):
@ -63,10 +62,10 @@ class ExtractLook(colorbleed.api.Extractor):
# Extract in correct render layer
layer = instance.data.get("renderlayer", "defaultRenderLayer")
with context.renderlayer(layer):
with lib.renderlayer(layer):
# TODO: Ensure membership edits don't become renderlayer overrides
with context.empty_sets(sets, force=True):
with maya.attribute_values(remap):
with lib.empty_sets(sets, force=True):
with lib.attribute_values(remap):
with avalon.maya.maintained_selection():
cmds.select(sets, noExpand=True)
cmds.file(maya_path,

View file

@ -4,8 +4,7 @@ from maya import cmds
import avalon.maya
import colorbleed.api
from cb.utils.maya import context
import colorbleed.maya.lib as lib
class ExtractModel(colorbleed.api.Extractor):
@ -47,15 +46,15 @@ class ExtractModel(colorbleed.api.Extractor):
noIntermediate=True,
long=True)
with context.no_display_layers(instance):
with context.displaySmoothness(members,
divisionsU=0,
divisionsV=0,
pointsWire=4,
pointsShaded=1,
polygonObject=1):
with context.shader(members,
shadingEngine="initialShadingGroup"):
with lib.no_display_layers(instance):
with lib.displaySmoothness(members,
divisionsU=0,
divisionsV=0,
pointsWire=4,
pointsShaded=1,
polygonObject=1):
with lib.shader(members,
shadingEngine="initialShadingGroup"):
with avalon.maya.maintained_selection():
cmds.select(members, noExpand=True)
cmds.file(path,

View file

@ -4,8 +4,6 @@ from colorbleed.maya import lib
import pyblish.api
import colorbleed.api
from cb.utils.maya import context
class ValidateLookSets(pyblish.api.InstancePlugin):
"""Validate if any sets are missing from the instance and look data
@ -57,7 +55,7 @@ class ValidateLookSets(pyblish.api.InstancePlugin):
invalid = []
renderlayer = instance.data.get("renderlayer", "defaultRenderLayer")
with context.renderlayer(renderlayer):
with lib.renderlayer(renderlayer):
for node in instance:
# get the connected objectSets of the node
sets = lib.get_related_sets(node)

View file

@ -1,10 +1,10 @@
from maya import cmds
import pyblish.api
import colorbleed.api
from cb.utils.maya.context import undo_chunk
import colorbleed.api
import colorbleed.maya.action
from colorbleed.maya.lib import undo_chunk
class ValidateRigControllers(pyblish.api.InstancePlugin):

View file

@ -2,8 +2,8 @@ from maya import cmds
import pyblish.api
import colorbleed.api
from cb.utils.maya.context import undo_chunk
import colorbleed.maya.lib as lib
import colorbleed.maya.action
@ -83,7 +83,7 @@ class ValidateRigControllersArnoldAttributes(pyblish.api.InstancePlugin):
def repair(cls, instance):
invalid = cls.get_invalid(instance)
with undo_chunk():
with lib.undo_chunk():
for node in invalid:
for attribute in cls.attributes:
if cmds.attributeQuery(attribute, node=node, exists=True):