# -*- coding: utf-8 -*- """ StandardizeSkeleton.py 标准化骨骼工具 - 用于从绑定文件中提取干净的骨骼层级 功能: 1. 识别所有有蒙皮权重的骨骼 2. 复制这些骨骼并保持原有层级关系 3. 移除控制器和其他非骨骼节点 4. 批量添加/剔除骨骼前缀后缀 5. 创建干净的骨骼树,便于导出到游戏引擎 作者: Kilo Code 日期: 2025 """ import maya.cmds as cmds import maya.mel as mel from functools import partial try: from PySide2 import QtWidgets, QtCore, QtGui from shiboken2 import wrapInstance except ImportError: from PySide6 import QtWidgets, QtCore, QtGui from shiboken6 import wrapInstance import maya.OpenMayaUI as omui def get_maya_main_window(): """获取Maya主窗口""" main_window_ptr = omui.MQtUtil.mainWindow() return wrapInstance(int(main_window_ptr), QtWidgets.QWidget) class StandardizeSkeletonUI(QtWidgets.QDialog): """标准化骨骼工具UI""" def __init__(self, parent=get_maya_main_window()): super(StandardizeSkeletonUI, self).__init__(parent) self.setWindowTitle("标准化骨骼工具") self.setMinimumWidth(500) self.setMinimumHeight(600) self.setWindowFlags(self.windowFlags() ^ QtCore.Qt.WindowContextHelpButtonHint) self.geometry_list = [] # 存储几何体列表 self.create_widgets() self.create_layouts() self.create_connections() def create_widgets(self): """创建UI控件""" # 几何体列表区域 self.geo_group = QtWidgets.QGroupBox("几何体列表") self.geo_list_widget = QtWidgets.QListWidget() self.geo_list_widget.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) self.add_selected_btn = QtWidgets.QPushButton("添加选中对象") self.add_selected_btn.setToolTip("选择对象后点击,会自动过滤出几何体") self.remove_selected_btn = QtWidgets.QPushButton("移除选中项") self.clear_list_btn = QtWidgets.QPushButton("清空列表") # 骨骼信息区域 self.info_group = QtWidgets.QGroupBox("骨骼信息") self.info_text = QtWidgets.QTextEdit() self.info_text.setReadOnly(True) self.info_text.setMaximumHeight(150) self.analyze_btn = QtWidgets.QPushButton("分析权重骨骼") self.analyze_btn.setStyleSheet("background-color: #4a90e2; color: white; font-weight: bold;") # 导出选项区域 self.export_group = QtWidgets.QGroupBox("导出选项") self.export_name_label = QtWidgets.QLabel("导出组名称:") self.export_name_edit = QtWidgets.QLineEdit("export_skeleton_grp") self.prefix_label = QtWidgets.QLabel("添加前缀:") self.prefix_edit = QtWidgets.QLineEdit() self.prefix_edit.setPlaceholderText("例如: SK_") self.suffix_label = QtWidgets.QLabel("添加后缀:") self.suffix_edit = QtWidgets.QLineEdit() self.suffix_edit.setPlaceholderText("例如: _jnt") self.remove_prefix_label = QtWidgets.QLabel("移除前缀:") self.remove_prefix_edit = QtWidgets.QLineEdit() self.remove_prefix_edit.setPlaceholderText("例如: L_, R_, M_") self.remove_suffix_label = QtWidgets.QLabel("移除后缀:") self.remove_suffix_edit = QtWidgets.QLineEdit() self.remove_suffix_edit.setPlaceholderText("例如: _joint, _jnt") self.exclude_keywords_label = QtWidgets.QLabel("排除关键词:") self.exclude_keywords_edit = QtWidgets.QLineEdit() self.exclude_keywords_edit.setPlaceholderText("例如: hair, lashe, zip, wrinkle") self.exclude_keywords_edit.setToolTip("包含这些关键词的骨骼将被排除(逗号分隔,不区分大小写)") self.root_joint_label = QtWidgets.QLabel("根骨骼限制:") self.root_joint_edit = QtWidgets.QLineEdit() self.root_joint_edit.setPlaceholderText("例如: rig|root_joint (留空=不限制)") self.root_joint_edit.setToolTip("只导出此骨骼下的子骨骼(留空则导出所有有权重的骨骼)") self.keep_hierarchy_cb = QtWidgets.QCheckBox("保持原始层级关系") self.keep_hierarchy_cb.setChecked(True) self.keep_hierarchy_cb.setToolTip("保持骨骼的父子层级关系") self.freeze_transforms_cb = QtWidgets.QCheckBox("冻结变换") self.freeze_transforms_cb.setChecked(False) self.freeze_transforms_cb.setToolTip("冻结导出骨骼的变换属性") self.delete_constraints_cb = QtWidgets.QCheckBox("删除所有约束") self.delete_constraints_cb.setChecked(True) self.delete_constraints_cb.setToolTip("删除复制骨骼上的所有约束节点") # 执行按钮 self.create_skeleton_btn = QtWidgets.QPushButton("创建导出骨骼") self.create_skeleton_btn.setStyleSheet("background-color: #5cb85c; color: white; font-weight: bold; padding: 10px;") self.create_skeleton_btn.setMinimumHeight(40) self.select_export_btn = QtWidgets.QPushButton("选择导出组") self.delete_export_btn = QtWidgets.QPushButton("删除导出组") # 帮助文本 self.help_text = QtWidgets.QLabel( "使用说明:\n" "1. 点击'添加选中对象'添加包含几何体的对象\n" "2. 点击'分析权重骨骼'查看将要导出的骨骼信息\n" "3. 设置导出选项(可选)\n" "4. 点击'创建导出骨骼'生成干净的骨骼层级" ) self.help_text.setStyleSheet("color: #666; padding: 10px; background-color: #f5f5f5; border-radius: 5px;") self.help_text.setWordWrap(True) def create_layouts(self): """创建布局""" main_layout = QtWidgets.QVBoxLayout(self) # 帮助文本 main_layout.addWidget(self.help_text) # 几何体列表区域 geo_layout = QtWidgets.QVBoxLayout() geo_layout.addWidget(self.geo_list_widget) geo_btn_layout = QtWidgets.QHBoxLayout() geo_btn_layout.addWidget(self.add_selected_btn) geo_btn_layout.addWidget(self.remove_selected_btn) geo_btn_layout.addWidget(self.clear_list_btn) geo_layout.addLayout(geo_btn_layout) self.geo_group.setLayout(geo_layout) main_layout.addWidget(self.geo_group) # 分析按钮 main_layout.addWidget(self.analyze_btn) # 骨骼信息区域 info_layout = QtWidgets.QVBoxLayout() info_layout.addWidget(self.info_text) self.info_group.setLayout(info_layout) main_layout.addWidget(self.info_group) # 导出选项区域 export_layout = QtWidgets.QGridLayout() row = 0 export_layout.addWidget(self.export_name_label, row, 0) export_layout.addWidget(self.export_name_edit, row, 1) row += 1 export_layout.addWidget(self.prefix_label, row, 0) export_layout.addWidget(self.prefix_edit, row, 1) row += 1 export_layout.addWidget(self.suffix_label, row, 0) export_layout.addWidget(self.suffix_edit, row, 1) row += 1 export_layout.addWidget(self.remove_prefix_label, row, 0) export_layout.addWidget(self.remove_prefix_edit, row, 1) row += 1 export_layout.addWidget(self.remove_suffix_label, row, 0) export_layout.addWidget(self.remove_suffix_edit, row, 1) row += 1 export_layout.addWidget(self.exclude_keywords_label, row, 0) export_layout.addWidget(self.exclude_keywords_edit, row, 1) row += 1 export_layout.addWidget(self.root_joint_label, row, 0) export_layout.addWidget(self.root_joint_edit, row, 1) row += 1 export_layout.addWidget(self.keep_hierarchy_cb, row, 0, 1, 2) row += 1 export_layout.addWidget(self.freeze_transforms_cb, row, 0, 1, 2) row += 1 export_layout.addWidget(self.delete_constraints_cb, row, 0, 1, 2) self.export_group.setLayout(export_layout) main_layout.addWidget(self.export_group) # 执行按钮 main_layout.addWidget(self.create_skeleton_btn) # 底部按钮 bottom_btn_layout = QtWidgets.QHBoxLayout() bottom_btn_layout.addWidget(self.select_export_btn) bottom_btn_layout.addWidget(self.delete_export_btn) main_layout.addLayout(bottom_btn_layout) def create_connections(self): """创建信号连接""" self.add_selected_btn.clicked.connect(self.add_selected_geometry) self.remove_selected_btn.clicked.connect(self.remove_selected_items) self.clear_list_btn.clicked.connect(self.clear_geometry_list) self.analyze_btn.clicked.connect(self.analyze_weighted_joints) self.create_skeleton_btn.clicked.connect(self.create_export_skeleton) self.select_export_btn.clicked.connect(self.select_export_group) self.delete_export_btn.clicked.connect(self.delete_export_group) def add_selected_geometry(self): """添加选中的几何体""" selection = cmds.ls(selection=True, long=True) if not selection: QtWidgets.QMessageBox.warning(self, "警告", "请先选择对象!") return added_count = 0 for obj in selection: # 获取所有子节点中的mesh meshes = self.get_meshes_from_object(obj) for mesh in meshes: # 获取transform节点 transform = cmds.listRelatives(mesh, parent=True, fullPath=True)[0] if transform not in self.geometry_list: self.geometry_list.append(transform) # 显示短名称 short_name = transform.split('|')[-1] self.geo_list_widget.addItem(short_name) added_count += 1 if added_count > 0: self.info_text.append(f"添加了 {added_count} 个几何体") else: QtWidgets.QMessageBox.information(self, "提示", "没有找到新的几何体或几何体已存在") def get_meshes_from_object(self, obj): """从对象中获取所有mesh""" meshes = [] # 检查对象本身是否是mesh shapes = cmds.listRelatives(obj, shapes=True, fullPath=True, type="mesh") or [] meshes.extend(shapes) # 检查所有子节点 descendants = cmds.listRelatives(obj, allDescendents=True, fullPath=True, type="mesh") or [] meshes.extend(descendants) return list(set(meshes)) # 去重 def remove_selected_items(self): """移除选中的列表项""" selected_items = self.geo_list_widget.selectedItems() if not selected_items: QtWidgets.QMessageBox.warning(self, "警告", "请先选择要移除的项!") return for item in selected_items: row = self.geo_list_widget.row(item) self.geo_list_widget.takeItem(row) if row < len(self.geometry_list): self.geometry_list.pop(row) def clear_geometry_list(self): """清空几何体列表""" self.geo_list_widget.clear() self.geometry_list = [] self.info_text.append("已清空几何体列表") def analyze_weighted_joints(self): """分析有权重的骨骼""" if not self.geometry_list: QtWidgets.QMessageBox.warning(self, "警告", "请先添加几何体!") return self.info_text.clear() self.info_text.append("正在分析权重骨骼...\n") try: # 获取排除关键词 exclude_keywords_text = self.exclude_keywords_edit.text().strip() exclude_keywords = [k.strip() for k in exclude_keywords_text.split(',') if k.strip()] if exclude_keywords_text else None # 获取根骨骼限制 root_joint = self.root_joint_edit.text().strip() or None if exclude_keywords: self.info_text.append(f"排除关键词: {', '.join(exclude_keywords)}") if root_joint: if cmds.objExists(root_joint): self.info_text.append(f"根骨骼限制: {root_joint}\n") else: self.info_text.append(f"警告: 根骨骼 '{root_joint}' 不存在,将忽略此限制\n") root_joint = None weighted_joints = self.get_weighted_joints(self.geometry_list, exclude_keywords, root_joint) if not weighted_joints: self.info_text.append("未找到有权重的骨骼!") return # 分析骨骼层级 root_joints = self.get_root_joints(weighted_joints) self.info_text.append(f"找到 {len(weighted_joints)} 个有权重的骨骼") self.info_text.append(f"根骨骼数量: {len(root_joints)}") self.info_text.append(f"\n根骨骼列表:") for root in root_joints: short_name = root.split('|')[-1] self.info_text.append(f" - {short_name}") # 显示前20个骨骼 self.info_text.append(f"\n前20个权重骨骼:") for i, joint in enumerate(weighted_joints[:20]): short_name = joint.split('|')[-1] self.info_text.append(f" {i+1}. {short_name}") if len(weighted_joints) > 20: self.info_text.append(f" ... 还有 {len(weighted_joints) - 20} 个骨骼") except Exception as e: self.info_text.append(f"\n错误: {str(e)}") import traceback self.info_text.append(traceback.format_exc()) def get_weighted_joints(self, geometry_list, exclude_keywords=None, root_joint=None): """获取所有有权重的骨骼""" weighted_joints = set() # 如果指定了根骨骼,获取其所有子骨骼 root_joint_children = None if root_joint and cmds.objExists(root_joint): root_joint_children = set() all_joints = cmds.listRelatives(root_joint, allDescendents=True, type="joint", fullPath=True) or [] root_joint_children = set(all_joints) root_joint_children.add(cmds.ls(root_joint, long=True)[0]) for geo in geometry_list: # 获取mesh shape shapes = cmds.listRelatives(geo, shapes=True, fullPath=True, type="mesh") or [] for shape in shapes: # 查找skinCluster history = cmds.listHistory(shape, pruneDagObjects=True) or [] # 过滤出skinCluster节点 skin_clusters = [node for node in history if cmds.nodeType(node) == "skinCluster"] for skin_cluster in skin_clusters: # 获取影响的骨骼 influences = cmds.skinCluster(skin_cluster, query=True, influence=True) or [] # 转换为长名称 for influence in influences: long_name = cmds.ls(influence, long=True)[0] if cmds.nodeType(long_name) == "joint": # 检查是否在根骨骼下 if root_joint_children is not None and long_name not in root_joint_children: continue # 检查是否需要排除 if exclude_keywords: short_name = long_name.split('|')[-1].lower() should_exclude = False for keyword in exclude_keywords: if keyword.lower() in short_name: should_exclude = True break if not should_exclude: weighted_joints.add(long_name) else: weighted_joints.add(long_name) return list(weighted_joints) def get_root_joints(self, joint_list): """获取根骨骼(在joint_list中没有父骨骼的骨骼)""" root_joints = [] for joint in joint_list: # 获取父节点 parent = cmds.listRelatives(joint, parent=True, fullPath=True) # 如果没有父节点,或者父节点不是joint,或者父节点不在列表中 if not parent: root_joints.append(joint) else: parent_joint = parent[0] if cmds.nodeType(parent_joint) != "joint" or parent_joint not in joint_list: root_joints.append(joint) return root_joints def create_export_skeleton(self): """创建导出骨骼""" if not self.geometry_list: QtWidgets.QMessageBox.warning(self, "警告", "请先添加几何体!") return export_name = self.export_name_edit.text().strip() if not export_name: QtWidgets.QMessageBox.warning(self, "警告", "请输入导出组名称!") return # 确认对话框 reply = QtWidgets.QMessageBox.question( self, "确认", f"将创建导出骨骼组: {export_name}\n是否继续?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No ) if reply == QtWidgets.QMessageBox.No: return try: self.info_text.clear() self.info_text.append("开始创建导出骨骼...\n") # 获取排除关键词 exclude_keywords_text = self.exclude_keywords_edit.text().strip() exclude_keywords = [k.strip() for k in exclude_keywords_text.split(',') if k.strip()] if exclude_keywords_text else None # 获取根骨骼限制 root_joint = self.root_joint_edit.text().strip() or None if exclude_keywords: self.info_text.append(f"排除关键词: {', '.join(exclude_keywords)}") if root_joint: if cmds.objExists(root_joint): self.info_text.append(f"根骨骼限制: {root_joint}") else: self.info_text.append(f"警告: 根骨骼 '{root_joint}' 不存在,将忽略此限制") root_joint = None # 获取有权重的骨骼 weighted_joints = self.get_weighted_joints(self.geometry_list, exclude_keywords, root_joint) if not weighted_joints: QtWidgets.QMessageBox.warning(self, "警告", "未找到有权重的骨骼!") return self.info_text.append(f"找到 {len(weighted_joints)} 个有权重的骨骼") # 删除已存在的导出组 if cmds.objExists(export_name): cmds.delete(export_name) self.info_text.append(f"删除已存在的 {export_name}") # 创建导出组 export_group = cmds.group(empty=True, name=export_name) self.info_text.append(f"创建导出组: {export_group}") # 复制骨骼 if self.keep_hierarchy_cb.isChecked(): # 保持层级关系 duplicated_joints = self.duplicate_joints_with_hierarchy( weighted_joints, export_group ) else: # 不保持层级,所有骨骼平铺 duplicated_joints = self.duplicate_joints_flat( weighted_joints, export_group ) self.info_text.append(f"复制了 {len(duplicated_joints)} 个骨骼") # 删除约束 if self.delete_constraints_cb.isChecked() and duplicated_joints: deleted_constraints = self.delete_all_constraints(duplicated_joints) if deleted_constraints > 0: self.info_text.append(f"删除了 {deleted_constraints} 个约束") # 重命名骨骼 if duplicated_joints: self.rename_joints( duplicated_joints, self.prefix_edit.text().strip(), self.suffix_edit.text().strip(), self.remove_prefix_edit.text().strip(), self.remove_suffix_edit.text().strip() ) # 冻结变换 if self.freeze_transforms_cb.isChecked() and duplicated_joints: try: cmds.makeIdentity(duplicated_joints, apply=True, translate=True, rotate=True, scale=True) self.info_text.append("已冻结变换") except: self.info_text.append("冻结变换失败(某些骨骼可能有约束)") # 选择导出组 cmds.select(export_group) self.info_text.append(f"\n成功创建导出骨骼!") self.info_text.append(f"导出组: {export_group}") QtWidgets.QMessageBox.information( self, "成功", f"成功创建导出骨骼!\n导出组: {export_group}\n骨骼数量: {len(duplicated_joints)}" ) except Exception as e: error_msg = f"创建导出骨骼失败: {str(e)}" self.info_text.append(f"\n错误: {error_msg}") import traceback self.info_text.append(traceback.format_exc()) QtWidgets.QMessageBox.critical(self, "错误", error_msg) def duplicate_joints_with_hierarchy(self, joint_list, parent_group): """复制骨骼并保持层级关系(只复制有权重的骨骼)""" duplicated_joints = [] joint_mapping = {} # 原始骨骼 -> 复制骨骼的映射 # 第一步:复制所有骨骼 for joint in joint_list: # 复制单个骨骼(不复制子节点) dup_joint = cmds.duplicate(joint, parentOnly=True, returnRootsOnly=True)[0] # 重命名(去掉Maya自动添加的数字后缀) short_name = joint.split('|')[-1] dup_joint = cmds.rename(dup_joint, short_name + "_export") joint_mapping[joint] = dup_joint duplicated_joints.append(dup_joint) # 第二步:重建层级关系(跳过没有权重的中间骨骼) for original_joint, dup_joint in joint_mapping.items(): # 查找最近的有权重的父骨骼 parent_joint = self.find_weighted_parent(original_joint, joint_list) if parent_joint and parent_joint in joint_mapping: # 如果找到有权重的父骨骼,建立父子关系 try: cmds.parent(dup_joint, joint_mapping[parent_joint]) except: pass else: # 如果没有找到,放到导出组下 try: cmds.parent(dup_joint, parent_group) except: pass # 可能已经在导出组下了 return duplicated_joints def find_weighted_parent(self, joint, weighted_joint_list): """查找最近的有权重的父骨骼""" current = joint while True: # 获取父节点 parent = cmds.listRelatives(current, parent=True, fullPath=True) if not parent: # 没有父节点了 return None parent_node = parent[0] # 检查父节点是否是joint if cmds.nodeType(parent_node) != "joint": # 父节点不是骨骼 return None # 检查父骨骼是否在权重骨骼列表中 if parent_node in weighted_joint_list: return parent_node # 继续向上查找 current = parent_node def duplicate_joints_flat(self, joint_list, parent_group): """复制骨骼(平铺,不保持层级)""" duplicated_joints = [] for joint in joint_list: # 复制单个骨骼 dup_joint = cmds.duplicate(joint, parentOnly=True, returnRootsOnly=True)[0] # 重命名 short_name = joint.split('|')[-1] dup_joint = cmds.rename(dup_joint, short_name + "_export") # 放到导出组下 cmds.parent(dup_joint, parent_group) duplicated_joints.append(dup_joint) return duplicated_joints def delete_all_constraints(self, joint_list): """删除所有约束""" deleted_count = 0 # 获取所有骨骼(包括子骨骼) all_joints = [] for joint in joint_list: all_joints.append(joint) descendants = cmds.listRelatives(joint, allDescendents=True, type="joint", fullPath=True) or [] all_joints.extend(descendants) all_joints = list(set(all_joints)) # 去重 # 删除约束 for joint in all_joints: # 获取连接到这个骨骼的所有约束 constraints = cmds.listConnections(joint, type="constraint") or [] for constraint in set(constraints): # 去重 if cmds.objExists(constraint): try: cmds.delete(constraint) deleted_count += 1 except: pass return deleted_count def rename_joints(self, joint_list, add_prefix, add_suffix, remove_prefix, remove_suffix): """重命名骨骼""" if not any([add_prefix, add_suffix, remove_prefix, remove_suffix]): return # 解析要移除的前缀和后缀(支持逗号分隔) remove_prefixes = [p.strip() for p in remove_prefix.split(',') if p.strip()] remove_suffixes = [s.strip() for s in remove_suffix.split(',') if s.strip()] renamed_count = 0 for joint in joint_list: old_name = joint.split('|')[-1] new_name = old_name # 移除后缀 for suffix in remove_suffixes: if new_name.endswith(suffix): new_name = new_name[:-len(suffix)] # 移除前缀 for prefix in remove_prefixes: if new_name.startswith(prefix): new_name = new_name[len(prefix):] # 添加前缀 if add_prefix: new_name = add_prefix + new_name # 添加后缀 if add_suffix: new_name = new_name + add_suffix # 重命名 if new_name != old_name: try: cmds.rename(joint, new_name) renamed_count += 1 except: pass if renamed_count > 0: self.info_text.append(f"重命名了 {renamed_count} 个骨骼") def select_export_group(self): """选择导出组""" export_name = self.export_name_edit.text().strip() if cmds.objExists(export_name): cmds.select(export_name) self.info_text.append(f"已选择: {export_name}") else: QtWidgets.QMessageBox.warning(self, "警告", f"导出组 '{export_name}' 不存在!") def delete_export_group(self): """删除导出组""" export_name = self.export_name_edit.text().strip() if not cmds.objExists(export_name): QtWidgets.QMessageBox.warning(self, "警告", f"导出组 '{export_name}' 不存在!") return reply = QtWidgets.QMessageBox.question( self, "确认删除", f"确定要删除导出组 '{export_name}' 吗?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No ) if reply == QtWidgets.QMessageBox.Yes: cmds.delete(export_name) self.info_text.append(f"已删除: {export_name}") def show(): """显示UI""" global standardize_skeleton_ui try: standardize_skeleton_ui.close() standardize_skeleton_ui.deleteLater() except: pass standardize_skeleton_ui = StandardizeSkeletonUI() standardize_skeleton_ui.show() if __name__ == "__main__": show()