前言

  最近新冠肺炎全国闹,我春节窝在家里没有什么事情要做
  于是就进一步将之前要开发出来的插件做好。

  之前月初的时候,研究一些动画的插件,当时调试代码使用了 VScode 插件 mayapy 来设断点调试。
  然而 mayapy 的调试有一些不太友好的点。
  比如调试的时候会导致 Maya 完全无法操作,只能观察脚本编辑器查看运行的状况。
  具体的一些细节可以参照 av82191243 视频
  而且调试的时候也遇到了一些奇奇怪怪的 Bug
  于是为了让我可以更加舒服的调试代码,我打算开发一个 Maya native debugger 解决我的问题。

准备

  我计划要开发一个基于 Qt 的调试器,包含断掉调试的图标以及面板。
  当然调试这种操作其实没必要自己全部手写,其实 python 就内置了非常强大的库来调试代码
  这个库就是官方提供的 pdb 模块
  通过这个库已经可以实现流行调试器的大多数功能,而且完全基于命令行,非常适合无键盘的服务器使用。

  pdb 模块也可以在 Maya 当中使用,但是用起来会非常不方便。
  pdb 模块会在断点模式下使用 raw_input 方法获取用户输入,由于 Maya 的脚本编辑器无法实现命令行的功能
  因此脚本编辑器在这种情况下会弹出输入框来获取用户输入。
  但是这种交互方式依然无法解决 Debug 的时候 Maya 被冻结的情况。

  于是我将 raw_input 等待输入的过程修改为 while 死循环,通过点击图标来打破 while 循环
  只要循环的过程保持 Maya 的响应就可以解决长久以来悬在头上的响应问题。
  因此我特意翻看了 pdb 模块的源码,研究里面的底层逻辑, 可以参照文章

使用方法

  具体使用方法可以参照 github 上的文档。

原理分析

  回溯到我最初的想法,其实 VScode 的 Debug 功能已经实现大部分我需要的了,关键是 Maya 的 UI 失去响应。
  而这个失去响应的情况其实和 死循环 差不多,因此我可以模拟一个 while True 死循环,然后想办法让 Maya 在死循环中保持响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 注意此代码在 Maya 2017 下执行会崩溃! 下文有说明!~
import time
import maya
try:
from PySide2 import QtCore
except:
from PySide import QtCore

def main():

curr = time.time()

while True:
# NOTE 超时跳出循环
elapsed = abs(curr - time.time())
if elapsed > 3:
break

print elapsed
# NOTE Maya2018 需要加入 Qt 事件响应 UI
QtCore.QCoreApplication.processEvents()
# NOTE cmds.refresh() 实现视窗响应 | 无法让所有 UI 响应
maya.utils.processIdleEvents()

if __name__ == "__main__":
main()

代码文件路径

  最初我是想通过 cmds.refresh 命令来保持界面刷新的
  然而这个方法只会刷新到 Viewport 界面,如果我想要打开大纲列表、曲线编辑器,这些操作统统都有各种问题。
  也不是不响应,只是很多状态没有及时同步反馈,比如选择物体 大纲类表没有同步反馈等等的瑕疵。
  于是为了让 UI 响应,我想起了之前有遇到的问题, 链接参考

  有时候代码想要让 Maya 的某些 UI 打开了之后再进一步执行,但是如果只是调用 mel 命令是无法立刻获取到相关的 UI 信息。
  原因是 mel 命令只是将 UI 构建的事件放入到了 eventLoop 里面, Maya 会确保在 idle 状态(即没有任何其他操作)下再去执行这些事件。
  代码执行的过程中就不会触发到这些事件,这样操作可以避免某些操作导致 Maya 崩溃。
  这就解释了 evalDeferred 命令存在的意义,在删除 UI 的一些操作需要使用 evalDeferred ,将UI删除事件添加到 idle 状态下执行。
  我之前就有遇到过这样的崩溃循环 链接
  最后还是参考了 ADV 的代码拯救了我 (哎!都是坑,当时没人指导只能自己摸索了(:з」∠) | 整个周末两天都在Maya的崩溃循环下度过,印象太深刻了)

  参考了网上文章之后,就知道可以通过 maya.utils.processIdleEvents() 来强制让 Maya 执行 idle 事件。
  文章也提到可以通过 evalDeferred -list 获取当前 idle 事件列表, processIdleEvents 是根据这个列表顺序执行的。

  当我将这个代码放到 Maya 2017 的 while 循环之后,的确解决了 UI 响应问题,但是一旦跳出循环, Maya2017 就立即崩溃!!!
  我测试 Maya 2018 就不会有崩溃问题,但是仅仅这样 Qt 很多组件依然没有响应,还和 Maya 2017 的情况不太一样(:з」∠)
  后来在Google 论坛的邮件列表里面查到了重要的信息
  Justin 大神推荐使用 QtCore.QApplication.processEvents() 来保持 UI 响应。
  我试了试,虽然没有解决 Maya 2017 崩溃的问题,不过的确可以实现 Maya 2018 的自由操作。

Maya 2017 执行崩溃分析

  其实当时执行就已经发现了代码运行的一些异常情况,我是持续打印时间的,但是我发现 Maya2017 打印的时间在某个时候会出现重复归零打印。
  打断 while 循环就立刻崩溃了。
  于是我将时间打印改为 cmds.evalDeferred(list=1) 这一句,然后打印列表就真相大白了。
  可以看到 idle事件列表里面一直包含了 当前脚本编辑器运行的代码, processIdleEvents 执行之后再次运行代码,这样会不断变成永动机式运行。
  于是重复运行一次之后,跳出循环 Maya 立刻崩溃了(:з」∠)
  大概执行按钮触发的代码也用到了 evalDeferred 命令导致永动机吧~

  当时很真就觉得这个问题几乎无解,在网上折腾了好久。
  我也不记得怎么就想到了用 shelf 工具架来测试这段代码的,反正我就这么试了。
  结果震惊的情况发生了,将上面的代码用 工具架 执行居然没有崩溃!
  至少 Maya 还是有办法让代码正确执行的。
  于是我就很好奇 脚本编辑器 的执行按钮背后到底执行了什么指令。

追踪 Script Editor 执行代码

  这里依然是使用 whatIs 的 mel 命令追踪大法 具体的最终过程可以参考之前轮廓生成文章 文档链接
  这个追踪大法在 大神的博客学来的 链接
  注意 whatIs 命令只支持 mel 语言,当然官方也提供了 Python OpenMaya API 的实现方法,可以在 devkit 里面找到,只不过需要加载插件来实现


  我们可以打开 Echo All Commands 通过命令回显追踪。点击 脚本编辑器 图标

1
2
ScriptEditor;
if (`scriptedPanel -q -exists scriptEditorPanel1`) { scriptedPanel -e -tor scriptEditorPanel1; showWindow scriptEditorPanel1Window; selectCurrentExecuterControl; }else { CommandWindow; };

  回显 ScriptEditor; 另外下面还有一大坨,这明显是一个 runTimeCommand
  这些 Maya 默认的 runTimeCommand 可以在 Maya2017\scripts\startup 目录的 defaultRunTimeCommand.mel 里面查询到

1
2
3
4
5
runTimeCommand -default true
-annotation (uiRes("m_defaultRunTimeCommands.kScriptEditorAnnot"))
-category ("Menu items.Common.Windows.General Editors")
-command ("if (`scriptedPanel -q -exists scriptEditorPanel1`) { scriptedPanel -e -tor scriptEditorPanel1; showWindow scriptEditorPanel1Window; selectCurrentExecuterControl; }else { CommandWindow; }")
ScriptEditor;

  当然上面这么找太麻烦,也可以通过 RuntimeCommand 的 mel 命令(这个命令也是 mel 特供 python 不支持) 文档链接

1
2
runTimeCommand -q -command ScriptEditor;
// Result: if (`scriptedPanel -q -exists scriptEditorPanel1`) { scriptedPanel -e -tor scriptEditorPanel1; showWindow scriptEditorPanel1Window; selectCurrentExecuterControl; }else { CommandWindow; } //

  绕了一圈,其实这行代码一开始就回显了(:з」∠)
  代码格式化之后如下显示

1
2
3
4
5
 if (`scriptedPanel -q -exists scriptEditorPanel1`) { 
scriptedPanel -e -tor scriptEditorPanel1;
showWindow scriptEditorPanel1Window;
selectCurrentExecuterControl;
}

  到这里可以脚本编辑器 通过 scriptedPanel 的 scriptEditorPanel 类型生成出来的。
  具体 mel 命令含义可以参考这些命令的文档 特别是 scriptedPanel 命令 官方文档
  通过文档的使用例子可以知道,通过这个命令可以定义一种类型的面板, Maya 可以根据里面的回调函数自动调用。

  根据这个线索 我在 Maya 的官方 mel 文件中可以搜索 scriptEditorPanel 关键字(我是用 VScode 进行文件搜索的)
  可以找到 scriptEditorPanel.mel 文件
  里面包含了 scriptEditorPanel 所有生成相关的回调 mel 函数
  其中 global proc addScriptEditorPanel 函数就是脚本编辑器整个界面生成的 mel 函数了
  细微到 menu 和 上面的 iconButton 都是 mel 命令生成的。
  代码比较长我就不全部弄过来了,只将主要的按钮功能截取出来。

1
2
3
4
5
6
7
8
9
10
11
12
iconTextButton 
-width $iconSize -height $iconSize
-annotation (uiRes("m_scriptEditorPanel.kExecuteAll"))
-image "executeAll.png"
-command "handleScriptEditorAction \"executeAll\""
executeAllButton;
iconTextButton
-width $iconSize -height $iconSize
-annotation (uiRes("m_scriptEditorPanel.kExecute"))
-image "execute.png"
-command "handleScriptEditorAction \"execute\""
executeButton;

  command 标记上接入了 handleScriptEditorAction 函数了
  从这个函数里面可以找到 execute 状态执行的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// NOTE switch 函数调用
case "execute":
{
string $filename = getCurrentExecuterFilename();
if (size($filename) > 0) {
delegateCommandToFocusedExecuterWindow "-e -executeAll" 1;
} else {
delegateCommandToFocusedExecuterWindow "-e -execute" 1;
}
}
break;
case "executeAll":
delegateCommandToFocusedExecuterWindow "-e -executeAll" 1;
break;

   delegateCommandToFocusedExecuterWindow 函数又可以去追查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
global proc delegateCommandToFocusedExecuterWindow( string $cmd, int $deferred) 
//
// Delegates a command to the last focused executer control.
// Parameter deferred means use evalDeferred instead of eval, this is important to avoid
// instability because the MEL scanner is not reentrant.
{
global string $gLastFocusedCommandExecuter;

if ($gLastFocusedCommandExecuter != "") {
string $evalCmd = "cmdScrollFieldExecuter " + $cmd + " " + $gLastFocusedCommandExecuter;
if ($deferred == 1) {
evalDeferred($evalCmd);
} else {
eval($evalCmd);
}
}
}

  这个函数会找到当前关注的代码编辑标签,通过 cmdScrollFieldExecuter mel 命令调用 -execute 指令执行。 官方文档
  其实到这里和我最初的预估还是有出入的,我还以为这里使用 evalDeferred 命令执行,没想到还有这种组件 mel 语句可以执行代码


  追查到这里基本就解开了Maya2017崩溃的重要谜团,主要是 cmdScrollFieldExecuter 的 excute 命令执行有底层的问题,这个问题大概在 Maya2018 已经修复了。
  于是我可以复制保留 addScriptEditorPanel 里面的大部分代码,修改一下 执行按钮调用的函数就可以实现对 Maya 脚本编辑器的模型修改。
  当然还需要给 scriptEditorPanel 类型的调用时间做函数修改。

github测试文件

  这个文件就是当时测试修改按钮的尝试
  当然这个测试其实有瑕疵的, executer = mel.eval("$temp = $gLastFocusedCommandExecuter") 这里获取的不一定是当前看到的脚本 executer
  不过大体的思路就是这样,修改了 scriptedPanelType 之后再次点开 脚本编辑器 调用的就是自己写的 python 函数了


UI 界面搭建

  UI界面搭建,还是需要从图标开始入手。
  我个人还是比较喜欢还原 VScode 的 Debug 图标,于是就去前端图标大全 阿里系的 iconfont 里面找调试图标。
  虽然没有调试图标合集,不过我还是找到了所需要的各个调试图标。
  当时考虑到图片的清晰度问题,还是下载了 SVG 图标,结果给自己挖坑了(:з」∠)
  SVG不带颜色的,后来我还要用 Qt 给 SVG 图标上色,不过弄好了的确更加灵活

  老规矩还是用 designer 来搭建组件

alt

  这里通过qrc文件加载图标,qrc文件需要转为 python 模块进行 import 就可以加载了。
  由于是用 SVG 图标, rc 文件还是比较小的。

  开发的过程中,为了区分 设定图标 和 调试图标的区别,特别做了一个 debug_icon 容器通过 disable 做出区分
  每一个图标的大小也做了 30*30 的限定。

图标上色

  主要参考 Stack Overflow 里面给 png 上色的方法 链接
  获取图标的 pixmap 继而获取到 pixel 来 循环修改颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def setButtonColor(self,button,color=QtGui.QColor("red"),size=25):
"""setButtonColor set SVG Icon Color
# NOTE https://stackoverflow.com/questions/53107173/change-color-png-image-qpushbutton
Parameters
----------
button : QPushButton
Icon Button
color : QColor, optional
set the Icon color, by default QtGui.QColor("red")
size : int, optional
icon size, by default 25
"""
icon = button.icon()
pixmap = icon.pixmap(size)
image = pixmap.toImage()
pcolor = image.pixelColor(size,size)
for x in range(image.width()):
for y in range(image.height()):
pcolor = image.pixelColor(x, y)
if pcolor.alpha() > 0:
color.setAlpha(pcolor.alpha())
image.setPixelColor(x, y, color)
button.setIcon(QtGui.QIcon(QtGui.QPixmap.fromImage(image)))

workspaceControl Toolbar 接入

  默认情况下生成的 workspaceControl 是以标签的形式嵌入到 Maya 的 Layout 里面的
  我希望可以制作一个类似 HelpLine CommandLine 这些组件一样有 workspaceControl Toolbar 的效果
  虽然也可以通过 Qt 自己实现这个效果,但是这就太过复杂了。

  最初我是想通过 mel 语言的 toolbar 来实现的 官方文档
  但是测试了一下示例代码,结果完全不是我想要的,只能嵌入到 Maya 特定的区域,限制很大。

  于是我就去研究 HelpLine 这些组件是怎么通过 mel 命令生成出来的。


alt

1
2
toggleUIComponentVisibility "Help Line"; updatePrefsMenu;
helpLineVisibilityStateChange(`workspaceControl -q -visible HelpLine`, "");

  关键的 mel 语句就是这两行,并且第二行还透露了很重要的信息 HelpLine 就是 workspaceControl 的名称
  关于这个切换显示 mel 函数也可以用上面的方法去追踪,不过我查完之后没有什么收获,还是对 workspaceControl 的操作而已
  于是我就以 HelpLine 作为切入点进行搜索,可以找到 initHelpLine.mel 脚本
  这个脚本就包含了创建 Maya Help Line 组件所有需要的代码
  其中有一个关键的代码 getUIComponentToolBar 可以获取到 workspaceControl 的 ToolBar
  但是往代码里面查,没有收获,因为 Toolbar 已经提前创建好了, get 函数只是从数组里面获取出来而已。

  辗转周折之后,最后 initMainWindow.mel 里面找到了 createUIComponentToolBar 函数
  继续追踪生成代码,还是让我好失望。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
global proc string createUIComponentToolBar(string $name, string $label, string $uiScript, string $area, int $tabbed)
{
global string $gUIComponentToolBarArray[];
global int $kNameIndex;
global int $kControlIndex;

// create toolbar
string $controlNameArray[] = stringToStringArray($name, " /");
string $controlName = stringArrayToString($controlNameArray, "");

string $createCmd = ("workspaceControl -uiScript \"") + $uiScript
+ ("\" -loadImmediately true -label \"") + $label
+ ("\" -dockToMainWindow ") + $area + " " + $tabbed
+ " " + $controlName;

string $toolbar = `eval($createCmd)`;

// add entry to array
int $count = size($gUIComponentToolBarArray);
$gUIComponentToolBarArray[$count + $kNameIndex] = $name;
$gUIComponentToolBarArray[$count + $kControlIndex] = $toolbar;

return $toolbar;
}

  其实这个生成就是利用 workspaceControl 实现的,我就纳闷了,为什么 HelpLine 实现的是 ToolBar 形式的 UI
  我自己搞的 workspaceControl 就是标签嵌入式的呢?

  于是我将当前的 HelpLine 通过 deleteUI 删除掉,然后自己用 python 代码生成 workspaceControl 命名为 HelpLine
  霎时间真相大白了, Maya 的 workspaceControl 还会根据名字不同生成不同的 UI 组件。

1
2
3
cmds.deleteUI("HelpLine")
cmds.workspaceControl("HelpLine",r=1)
cmds.workspaceControl("HelpLine2",r=1)

  大家可以自个人感受一下 HelpLineHelpLine2 两个 UI 窗口的嵌入 Maya 之后的区别。
附注:写文章的时候去测试,发现名称大小写也不受影响,也可以通过大小写的差异来获取的 UIComponentToolBar

  但是问题来了,不可能将 Maya 一个ToolBar组件给牺牲了,然后嵌入自己的插件。
  但是如果不删除 HelpLine 组件的话,生成就会报错,遇到了这样的矛盾问题。


  后来使用 mayaToQT 将 Maya 的组件转成 Qt 的组件之后,获取组件的 objectName
  发现组件的 objectName 其实是和 Maya 的UI名称保持一致的。于是我做了下面的代码测试。

1
2
3
4
5
6
7
from maya import cmds
from PySide2 import QtWidgets

btn = QtWidgets.QPushButton("myTestButton")
btn.setObjectName("myTestButton")
btn.clicked.connect(lambda:cmds.deleteUI("myTestButton"))
btn.show()

  上面这段代码生成了一个 Maya 按钮,并且点击按钮会删除一个名为 “myTestButton” 的 Maya UI
  虽然我前面没有任何调用 Maya Cmds 的操作,但是我仍然可以通过点击按钮来将自己删除掉
  通过这个原理,我就可以用很 tricky 的方式解决 HelpLine 名称冲突的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def createUIComponentToolBar(ControlName="CustomToolBar"):
"""createUIComponentToolBar
create a Maya Component Tool Bar Widget

:param ControlName: str, defaults to "CustomToolBar"
:type ControlName: str, optional
"""

help_line = mayaToQT("HelpLine")
help_line.setObjectName("_HelpLine")

cmds.workspaceControl("HelpLine",
label=ControlName,
loadImmediately= 1,
initialHeight=20,
heightProperty="fixed",
)

UIComponentToolBar = mayaToQT("HelpLine")
UIComponentToolBar.setObjectName(ControlName)
help_line.setObjectName("HelpLine")

layout = UIComponentToolBar.layout()
# NOTE add spacing
layout.setContentsMargins(10,0,0,0)

return UIComponentToolBar

  通过 setObjectName 可以给 Maya UI 改名
  我可以暂时将 HelpLine 的名称改掉,生成出新的 HelpLine 壳也改名,然后将原本的 HelpLine 改回去,就实现无缝获取 ToolBar 的操作了。


调试功能接入

  解决了上面 Maya2017 执行崩溃的问题和界面搭建问题之后,我们终于可以回到正题从设置调试图标接入调试功能开始了
  其实我开发的时候并不是按着这样的思路弄下去,不过不妨碍我总结的时候重新整理一下不同模块的内容。

  断点调试的基本原理可以参考 Python pdb 调试模块研究
  通过代码最终,我最后找到 cmdloop 函数是导致 等待输入的罪魁祸首
  我可以将 cmdloop 里面的核心代码提取出来,然后将 input 命令改为 while 死循环
  这样就可以通过花式打断循环来返回 pdb 调试用的命令,我也省心许多,不用一一实现各个调试功能了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41



def breakpoint(self,MPDB,frame):
while True:

if self.debug_continue_state :
self.debug_continue_state = False
return "c"

elif self.debug_step_over_state :
self.debug_step_over_state = False
return "n"

elif self.debug_step_into_state :
self.debug_step_into_state = False
return "s"

elif self.debug_step_out_state :
self.debug_step_out_state = False
return "u;;n"

elif self.debug_cancel_state :
self.debug_cancel_state = False
return "q"

elif self.debug_cancel_run_state :
self.debug_cancel_run_state = False
import mpdb
mpdb.quitting = True
return "disable;;c"

elif self.debug_pdb_state :
self.debug_pdb_state = False
text,ok = QtWidgets.QInputDialog.getText(self,self.pdb_title,self.pdb_msg)
if ok and text:
return text

# NOTE Keep Maya alive
QtCore.QCoreApplication.processEvents()
maya.utils.processIdleEvents()

  给 ToolBar 添加一个 breakpoint 死循环函数
  只要点击按钮图标就修改一个状态,返回对应的 pdb 命令就可以实现调试了。
  后续就是将 toolbar 引入到 魔改的 MPDB 类里面去调用获取返回的指令实现 点击图标执行对应的调试动作。

调试面板

  仅仅只是实现按钮效果还是不够的,我觉得还得还原 VScode 的调试体验才行。
  需要将代码显示出来,并且告诉大家目前调试的行数。
  于是我又研究怎么编写一个 CodeEditor 来显示代码。

CodeEditor 编写

  这里要感谢 logcatMaya 插件
  里面提到的 cmdReporterHighlighter.py 我也是完全照搬嵌入到我的 CodeEditor 代码里面去了
  不过由于这个代码高亮需要读取所有的 melInfo 函数,运行会有一点卡顿。(后续通过多线程解决了卡顿)

  另外,这里对我最大的难点就是在对应的行可以给出对应反馈效果。
  原本是打算将 cmdScrollFieldExecuter 直接拿来用的,但是考虑到还要进行断点行的的显示,还是手动实现比较靠谱一点。
  于是再次借助 Stack Overflow 参考了网上实现的 CodeEditor 方案

  至于对 paintLine 函数 主要通过 Qt 的 paintEvent 来实现自定义绘制
  完全参考上面链接里面对左侧行数显示的实现方案,上面的代码实现左侧行高亮当前显示行数。
  我也可以根据这个高亮 QPlainTextEdit 里面的一行,基本上依葫芦画瓢而已。

  当时 QPlainTextEdit 里面需要将 painter 设置到 viewport 上才可以绘制。
  但是绘制了之后,如果点到其他行,之前绘制的效果会留有奇奇怪怪的残留问题,于是又折腾了好久。
  最后我发现 要加上一行 update 代码才可以 self.viewport().update()


  默认的字体设置,空格只有半个字符的宽度,于是还需要我修改一个 monospace 的字体来解决问题,网上咨询之后,推荐使用 VScode 也采用的字体 Courier New
  采用了新字体之后行数的像素高度还需要 +1 微调 否则点击显示的行数会不一致。

信息面板

  信息面板的 TableView 功能还是相当复杂的
  当时在 Stack Overflow 上找到很不错的示例代码,就在它上面进行了魔改 链接
  正则过滤的功能上面已经弄得差不多了,我主要是将 ui 文件里面的组件替换
  并且添加了可以修改变量数据的功能

  修改变量数据的方法也是参考 pdb 的单行代码执行的方式
  核心方式就是 exec code in globals(),locals() ,这个操作将 globals 和 locals 域修改
  全局域和局部域就是一个字典,记录了当前域下所有的变量数据,修改字典等同于对变量进行赋值操作。

i18n

  i18n 就是 internationalization ,具体可以参照 百度百科
  大致就是指代国际化多国语言。

  Qt 其实是配套有 Linguist 语言家来做翻译文本的操作,实现 i18n 非常方便了。
  我们在使用 pyside2-uic 编译出 python 的时候,其实就已经使用了 Qt i18n 的功能。
  所有和文本相关的操作都会挪动 retranslateUi 函数当中,并且上都使用 QtWidgets.QApplication.translate 来获取文本。

  QtWidgets.QApplication.translate 最少接受两个参数,第一个参数用来分组、第二个参数就是默认文本。
  所有的文本设定好之后,可以使用 pylupdate5.exe 来读取 py 文件编译出 ts 文件 (pylupdate5.exe 是 Python PyQt5 库自带的)
  ts文件是 Qt 的翻译标记文件,里面的内容也是 xml ,记录了 QtWidgets.QApplication.translate 在 python 文件的位置,以及翻译的数据情况。
  所以这里的 ts 并不是 Typescript 文件,默认 VScode 会根据后缀识别成 Typescript 的。

  我们可以通过 Qt Linguist 来打开 ts 文件,这个也是 PyQt5 库自带的。 可以使用 anaconda 的版本,自带 PyQt5 。

alt

  翻译完成之后可以使用 Linguist 发布文件,会自动根据 ts 文件生成一个同名的 qm 文件
  qm文件是不可编辑的文件了,Qt可以读取 qm 文件的数据,让 QtWidgets.QApplication.translate 返回当前选择的语言版本的文本。

  具体的使用方法可以参考 Stack Overflow 的使用 Demo 链接
  我也是参照着 eyllanesc 的方法,自己依葫芦画瓢实现的。
  核心方法也是将 setText 之类的函数全部提取成一个单独的函数,当语言切换的时候,让 changeEvent 调用 retranslateUi 函数。

事件扩展处理 eventFilter

  说来感慨,eventFilter 我最初认识这个函数是在弄 Qt 系列教程的时候。
  现在看来这个 事件过滤器 真乃 Qt 的神器,他可以安装任意的组件上,可以接受不同类型事件的回调。
  真的是嵌入信号槽的绝佳函数!~

  后来在 Stack Overflow 上看到一种更加简洁的用法(忘记保留链接了)。
  直接使用 QObject 类来调用 eventFilter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class MiddleClickSignal(QtCore.QObject):
"""addExecuteShortcut 监听鼠标中键事件
"""
middleClicked = QtCore.Signal()
def __init__(self,parent):
super(MiddleClickSignal,self).__init__()
# NOTE 确保自己不会被垃圾回收掉
self.setParent(parent)
parent.installEventFilter(self)

def eventFilter(self,reciever,event):
if event.type() == QtCore.QEvent.Type.MouseButtonPress:
if event.button() == QtCore.Qt.MidButton:
self.middleClicked.emit()
return True
return False

# NOTE 使用例子
Setting_signal = MiddleClickSignal(self.debug_setting)
Setting_signal.middleClicked.connect(partial(self.setPdb,True))

  这样就可以制作一个鼠标中键触发器的功能,而且这种写法既简洁扩展性还很强。
  不过 eventFilter 要响应组件内所有的事件,会有一定的运算浪费。
  但我觉得 GUI 从来都不是 CPU 密集型的操作,快速响应 实现功能 才是 GUI 编程的核心,浪费一些算力是值得的。

遇到的问题

  其实在开发的过程中遇到了不少的坑,有些是解决了有些到目前还悬而未决,在这里给大家分享一下。
  有不少的坑我已经在上面有所提及,这里就不在复述。
  另外下面标题前带 * 号是目前仍然没有解决方案的,因此我可能不去实现或者用了其他方案替代了的。

toolbar 嵌入问题

  由于 toolbar 是通过我自己的特殊手段生成的,有时候关闭了 Maya 之后,Maya 会记录了 UI 的位置。
  下次打开 Maya 的时候还给我创建出来了,但是创建出来是空壳。
  因此我需要先判断一下 workspaceControl 这个 UI 的名称是否存在,存在需要先关闭再嵌入。


  另外原本是想要一步到位生成 workspaceControl 的时候同时嵌入到右侧的
  但是这样会导致 toolbar 整体拉高各种问题,而且怎么调整高度都不奏效。
  后来只好使用 workspaceControl -r 标记将嵌入窗口拉出来显示,接入 调试工具 然后再嵌入到 Maya 里面才稍微解决了问题。

调试面板 修改变量

  调试面板上的数据是根据 Qt MVC 基于 QTableView 实现的
  这样有个好处就是甚至可以通过 createEditor 函数来管理点击修改生成 gui
  当时明显 QTableView 操作的工作量要比 QTableWidget 大很多。

  在修改上,也参考了 pdb 单行修改变量的方法去做的。
  为了方便条用函数,还专门写了一个 modify 信号槽了给外部调用修改域。


  最后比较坑爹的是正则过滤导致执行的修改变量错乱。
  最后的解决方案是获取当前修改 Table 左侧 Header 的行数才能正确处理

代码执行同步域 | 隐藏 pdb 调试中途跳出产生的报错

  由于执行按钮的功能被我改写了,执行的时候获取的是执行函数的域而不是 Maya 脚本编辑器的域
  这会导致一些变量信息打印会报错提示变量不存在的问题。

  后来折腾了好久之后,决定采用 maya.utils.executeInMainThreadWithResult 函数来解决问题。
  当然也可以用 evalDeferred 之类的方法来获取到 全局域 但是这会导致之前永动机报错问题。
  后来也想到可以利用 mel 调用 python 来执行 python 代码 获取 全局域。 但是这样没法准确抓取到报错的信息, python 代码报错反馈确实 mel 执行出错了。
  所以最后发现 maya.utils.executeInMainThreadWithResult 函数可以完美解决域的问题。
  而且用这个函数执行代码,如果中途退出 pdb 调试会报错 BdbQuit 来中断代码执行,最后错误也会返回到这个函数上。
  我可以通过异常处理来掩盖掉错误提示,如果是 exec 执行的话,报错在 pdb 内部,异常处理会让 pdb 调试继续下去的(:з」∠)

  虽然这个方案很好,但是测试的时候还是出问题了。
  使用 maya.utils.executeInMainThreadWithResult 进入调试之后, MainThread 已经被这个执行占用等待结果了。
  如果在这个情况下再次用脚本编辑器执行代码,会导致Maya直接卡死无响应。


  最后折腾了一大圈,我放弃了 maya.utils.executeInMainThreadWithResult 这个函数,虽然完美模拟了 脚本编辑器运行的函数域。
  最后再次参考了 Stack Overflow 上的回答(链接)将 globals() 作为参数传递进来,另外给 mpdb 模块添加 f_locals 和 f_globals 变量来同步函数域。
  使用按钮执行调用的是 mel 的 python ,这个 python 语句也是全局域的,因此可以调用 执行函数的时候将 全局域的 globas() 当做参数传递到函数内。
  函数内通过 update 同步到 globals() 上。
  感觉这种做法有点蠢,不知道大家有没有更好的方案。
  目前的确表面上解决找不到变量的问题。


  最后如何隐藏掉 BdbQuit 报错呢?
  我发现代码是在 bdb.py 文件的 68 行报错。
  使用 decorator 来忽略掉异常会导致代码继续执行。
  后来又是查了 Stack Overflow 之后(链接) 可以通过 sys.exit 来中断执行

  上面的确实现了终止运行又隐藏代码报错了,但是我在使用 Ctrl + E 快键键执行调试的时候。
  点击终止按钮给我强行退出了 Maya_(:з」∠)_
  这个问题我也是摸不着头脑。后来是看 调试面板上 多了个 SystemExit 的异常
  只要在 快键键执行的函数上加上 这个异常处理 就不会导致 Maya 强制退出了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class AddExecuteShortcut(QtCore.QObject):
"""AddExecuteShortcut 监听键盘输入事件
"""
def __init__(self,editor):
super(AddExecuteShortcut,self).__init__()
editor.installEventFilter(self)

def eventFilter(self,reciever,event):
if event.type() == QtCore.QEvent.Type.ShortcutOverride:
key = event.key()
KeySequence = QtGui.QKeySequence(key+int(event.modifiers()))
if KeySequence == QtGui.QKeySequence("Ctrl+E"):
# NOTE Ctrl + E 执行代码
from exceptions import SystemExit
try:
scriptEditorExecute(f_globals=globals(),clear=False)
except SystemExit:
pass
import mpdb
mpdb.quitting = False
return True

return False

* 调试红框提示

  进入调试模式的时候需要用比较显眼的方式告诉用户。
  我最初其实想给 Maya 包裹一个红框来做提示用的。
  后来遇到了一些无法解决的问题作罢了。
  现在的方案其实只是给 Maya 嵌入 QSS #MayaWindow{background:red} 这样修改了主窗口的背景颜色为红色。

alt

  Maya2017很难看到效果,在 Maya2018 下切换工作区就看得很明显了。
  这个方案其实有个小瑕疵的,时间滚动条的背景颜色受到了这个设置的影响,背景颜色变灰色了,不再是暗黑色了。
  这个小瑕疵也是其中一个未解之谜,倒是不影响使用。


  这里还是得重点说说红框的问题,我搞了好久最后给放弃了,这个问题也属实奇怪得很,希望有人能帮忙解惑~

  给程序套上红框,其实我也在 Stack Overflow 上找到了解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# coding:utf-8
from PySide2 import QtGui
from PySide2 import QtWidgets
from PySide2 import QtCore

class OverLay(QtWidgets.QWidget):
BorderColor = QtGui.QColor(255, 0, 0, 255)
BackgroundColor = QtGui.QColor(0, 255, 0, 125)

def __init__(self, parent):
super(OverLay,self).__init__()
self.setAttribute(QtCore.Qt.WA_NoSystemBackground)
self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
self.setWindowFlags(QtCore.Qt.WindowTransparentForInput | QtCore.Qt.FramelessWindowHint)
self.setFocusPolicy( QtCore.Qt.NoFocus )
self.hide()

# self.setEnabled(False)
# self.setAutoFillBackground(True)
# self.setWindowFlags(QtCore.Qt.FramelessWindowHint)

self.setParent(parent)
parent.installEventFilter(self)

def paintEvent(self, event):

# NOTE https://stackoverflow.com/questions/51687692/how-to-paint-roundedrect-border-outline-the-same-width-all-around-in-pyqt-pysi
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.Antialiasing)

rectPath = QtGui.QPainterPath()
height = self.height() - 4
rect = QtCore.QRectF(2, 2, self.width()-4, height)

# NOTE 绘制边界颜色
rectPath.addRoundedRect(rect, 15, 15)
painter.setPen(QtGui.QPen(self.BorderColor, 2, QtCore.Qt.SolidLine,QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin))
painter.drawPath(rectPath)

# # NOTE 绘制背景颜色
# painter.setBrush(self.BackgroundColor)
# painter.drawRoundedRect(rect, 15, 15)

def eventFilter(self, obj, event):
if not obj.isWidgetType():
return False

if self.isVisible():
self.setGeometry(obj.rect())
elif event.type() == QtCore.QEvent.Resize:
self.setGeometry(obj.rect())

return False

if __name__ == "__main__":
button = QtWidgets.QPushButton("click")
frame = OverLay(button)
button.clicked.connect(lambda:frame.setVisible(not frame.isVisible()))
button.show()

  上面的代码经过我的优化,使用更加方便一些。
  可以将代码直接在 Maya 上运行(当然也可以在外部运行 参考

alt

  可以看到 OverLay 类可以覆盖在按钮上但是完全不影响到 按钮的点击触发。
  只要去掉背景颜色的添加,只红框就是我想要的 Debug 提示效果了。


  下面就需要获取 Maya 的组件然后添加 Overlay 组件测试效果了
  上文已经提到过 objectName 就对应 Maya UI 的名称了,如果可以将 Maya 所有的组件打印出来就清晰了。
  这里可以利用递归打印的方法来实现,我在 utils.py 里面有具体的代码来循环打印 Maya 所有的 UI 以及 objectName

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

from maya import OpenMayaUI
try:
from Qt import QtWidgets
from Qt.QtCompat import wrapInstance
except:
from PySide2 import QtWidgets
from shiboken2 import wrapInstance

def mayaToQT(name,wrapType=QtWidgets.QWidget):
"""
Maya -> QWidget

:param str name: Maya name of an ui object
:return: QWidget of parsed Maya name
:rtype: QWidget
"""
ptr = OpenMayaUI.MQtUtil.findControl( name )
if ptr is None:
ptr = OpenMayaUI.MQtUtil.findLayout( name )
if ptr is None:
ptr = OpenMayaUI.MQtUtil.findMenuItem( name )
if ptr is not None:
return wrapInstance( long( ptr ), wrapType )

def mayaWindow():
"""
Get Maya's main window.

:rtype: QMainWindow
"""
window = OpenMayaUI.MQtUtil.mainWindow()
window = wrapInstance(long(window), QtWidgets.QMainWindow)
return window

def traverseChildren(parent,indent="",log=True):
"""traverseChildren
Traverse into the widget children | print the children hierarchy

:param parent: traverse widget
:type parent: QWidget
:param indent: indentation space, defaults to ""
:type indent: str, optional
:param log: print the data, defaults to True
:type log: bool, optional
"""
if log:
print ("%s%s %s" % (indent,parent,parent.objectName()))

if not hasattr(parent,"children"):
return

for child in parent.children():
traverseChildren(child,indent=indent+" ")

if __name__ == "__main__":
traverseChildren(mayaWindow())

  使用上面的代码就可以将 Maya 所有的 Qt 组件按照树形结果打印出来
  打印结果可以参照这个 github 仓库上的 文件
  VScode 可以根据文件的缩进进行代码收缩,看起来舒服很多。
  从中可以看到 Maya 内部组件的信息以及 相关的 objectName

  开始我是直奔主题,直接让 MainWindow 接上 Overlay 类看看红框效果。(我将 Overlay 的绿色背景去掉了)

1
2
3
4
main_win = mayaWindow()
frame = OverLay(main_win)
frame.show()
# frame.setParent(None)

alt

  但是我发现我将 WA_TransparentForMouseEvents 之类的属性全部打上还是存在组件覆盖遮挡的问题。
  这让我很困惑,不知道到底是哪里导致遮挡产生。

  后来我就根据 上面打印的 组件树 一个一个测试添加效果。
  结果发现 脚本编辑器 加上居然没有问题

1
2
3
4
5
# NOTE 脚本编辑器
widget = mayaToQT("scriptEditorPanel1Window")
frame = OverLay(widget)
frame.show()
# frame.setParent(None)

alt

  这就让我很是头疼,完全没有头绪。
  后面逐一测试了 Maya 内各个主要的组件,还有 视窗窗口 和 主菜单栏 是会受到遮挡影响的。

1
2
3
4
widget = mayaToQT("MainPane")
frame = OverLay(widget)
frame.show()
# frame.setParent(None)

alt

  其他的组件都不存在遮挡问题,目前对我来说无解,所以暂时弃用了这个 Debug 提示方案。

2021-4-6 setMask 解决方案

  最近研究 Qt Designer Overlay 的方案也遇到类似的问题。
  通过 setMask 应该可以解决 链接

多线程加速加载

  插件加载需要 2 - 3s 的加载时间。
  在这个过程中 Maya 处于卡死状态,我希望通过多线程保持 Maya UI 的响应。
  一开始我是看了 Qt 多线程 和 python 多线程的对比。
  网上提到 Qt Gui 相关的多线程建议用 QThread 而 Python 运算相关的还是建议用 threading 模块。
  于是我就是通了 Python 内置 threading 库。

  经过测试,我发现多线程的效果和不使用 threading 完全一样
  写文章的时候我重新求证了一下 Qt 多线程加速优化的可能性。

参考

  经过了一轮研究之后,有很多收获,我写了一篇新的文章记录 - Maya Qt 多线程提升交互体验

总结

  经过这个插件开发的锻炼,进一步加深了对 Qt 开发的理解。
  趁着这段时间有空,最近还要开发已经很久计划要做的 Maya 工具 CommandLauncher。
  并且还要将最近博客上欠下的文章补上。