# -*- coding: utf-8 -*-
"""
Maya Python脚本:根据选择的多边形顶点位置创建控制器(Maya 2022 / Python 3)
功能 / Features:
- 常驻 UI:记录你 Shift 逐个点选的顶点顺序(使用 orderedSelection,避免组件 index 排序)
- 列表交互:双击列表条目 -> 选中并框显对应顶点(Select & Frame)
- 可调参数:控制器颜色色卡(RGB)+ 控制器大小(半径,默认 1)
- 一键创建:从记录列表创建关节链 + 控制器 + 约束
- UI 全部中英双语
- 重要按钮配色区分(开始记录/清空/从列表创建)
- 体验优化:列表为空时“从列表创建”置灰;录制中窗口标题显示“● Recording”
- 作者信息:董帅
作者:董帅
个人博客:https://www.miaodonghua.com/
YouTube:https://www.youtube.com/channel/UCPw4S_MYsO1Tuv3VzU-e5Qw
Bilibili:https://space.bilibili.com/353020636
"""
import math
import maya.cmds as cmds
# -------------------------
# UI Button Colors (0~1 RGB)
# -------------------------
BTN_PRIMARY_GREEN = (0.22, 0.60, 0.26) # Start recording
BTN_PRIMARY_RED = (0.70, 0.22, 0.22) # Stop recording
BTN_PRIMARY_BLUE = (0.18, 0.42, 0.72) # Create
BTN_WARN_ORANGE = (0.78, 0.45, 0.12) # Clear
BTN_SECONDARY = (0.32, 0.32, 0.32) # Other buttons
# -------------------------
# Author Links (not shown in UI; open via buttons)
# -------------------------
AUTHOR_NAME = "董帅"
URL_BLOG = "https://www.miaodonghua.com/"
URL_YOUTUBE = "https://www.youtube.com/channel/UCPw4S_MYsO1Tuv3VzU-e5Qw"
URL_BILIBILI = "https://space.bilibili.com/353020636"
# -------------------------
# Helpers
# -------------------------
def _open_url(url):
"""Open URL in default browser (Maya-friendly)."""
try:
cmds.launch(web=url)
return
except Exception:
pass
try:
import webbrowser
webbrowser.open(url, new=2)
except Exception:
cmds.warning("无法打开浏览器链接。\nFailed to open browser link.")
def _set_ctrl_color_rgb(ctrl_transform, rgb):
"""将控制器曲线设为指定 RGB 颜色(viewport override)。"""
if not ctrl_transform or not cmds.objExists(ctrl_transform):
return
try:
r, g, b = rgb
r, g, b = float(r), float(g), float(b)
except Exception:
r, g, b = 1.0, 1.0, 0.0
shapes = cmds.listRelatives(ctrl_transform, shapes=True, fullPath=True) or []
for s in shapes:
try:
cmds.setAttr(s + ".overrideEnabled", 1)
cmds.setAttr(s + ".overrideRGBColors", 1)
cmds.setAttr(s + ".overrideColorRGB", r, g, b)
except Exception:
# 兜底:使用索引色(17 通常为黄)
try:
cmds.setAttr(s + ".overrideEnabled", 1)
cmds.setAttr(s + ".overrideRGBColors", 0)
cmds.setAttr(s + ".overrideColor", 17)
except Exception:
pass
def get_available_suffix(base_name):
"""获取可用的后缀(A-Z 或数字)。"""
for i in range(26):
suffix = chr(ord('A') + i)
full_name = "{}_{}".format(base_name, suffix)
main_grp = "{}_rig_grp".format(full_name)
if not cmds.objExists(main_grp):
return suffix
num = 1
while True:
suffix = str(num)
full_name = "{}_{}".format(base_name, suffix)
main_grp = "{}_rig_grp".format(full_name)
if not cmds.objExists(main_grp):
return suffix
num += 1
if num > 10000:
break
return "1"
def aim_controller_group_to_world_center(grp, position):
"""让控制器组本地 +X 轴指向“世界中心的垂线方向”(与径向向量垂直)。"""
radial = [-position[0], -position[1], -position[2]]
magnitude = math.sqrt(radial[0] ** 2 + radial[1] ** 2 + radial[2] ** 2)
if magnitude < 1e-6:
return
radial = [radial[0] / magnitude, radial[1] / magnitude, radial[2] / magnitude]
world_up = [0.0, 1.0, 0.0]
tangent = [
world_up[1] * radial[2] - world_up[2] * radial[1],
world_up[2] * radial[0] - world_up[0] * radial[2],
world_up[0] * radial[1] - world_up[1] * radial[0],
]
tangent_len = math.sqrt(tangent[0] ** 2 + tangent[1] ** 2 + tangent[2] ** 2)
if tangent_len < 1e-6:
world_forward = [0.0, 0.0, 1.0]
tangent = [
world_forward[1] * radial[2] - world_forward[2] * radial[1],
world_forward[2] * radial[0] - world_forward[0] * radial[2],
world_forward[0] * radial[1] - world_forward[1] * radial[0],
]
tangent_len = math.sqrt(tangent[0] ** 2 + tangent[1] ** 2 + tangent[2] ** 2)
if tangent_len < 1e-6:
return
tangent = [tangent[0] / tangent_len, tangent[1] / tangent_len, tangent[2] / tangent_len]
rotation = cmds.angleBetween(euler=True, v1=[1, 0, 0], v2=tangent)
cmds.xform(grp, worldSpace=True, rotation=rotation)
# -------------------------
# Rig building
# -------------------------
def create_joint_chain(positions, name_prefix, start_index=1):
"""按位置顺序创建关节链。"""
if not positions:
return []
joints = []
joint_name = "{}_{:03d}_jnt".format(name_prefix, start_index)
current_joint = cmds.joint(name=joint_name, position=positions[0])
joints.append(current_joint)
for i in range(1, len(positions)):
joint_index = start_index + i
joint_name = "{}_{:03d}_jnt".format(name_prefix, joint_index)
cmds.select(current_joint, replace=True)
current_joint = cmds.joint(name=joint_name, position=positions[i])
joints.append(current_joint)
if joints:
cmds.joint(
joints[0],
edit=True,
orientJoint='xyz',
secondaryAxisOrient='yup',
zeroScaleOrient=True,
children=True
)
return joints
def create_constraints(controller, joint):
"""为控制器和关节创建 parentConstraint(无 offset)。"""
if not controller or not joint:
return None
if not cmds.objExists(controller) or not cmds.objExists(joint):
return None
parent_constraint_name = "{}_parentConstraint".format(joint)
if cmds.objExists(parent_constraint_name):
cmds.delete(parent_constraint_name)
parent_constraint = cmds.parentConstraint(
controller,
joint,
name=parent_constraint_name,
maintainOffset=False
)
return parent_constraint
def create_controller_at_position(position, index, name_prefix, ctrl_radius, ctrl_rgb):
"""在指定位置创建控制器(circle)+ group,并设置颜色与朝向。"""
ctrl_name = "{}_{:03d}_ctrl".format(name_prefix, index)
ctrl = cmds.circle(name=ctrl_name, radius=float(ctrl_radius), normal=[0, 1, 0])[0]
_set_ctrl_color_rgb(ctrl, ctrl_rgb)
cmds.xform(ctrl, centerPivots=True)
grp_name = "{}_grp".format(ctrl_name)
grp = cmds.group(ctrl, name=grp_name)
cmds.xform(grp, worldSpace=True, translation=position)
aim_controller_group_to_world_center(grp, position)
return grp, ctrl
def create_controllers_from_positions(vertex_positions, base_name, ctrl_radius=1.0, ctrl_rgb=(1.0, 1.0, 0.0)):
"""从位置列表创建:组 + 关节链 + 控制器链 + 约束。"""
if not vertex_positions:
cmds.warning("没有顶点位置数据!\nNo vertex positions provided!")
return
if not base_name or not base_name.strip():
cmds.warning("名称为空!\nName is empty!")
return
try:
ctrl_radius = float(ctrl_radius)
except Exception:
ctrl_radius = 1.0
if ctrl_radius <= 0.0:
cmds.warning("控制器大小必须大于 0,已使用默认值 1。\nController size must be > 0. Using default 1.")
ctrl_radius = 1.0
base_name = base_name.strip()
suffix = get_available_suffix(base_name)
name_prefix = "{}_{}".format(base_name, suffix)
main_grp = "{}_rig_grp".format(name_prefix)
joint_grp = "{}_joints_grp".format(name_prefix)
ctrl_grp = "{}_controllers_grp".format(name_prefix)
if not cmds.objExists(main_grp):
main_grp = cmds.group(empty=True, name=main_grp)
if not cmds.objExists(joint_grp):
joint_grp = cmds.group(empty=True, name=joint_grp)
cmds.parent(joint_grp, main_grp)
else:
p = cmds.listRelatives(joint_grp, parent=True) or []
if not p or p[0] != main_grp:
cmds.parent(joint_grp, main_grp)
if not cmds.objExists(ctrl_grp):
ctrl_grp = cmds.group(empty=True, name=ctrl_grp)
cmds.parent(ctrl_grp, main_grp)
start_index = 1
else:
p = cmds.listRelatives(ctrl_grp, parent=True) or []
if not p or p[0] != main_grp:
cmds.parent(ctrl_grp, main_grp)
existing_ctrls = cmds.listRelatives(ctrl_grp, children=True, type="transform") or []
start_index = len(existing_ctrls) + 1
created_joints = create_joint_chain(vertex_positions, name_prefix, start_index)
if created_joints:
cmds.parent(created_joints[0], joint_grp)
created_controllers = []
parent_ctrl = None
for i, (pos, joint) in enumerate(zip(vertex_positions, created_joints)):
ctrl_index = start_index + i
ctrl_grp_item, ctrl_item = create_controller_at_position(
pos, ctrl_index, name_prefix,
ctrl_radius=ctrl_radius,
ctrl_rgb=ctrl_rgb
)
if not cmds.objExists(ctrl_grp_item):
continue
if parent_ctrl is None:
cmds.parent(ctrl_grp_item, ctrl_grp)
else:
if ctrl_item and cmds.objExists(parent_ctrl):
cmds.parent(ctrl_grp_item, parent_ctrl)
else:
cmds.parent(ctrl_grp_item, ctrl_grp)
created_controllers.append(ctrl_grp_item)
if ctrl_item and joint and cmds.objExists(ctrl_item) and cmds.objExists(joint):
create_constraints(ctrl_item, joint)
if ctrl_item:
parent_ctrl = ctrl_item
existing_controllers = [c for c in created_controllers if cmds.objExists(c)]
if existing_controllers:
cmds.select(existing_controllers, replace=True)
cmds.confirmDialog(
title="完成 / Complete",
message="成功创建 {} 个关节和 {} 个控制器!\nSuccessfully created {} joints and {} controllers!\n\n使用命名前缀 / Name prefix: {}".format(
len(created_joints), len(created_controllers),
len(created_joints), len(created_controllers),
name_prefix
),
button=["确定 / OK"],
defaultButton="确定 / OK"
)
return created_joints, created_controllers
# =========================
# UI: Record vertex order + Create (Vertical Layout, Shift-click optimized)
# =========================
class VertexOrderUI(object):
WIN = "createCtrlsFromVtx_orderUI"
def __init__(self):
self.job_id = None
self.vertices = []
self._last_ordered_vtx = []
self._recording = False
self._building = False
self._title_base = "从顶点创建控制器 / Create Ctrls From Vtx (Ordered)"
# UI controls
self.name_field = None
self.size_field = None
self.color_field = None
self.list_ui = None
self.record_btn = None
self.clear_btn = None
self.create_btn = None
self.status_txt = None
# ---- Window title
def _set_window_title(self, recording=False):
if not cmds.window(self.WIN, exists=True):
return
title = self._title_base
if recording:
title = "● Recording | " + title
cmds.window(self.WIN, edit=True, title=title)
# ---- Create button state
def _update_create_button_state(self):
if not self.create_btn or not cmds.control(self.create_btn, exists=True):
return
enable = len(self.vertices) > 0
cmds.button(self.create_btn, edit=True, enable=enable)
# ---- UI
def show(self):
if cmds.window(self.WIN, exists=True):
cmds.deleteUI(self.WIN)
cmds.window(self.WIN, title=self._title_base, sizeable=False)
cmds.columnLayout(adjustableColumn=True, rowSpacing=8)
# Settings
cmds.frameLayout(label="参数 / Settings", collapsable=True, collapse=False, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
self.name_field = cmds.textFieldGrp(
label="名称 Name",
text="Skirt",
columnAlign=(1, "right"),
columnWidth=[(1, 90), (2, 320)]
)
self.size_field = cmds.floatFieldGrp(
label="大小 Size",
numberOfFields=1,
value1=1.0,
columnAlign=(1, "right"),
columnWidth=[(1, 90), (2, 120)]
)
self.color_field = cmds.colorSliderGrp(
label="颜色 Color",
rgb=(1.0, 1.0, 0.0),
columnAlign=(1, "right"),
columnWidth=[(1, 90), (2, 220)]
)
cmds.text(label="提示 / Tip:Shift 点选最稳定(按新增顺序记录)", align="left")
cmds.setParent("..")
cmds.setParent("..")
# Recording
cmds.frameLayout(label="记录 / Recording", collapsable=True, collapse=False, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
self.record_btn = cmds.button(
label="开始记录 / Start Recording",
height=34,
bgc=BTN_PRIMARY_GREEN,
command=lambda *_: self.toggle_recording()
)
cmds.button(
label="添加当前选择 / Add Selection",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: self.add_current_selection()
)
cmds.button(
label="撤销最后 / Undo Last",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: self.pop_last()
)
self.clear_btn = cmds.button(
label="清空 / Clear",
height=30,
bgc=BTN_WARN_ORANGE,
command=lambda *_: self.clear()
)
self.status_txt = cmds.text(label="状态 / Status:未记录 / Idle(已记录 0)", align="left")
cmds.setParent("..")
cmds.setParent("..")
# List
cmds.frameLayout(label="顶点列表(按顺序)/ Vertex List (Ordered)", collapsable=False, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
self.list_ui = cmds.textScrollList(
numberOfRows=12,
allowMultiSelection=True,
height=220,
doubleClickCommand=lambda *_: self.select_and_frame_from_list()
)
cmds.setParent("..")
cmds.setParent("..")
# Actions
cmds.frameLayout(label="操作 / Actions", collapsable=False, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
cmds.button(
label="移除选中条目 / Remove Selected",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: self.remove_selected_items()
)
cmds.button(
label="选中并框显 / Select & Frame",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: self.select_and_frame_from_list()
)
self.create_btn = cmds.button(
label="从列表创建 / Create From List",
height=40,
bgc=BTN_PRIMARY_BLUE,
command=lambda *_: self.create_from_list()
)
cmds.button(
label="关闭 / Close",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: cmds.deleteUI(self.WIN)
)
cmds.setParent("..")
cmds.setParent("..")
# About (links open browser; URLs not shown in UI)
cmds.frameLayout(label="关于 / About", collapsable=True, collapse=True, marginWidth=8, marginHeight=6)
cmds.columnLayout(adjustableColumn=True, rowSpacing=6)
cmds.text(label="作者 / Author:{}".format(AUTHOR_NAME), align="left")
cmds.button(
label="个人博客 / Blog",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: _open_url(URL_BLOG)
)
cmds.button(
label="YouTube / YouTube",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: _open_url(URL_YOUTUBE)
)
cmds.button(
label="Bilibili / Bilibili",
height=26,
bgc=BTN_SECONDARY,
command=lambda *_: _open_url(URL_BILIBILI)
)
cmds.setParent("..")
cmds.setParent("..")
cmds.showWindow(self.WIN)
# initial states
self._set_window_title(recording=False)
self._update_create_button_state()
# ---- Internal
def _set_status(self, cn, en):
count = len(self.vertices)
if self.status_txt and cmds.control(self.status_txt, exists=True):
cmds.text(self.status_txt, edit=True, label="状态 / Status:{} / {}(已记录 {})".format(cn, en, count))
def _refresh_list(self):
if not self.list_ui or not cmds.control(self.list_ui, exists=True):
return
cmds.textScrollList(self.list_ui, edit=True, removeAll=True)
for v in self.vertices:
cmds.textScrollList(self.list_ui, edit=True, append=v)
self._update_create_button_state()
def _install_job(self):
try:
cmds.selectPref(trackSelectionOrder=True)
except Exception:
pass
if self.job_id and cmds.scriptJob(exists=self.job_id):
cmds.scriptJob(kill=self.job_id, force=True)
self.job_id = None
self.job_id = cmds.scriptJob(
event=["SelectionChanged", self._on_selection_changed],
protected=True,
parent=self.WIN
)
def _get_ordered_selected_vtx(self):
ordered = cmds.ls(orderedSelection=True, flatten=True, long=True) or []
return [x for x in ordered if ".vtx[" in x]
# ---- Recording (Shift-click optimized)
def toggle_recording(self):
self._recording = not self._recording
if self._recording:
try:
cmds.selectPref(trackSelectionOrder=True)
except Exception:
pass
self._last_ordered_vtx = self._get_ordered_selected_vtx()
self._install_job()
cmds.button(self.record_btn, edit=True, label="停止记录 / Stop Recording", bgc=BTN_PRIMARY_RED)
self._set_window_title(recording=True)
self._set_status("记录中(Shift 点选)", "Recording (Shift-click)")
else:
cmds.button(self.record_btn, edit=True, label="开始记录 / Start Recording", bgc=BTN_PRIMARY_GREEN)
self._set_window_title(recording=False)
self._set_status("未记录", "Idle")
def _on_selection_changed(self):
if not self._recording or self._building:
return
cur_ordered = self._get_ordered_selected_vtx()
prev_set = set(self._last_ordered_vtx)
self._last_ordered_vtx = cur_ordered
added = [v for v in cur_ordered if v not in prev_set]
if not added:
return
if len(added) > 1:
cmds.warning(
"一次新增多个顶点(可能框选/套索),内部顺序可能不稳定;Shift 逐个点选最稳。\n"
"Multiple vertices added at once; internal order may be unstable."
)
changed = False
for v in added:
if v not in self.vertices:
self.vertices.append(v)
changed = True
if changed:
self._refresh_list()
self._set_status("记录中", "Recording")
# ---- List ops
def add_current_selection(self):
cur_ordered = self._get_ordered_selected_vtx()
if not cur_ordered:
cmds.warning("当前选择里没有顶点!\nNo vertices in current selection!")
return
for v in cur_ordered:
if v not in self.vertices:
self.vertices.append(v)
self._refresh_list()
self._set_status("已添加当前选择", "Selection added")
def clear(self):
self.vertices = []
self._refresh_list()
self._set_status("已清空", "Cleared")
def pop_last(self):
if self.vertices:
self.vertices.pop()
self._refresh_list()
self._set_status("已撤销最后一个", "Undid last")
def remove_selected_items(self):
if not self.list_ui or not cmds.control(self.list_ui, exists=True):
return
selected = cmds.textScrollList(self.list_ui, query=True, selectItem=True) or []
if not selected:
return
s = set(selected)
self.vertices = [v for v in self.vertices if v not in s]
self._refresh_list()
self._set_status("已移除选中条目", "Removed selected")
def select_and_frame_from_list(self):
if not self.list_ui or not cmds.control(self.list_ui, exists=True):
return
items = cmds.textScrollList(self.list_ui, query=True, selectItem=True) or []
if not items:
return
existing = [v for v in items if cmds.objExists(v)]
if not existing:
cmds.warning("所选条目在场景中不存在(可能 mesh 被删除/重命名)。\nSelected items not found in scene.")
return
try:
cmds.select(existing, replace=True)
except Exception:
return
try:
cmds.viewFit()
except Exception:
pass
# ---- Create
def _get_ui_options(self):
base_name = cmds.textFieldGrp(self.name_field, query=True, text=True).strip()
try:
size_val = float(cmds.floatFieldGrp(self.size_field, query=True, value1=True))
except Exception:
size_val = 1.0
try:
rgb = cmds.colorSliderGrp(self.color_field, query=True, rgb=True)
rgb = (float(rgb[0]), float(rgb[1]), float(rgb[2]))
except Exception:
rgb = (1.0, 1.0, 0.0)
return base_name, size_val, rgb
def create_from_list(self):
if not self.vertices:
cmds.warning("列表为空!请先记录/添加顶点。\nList is empty! Please record/add vertices first.")
self._update_create_button_state()
return
base_name, size_val, rgb = self._get_ui_options()
if not base_name:
cmds.warning("名称为空!\nName is empty!")
return
positions = []
for v in self.vertices:
if not cmds.objExists(v):
cmds.warning("顶点不存在,已跳过:{}\nVertex not found, skipped: {}".format(v, v))
continue
pos = cmds.xform(v, query=True, worldSpace=True, translation=True)
if pos:
positions.append(pos)
if not positions:
cmds.warning("没有可用的顶点位置!\nNo valid vertex positions!")
return
self._building = True
try:
create_controllers_from_positions(
positions,
base_name,
ctrl_radius=size_val,
ctrl_rgb=rgb
)
finally:
self._building = False
def show_create_controllers_ui():
ui = VertexOrderUI()
ui.show()
return ui
if __name__ == "__main__":
show_create_controllers_ui()