From 6940f17517351878819c6434f762f0a128d5f4bd Mon Sep 17 00:00:00 2001 From: jeffreytsai1004 Date: Mon, 24 Nov 2025 01:37:59 +0800 Subject: [PATCH] Update --- 2023/icons/ngskintools.png | Bin 0 -> 24071 bytes 2023/icons/skinapi.png | Bin 0 -> 9368 bytes 2023/scripts/rigging_tools/skin_api/README.md | 155 +++++ .../rigging_tools/skin_api/Skinning.py | 551 ++++++++++++++++++ 2023/scripts/rigging_tools/skin_api/Utils.py | 535 +++++++++++++++++ .../rigging_tools/skin_api/__init__.py | 0 .../rigging_tools/skin_api/apiVtxAttribs.py | 130 +++++ 2023/scripts/rigging_tools/skin_api/ui.py | 212 +++++++ 2023/shelves/shelf_Nexus_Rigging.mel | 82 ++- 9 files changed, 1659 insertions(+), 6 deletions(-) create mode 100644 2023/icons/ngskintools.png create mode 100644 2023/icons/skinapi.png create mode 100644 2023/scripts/rigging_tools/skin_api/README.md create mode 100644 2023/scripts/rigging_tools/skin_api/Skinning.py create mode 100644 2023/scripts/rigging_tools/skin_api/Utils.py create mode 100644 2023/scripts/rigging_tools/skin_api/__init__.py create mode 100644 2023/scripts/rigging_tools/skin_api/apiVtxAttribs.py create mode 100644 2023/scripts/rigging_tools/skin_api/ui.py diff --git a/2023/icons/ngskintools.png b/2023/icons/ngskintools.png new file mode 100644 index 0000000000000000000000000000000000000000..1665e944ef9b9f5e42e0aa4de3b6acae069f2f26 GIT binary patch literal 24071 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 literal 0 HcmV?d00001 diff --git a/2023/icons/skinapi.png b/2023/icons/skinapi.png new file mode 100644 index 0000000000000000000000000000000000000000..b2bab7029768dd3094af199db4714ae2cccf862f GIT binary patch literal 9368 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 literal 0 HcmV?d00001 diff --git a/2023/scripts/rigging_tools/skin_api/README.md b/2023/scripts/rigging_tools/skin_api/README.md new file mode 100644 index 0000000..3caf2bc --- /dev/null +++ b/2023/scripts/rigging_tools/skin_api/README.md @@ -0,0 +1,155 @@ +# Skin API + +高性能的 Maya 蒙皮权重管理工具,使用 Maya API 进行快速的权重导出、导入和操作。 + +## 功能特性 + +### 1. 权重导出 (WeightExport) +- 导出选中物体的蒙皮权重到文件 +- 保存骨骼层级信息 +- 支持多物体批量导出 +- 使用 pickle 格式高效存储 + +### 2. 权重导入 (WeightImport) +- 从文件导入蒙皮权重 +- 自动匹配场景中的物体 +- 支持选择导入或全场景导入 +- 自动重建骨骼层级 +- 支持命名空间处理 + +### 3. 解绑蒙皮 (UnbindSkin) +- 移除选中物体的蒙皮集群 +- 保留模型几何体 +- 支持批量操作 +- 安全确认对话框 + +## 使用方法 + +### 从工具架使用 + +在 Rigging 工具架上点击对应按钮: +- **Export** - 导出权重 +- **Import** - 导入权重 +- **Unbind** - 解绑蒙皮 + +### 从 Python 使用 + +```python +from rigging_tools.skin_api import ui + +# 导出权重 +ui.WeightExport() + +# 导入权重 +ui.WeightImport() + +# 解绑蒙皮 +ui.UnbindSkin() +``` + +### 高级用法 + +```python +from rigging_tools.skin_api import apiVtxAttribs + +# 创建 API 实例 +api = apiVtxAttribs.ApiVtxAttribs() + +# 导出选中物体的权重 +msg = api.exportSkinWeights(selected=True, saveJointInfo=True) +print(msg) + +# 导入权重到选中物体 +msg = api.importSkinWeights(selected=True, stripJointNamespaces=False, addNewToHierarchy=True) +print(msg) + +# 导出所有场景物体的权重 +msg = api.exportSkinWeights(filePath="D:/weights.skinWeights", selected=False) + +# 导入权重到所有匹配的场景物体 +msg = api.importSkinWeights(filePath="D:/weights.skinWeights", selected=False) +``` + +### 底层 API 使用 + +```python +from rigging_tools.skin_api import Skinning, Utils + +# 获取蒙皮集群信息 +skinInfo = Skinning.getSkinClusterInfo("pSphere1", saveJointInfo=True) + +# 构建权重字典 +weightDict = Skinning.buildSkinWeightsDict(["pSphere1", "pCube1"]) + +# 保存权重到文件 +Utils.pickleDumpWeightsToFile(weightDict, "D:/weights.skinWeights") + +# 从文件加载权重 +loadedWeights = Utils.getPickleObject("D:/weights.skinWeights") + +# 应用权重到物体 +Skinning.skinClusterBuilder("pSphere1", skinInfo, stripJointNamespaces=False) +``` + +## 模块结构 + +- **Skinning.py** - 核心蒙皮权重操作函数 + - `getSkinClusterInfo()` - 获取蒙皮集群信息 + - `getSkinClusterWeights()` - 获取权重数据 + - `setSkinWeights()` - 设置权重数据 + - `buildSkinWeightsDict()` - 构建权重字典 + - `skinClusterBuilder()` - 重建蒙皮集群 + - `transferSkinWeights()` - 传递权重 + +- **Utils.py** - 工具函数 + - `filePathPrompt()` - 文件对话框 + - `pickleDumpWeightsToFile()` - 保存权重文件 + - `getPickleObject()` - 加载权重文件 + - `matchDictionaryToSceneMeshes()` - 匹配场景物体 + - `getBarycentricWeights()` - 计算重心坐标权重 + +- **ui.py** - 用户界面函数 + - `WeightExport()` - 导出权重 UI + - `WeightImport()` - 导入权重 UI + - `UnbindSkin()` - 解绑蒙皮 UI + +- **apiVtxAttribs.py** - 顶点属性 API 操作 + +## 文件格式 + +权重文件使用 `.skinWeights` 扩展名,内部为 pickle 格式的 Python 字典: + +```python +{ + "objectName": { + "vtxCount": 482, + "skinCluster": { + "clusterInfluenceNames": ["joint1", "joint2", ...], + "clusterMaxInf": 4, + "clusterWeights": {...}, + "skinJointInformation": {...} + } + } +} +``` + +## 性能优化 + +- 使用 Maya OpenMaya API 进行快速权重读写 +- 批量操作减少 Maya 命令调用 +- 进度条显示长时间操作 +- 支持大规模模型(10万+顶点) + +## 注意事项 + +1. 导出前确保物体已绑定蒙皮 +2. 导入时会删除物体历史记录 +3. 骨骼名称需要匹配(支持命名空间处理) +4. 顶点数量需要匹配 +5. 解绑操作不可撤销,建议先保存场景 + +## 依赖 + +- PyMEL +- Maya OpenMaya API +- Maya OpenMayaAnim API diff --git a/2023/scripts/rigging_tools/skin_api/Skinning.py b/2023/scripts/rigging_tools/skin_api/Skinning.py new file mode 100644 index 0000000..43d523b --- /dev/null +++ b/2023/scripts/rigging_tools/skin_api/Skinning.py @@ -0,0 +1,551 @@ +try: + import pymel.core as pm +except ImportError: + pm = None + +import maya.cmds as cmds +import maya.OpenMaya as OpenMaya +import maya.OpenMayaAnim as OpenMayaAnim +import traceback +import time +import copy + +try: + import skin_api.Utils as apiUtils +except ImportError: + from . import Utils as apiUtils + + +def getMfNSkinCluster(clusterName): + ''' + Helper function to generate an MFnSkinCluster object + :param clusterName: + :return: + ''' + try: + # get the MFnSkinCluster for clusterName + selList = OpenMaya.MSelectionList() + selList.add(clusterName) + clusterNode = OpenMaya.MObject() + selList.getDependNode(0, clusterNode) + skinFn = OpenMayaAnim.MFnSkinCluster(clusterNode) + return skinFn + except: + raise "Could not find an MFnSkinCluster named '%s'" % clusterName + + + +def getInfluenceNames(skincluster): + ''' + Returns a list of names of influences (joints) in a skincluster + :param skincluster: or + :return: + ''' + if pm: + return [str(inf) for inf in pm.skinCluster(skincluster, influence=True, q=True)] + else: + return [str(inf) for inf in cmds.skinCluster(skincluster, influence=True, q=True)] + + + +def getMaxInfluences(skincluster): + ''' + Returns the maxInfluences for a skincluster as an int + :param skincluster: or + :return: + ''' + if pm: + return pm.getAttr(skincluster + ".maxInfluences") + else: + return cmds.getAttr(skincluster + ".maxInfluences") + + + +def getSkinClusterNode(node): + ''' + Gets the connected skincluster node for a given node + :param objectName: + :return: + ''' + + if pm: + objHistory = pm.listHistory(pm.PyNode(node)) + skinClusterList = pm.ls(objHistory, type="skinCluster") + else: + # cmds 版本 + objHistory = cmds.listHistory(node) + if objHistory: + skinClusterList = [h for h in objHistory if cmds.nodeType(h) == "skinCluster"] + else: + skinClusterList = [] + + if len(skinClusterList): + return str(skinClusterList[0]) + + return None + + + +def getSkinClusterInfo(objectName, saveJointInfo=False): + ''' + Builds a skincluster info dict. Structured as: + {"ObjectName": {skinCluster:{clusterWeights: {}, clusterInflNames: [], clusterMaxInf: int} + :param objectName: or + :param saveJointInfo: saves joint orient, world transform and parent joint info, default is False + :return: + ''' + + skinClustName = getSkinClusterNode(objectName) + + if skinClustName: + skinClustInfoDict = {} + skinClustInfoDict["clusterInfluenceNames"] = getInfluenceNames(skinClustName) + skinClustInfoDict["clusterMaxInf"] = getMaxInfluences(skinClustName) + skinClustInfoDict["clusterWeights"] = getSkinClusterWeights(skinClustName) + if saveJointInfo: + skinClustInfoDict["skinJointInformation"] = getSkinJointInformation(skinClustInfoDict["clusterInfluenceNames"]) + return skinClustInfoDict + + else: + print(objectName + " is not connected to a skinCluster") + return False + +def getSkinJointInformation(influences): + jointInformation = {} + + for inf in influences: + jointInfo = {} + if pm: + infNode = pm.PyNode(inf) + jointInfo["parent"] = str(infNode.getParent().name()) + jointInfo["matrix"] = infNode.getMatrix(worldSpace=True) + jointInfo["rotation"] = infNode.getRotation() + jointInfo["jointOrient"] = infNode.getAttr("jointOrient") + jointInformation[str(infNode)] = copy.deepcopy(jointInfo) + else: + # cmds 版本 + infName = str(inf) + parents = cmds.listRelatives(infName, parent=True) + jointInfo["parent"] = parents[0] if parents else "" + jointInfo["matrix"] = cmds.xform(infName, q=True, matrix=True, worldSpace=True) + jointInfo["rotation"] = cmds.xform(infName, q=True, rotation=True) + jointInfo["jointOrient"] = cmds.getAttr(infName + ".jointOrient")[0] + jointInformation[infName] = copy.deepcopy(jointInfo) + return jointInformation + +def getMPlugObjects(MFnSkinCluster): + ''' + Gets the plug objects for a given MFnSkinCluster object + Weights are stored in objects like this: + skinCluster1.weightList[vtxID].weights[influenceId] = floatvalue + :param MFnSkinCluster: + :return: + ''' + + weightListPlug = MFnSkinCluster.findPlug('weightList') + weightsPlug = MFnSkinCluster.findPlug('weights') + weightListAttribute = weightListPlug.attribute() + weightsAttribute = weightsPlug.attribute() + + return weightListPlug, weightsPlug, weightListAttribute, weightsAttribute + + + +def getSkinClusterWeights(skinCluster): + ''' + Gets the clusterweights for a given skincluster node + Reads them by using the MPlug attribute from OpenMaya + :param skinCluster: + :return: + ''' + + try: + # get the MFnSkinCluster for clusterName + skinFn = getMfNSkinCluster(str(skinCluster)) + + # get the MDagPath for all influence + infDagArray = OpenMaya.MDagPathArray() + skinFn.influenceObjects(infDagArray) + + # create a dictionary whose key is the MPlug indice id and + # whose value is the influence list id + infIds = {} + + for i in range(infDagArray.length()): + infId = int(skinFn.indexForInfluenceObject(infDagArray[i])) + infIds[infId] = i + + # get the MPlug for the weightList and weights attributes + wlPlug, wPlug, wlAttr, wAttr = getMPlugObjects(skinFn) + + wInfIds = OpenMaya.MIntArray() + + # the weights are stored in dictionary, the key is the vertId, + # the value is another dictionary whose key is the influence id and + # value is the weight for that influence + weights = {} + for vId in range(wlPlug.numElements()): + vWeights = {} + + # tell the weights attribute which vertex id it represents + wPlug.selectAncestorLogicalIndex(vId, wlAttr) + + # get the indice of all non-zero weights for this vert + wPlug.getExistingArrayAttributeIndices(wInfIds) + + # create a copy of the current wPlug + infPlug = OpenMaya.MPlug(wPlug) + for infId in wInfIds: + # tell the infPlug it represents the current influence id + infPlug.selectAncestorLogicalIndex(infId, wAttr) + + # add this influence and its weight to this verts weights + vWeights[infIds[infId]] = infPlug.asDouble() + + weights[vId] = vWeights + return weights + except: + print(traceback.format_exc()) + print("Unable to query skincluster, influence order have changed on the skincluster.\n Please re-initialize the skincluster with a clean influence index order") + return False + + + +def setMaxInfluencesDialog(): + ''' + Uses the setMaxInfluencesEngine and allows user to give input max value in a promptbox + :return: success + ''' + + nodeList = pm.ls(sl=True) + + if len(nodeList): + maxValueWin = pm.promptDialog(title="API Max Influence", message='Set Max influence Value', button=['OK']) + + try: + maxInfValue = int(pm.promptDialog(q=True, tx=True)) + except: + return "Unable to fetch an integer input from the dialog" + + if maxValueWin == "OK": + return setMaxInfluences(maxInfValue, nodeList) + + else: + return "Nothing selected" + + + +def setMaxInfluences(maxInfValue, nodeList=[]): + ''' + Reads the skin weights for nodes given as list. + Then renormalizes and prunes the influence weights down to value specified by maxInfValues + :param maxInfValue: + :param nodeList: + :return: + ''' + + if maxInfValue > 0: + if not len(nodeList): + nodeList = apiUtils.getTransforms(pm.ls(os=True)) + if len(nodeList): + if None in apiUtils.getShapes(nodeList): + print("Non-mesh objects found in selection") + return False + else: + print("Nothing selected") + return False + + for node in nodeList: + skinClusterNode = getSkinClusterNode(node) + if skinClusterNode: + clusterWeights = getSkinClusterWeights(skinClusterNode) + # build a new cluster weight list + newClusterWeights = {} + for vtxId, weights in clusterWeights.items(): + sizeSortedDict = {} + for x in range(maxInfValue): + lastResult = 0.0 + lastKey = -1 + for key, value in weights.items(): + if value >= lastResult and key not in sizeSortedDict.keys(): + lastResult = value + lastKey = key + if lastKey >= 0: + sizeSortedDict[lastKey] = lastResult + + # normalize values to a total of 1 and remove invalid key values from dictionary + maxVal = sum(sizeSortedDict.values()) + for key in sizeSortedDict.keys(): + sizeSortedDict[key] = sizeSortedDict[key] / maxVal + + # replace old vtxId info with the new normalized and re-sized influence dict + newClusterWeights[vtxId] = sizeSortedDict + + # turn of normalization to nuke weights to 0, this is to get a true 1->1 application of old weights + pm.setAttr('%s.normalizeWeights' % skinClusterNode, 0) + pm.skinPercent(skinClusterNode, node, nrm=False, prw=100) + pm.setAttr('%s.normalizeWeights' % skinClusterNode, 1) + + # set new skinweights + setSkinWeights(skinClusterNode, newClusterWeights) + + pm.setAttr(skinClusterNode + ".maxInfluences", maxInfValue) + pm.skinCluster(skinClusterNode, e=True, fnw=True) + + print("Max Influences set for '%s'" % node) + else: + print("'%s' is not connected to a skinCluster" % node) + else: + return "Max Influences has to be a positive integer" + + + +def setSkinWeights(skinClusterNode, clusterWeightDict, vtxIdFilter=[]): + ''' + Sets the weight for a given skinCluster using a given weightDict in the format given by .getSkinClusterWeights() + :param skinClusterNode: + :param weightDict: + :param vtxIdFilter: List of objects to gather weight info from + :param showLoadingBar: default is True + :param saveJointInfo: saves joint orient, world transform and parent joint info, default is False + :return: dictionary for skincluster + ''' + loadBarMaxVal = len(objectList) + loadBarObj = apiUtils.LoadingBar() + + sourceWeightDict = {} + for object in objectList: + if pm: + objectAsString = pm.PyNode(object).name() + else: + # cmds 版本 - object 已经是字符串 + objectAsString = str(object) + + if showLoadingBar: + loadBarObj.loadingBar("Building Skinweights Info...", loadBarMaxVal, "Building...") + + skinClusterInfo = getSkinClusterInfo(object, saveJointInfo=saveJointInfo) + + if skinClusterInfo: + sourceWeightDict[objectAsString] = {} + sourceWeightDict[objectAsString]["vtxCount"] = apiUtils.getVtxCount(object) + sourceWeightDict[objectAsString]["skinCluster"] = skinClusterInfo + + if bool(sourceWeightDict): + return sourceWeightDict + else: + return False + + + +def transferSkinWeights(transferNodes=None, showLoadingBar=True): + ''' + transfers skin weights between a given set of meshes by: + Getting the source skincluster info from the first mesh in the transfer objects list + Calculating the barycentric relationship with all consecutive meshes + Zipping the source and target mesh clusterweight dictionaries + Importing the resulting skin cluster weight dict to the skinclusterbuilder + + If no transferObjects are provided the function will attempt to fetch a selection from the scene to operate on + :param transferNodes: A list of transfer objects + :return: + ''' + + if transferNodes is None: + transferNodes = apiUtils.handleTransferNodesList(transferNodes) + # buffer and re-assign + + success = False + if len(transferNodes): + + sourceObj = transferNodes[0] + sourceName = sourceObj.name() + targetNameList = transferNodes[1:] + + loadBarMaxVal = len(targetNameList) + loadBarObj = apiUtils.LoadingBar() + + # initialize self.sourceWeightDict which is populated by both functions + sourceWeightDict = buildSkinWeightsDict([sourceName], showLoadingBar) + + if sourceWeightDict: + + for tgtObject in targetNameList: + + # deep copy because: Mutable datatypes + sourceWeightDictCopy = copy.deepcopy(sourceWeightDict) + + targetName = str(tgtObject.name()) + + barycentrWeightDict = apiUtils.getBarycentricWeights(sourceName, targetName) + + # initialize transferWeightDict + transferWeightDict = {} + # clone the sourceweight skincluster information + transferWeightDict[targetName] = sourceWeightDictCopy[sourceName] + # swap the clusterweights for the zipped result from zipClusterWeights + transferWeightDict[targetName]["skinCluster"]["clusterWeights"] = apiUtils.zipClusterWeights( + barycentrWeightDict, sourceWeightDictCopy[sourceName]["skinCluster"]["clusterWeights"]) + + skinClusterBuilder(targetName, transferWeightDict) + maxInfVal = transferWeightDict[targetName]["skinCluster"]["clusterMaxInf"] + setMaxInfluences(maxInfVal, [targetName]) + + #progress loadingBar + if showLoadingBar: + loadBarObj.loadingBar("Transferring Skinweights...", loadBarMaxVal, "Transferring...") + + success = True + + if success: + pm.select(transferNodes, r=True) + + +def skinClusterBuilder(objName, weightDict, deleteHist=True, stripJointNamespaces=False, addNewToHierarchy=False): + ''' + Builds a skin cluster for a given object and a given weight dictionary + By default the function deletes the history on the target object to create a "clean start" + :param objName: + :param weightDict: + :param deleteHist: + :param stripJointNamespaces: strips joint namespaces on skinWeights file, default is False + :param addNewToHierarchy: adds missing joints, world transform and parent only correct when exported with joint info, default is False + :return: + ''' + startBuildTimer = time.time() + + # clear any messy history on the validObjects + if deleteHist: + if pm: + pm.select(objName, r=True) + pm.mel.eval("DeleteHistory;") + pm.select(clear=True) + else: + cmds.select(objName, r=True) + cmds.delete(objName, constructionHistory=True) + cmds.select(clear=True) + + clusterInfo = weightDict[objName]["skinCluster"] + + # lclusterInfo has 4 attribute keys : + # ['clusterInfluenceNames', 'clusterMaxInf', 'clusterVtxCount', 'clusterWeights'] + clusterJoints = clusterInfo["clusterInfluenceNames"] + clusterMaxInf = clusterInfo["clusterMaxInf"] + clusterJointInfo = clusterInfo.get("skinJointInformation") + + for jointNameOrig in clusterJoints: + jointName = jointNameOrig + if stripJointNamespaces: + jointName = jointNameOrig.split(":")[-1] + + objExists = pm.objExists(jointName) if pm else cmds.objExists(jointName) + if not objExists: + if pm: + pm.select(cl=True) + joint = pm.joint(p=(0, 0, 0), name=jointName) + # putting joint in the hierarchy and setting matrix + if addNewToHierarchy and clusterJointInfo: + jointInfo = clusterJointInfo.get(jointNameOrig) + if not jointInfo: + continue + parentJoint = jointInfo.get("parent") + if stripJointNamespaces: + parentJoint = parentJoint.split(":")[-1] + if pm.objExists(parentJoint): + parentJoint = pm.PyNode(parentJoint) + joint.setParent(parentJoint) + joint.setMatrix(jointInfo.get("matrix"), worldSpace=True) + joint.setAttr("jointOrient", jointInfo.get("jointOrient")) + joint.setRotation(jointInfo.get("rotation")) + else: + joint.setMatrix(jointInfo.get("matrix"), worldSpace=True) + pm.select(cl=True) + else: + # cmds 版本 + cmds.select(clear=True) + joint = cmds.joint(position=(0, 0, 0), name=jointName) + # putting joint in the hierarchy and setting matrix + if addNewToHierarchy and clusterJointInfo: + jointInfo = clusterJointInfo.get(jointNameOrig) + if not jointInfo: + continue + parentJoint = jointInfo.get("parent") + if stripJointNamespaces: + parentJoint = parentJoint.split(":")[-1] + if cmds.objExists(parentJoint): + cmds.parent(joint, parentJoint) + cmds.xform(joint, matrix=jointInfo.get("matrix"), worldSpace=True) + cmds.setAttr(joint + ".jointOrient", *jointInfo.get("jointOrient")) + cmds.xform(joint, rotation=jointInfo.get("rotation")) + else: + cmds.xform(joint, matrix=jointInfo.get("matrix"), worldSpace=True) + cmds.select(clear=True) + try: + if stripJointNamespaces: + clusterJoints = [a.split(":")[-1] for a in clusterJoints] + + if pm: + clusterNode = pm.skinCluster(clusterJoints, objName, tsb=True, mi=clusterMaxInf, omi=True) + # turn of normalization to nuke weights to 0, this is to get a true 1->1 application of old weights + pm.setAttr('%s.normalizeWeights' % clusterNode, 0) + pm.skinPercent(clusterNode, objName, nrm=False, prw=100) + pm.setAttr('%s.normalizeWeights' % clusterNode, 1) + clusterNodeName = str(clusterNode) + else: + # cmds 版本 + clusterNode = cmds.skinCluster(clusterJoints, objName, tsb=True, mi=clusterMaxInf, omi=True)[0] + # turn of normalization to nuke weights to 0, this is to get a true 1->1 application of old weights + cmds.setAttr('%s.normalizeWeights' % clusterNode, 0) + cmds.skinPercent(clusterNode, objName, normalize=False, pruneWeights=100) + cmds.setAttr('%s.normalizeWeights' % clusterNode, 1) + clusterNodeName = clusterNode + + # set the skinweights + setSkinWeights(clusterNodeName, clusterInfo["clusterWeights"]) + + + except RuntimeError: + print("Failed to build new skinCluster on %s" % objName) + except: + print(traceback.format_exc()) + + endBuildTimer = time.time() + print("Skin Cluster Built %s : " % objName + (str(endBuildTimer - startBuildTimer))) + + + diff --git a/2023/scripts/rigging_tools/skin_api/Utils.py b/2023/scripts/rigging_tools/skin_api/Utils.py new file mode 100644 index 0000000..2494ce0 --- /dev/null +++ b/2023/scripts/rigging_tools/skin_api/Utils.py @@ -0,0 +1,535 @@ +try: + import pymel.core as pm +except ImportError: + pm = None + +import maya.cmds as cmds +import maya.OpenMaya as OpenMaya +import traceback +import pickle +# import cPickle as pickle + +# import atcore.atvc.atvc as atvc +# import atcore.atmaya.utils as utils + +# # init versionControl object +# vc = atvc.VersionControl() + + +def getShape(node, intermediate=False): + ''' + Gets the shape of a given node + Returns None if unable to find shape + Returns shape if found + :param node: + :param intermediate: + :return: + ''' + + if pm: + if pm.nodeType(node) == "transform": + shapeNodes = pm.listRelatives(node, shapes=True, path=True) + + if not shapeNodes: + shapeNodes = [] + + for shapeNode in shapeNodes: + isIntermediate = pm.getAttr("%s.intermediateObject" % shapeNode) + + if intermediate and isIntermediate and pm.listConnections(shapeNode, source=False): + return shapeNode + + elif not intermediate and not isIntermediate: + return shapeNode + + if shapeNodes: + return shapeNodes[0] + + elif pm.nodeType(node) in ["mesh", "nurbsCurve", "nurbsSurface"]: + return pm.PyNode(node) + else: + # cmds 版本 + nodeType = cmds.nodeType(node) + if nodeType == "transform": + shapeNodes = cmds.listRelatives(node, shapes=True, path=True) + + if not shapeNodes: + shapeNodes = [] + + for shapeNode in shapeNodes: + isIntermediate = cmds.getAttr("%s.intermediateObject" % shapeNode) + + if intermediate and isIntermediate and cmds.listConnections(shapeNode, source=False): + return shapeNode + + elif not intermediate and not isIntermediate: + return shapeNode + + if shapeNodes: + return shapeNodes[0] + + elif nodeType in ["mesh", "nurbsCurve", "nurbsSurface"]: + return node + + return None + + +def getShapes(nodeList): + ''' + Runs getShape on a list of nodes + :param nodeList: + :return: + ''' + + shapeList = [] + + for node in nodeList: + shapeNode = getShape(node) + if shapeNode: + shapeList.append(shapeNode) + + return shapeList + + +def getTransform(node): + ''' + Gets the Transform node if a given node + Returns None if no Transform was found + Returns Transform object if found + :param node: + :return: + ''' + + if pm: + node = pm.PyNode(node) + if node.type() == 'transform': + lhistory = node.listHistory(type='mesh') + if len(lhistory) > 0: + return node + elif node.type() == "mesh": + transNode = node.getParent() + if transNode: + return transNode + else: + # cmds 版本 + nodeType = cmds.nodeType(node) + if nodeType == 'transform': + # 检查是否有 mesh shape + shapes = cmds.listRelatives(node, shapes=True, type='mesh') + if shapes: + return node + elif nodeType == "mesh": + parents = cmds.listRelatives(node, parent=True) + if parents: + return parents[0] + return None + + +def getTransforms(nodeList): + ''' + Gets the Transform nodes for all nodes in a given list + returns list of nodes + :param nodeList: + :return: + ''' + + transList = [] + + for node in nodeList: + transNode = getTransform(node) + if transNode: + transList.append(transNode) + + return transList + +def getMDagPath(nodeName): + ''' + Helper function to create an MDagPath for a given object name + :param nodeName: name of node + :return: + ''' + + selectionList = OpenMaya.MSelectionList() + selectionList.add(str(nodeName)) + objDagPath = OpenMaya.MDagPath() + selectionList.getDagPath(0, objDagPath) + return objDagPath + + +def getBarycentricWeights(srcMesh, tgtMesh, flipX=False): + ''' + Builds a barycentric weight dictionary for a given source and target mesh + Iterates over each vtx in the target mesh finding the relation to the closest point and triangle on the source mesh + :param srcMesh: or + :param tgtMesh: or + :param flipX: flip the source mesh in X before calculating weights + :return: + ''' + baryWeightDict = {} + + if pm: + if pm.nodeType(srcMesh) != "mesh" and pm.nodeType(tgtMesh) != "mesh": + srcMesh = getShape(srcMesh) + tgtMesh = getShape(tgtMesh) + else: + if cmds.nodeType(srcMesh) != "mesh" and cmds.nodeType(tgtMesh) != "mesh": + srcMesh = getShape(srcMesh) + tgtMesh = getShape(tgtMesh) + + try: + srcMeshDagPath = getMDagPath(srcMesh) + tgtMeshDagPath = getMDagPath(tgtMesh) + except: + print(traceback.format_exc()) + return + + # create mesh iterator + comp = OpenMaya.MObject() + currentFace = OpenMaya.MItMeshPolygon(srcMeshDagPath, comp) + + # get all points from target mesh + meshTgtMPointArray = OpenMaya.MPointArray() + meshTgtMFnMesh = OpenMaya.MFnMesh(tgtMeshDagPath) + meshTgtMFnMesh.getPoints(meshTgtMPointArray, OpenMaya.MSpace.kWorld) + + # create mesh intersector + matrix = srcMeshDagPath.inclusiveMatrix() + node = srcMeshDagPath.node() + intersector = OpenMaya.MMeshIntersector() + intersector.create(node, matrix) + + # create variables to store the returned data + pointInfo = OpenMaya.MPointOnMesh() + uUtil, vUtil = OpenMaya.MScriptUtil(0.0), OpenMaya.MScriptUtil(0.0) + uPtr = uUtil.asFloatPtr() + vPtr = vUtil.asFloatPtr() + pointArray = OpenMaya.MPointArray() + vertIdList = OpenMaya.MIntArray() + + # dummy variable needed in .setIndex() + dummy = OpenMaya.MScriptUtil() + dummyIntPtr = dummy.asIntPtr() + + # For each point on the target mesh + # Find the closest triangle on the source mesh. + # Get the vertIds and the barycentric coords. + + # + if flipX: + meshTgtMPointArray = flipMPointArrayInX(meshTgtMPointArray) + + for i in range(meshTgtMPointArray.length()): + + intersector.getClosestPoint(meshTgtMPointArray[i], pointInfo) + pointInfo.getBarycentricCoords(uPtr, vPtr) + u = uUtil.getFloat(uPtr) + v = vUtil.getFloat(vPtr) + + faceId = pointInfo.faceIndex() + triangleId = pointInfo.triangleIndex() + + currentFace.setIndex(faceId, dummyIntPtr) + currentFace.getTriangle(triangleId, pointArray, vertIdList, OpenMaya.MSpace.kWorld) + + weightDict = {} + weightDict[vertIdList[0]] = u + weightDict[vertIdList[1]] = v + weightDict[vertIdList[2]] = 1 - u - v + + baryWeightDict[i] = weightDict + + return baryWeightDict + + +def flipMPointArrayInX(MPointArray): + ''' + Flips an MPoint array in X + :param MPointArray: + :return: + ''' + + flippeMPointArray = OpenMaya.MPointArray() + + for i in range(MPointArray.length()): + pX = MPointArray[i][0] * -1 + pY = MPointArray[i][1] + pZ = MPointArray[i][2] + flippeMPointArray.append(OpenMaya.MPoint(pX, pY, pZ)) + + return flippeMPointArray + + +def findVertsOnSidesOfX(MPointArray): + ''' + Bins an MPointArray into two lists of verticeIndexes. + Points living within -0.0001 to 0.0001 on the X axis are discarded. + :param MPointArray: + :param positiveSide: + :return: positiveVtxs, negativeVtxs + ''' + + positiveVtxs = [] + negativeVtxs = [] + + for i in range(MPointArray.length()): + pX = MPointArray[i][0] + + # not equal to 0 as we are not interested in vertices in the "perfect middle" + if pX > 0.0001: + positiveVtxs.append(i) + elif pX < -0.0001: + negativeVtxs.append(i) + + return positiveVtxs, negativeVtxs + + +def getMPointArray(MDagPath): + ''' + Gets an MPointArray for all points in a mesh. + MPoint is usable like a python list in the structure + MPoint[index][x-pos, y-pos, z-pos] + :param MDagPath: + :return: + ''' + + MPointArray = OpenMaya.MPointArray() + meshTgtMFnMesh = OpenMaya.MFnMesh(MDagPath) + meshTgtMFnMesh.getPoints(MPointArray, OpenMaya.MSpace.kWorld) + + return MPointArray + + +def zipClusterWeights(baryWeightDict, clusterWeightDict): + ''' + Zip a barycentric weight dictionary with a source cluster weight dictionary. + By iterating over each target vertice in the baryWeightDict, build resulting weights, store in zippedCluster + :param baryWeightDict: weight dict structured by the Utils modules .getBarycentricWeights() + :param srcWeightDict: clusterWeight dict structured by eg. the Skinning module's .getSkinWeights() + :return: of final cluster weights for the transfer + ''' + + zippedCluster = {} + + for tgtVtxId in baryWeightDict.keys(): + zippedCluster[tgtVtxId] = {} + + for srcVtxId, baryWeight in baryWeightDict[tgtVtxId].items(): + + for infVtxId, infWeight in clusterWeightDict[srcVtxId].items(): + zippedWeight = infWeight * baryWeight + + # if influence already exists, add them together + if infVtxId in zippedCluster[tgtVtxId].keys(): + zippedWeight += zippedCluster[tgtVtxId][infVtxId] + + zippedCluster[tgtVtxId][infVtxId] = zippedWeight + + return zippedCluster + + +def getVtxCount(shapeNode): + ''' + Gets the vertex count for a given shape node + :param shapeNode: + :return: vertex count + ''' + # lazy buffer if object is passed + shapeNode = getShape(shapeNode) + if pm: + return len(shapeNode.vtx) + else: + # cmds 版本 + vtxCount = cmds.polyEvaluate(shapeNode, vertex=True) + return vtxCount if vtxCount else 0 + + +def filePathPrompt(dialogMode, caption="SkinWeights", dirPath="D:/", filter="API Skin weights file (*.skinWeights)"): + ''' + Generates a maya file dialogue window and returns a filepath + :param fileMode: 0 is export path, 1 is save path + :param caption: title of dialog + :param dirPath: starting directory + :param filter: file type filter in format "(*.fileType)" + :return: file path + ''' + + if pm: + filePath = pm.system.fileDialog2(fileMode=dialogMode, + caption=caption, + dir=dirPath, + fileFilter=filter) + else: + filePath = cmds.fileDialog2(fileMode=dialogMode, + caption=caption, + dir=dirPath, + fileFilter=filter) + if filePath: + # fileDialog2 returns a list if successful so we fetch the first element + filePath = filePath[0] + + return filePath + + +def matchDictionaryToSceneMeshes(weightDictionary, selected=False): + ''' + Matches names of the dictionary.keys() with meshes in the scene. + Matches ALL objects, then filters based on user selection + :param weightDictionary: weightDictionary generated by the apiVtxAttribs module + :param selected: operate on selection + :return: , reformatted weight dictionary, found matching objects in scene + ''' + validNodeList = [] + sceneWeightDict = {} + # find all matching or valid objects in the scene + for dictNodeName in weightDictionary.keys(): + if pm: + sceneNodeList = pm.ls(dictNodeName, recursive=True, type="transform") + else: + sceneNodeList = cmds.ls(dictNodeName, recursive=True, type="transform") + + if not len(sceneNodeList): + # we have not found matches by the stored name, try short names + shortDictNodeName = dictNodeName.split("|")[-1] + if pm: + sceneNodeList = pm.ls(shortDictNodeName, recursive=True, type="transform") + else: + sceneNodeList = cmds.ls(shortDictNodeName, recursive=True, type="transform") + print(sceneNodeList) + for sceneNode in sceneNodeList: + if vtxCountMatch(sceneNode, weightDictionary[dictNodeName]["vtxCount"]): + # found match on both name and vtxcount, copy info to the local sceneWeightDict + if pm: + sceneWeightDict[sceneNode.name()] = weightDictionary[dictNodeName] + validNodeList.append(sceneNode.name()) + else: + sceneWeightDict[sceneNode] = weightDictionary[dictNodeName] + validNodeList.append(sceneNode) + + # filter on selection + if selected: + selectionMatchedList = [] + if pm: + selectedNodes = getTransforms(pm.ls(sl=True)) + else: + selectedNodes = getTransforms(cmds.ls(sl=True)) + for selectedNode in selectedNodes: + if selectedNode in validNodeList: + selectionMatchedList.append(str(selectedNode)) + + # replace validNodeList with selection match + validNodeList = selectionMatchedList + + if sceneWeightDict and validNodeList: + return sceneWeightDict, validNodeList + else: + return {}, [] + + +def vtxCountMatch(node, vtxCount): + ''' + Queries if the vtx count of the given node and the given vtxCount matches + :param node: or + :param vtxCount: + :return: + ''' + node = getShape(node) + try: + if pm: + nodeVtxCount = len(node.vtx) + else: + nodeVtxCount = cmds.polyEvaluate(node, vertex=True) + if nodeVtxCount == vtxCount: + return True + return False + except: + print("Unable to retrieve .vtx list from %s" % node) + return False + + +def getPickleObject(filePath): + ''' + Unpacks a the pickled weights files into a human readable weight dictionary + :param filePath: + :return: + ''' + with open(filePath, 'rb') as fp: + try: + pickleObject = pickle.Unpickler(fp) + return pickleObject.load() + except: + raise Exception("Unable to unpack %s" % filePath) + + +def pickleDumpWeightsToFile(weightDict, filePath): + ''' + Export a weights file and handle versioncontrol + :param weightDict: Dictionary of clusterweights + :param filePath: + :return: + ''' + + # if vc.checkout([filePath]): + # with open(filePath, 'wb') as fp: + # pickle.dump(weightDict, fp, pickle.HIGHEST_PROTOCOL) + # vc.add([filePath]) + # else: + # with open(filePath, 'wb') as fp: + # pickle.dump(weightDict, fp, pickle.HIGHEST_PROTOCOL) + # print "WARNING: Successfully exported weights but was unable to connect to perforce" + + with open(filePath, 'wb') as fp: + pickle.dump(weightDict, fp, pickle.HIGHEST_PROTOCOL) + + +def handleTransferNodesList(transferNodes=None): + ''' + Handle empty transferNodeslist in transfer functions + Basically fetches the maya scene selection to the transfernodeslist if nothing is provided + Checks if all transfer objects are valid mesh objects + :param transferNodes: + :return: + ''' + + # operate on selection + if transferNodes is None: + if pm: + transferNodes = getTransforms(pm.ls(os=True)) + else: + transferNodes = getTransforms(cmds.ls(os=True)) + + if len(transferNodes) < 2: + print("# Error: Select atleast 2 objects, a source and a target") + return [] + + if None in getShapes(transferNodes): + print("# Error: Non-mesh objects found in selection") + return [] + + return transferNodes + + +class LoadingBar(): + + def __init__(self): + self.lprogressAmount = 0 + + def loadingBar(self, ltitle, lmaxValue, lstatus="Working..."): + #if loadingBar is accessed the first time, create progressWindow + if self.lprogressAmount == 0: + if pm: + pm.progressWindow(title=ltitle, progress=self.lprogressAmount, status=lstatus, isInterruptable=False, max=lmaxValue) + else: + cmds.progressWindow(title=ltitle, progress=self.lprogressAmount, status=lstatus, isInterruptable=False, maxValue=lmaxValue) + self.lprogressAmount += 1 + #increases progress in progressWindow + if pm: + pm.progressWindow(edit=True, progress=self.lprogressAmount) + else: + cmds.progressWindow(edit=True, progress=self.lprogressAmount) + #if progressAmount is lmaxValue, finish progressWindow + if self.lprogressAmount == lmaxValue: + if pm: + pm.progressWindow(endProgress=True) + else: + cmds.progressWindow(endProgress=True) + self.progressAmount = 0 diff --git a/2023/scripts/rigging_tools/skin_api/__init__.py b/2023/scripts/rigging_tools/skin_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/2023/scripts/rigging_tools/skin_api/apiVtxAttribs.py b/2023/scripts/rigging_tools/skin_api/apiVtxAttribs.py new file mode 100644 index 0000000..5dc22b0 --- /dev/null +++ b/2023/scripts/rigging_tools/skin_api/apiVtxAttribs.py @@ -0,0 +1,130 @@ +import time +try: + import pymel.core as pm +except ImportError: + pm = None + +import maya.cmds as cmds +# import atcore.atvc.atvc as atvc +import os + +try: + import skin_api.Utils as apiUtils + import skin_api.Skinning as apiSkinning +except ImportError: + from . import Utils as apiUtils + from . import Skinning as apiSkinning + +''' +apiVtxAttribs is a "caller"-class in order to organize the simplified returns of the other modules such as EACloth or Skinning +I deem it the "garbage" collector for any general combination of the Utils, Skinning, EACloth, Havok modules that we find useful +eg a list with skinning, blendshape and paintableAttrs looks like this +{"ObjectName": {skinCluster:{clusterWeights, clusterInflNames, clusterMaxInf} + {blendShape:{clusterWeights, clusterTargets} + {paintableAttr:{clusterWeights} +''' + + +class ApiVtxAttribs(): + def __init__(self): + if pm: + self.activeScenePath = os.path.dirname(pm.sceneName()) + else: + scene_name = cmds.file(q=True, sn=True) + self.activeScenePath = os.path.dirname(scene_name) if scene_name else "" + # self.vc = atvc.VersionControl() + + self.filePath = "" + + self.sourceWeightDict = {} + self.transferWeightDict = {} + self.barycentrWeightDict = {} + + self.skinWeightsFilter = "API Skin weights file (*.skinWeights)" + + + + def exportSkinWeights(self, filePath=None, selected=False, saveJointInfo=False): + ''' + Export skinweights for meshes in maya + If no filePath is provided the function prompts a file dialogue window + If selected is False, function will operate on all valid objects (meshes) found in the active maya scene. + :param filePath: default is None + :param selected: default is False + :param saveJointInfo: saves joint orient, world transform and parent joint info, default is False + :return: + ''' + msg = "" + # get filepath for skinweightsfile + if not filePath: + filePath = apiUtils.filePathPrompt(dialogMode=0, caption= "Export Skinweights", dirPath=self.activeScenePath, filter = self.skinWeightsFilter) + + if filePath: + if selected: + if pm: + transNodes = apiUtils.getTransforms(pm.ls(sl=True)) + else: + transNodes = apiUtils.getTransforms(cmds.ls(sl=True)) + else: + if pm: + transNodes = apiUtils.getTransforms(pm.ls(type="mesh")) + else: + # cmds.ls 也使用 type 参数 + transNodes = apiUtils.getTransforms(cmds.ls(type="mesh")) + + start = time.time() + skinWeightsDict = apiSkinning.buildSkinWeightsDict(transNodes, saveJointInfo=saveJointInfo) + + apiUtils.pickleDumpWeightsToFile(skinWeightsDict, filePath) + end = time.time() + msg = "Skinning Exported to %s in: " % filePath + str(end - start) + " seconds" + + return msg + + + + def importSkinWeights(self, filePath=None, selected=False, stripJointNamespaces=False, addNewToHierarchy=False): + ''' + Import skinweights for meshes in maya. + If no filePath is provided the function prompts a file dialogue window + If selected is False, function will operate on all valid objects (meshes) found in the active maya scene. + Runs a filtering process on the given .skinWeights file, matching objects based on name AND vtx count. + :param filePath: default is None, accepts .skinWeights files + :param selected: default is False + :param stripJointNamespaces: strips joint namespaces on skinWeights file, default is False + :param addNewToHierarchy: adds missing joints, world transform and parent only correct when exported with joint info, default is False + :return: + ''' + msg = "" + # get filepath for skinweightsfile + if not filePath: + filePath = apiUtils.filePathPrompt(dialogMode= 1, + caption="Import Skinweights", + dirPath=self.activeScenePath, + filter=self.skinWeightsFilter) + if filePath: + if os.path.exists(filePath) and filePath.endswith(".skinWeights"): + + fileWeightDict = apiUtils.getPickleObject(filePath) + + if fileWeightDict: # then build a filtered local "scene" weight dictionary and a list of valid Objects + + sceneWeightDict, validNodeList = apiUtils.matchDictionaryToSceneMeshes(weightDictionary=fileWeightDict, selected=selected) + + if len(validNodeList) > 0: + loadBarMaxVal = len(validNodeList) + loadBarObj = apiUtils.LoadingBar() + + msg = "Importing skinning for : " + str(validNodeList) + for validNode in validNodeList: + loadBarObj.loadingBar("Importing Skinweights...", loadBarMaxVal, "Importing...") + apiSkinning.skinClusterBuilder(validNode, sceneWeightDict, stripJointNamespaces=stripJointNamespaces, addNewToHierarchy=addNewToHierarchy) + else: + # log.error("No valid objects found in scene!") + msg = "No valid objects found in scene!" + return False + else: + msg = "Could not find a .skinWeights file with path %s" % filePath + # return False + + return msg diff --git a/2023/scripts/rigging_tools/skin_api/ui.py b/2023/scripts/rigging_tools/skin_api/ui.py new file mode 100644 index 0000000..8c7b5a7 --- /dev/null +++ b/2023/scripts/rigging_tools/skin_api/ui.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +Skin API UI Functions +提供权重导出、导入和解绑的用户界面函数 +""" + +# 确保 Maya 环境已初始化 +try: + import maya.standalone + maya.standalone.initialize() +except: + pass + +try: + import pymel.core as pm +except ImportError: + pm = None + +import maya.cmds as cmds +import maya.mel as mel + +try: + from . import apiVtxAttribs +except ImportError: + import rigging_tools.skin_api.apiVtxAttribs as apiVtxAttribs + + +def WeightExport(): + """ + 导出选中物体的蒙皮权重 + Export skin weights for selected objects + """ + try: + # 检查是否有选中物体 + if pm: + selectedNodes = pm.ls(sl=True) + if not selectedNodes: + pm.warning("请选择至少一个蒙皮物体 / Please select at least one skinned object") + return + else: + selectedNodes = cmds.ls(sl=True) + if not selectedNodes: + cmds.warning("请选择至少一个蒙皮物体 / Please select at least one skinned object") + return + + # 使用 ApiVtxAttribs 类导出权重 + api = apiVtxAttribs.ApiVtxAttribs() + msg = api.exportSkinWeights(selected=True, saveJointInfo=True) + + if msg: + print(msg) + if pm: + pm.confirmDialog( + title="Export Success", + message="Skin weights exported successfully!", + button=["OK"] + ) + else: + cmds.confirmDialog( + title="Export Success", + message="Skin weights exported successfully!", + button=["OK"] + ) + else: + print("Export cancelled") + + except Exception as e: + error_msg = f"Failed to export skin weights: {str(e)}" + if pm: + pm.error(error_msg) + else: + cmds.error(error_msg) + + +def WeightImport(): + """ + 导入蒙皮权重到选中物体或场景中匹配的物体 + Import skin weights to selected or matching objects in scene + """ + try: + # 检查是否有选中物体 + if pm: + selectedNodes = pm.ls(sl=True) + else: + selectedNodes = cmds.ls(sl=True) + useSelection = len(selectedNodes) > 0 + + print(f"Import mode: {'selected objects' if useSelection else 'all matching objects'}") + + # 使用 ApiVtxAttribs 类导入权重 + api = apiVtxAttribs.ApiVtxAttribs() + msg = api.importSkinWeights(selected=useSelection, stripJointNamespaces=False, addNewToHierarchy=True) + + print(f"Import result: {repr(msg)}") + + if msg and msg != False: + print(msg) + if "No valid objects found" in str(msg): + warning_msg = "No valid objects found in scene!\nMake sure:\n1. Object names match\n2. Vertex counts match\n3. Objects exist in scene" + if pm: + pm.warning(warning_msg) + pm.confirmDialog( + title="Import Failed", + message=warning_msg, + button=["OK"] + ) + else: + cmds.warning(warning_msg) + cmds.confirmDialog( + title="Import Failed", + message=warning_msg, + button=["OK"] + ) + elif "Could not find" in str(msg): + if pm: + pm.warning(msg) + else: + cmds.warning(msg) + else: + # 成功导入 + if pm: + pm.confirmDialog( + title="Import Complete", + message="Skin weights imported successfully!", + button=["OK"] + ) + else: + cmds.confirmDialog( + title="Import Complete", + message="Skin weights imported successfully!", + button=["OK"] + ) + else: + print("Import cancelled or no file selected") + + except Exception as e: + import traceback + error_msg = f"Failed to import skin weights: {str(e)}\n{traceback.format_exc()}" + print(error_msg) + if pm: + pm.error(error_msg) + else: + cmds.error(error_msg) + + +def UnbindSkin(): + """ + 解绑选中物体的蒙皮 + Unbind skin from selected objects + """ + try: + # 获取选中的物体 + if pm: + selectedNodes = pm.ls(sl=True) + if not selectedNodes: + pm.warning("请选择至少一个物体 / Please select at least one object") + return + else: + selectedNodes = cmds.ls(sl=True) + if not selectedNodes: + cmds.warning("请选择至少一个物体 / Please select at least one object") + return + + # 确认对话框 + if pm: + result = pm.confirmDialog( + title="Unbind Skin", + message=f"确定要解绑 {len(selectedNodes)} 个物体的蒙皮吗?\nAre you sure you want to unbind skin from {len(selectedNodes)} object(s)?", + button=["Yes", "No"], + defaultButton="Yes", + cancelButton="No", + dismissString="No" + ) + else: + result = cmds.confirmDialog( + title="Unbind Skin", + message=f"确定要解绑 {len(selectedNodes)} 个物体的蒙皮吗?\nAre you sure you want to unbind skin from {len(selectedNodes)} object(s)?", + button=["Yes", "No"], + defaultButton="Yes", + cancelButton="No", + dismissString="No" + ) + + if result != "Yes": + print("Unbind cancelled") + return + + # 使用 MEL 命令解绑蒙皮 + mel.eval('doDetachSkin "2" { "1","1" };') + print("已取消蒙皮!/ Skin unbound!") + + if pm: + pm.confirmDialog( + title="Unbind Complete", + message="Skin unbound successfully!", + button=["OK"] + ) + else: + cmds.confirmDialog( + title="Unbind Complete", + message="Skin unbound successfully!", + button=["OK"] + ) + + except Exception as e: + error_msg = f"Failed to unbind skin: {str(e)}" + if pm: + pm.error(error_msg) + else: + cmds.error(error_msg) diff --git a/2023/shelves/shelf_Nexus_Rigging.mel b/2023/shelves/shelf_Nexus_Rigging.mel index 3015b3c..ecb72b8 100644 --- a/2023/shelves/shelf_Nexus_Rigging.mel +++ b/2023/shelves/shelf_Nexus_Rigging.mel @@ -14,27 +14,97 @@ global proc shelf_Nexus_Rigging () { -manage 1 -visible 1 -preventOverride 0 - -annotation "Nexus Test - Verify plugin system" + -annotation "Export Skin Weights - Export skin weights to file" -enableBackground 0 -backgroundColor 0 0 0 -highlightColor 0.321569 0.521569 0.65098 -align "center" - -label "Test" + -label "Export" -labelOffset 0 -rotation 0 -flipX 0 -flipY 0 -useAlpha 1 -font "plainLabelFont" - -imageOverlayLabel "Test" + -imageOverlayLabel "Export" -overlayLabelColor 0.8 0.8 0.8 -overlayLabelBackColor 0 0 0 0.5 - -image "commandButton.png" - -image1 "commandButton.png" + -image "skinapi.png" + -image1 "skinapi.png" -style "iconOnly" -marginWidth 0 -marginHeight 1 - -command "import nexus_test\nnexus_test.run_test()" + -command "from rigging_tools.skin_api import ui\nui.WeightExport()" + -sourceType "python" + -commandRepeatable 1 + -flat 1 + ; + shelfButton + -enableCommandRepeat 1 + -flexibleWidthType 3 + -flexibleWidthValue 32 + -enable 1 + -width 35 + -height 34 + -manage 1 + -visible 1 + -preventOverride 0 + -annotation "Import Skin Weights - Import skin weights from file" + -enableBackground 0 + -backgroundColor 0 0 0 + -highlightColor 0.321569 0.521569 0.65098 + -align "center" + -label "Import" + -labelOffset 0 + -rotation 0 + -flipX 0 + -flipY 0 + -useAlpha 1 + -font "plainLabelFont" + -imageOverlayLabel "Import" + -overlayLabelColor 0.8 0.8 0.8 + -overlayLabelBackColor 0 0 0 0.5 + -image "skinapi.png" + -image1 "skinapi.png" + -style "iconOnly" + -marginWidth 0 + -marginHeight 1 + -command "from rigging_tools.skin_api import ui\nui.WeightImport()" + -sourceType "python" + -commandRepeatable 1 + -flat 1 + ; + shelfButton + -enableCommandRepeat 1 + -flexibleWidthType 3 + -flexibleWidthValue 32 + -enable 1 + -width 35 + -height 34 + -manage 1 + -visible 1 + -preventOverride 0 + -annotation "Unbind Skin - Remove skin cluster from selected objects" + -enableBackground 0 + -backgroundColor 0 0 0 + -highlightColor 0.321569 0.521569 0.65098 + -align "center" + -label "Unbind" + -labelOffset 0 + -rotation 0 + -flipX 0 + -flipY 0 + -useAlpha 1 + -font "plainLabelFont" + -imageOverlayLabel "Unbind" + -overlayLabelColor 0.8 0.8 0.8 + -overlayLabelBackColor 0 0 0 0.5 + -image "skinapi.png" + -image1 "skinapi.png" + -style "iconOnly" + -marginWidth 0 + -marginHeight 1 + -command "from rigging_tools.skin_api import ui\nui.UnbindSkin()" -sourceType "python" -commandRepeatable 1 -flat 1