From 99c51a90272532572e0537492b94496a5aa8d800 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sun, 30 Jan 2022 12:13:29 +0000 Subject: [PATCH 01/13] Add support for cloning enum attributes --- cmdx.py | 50 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 38 insertions(+), 12 deletions(-) diff --git a/cmdx.py b/cmdx.py index 853e47e..4a448ef 100644 --- a/cmdx.py +++ b/cmdx.py @@ -1076,7 +1076,7 @@ def dump(self, ignore_error=True, preserve_order=False): >>> dump = node.dump() >>> isinstance(dump, dict) True - >>> dump["choice1.caching"] + >>> dump["caching"] False """ @@ -1097,7 +1097,7 @@ def dump(self, ignore_error=True, preserve_order=False): if not ignore_error: raise - attrs[plug.name()] = value + attrs[plug.name().split(".", 1)[-1]] = value return attrs @@ -2840,8 +2840,8 @@ def clone(self, name, shortName=None, niceName=None): cls = self.typeClass() fn = self.fn() - attr = cls( - name, + + kwargs = dict( default=self.default, label=niceName, shortName=shortName, @@ -2857,14 +2857,35 @@ def clone(self, name, shortName=None, niceName=None): array=fn.array, indexMatters=fn.indexMatters, connectable=fn.connectable, - disconnectBehavior=fn.disconnectBehavior, + disconnectBehavior=fn.disconnectBehavior ) - if hasattr(fn, "getMin") and fn.hasMin(): - attr["min"] = fn.getMin() + if isinstance(fn, om.MFnEnumAttribute): + kwargs["fields"] = [] + + for index in range(fn.getMax() + 1): + try: + field = fn.fieldName(index) + + except RuntimeError: + # Indices may not be consecutive, e.g. + # 0 = Off + # 2 = Kinematic + # 3 = Dynamic + # (missing 1!) + continue + + else: + kwargs["fields"].append((index, field)) + + else: + if hasattr(fn, "getMin") and fn.hasMin(): + kwargs["min"] = fn.getMin() + + if hasattr(fn, "getMax") and fn.hasMax(): + kwargs["max"] = fn.getMax() - if hasattr(fn, "getMax") and fn.hasMax(): - attr["max"] = fn.getMax() + attr = cls(name, **kwargs) return attr @@ -3606,6 +3627,9 @@ def fn(self): elif typ == om.MFn.kMessageAttribute: fn = om.MFnMessageAttribute(attr) + elif typ == om.MFn.kEnumAttribute: + fn = om.MFnEnumAttribute(attr) + else: raise TypeError( "Couldn't figure out function set for '%s.%s'" % ( @@ -7071,9 +7095,11 @@ def delete(*nodes): with DagModifier(undoable=False) as mod: for node in flattened: if isinstance(node, str): - node, node = node.rsplit(".", 1) - node = encode(node) - node = node[node] + try: + node, attr = node.rsplit(".", 1) + node = encode(node) + except ExistError: + continue if not node.exists: # May have been a child of something From a86ef3482161e57136f5a9fd38ee10da03a35c94 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 28 Feb 2022 06:55:37 +0000 Subject: [PATCH 02/13] - Load undo plug-in when not already loaded - Upgrade NurbsCurve to API 2.0 (Maya 2017+) - Expose Tm.asScaleMatrix and Tm.asRotateMatrix - Expose MFn.kMesh --- cmdx.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/cmdx.py b/cmdx.py index 4a448ef..6ad8b92 100644 --- a/cmdx.py +++ b/cmdx.py @@ -308,6 +308,7 @@ class _Type(int): MFn = om.MFn kDagNode = _Type(om.MFn.kDagNode) kShape = _Type(om.MFn.kShape) +kMesh = _Type(om.MFn.kMesh) kTransform = _Type(om.MFn.kTransform) kJoint = _Type(om.MFn.kJoint) kSet = _Type(om.MFn.kSet) @@ -4410,6 +4411,12 @@ def asMatrix(self): # type: () -> MatrixType def asMatrixInverse(self): # type: () -> MatrixType return MatrixType(super(TransformationMatrix, self).asMatrixInverse()) + def asScaleMatrix(self): # type: () -> MatrixType + return MatrixType(super(TransformationMatrix, self).asScaleMatrix()) + + def asRotateMatrix(self): # type: () -> MatrixType + return MatrixType(super(TransformationMatrix, self).asRotateMatrix()) + if ENABLE_PEP8: x_axis = xAxis y_axis = yAxis @@ -4421,6 +4428,8 @@ def asMatrixInverse(self): # type: () -> MatrixType set_scale = setScale as_matrix = asMatrix as_matrix_inverse = asMatrixInverse + as_scale_matrix = asScaleMatrix + as_rotate_matrix = asRotateMatrix class MatrixType(om.MMatrix): @@ -4734,7 +4743,7 @@ def isEquivalent(self, other, tolerance=om.MEulerRotation.kTolerance): Euler = EulerRotation -def NurbsCurveData(points, degree=1, form=om1.MFnNurbsCurve.kOpen): +def NurbsCurveData(points, degree=1, form=om.MFnNurbsCurve.kOpen): """Tuple of points to MObject suitable for nurbsCurve-typed data Arguments: @@ -4761,13 +4770,13 @@ def NurbsCurveData(points, degree=1, form=om1.MFnNurbsCurve.kOpen): degree = min(3, max(1, degree)) - cvs = om1.MPointArray() - curveFn = om1.MFnNurbsCurve() - data = om1.MFnNurbsCurveData() + cvs = om.MPointArray() + curveFn = om.MFnNurbsCurve() + data = om.MFnNurbsCurveData() mobj = data.create() for point in points: - cvs.append(om1.MPoint(*point)) + cvs.append(om.MPoint(*point)) curveFn.createWithEditPoints(cvs, degree, @@ -5268,6 +5277,9 @@ def _python_to_mod(value, plug, mod): obj = om.MFnMatrixData().create(value) mod.newPlugValue(mplug, obj) + elif isinstance(value, om.MObject): + mod.newPlugValue(mplug, value) + elif isinstance(value, om.MEulerRotation): for index, value in enumerate(value): value = om.MAngle(value, om.MAngle.kRadians) @@ -8118,7 +8130,9 @@ def install(): """ - cmds.loadPlugin(__file__, quiet=True) + plugin_name = os.path.basename(__file__).rsplit(".", 1)[0] + if not cmds.pluginInfo(plugin_name, query=True, loaded=True): + cmds.loadPlugin(__file__, quiet=True) self.installed = True From dae2b4c78df713c1920419d17fd29f3e7a791ae9 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Mon, 28 Feb 2022 08:46:27 +0000 Subject: [PATCH 03/13] Fix tests and do PEP8 cleanup --- cmdx.py | 74 ++++++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 52 insertions(+), 22 deletions(-) diff --git a/cmdx.py b/cmdx.py index 6ad8b92..ddc3a59 100644 --- a/cmdx.py +++ b/cmdx.py @@ -2815,6 +2815,13 @@ def clone(self, name, shortName=None, niceName=None): >>> cam["fClone"].read() 5.6 + # Can clone enum attributes + >>> cam["myEnum"] = Enum(fields=["a", "b", "c"]) + >>> clone = cam["myEnum"].clone("cloneEnum") + >>> cam.addAttr(clone) + >>> fields = cam["cloneEnum"].fields() + >>> assert fields == ((0, "a"), (1, "b"), (2, "c")), fields + """ assert isinstance(self, Plug) @@ -2862,22 +2869,7 @@ def clone(self, name, shortName=None, niceName=None): ) if isinstance(fn, om.MFnEnumAttribute): - kwargs["fields"] = [] - - for index in range(fn.getMax() + 1): - try: - field = fn.fieldName(index) - - except RuntimeError: - # Indices may not be consecutive, e.g. - # 0 = Off - # 2 = Kinematic - # 3 = Dynamic - # (missing 1!) - continue - - else: - kwargs["fields"].append((index, field)) + kwargs["fields"] = self.fields() else: if hasattr(fn, "getMin") and fn.hasMin(): @@ -2984,6 +2976,33 @@ def index(self): "%s is not a child or element" % self.path() ) + def fields(self): + """Return fields of an Enum attribute, if any""" + + fn = self.fn() + + assert isinstance(fn, om.MFnEnumAttribute), ( + "%s was not an enum attribute" % self.path() + ) + + fields = [] + + for index in range(fn.getMax() + 1): + try: + field = fn.fieldName(index) + + except RuntimeError: + # Indices may not be consecutive, e.g. + # 0 = Off + # 2 = Kinematic + # 3 = Dynamic + # (missing 1!) + continue + + else: + fields.append((index, field)) + + return tuple(fields) def nextAvailableIndex(self, startIndex=0): """Find the next unconnected element in an array plug @@ -3280,16 +3299,19 @@ def findAnimatedPlug(self): >>> parent = createNode("transform") >>> _ = cmds.parentConstraint(str(parent), str(node)) >>> blend = ls(type="pairBlend")[0] - >>> node["tx"].findAnimatedPlug().path() == blend["inTranslateX1"].path() + >>> animPlug = node["tx"].findAnimatedPlug().path() + >>> animPlug == blend["inTranslateX1"].path() True # Animation layers >>> layer = encode(cmds.animLayer(at=node["tx"].path())) - >>> node["tx"].findAnimatedPlug().path() == blend["inTranslateX1"].path() + >>> animPlug = node["tx"].findAnimatedPlug().path() + >>> animPlug == blend["inTranslateX1"].path() True >>> cmds.animLayer(str(layer), e=True, preferred=True) >>> animBlend = ls(type="animBlendNodeBase")[0] - >>> node["tx"].findAnimatedPlug().path() == animBlend["inputB"].path() + >>> animPlug = node["tx"].findAnimatedPlug().path() + >>> animPlug == animBlend["inputB"].path() True # Animation layer then constraint @@ -3299,10 +3321,12 @@ def findAnimatedPlug(self): >>> parent = createNode("transform") >>> _ = cmds.parentConstraint(str(parent), str(node)) >>> animBlend = ls(type="animBlendNodeBase")[0] - >>> node["tx"].findAnimatedPlug().path() == animBlend["inputA"].path() + >>> animPlug = node["tx"].findAnimatedPlug().path() + >>> animPlug == animBlend["inputA"].path() True >>> cmds.animLayer(str(layer), e=True, preferred=True) - >>> node["tx"].findAnimatedPlug().path() == animBlend["inputB"].path() + >>> animPlug = node["tx"].findAnimatedPlug().path() + >>> animPlug == animBlend["inputB"].path() True """ @@ -3329,7 +3353,10 @@ def findAnimatedPlug(self): plug = plug[self.index()] if plug.isCompound else plug # Search for more pair blends or anim blends - con = plug.input(type=AnimBlendTypes + (MFn.kPairBlend,), plug=True) + con = plug.input( + type=AnimBlendTypes + (MFn.kPairBlend,), + plug=True + ) # If no animation layers or pair blends then plug is self plug = plug if plug is not None else self @@ -5057,6 +5084,9 @@ def _python_to_plug(value, plug): # Native Maya types + elif isinstance(value, om.MObject): + plug._mplug.setMObject(value) + elif isinstance(value, om1.MObject): node = _encode1(plug._node.path()) shapeFn = om1.MFnDagNode(node) From f262aa2898209a1363630b9c6591623c16ca18d0 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sun, 29 May 2022 09:34:15 +0100 Subject: [PATCH 04/13] - Support passing animCurve interpolation with attribute, e.g. ["animated", cmdx.kStepped] = {1: True} - Support for path-like syntax, e.g. parent | "child" | "grandchild" - Add Vector.rotate_by PEP8 syntax - Protect against crash when using MPoint with MObject - Support BoundingBox.expand with Vector-type - Expose math.sqrt as cmdx.sqrt - Expose isScrubbing - Expose isPlaying - Support for cmdx.setAttr(undoable=True) - Support for cmdx.setAttr with Matrix - Make Maya aware of cmdx version --- cmdx.py | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 100 insertions(+), 7 deletions(-) diff --git a/cmdx.py b/cmdx.py index ddc3a59..90f1781 100644 --- a/cmdx.py +++ b/cmdx.py @@ -18,7 +18,7 @@ from maya.api import OpenMaya as om, OpenMayaAnim as oma, OpenMayaUI as omui from maya import OpenMaya as om1, OpenMayaMPx as ompx1, OpenMayaUI as omui1 -__version__ = "0.6.2" +__version__ = "0.6.3" IS_VENDORED = "." in __name__ @@ -583,6 +583,8 @@ def __getitem__(self, key): for item in items: if isinstance(item, _Unit): unit = item + elif isinstance(item, int): + unit = item elif isinstance(item, _Cached): cached = True @@ -592,6 +594,10 @@ def __getitem__(self, key): except KeyError: pass + assert isinstance(key, str), ( + "%s was not the name of an attribute" % key + ) + try: plug = self.findPlug(key) except RuntimeError: @@ -1501,6 +1507,29 @@ def __str__(self): def __repr__(self): return self.path() + def __or__(self, other): + """Syntax sugar for finding a child + + Examples: + >>> _new() + >>> parent = createNode("transform", "parent") + >>> child = createNode("transform", "child", parent) + >>> parent | "child" + |parent|child + + # Stacklable too + >>> grand = createNode("transform", "grand", child) + >>> parent | "child" | "grand" + |parent|child|grand + + """ + + return encode("%s|%s" % (self.path(), other)) + + def __iter__(self): + for child in self.children(): + yield child + @property def _tfn(self): if SAFE_MODE: @@ -2345,7 +2374,7 @@ def keys(self, times, values, tangents=None, change=None): except RuntimeError: # The error provided by Maya aren't very descriptive, - # help a brother out by look for common problems. + # help a brother out by looking for common problems. if not times: log.error("No times were provided: %s" % str(times)) @@ -2747,7 +2776,8 @@ def __getitem__(self, logicalIndex): raise TypeError("'%s' is not a compound attribute" % self.path()) - raise ExistError("'%s' was not found" % logicalIndex) + raise ExistError("'%s.%s' was not found" % ( + self.path(), logicalIndex)) def __setitem__(self, index, value): """Write to child of array or compound plug @@ -3361,6 +3391,9 @@ def findAnimatedPlug(self): # If no animation layers or pair blends then plug is self plug = plug if plug is not None else self + # Preserve unit passed by user, e.g. Stepped + plug._unit = self._unit + return plug def lock(self): @@ -4590,6 +4623,9 @@ def __iadd__(self, value): def dot(self, value): return Vector(super(Vector, self).__mul__(value)) + def rotateBy(self, value): + return Vector(super(Vector, self).rotateBy(value)) + def cross(self, value): return Vector(super(Vector, self).__xor__(value)) @@ -4598,6 +4634,7 @@ def isEquivalent(self, other, tolerance=om.MVector.kTolerance): if ENABLE_PEP8: is_equivalent = isEquivalent + rotate_by = rotateBy # Alias, it can't take anything other than values @@ -4628,6 +4665,26 @@ def divide_vectors(vec1, vec2): class Point(om.MPoint): """Maya's MPoint""" + def __init__(self, *args): + + # Protect against passing an instance of a Node + # into here, as Maya would crash if that happens + if len(args) == 1: + assert ( + isinstance(args[0], om.MPoint) or + isinstance(args[0], om.MVector) + ), "%s was not a MPoint or MVector" % args[0] + + return super(Point, self).__init__(*args) + + def distanceTo(self, other): + if isinstance(other, om.MVector): + other = Point(other) + return super(Point, self).distanceTo(other) + + if ENABLE_PEP8: + distance_to = distanceTo + class Color(om.MColor): """Maya's MColor""" @@ -4636,6 +4693,11 @@ class Color(om.MColor): class BoundingBox(om.MBoundingBox): """Maya's MBoundingBox""" + def expand(self, position): + if isinstance(position, om.MVector): + position = Point(position) + return super(BoundingBox, self).expand(position) + def volume(self): return self.width * self.height * self.depth @@ -5484,6 +5546,7 @@ def asHex(mobj): cos = math.cos tan = math.tan pi = math.pi +sqrt = math.sqrt def time(frame): @@ -6815,6 +6878,14 @@ def maxTime(time=None): return oma.MAnimControl.setMaxTime(time) +def isScrubbing(): + return oma.MAnimControl.isScrubbing() + + +def isPlaying(): + return oma.MAnimControl.isPlaying() + + class DGContext(om.MDGContext): """Context for evaluating the Maya DG @@ -6952,7 +7023,7 @@ def getAttr(attr, type=None, time=None): return attr.read(time=time) -def setAttr(attr, value, type=None): +def setAttr(attr, value, type=None, undoable=False): """Write `value` to `attr` Arguments: @@ -6966,12 +7037,19 @@ def setAttr(attr, value, type=None): """ + if type == "matrix": + value = Matrix4(value) + if isinstance(attr, str): node, attr = attr.rsplit(".", 1) node = encode(node) attr = node[attr] - attr.write(value) + if undoable: + with DGModifier() as mod: + mod.setAttr(attr, value) + else: + attr.write(value) def addAttr(node, @@ -7244,6 +7322,8 @@ def setUpAxis(axis=Y): current_time = currentTime min_time = minTime max_time = maxTime + is_scrubbing = isScrubbing + is_playing = isPlaying animation_start_time = animationStartTime animation_end_time = animationEndTime selected_time = selectedTime @@ -7323,12 +7403,22 @@ def curve(parent, points, degree=1, form=kOpen, mod=None): degree = min(3, max(1, degree)) - knotcount = len(points) - degree + 2 * degree - 1 + if degree > 1: + points.insert(0, points[0]) + points.append(points[-1]) + + if degree > 2: + points.insert(0, points[0]) + points.append(points[-1]) + + spans = len(points) - degree + knotcount = spans + 2 * degree - 1 cvs = [p for p in points] knots = [i for i in range(knotcount)] curveFn = om.MFnNurbsCurve() + mobj = curveFn.create(cvs, knots, degree, @@ -8242,7 +8332,10 @@ def isUndoable(self): def initializePlugin(plugin): - om.MFnPlugin(plugin).registerCommand( + # Only supports major.minor (no patch) + version = ".".join(__version__.rsplit(".")[:2]) + + om.MFnPlugin(plugin, "cmdx", version).registerCommand( unique_command, _apiUndo ) From e7d24837582b940ae5fc6eb44deea0f4a6d48c75 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Sun, 29 May 2022 09:48:35 +0100 Subject: [PATCH 05/13] Update README --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/README.md b/README.md index 396931e..9b8815e 100644 --- a/README.md +++ b/README.md @@ -528,6 +528,50 @@ The reason for this limitation is because the functions `cmds`
+### Path-like Syntax + +Neatly traverse a hierarchy with the `|` syntax. + +```py +# Before +group = cmdx.encode("|some_grp") +hand = cmdx.encode(group.path() + "|hand_ctl") + +# After +hand = group | "hand_ctl" +``` + +It can be nested too. + +```py +finger = group | "hand_ctl" | "finger_ctl" +``` + +
+ +### setAttr + +Maya's `cmds.setAttr` depends on the UI settings for units. + +```py +cmds.setAttr("hand_ctl.translateY", 5) +``` + +For a user with Maya set to `Centimeters`, this would set `translateY` to 5 centimeters. For any user with any other unit, like `Foot`, it would instead move it 5 feet. That is terrible behaviour for a script, how can you possibly define the length of something if you don't know the unit? A dog is 100 cm tall, not 100 "any unit" tall. + +The `cmdx.setAttr` on the other hand does what Maya's API does, which is to treat all units consistently. + +```py +cmdx.setAttr("hand_ctl.translateY", 5) # centimeters, always +``` + +- Distance values are in `centimeters` +- Angular values are in `radians` + +So the user is free to choose any unit for their UI without breaking their scripts. + +
+ ### Units `cmdx` takes and returns values in the units used by the UI. For example, Maya's default unit for distances, such as `translateX` is in Centimeters. @@ -1018,6 +1062,28 @@ node["myMatrix"] = node["worldMatrix"][0].asMatrix()
+### Cloning + +Support for cloning enum attributes. + +```py +parent = createNode("transform") +camera = createNode("camera", parent=parent) + +# Make new enum attribute +camera["myEnum"] = Enum(fields=["a", "b", "c"]) + +# Clone it +clone = camera["myEnum"].clone("cloneEnum") +cam.addAttr(clone) + +# Compare it +fields = camera["cloneEnum"].fields() +assert fields == ((0, "a"), (1, "b"), (2, "c")) +``` + +
+ ### Native Types Maya boasts a library of classes that provide mathematical convenience functionality, such as rotating a vector, multiplying matrices or converting between Euler degrees and Quaternions. From 2403f64a00eea170a5a9b2cb5828e0ec78f022b1 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Fri, 28 Oct 2022 08:34:21 +0100 Subject: [PATCH 06/13] Minor tweaks - setChannelBox - currentFrame - Handle passing tuple to curve() - Integer attribute type - Handle multiple vendored cmdx's --- cmdx.py | 115 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 110 insertions(+), 5 deletions(-) diff --git a/cmdx.py b/cmdx.py index 90f1781..e44dd3c 100644 --- a/cmdx.py +++ b/cmdx.py @@ -1517,13 +1517,18 @@ def __or__(self, other): >>> parent | "child" |parent|child - # Stacklable too + # Stackable too >>> grand = createNode("transform", "grand", child) >>> parent | "child" | "grand" |parent|child|grand """ + # Handle cases where self has a namespace + # and no namespace is provided. + if ":" not in other: + other = "%s:%s" % (self.namespace(), other) + return encode("%s|%s" % (self.path(), other)) def __iter__(self): @@ -4579,6 +4584,12 @@ def element(self, row, col): values = tuple(self) return values[row * 4 + col % 4] + def isEquivalent(self, other, tolerance=1e-10): + return super(MatrixType, self).isEquivalent(other, tolerance) + + if ENABLE_PEP8: + is_equivalent = isEquivalent + # Alias Transformation = TransformationMatrix @@ -4600,6 +4611,9 @@ class Vector(om.MVector): """ + def __mul__(self, value): + return Vector(super(Vector, self).__mul__(value)) + def __add__(self, value): if isinstance(value, (int, float)): return type(self)( @@ -4746,12 +4760,16 @@ def inverse(self): def asMatrix(self): return Matrix4(super(Quaternion, self).asMatrix()) + def isEquivalent(self, other, tolerance=om.MQuaternion.kTolerance): + return super(Quaternion, self).isEquivalent(other, tolerance) + if ENABLE_PEP8: as_matrix = asMatrix is_normalised = isNormalised length_squared = lengthSquared as_euler_rotation = asEulerRotation as_euler = asEulerRotation + is_equivalent = isEquivalent # Alias @@ -5756,6 +5774,7 @@ def redoit(): # These all involve calling on cmds, # which manages undo on its own. self._doKeyableAttrs() + self._doChannelBoxAttrs() self._doNiceNames() self._doLockAttrs() @@ -5790,6 +5809,7 @@ def __init__(self, # Extras self._lockAttrs = [] self._keyableAttrs = [] + self._channelBoxAttrs = [] self._niceNames = [] self._animChanges = [] @@ -5914,6 +5934,42 @@ def setKeyable(self, plug, value=True): assert isinstance(plug, Plug), "%s was not a plug" % plug self._keyableAttrs.append((plug, value)) + @record_history + def setChannelBox(self, plug, value=True): + """Make a plug appear in channel box + + Examples: + >>> with DagModifier() as mod: + ... node = mod.createNode("transform") + ... mod.setChannelBox(node["rotatePivotX"]) + ... mod.setChannelBox(node["translateX"], False) + ... + >>> node["rotatePivotX"].channelBox + True + >>> node["translateX"].channelBox + False + + # Also works with dynamic attributes + >>> with DagModifier() as mod: + ... node = mod.createNode("transform") + ... _ = mod.addAttr(node, Double("myDynamic")) + ... + >>> node["myDynamic"].channelBox + False + >>> with DagModifier() as mod: + ... mod.setChannelBox(node["myDynamic"]) + ... + >>> node["myDynamic"].channelBox + True + + """ + + if isinstance(plug, om.MPlug): + plug = Plug(Node(plug.node()), plug) + + assert isinstance(plug, Plug), "%s was not a plug" % plug + self._channelBoxAttrs.append((plug, value)) + def _doLockAttrs(self): while self._lockAttrs: plug, value = self._lockAttrs.pop(0) @@ -5930,6 +5986,14 @@ def _doKeyableAttrs(self): for el in elements: cmds.setAttr(el.path(), keyable=value) + def _doChannelBoxAttrs(self): + while self._channelBoxAttrs: + plug, value = self._channelBoxAttrs.pop(0) + elements = plug if plug.isArray or plug.isCompound else [plug] + + for el in elements: + cmds.setAttr(el.path(), channelBox=value) + def _doNiceNames(self): while self._niceNames: plug, value = self._niceNames.pop(0) @@ -6107,7 +6171,7 @@ def addAttr(self, node, attr): """ - assert isinstance(node, Node), "%s was not a cmdx.Node" + assert isinstance(node, Node), "%s was not a cmdx.Node" % str(node) if SAFE_MODE: assert _isalive(node._mobject) @@ -6664,6 +6728,7 @@ def disconnect(self, a, b=None, source=True, destination=True): connect_attr = connectAttr connect_attrs = connectAttrs set_keyable = setKeyable + set_channel_box = setChannelBox set_locked = setLocked set_nice_name = setNiceName @@ -6819,7 +6884,22 @@ def currentTime(time=None): if not isinstance(time, om.MTime): time = om.MTime(time, TimeUiUnit()) - return oma.MAnimControl.setCurrentTime(time) + cmds.currentTime(time.value) + + # For whatever reason, MAnimControl.setCurrentTime + # interferes with threading, cause of deadlocks. So + # we instead rely on the trusty old cmds.currentTime + + +def currentFrame(frame=None): + """Set or return current time as an integer""" + if frame is None: + return int(oma.MAnimControl.currentTime().value) + else: + if isinstance(time, om.MTime): + frame = int(frame.value) + + cmds.currentTime(frame) def selectedTime(): @@ -7320,6 +7400,7 @@ def setUpAxis(axis=Y): connect_attr = connectAttr obj_exists = objExists current_time = currentTime + current_frame = currentFrame min_time = minTime max_time = maxTime is_scrubbing = isScrubbing @@ -7401,6 +7482,9 @@ def curve(parent, points, degree=1, form=kOpen, mod=None): "parent must be of type cmdx.DagNode" ) + if isinstance(points, tuple): + points = list(points) + degree = min(3, max(1, degree)) if degree > 1: @@ -7867,6 +7951,19 @@ def read(self, data): return data.inputValue(self["mobject"]).asLong() +class Integer(_AbstractAttribute): + Fn = om.MFnNumericAttribute + Type = om.MFnNumericData.kInt + Default = 0 + + def read(self, data): + return data.inputValue(self["mobject"]).asLong() + + +# Alias +Int = Integer + + class Double(_AbstractAttribute): Fn = om.MFnNumericAttribute Type = om.MFnNumericData.kDouble @@ -8155,11 +8252,11 @@ class Distance4(Compound): # Support for multiple co-existing versions of apiundo. -unique_command = "cmdx_%s_command" % __version__.replace(".", "_") +unique_command = "cmdx_%s_command" % (__version__.replace(".", "_")) # This module is both a Python module and Maya plug-in. # Data is shared amongst the two through this "module" -unique_shared = "cmdx_%s_shared" % __version__.replace(".", "_") +unique_shared = "cmdx_%s_shared" % (__version__.replace(".", "_")) if unique_shared not in sys.modules: sys.modules[unique_shared] = types.ModuleType(unique_shared) @@ -8332,6 +8429,11 @@ def isUndoable(self): def initializePlugin(plugin): + if hasattr(cmds, unique_command): + # E.g. another cmdx from another vendored version + initializePlugin.installedElsewhere = True + return + # Only supports major.minor (no patch) version = ".".join(__version__.rsplit(".")[:2]) @@ -8342,6 +8444,9 @@ def initializePlugin(plugin): def uninitializePlugin(plugin): + if hasattr(initializePlugin, "installedElsewhere"): + return + om.MFnPlugin(plugin).deregisterCommand(unique_command) From e3234a6c052ca50ccf95959dbedd4fb13b682f08 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Fri, 28 Oct 2022 09:08:27 +0100 Subject: [PATCH 07/13] Fix tests --- cmdx.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmdx.py b/cmdx.py index e44dd3c..3f0650c 100644 --- a/cmdx.py +++ b/cmdx.py @@ -4612,7 +4612,7 @@ class Vector(om.MVector): """ def __mul__(self, value): - return Vector(super(Vector, self).__mul__(value)) + return super(Vector, self).__mul__(value) def __add__(self, value): if isinstance(value, (int, float)): From c43654ced726905b97f029cd9f1931d148bc29c0 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 14 Dec 2023 15:38:22 +0000 Subject: [PATCH 08/13] Added is_vector, is_compound, HashableTime --- cmdx.py | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/cmdx.py b/cmdx.py index 3f0650c..bd44eea 100644 --- a/cmdx.py +++ b/cmdx.py @@ -4249,6 +4249,8 @@ def node(self): type_class = typeClass next_available_index = nextAvailableIndex find_animated_plug = findAnimatedPlug + is_array = isArray + is_compound = isCompound class TransformationMatrix(om.MTransformationMatrix): @@ -4608,11 +4610,18 @@ class Vector(om.MVector): 0.0 >>> vec ^ Vector(0, 1, 0) # Cross product maya.api.OpenMaya.MVector(0, 0, 1) + >>> Vector(0, 1, 0) * 2 # Scale + maya.api.OpenMaya.MVector(0, 2, 0) """ def __mul__(self, value): - return super(Vector, self).__mul__(value) + if isinstance(value, om.MVector): + # Dot product + return super(Vector, self).__mul__(value) + else: + # Scaling + return Vector(super(Vector, self).__mul__(value)) def __add__(self, value): if isinstance(value, (int, float)): @@ -5569,7 +5578,7 @@ def asHex(mobj): def time(frame): assert isinstance(frame, int), "%s was not an int" % frame - return om.MTime(frame, TimeUiUnit()) + return HashableTime(om.MTime(frame, TimeUiUnit())) def frame(time): @@ -6869,6 +6878,10 @@ class HashableTime(om.MTime): def __hash__(self): return hash(self.value) + if ENABLE_PEP8: + def as_units(self, unit): + return self.asUnits(unit) + # Convenience functions def connect(a, b): @@ -6902,6 +6915,9 @@ def currentFrame(frame=None): cmds.currentTime(frame) +ticksPerSecond = om.MTime.ticksPerSecond + + def selectedTime(): """Return currently selected time range in MTime format""" from maya import mel @@ -7369,6 +7385,14 @@ def upAxis(): return Vector(0, 0, 1) +if __maya_version__ >= 2019: + isYAxisUp = om.MGlobal.isYAxisUp + isZAxisUp = om.MGlobal.isZAxisUp + + is_y_axis_up = om.MGlobal.isYAxisUp + is_z_axis_up = om.MGlobal.isZAxisUp + + def setUpAxis(axis=Y): """Set the current up-axis as Y or Z @@ -7410,6 +7434,8 @@ def setUpAxis(axis=Y): selected_time = selectedTime up_axis = upAxis set_up_axis = setUpAxis + ticks_per_second = ticksPerSecond + # Special-purpose functions @@ -8361,7 +8387,8 @@ def uninstall(): # therefore cannot be unloaded until flushed. clear() - cmds.unloadPlugin(__file__) + name = os.path.basename(__file__) + cmds.unloadPlugin(name) self.installed = False From 22f9e6b18aab72415aac97b6a9aff528e4cbd89c Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 14 Dec 2023 15:40:18 +0000 Subject: [PATCH 09/13] Include 2023 and 2024 in tests But leave 2017 to the history books --- .github/workflows/main.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1e65c83..21b7b08 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,8 +22,6 @@ jobs: matrix: include: - - maya: "2017" - pip: "2.7/get-pip.py" - maya: "2018" pip: "2.7/get-pip.py" - maya: "2019" @@ -32,6 +30,10 @@ jobs: pip: "2.7/get-pip.py" - maya: "2022" pip: "get-pip.py" + - maya: "2023" + pip: "get-pip.py" + - maya: "2024" + pip: "get-pip.py" container: mottosso/maya:${{ matrix.maya }} From 976cae8e36f5b646bcdf5323dce073c24f5e6467 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 14 Dec 2023 16:02:44 +0000 Subject: [PATCH 10/13] Repair tests for 2023 and 2024 --- README.md | 2 +- cmdx.py | 12 ++++++------ run_tests.py | 8 ++++++++ 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9b8815e..45ae8af 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

-

A fast subset of maya.cmds
For Maya 2017-2022

+

A fast subset of maya.cmds
For Maya 2018-2024


diff --git a/cmdx.py b/cmdx.py index bd44eea..2cee3d7 100644 --- a/cmdx.py +++ b/cmdx.py @@ -188,12 +188,12 @@ def timings_decorator(func): @wraps(func) def func_wrapper(*args, **kwargs): - t0 = time_.clock() + t0 = time_.perf_counter() try: return func(*args, **kwargs) finally: - t1 = time_.clock() + t1 = time_.perf_counter() duration = (t1 - t0) * 10 ** 6 # microseconds Stats.LastTiming = duration @@ -2833,10 +2833,10 @@ def clone(self, name, shortName=None, niceName=None): >>> clone = original.clone("focalClone") # Original setup is preserved - >>> clone["min"] - 2.5 - >>> clone["max"] - 100000.0 + >>> clone["min"] == original.fn().getMin() + True + >>> clone["max"] == original.fn().getMax() + True >>> cam.addAttr(clone) >>> cam["focalClone"].read() diff --git a/run_tests.py b/run_tests.py index 249edef..f4e9db4 100644 --- a/run_tests.py +++ b/run_tests.py @@ -5,6 +5,12 @@ import nose import flaky.flaky_nose_plugin as flaky +# For nose +if sys.version_info[0] == 3: + import collections + collections.Callable = collections.abc.Callable + + if __name__ == "__main__": print("Initialising Maya..") from maya import standalone, cmds @@ -28,6 +34,8 @@ "cmdx.py", ]) + cmds.colorManagementPrefs(cmEnabled=False, edit=True) + result = nose.main( argv=argv, addplugins=[flaky.FlakyPlugin()], From b71120e71dade9e0c663122a087fcbeb2b348cc4 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 14 Dec 2023 16:07:14 +0000 Subject: [PATCH 11/13] Re-repair tests for 2019 --- cmdx.py | 11 +++++++++-- run_tests.py | 6 ++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/cmdx.py b/cmdx.py index 2cee3d7..5ab6361 100644 --- a/cmdx.py +++ b/cmdx.py @@ -180,6 +180,13 @@ def function(): """ + try: + perf_counter = time_.perf_counter + + # Python 2.7 + except AttributeError: + perf_counter = time_.clock + def timings_decorator(func): if not TIMINGS: # Do not wrap the function. @@ -188,12 +195,12 @@ def timings_decorator(func): @wraps(func) def func_wrapper(*args, **kwargs): - t0 = time_.perf_counter() + t0 = perf_counter() try: return func(*args, **kwargs) finally: - t1 = time_.perf_counter() + t1 = perf_counter() duration = (t1 - t0) * 10 ** 6 # microseconds Stats.LastTiming = duration diff --git a/run_tests.py b/run_tests.py index f4e9db4..8d53284 100644 --- a/run_tests.py +++ b/run_tests.py @@ -6,8 +6,8 @@ import flaky.flaky_nose_plugin as flaky # For nose -if sys.version_info[0] == 3: - import collections +import collections +if not hasattr(collections, "Callable"): collections.Callable = collections.abc.Callable @@ -34,8 +34,6 @@ "cmdx.py", ]) - cmds.colorManagementPrefs(cmEnabled=False, edit=True) - result = nose.main( argv=argv, addplugins=[flaky.FlakyPlugin()], From ba7dbdde4ec668dbe5f49934c92172305489d57e Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 14 Dec 2023 16:12:16 +0000 Subject: [PATCH 12/13] Repair docs test --- README.md | 1 + build_livedocs.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 45ae8af..f24cacd 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ On average, `cmdx` is **140x faster** than [PyMEL](https://github.com/LumaPictur | Date | Version | Event |:---------|:----------|:---------- +| Dec 2023 | 0.6.3 | Cloning of attributes | Apr 2020 | 0.6.0 | Stable Undo/Redo, dropped support for Maya 2015-2016 | Mar 2020 | 0.5.1 | Support for Maya 2022 | Mar 2020 | 0.5.0 | Stable release diff --git a/build_livedocs.py b/build_livedocs.py index 88eae6e..aa9f699 100644 --- a/build_livedocs.py +++ b/build_livedocs.py @@ -107,6 +107,12 @@ def test_{count}_{header}(): # -*- coding: utf-8 -*- import os import sys + +# For nose +import collections +if not hasattr(collections, "Callable"): + collections.Callable = collections.abc.Callable + import nose from nose.tools import assert_raises From d789b22b93164879c67df89d8b171d26f5a281a9 Mon Sep 17 00:00:00 2001 From: Marcus Ottosson Date: Thu, 14 Dec 2023 16:19:14 +0000 Subject: [PATCH 13/13] Repair docs tests --- build_livedocs.py | 15 ++++++++++----- run_tests.py | 4 ++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/build_livedocs.py b/build_livedocs.py index aa9f699..1c17284 100644 --- a/build_livedocs.py +++ b/build_livedocs.py @@ -108,11 +108,6 @@ def test_{count}_{header}(): import os import sys -# For nose -import collections -if not hasattr(collections, "Callable"): - collections.Callable = collections.abc.Callable - import nose from nose.tools import assert_raises @@ -121,6 +116,11 @@ def test_{count}_{header}(): from maya import standalone standalone.initialize() + # For nose + if sys.version_info[0] == 3: + import collections + collections.Callable = collections.abc.Callable + from maya import cmds import cmdx @@ -133,6 +133,11 @@ def test_{count}_{header}(): ]) result = nose.main(argv=argv, exit=False) + + if os.name == "nt": + # Graceful exit, only Windows seems to like this consistently + standalone.uninitialize() + os._exit(0 if result.success else 1) """) f.write("".join(tests)) diff --git a/run_tests.py b/run_tests.py index 8d53284..09c7624 100644 --- a/run_tests.py +++ b/run_tests.py @@ -6,8 +6,8 @@ import flaky.flaky_nose_plugin as flaky # For nose -import collections -if not hasattr(collections, "Callable"): +if sys.version_info[0] == 3: + import collections collections.Callable = collections.abc.Callable