Skip to content

Instantly share code, notes, and snippets.

@yamahigashi
Last active November 25, 2023 11:07
Show Gist options
  • Save yamahigashi/49bd1a8866c119ae78a166a8656a380e to your computer and use it in GitHub Desktop.
Save yamahigashi/49bd1a8866c119ae78a166a8656a380e to your computer and use it in GitHub Desktop.
# -*- coding: utf-8 -*-
"""Module for generate menu from module."""
import os
import re
import six
import sys
import inspect
import functools
import traceback
from textwrap import dedent
from Qt.QtWidgets import (
QApplication,
QWidget,
QVBoxLayout,
QLabel,
QWidgetAction,
# QTextBrowser,
QTextEdit,
)
from Qt.QtGui import (
QCursor,
QPixmap,
# QTextDocument,
)
from Qt.QtCore import (
QObject,
Qt as C,
QEvent,
QPoint,
QTimer,
)
import maya.cmds as cmds
from gml_maya.util import jpn
from logging import getLogger, WARN, DEBUG, INFO, StreamHandler # pylint: disable=unused-import # noqa
# ----------------------------------------------------------------------------
if sys.version_info > (3, 0):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import (
Optional, # noqa: F401
Dict, # noqa: F401
List, # noqa: F401
Tuple, # noqa: F401
Pattern, # noqa: F401
Callable, # noqa: F401
Any, # noqa: F401
Text, # noqa: F401
Generator, # noqa: F401
Union # noqa: F401
)
from types import ( # NOQA: F401 pylint: disable=unused-import
ModuleType, # noqa: F401
)
# ----------------------------------------------------------------------------
handler = StreamHandler() # pylint: disable=invalid-name
handler.setLevel(DEBUG)
logger = getLogger(__name__) # pylint: disable=invalid-name
logger.setLevel(DEBUG)
logger.setLevel(INFO)
logger.addHandler(handler)
logger.propagate = False
# ----------------------------------------------------------------------------
__all__ = [
"command_item",
"register_module_menu",
"deregister_module_menu",
"register_menu_help",
]
# ----------------------------------------------------------------------------
# decorators
# ----------------------------------------------------------------------------
def command_item(label, folder=None, divider=None, inviewmessage=False):
# type: (Text, Optional[Text], Optional[Text], bool) -> Callable[[Any], Any]
"""Decorator that converts a function into Maya command menu."""
def decorator(func):
# type: (Callable[[Any], Any]) -> Callable[[Any], Any]
@functools.wraps(func)
def wrap(*_args, **_kwargs):
# type: (Any, Any) -> Any
amg = ""
try:
results = func() # type: ignore
try:
status_message = "successfully completed"
amg = """<p style="color:#FFFF22></p>{} finished <hl style="color:#00fcff>{}</hl>.""".format(jpn(label), status_message)
except UnicodeEncodeError:
amg = """<p style="color:#FFFF22></p>job successfully finished <hl>{}</hl>.<br>Please see the script editor log to more detail."""
return results
except Exception:
traceback.print_exc()
traceback.print_stack()
amg = """<p style="color:#FFFF22></p> failed<hl>{}</hl>.<br>Please see the script editor log to more detail."""
raise
finally:
if inviewmessage:
cmds.inViewMessage(
amg=amg,
pos="topCenter",
fade=True,
fadeStayTime=3400
)
# these lines are sample for using not wrapped function but using class prototyping.
"""
CommandMenu = type(
'GmlCommandMenuItem',
(GmlCommandMenu,),
{'__doc__': func.__doc__}
)
CommandMenu.__name__ = func.__name__
CommandMenu.__module__ = func.__module__
CommandMenu.__call__ = staticmethod(func)
setattr(CommandMenu, "linenumber", inspect.getsourcelines(func)[1])
setattr(CommandMenu, "folder", folder)
setattr(CommandMenu, "label", label)
setattr(CommandMenu, "divider", divider)
return CommandMenu()
"""
try:
setattr(wrap, "linenumber", inspect.getsourcelines(func)[1])
except TypeError:
setattr(wrap, "linenumber", 10000)
setattr(wrap, "folder", folder)
setattr(wrap, "label", label)
setattr(wrap, "divider", divider)
setattr(wrap, "is_maya_menu_item", True)
return wrap
return decorator
# ----------------------------------------------------------------------------
# exported functions
# ----------------------------------------------------------------------------
def get_folder_name_for_module(module):
# type: (ModuleType) -> Text
"""Returns escaped packages name of given module"""
package_path = module.__name__
safe_package_name = package_path.replace(".", "_")
return safe_package_name
def register_module_menu(module, menu_label, parent_menu_name):
# type: (ModuleType, Text, Text) -> None # noqa
"""Register command of given module under given parent"""
package_path = module.__name__
safe_package_name = get_folder_name_for_module(module)
folder = get_menu_by_label(parent_menu_name, menu_label)
if not folder:
folder = cmds.menuItem(
safe_package_name,
label=jpn(menu_label),
subMenu=True,
tearOff=True,
parent=parent_menu_name
) # type: Any
members = [o for o in inspect.getmembers(module) if _is_menu_item(o[1])]
for klass_info in inspect.getmembers(module, inspect.isclass):
logger.debug(klass_info)
if not klass_info[1].__module__ == module.__name__:
continue
for method in inspect.getmembers(klass_info[1], inspect.ismethod):
logger.debug(method)
if _is_menu_item(method[1]):
members.append(method)
members.sort(key=_linenumber)
for func in members:
parent = folder
func_name = func[0]
label = func[1].label
sub_folder_label = func[1].folder
divider = func[1].divider
annotation = jpn(_get_annotation(func[1], as_one_line=True))
if sub_folder_label:
sub_folder_parent = parent # type: Text
for hierarchy in sub_folder_label.split("/"):
# sub_folder_name = hierarchy.replace(" ", "_").replace("-", "_")
# sub_folder_name = "{}_{}".format(safe_package_name, sub_folder_name)
sub_folder = get_menu_by_label(sub_folder_parent, hierarchy) or "" # type: Any
if not sub_folder:
sub_folder = cmds.menuItem(
hierarchy,
label=jpn(hierarchy),
subMenu=True,
tearOff=True,
parent=sub_folder_parent
)
sub_folder_parent = sub_folder
parent = sub_folder_parent
if isinstance(divider, six.string_types):
cmds.menuItem(
dividerLabel=jpn(divider),
parent=parent,
divider=True
)
object_name = "{}_{}".format(package_path, func_name)
menu_item_path = cmds.menuItem(
object_name,
label=jpn(label),
parent=parent,
echoCommand=True,
annotation=annotation,
command=func[1],
) # type: Any
if annotation:
annotation = jpn(_get_annotation(func[1], as_one_line=False))
register_menu_help(module, menu_item_path, annotation)
def get_menu_by_label(parent, label):
# type: (Text, Text) -> Optional[Text]
"""Returns menu item name by given label under given parent menu"""
for sub_menu in cmds.menu(parent, q=True, itemArray=True) or []: # type: ignore
sub_menu_label = cmds.menuItem(sub_menu, q=True, label=True)
if label == sub_menu_label:
return parent + "|" + sub_menu
def get_menu_by_name(parent, name):
# type: (Text, Text) -> Optional[Text]
"""Returns menu item name by given name"""
for sub_menu in cmds.menu(parent, q=True, itemArray=True) or []: # type: ignore
## exists = cmds.menuItem(sub_folder_name, exists=True)
if name == sub_menu:
return sub_menu
def deregister_module_menu(module):
# type: (ModuleType) -> None # noqa
"""Deregister commands given module from Maya menu."""
if not module.__package__:
logger.error("missing module name %s", module)
return
safe_package_name = module.__package__.replace(".", "_")
members = [o for o in inspect.getmembers(module) if isinstance(o[1], GmlCommandMenu)]
for func in members:
func_name = func[0]
item_name = "{}_{}".format(safe_package_name, func_name)
try:
cmds.deleteUI(item_name, menuItem=True)
except RuntimeError:
logger.error("menu item: %s can not delete", item_name)
try:
cmds.deleteUI("{}".format(safe_package_name), menuItem=True)
except RuntimeError:
logger.error("menu folder: %s can not delete", safe_package_name)
# ----------------------------------------------------------------------------
# Hover Help Widget
# ----------------------------------------------------------------------------
class HoverTooltipWidget(QWidget):
"""Hover help widget"""
MAX_IMAGE_WIDTH = 600
def __init__(self, message):
super(HoverTooltipWidget, self).__init__()
self.setWindowFlags(
# C.FramelessWindowHint |
# C.WindowDoesNotAcceptFocus |
C.WindowStaysOnTopHint |
C.ToolTip
)
layout = QVBoxLayout()
self.contents = QTextEdit(self)
layout.addWidget(self.contents)
# to avoid image display issue, use QLabel instead of QTextEdit with html
self.images = re.findall(r'<img alt="" src="([^"]+)" />', message)
message = re.sub(r'<p>(<img alt="" src="([^"]+)" />[ \n]*)+</p>', "", message)
self.contents.setHtml(message)
if len(self.images) == 0:
pass
elif len(self.images) == 1:
pixmap = QPixmap(self.images[0])
width = min(pixmap.width(), self.MAX_IMAGE_WIDTH)
pixmap = pixmap.scaledToWidth(width)
img = QLabel(self)
img.setPixmap(pixmap)
layout.addWidget(img)
else:
self.rotate_images = ImageRotatingWidget(self, self.images)
layout.addWidget(self.rotate_images)
self.setLayout(layout)
self.hide()
def showEvent(self, event):
super(HoverTooltipWidget, self).showEvent(event)
self.adjust_contets_size()
def adjust_contets_size(self):
# type: () -> None
"""Adjust contents size"""
doc_height = self.contents.document().size().height()
doc_height = doc_height + 30
self.contents.setFixedHeight(doc_height)
self.contents.adjustSize()
self.adjustSize()
# avoid garbage collection, store filters in global variable
__MENU_HELP_EVENT_FILTERS__ = [] # type: List[HoverEventFilter]
class HoverEventFilter(QObject):
"""Hover event filter.
This filter shows and hides given widget on hover events.
"""
def __init__(self, hover_widget):
super(HoverEventFilter, self).__init__()
self.hover_widget = hover_widget
self.delete_later = False
def eventFilter(self, watched, event):
# type: (QObject, QEvent) -> bool
"""Event filter."""
global __MENU_HELP_EVENT_FILTERS__ # pylint: disable=global-statement
if any((
event.type() == QEvent.HoverLeave,
event.type() == QEvent.Leave,
event.type() == QEvent.Hide,
event.type() == QEvent.FocusOut,
event.type() == QEvent.WindowDeactivate,
event.type() == QEvent.Enter, # Explicitly squash, quick focus change will trigger this unintentionally
)):
self.hover_widget.hide()
self.deleteLater()
self.delete_later = True
try:
__MENU_HELP_EVENT_FILTERS__.remove(self)
except ValueError:
pass
return True
if any((
event.type() == QEvent.HoverEnter,
)):
if self.delete_later:
return True
self.hover_widget.show()
return True
return False
class ImageRotatingWidget(QWidget):
"""Rotating image widget"""
DURATION_MILLISECOND = 800 # 1.2 seconds
def __init__(self, parent, image_paths):
# type: (QWidget, List[Text]) -> None
super(ImageRotatingWidget, self).__init__(parent)
self.layout = QVBoxLayout(self) # type: QVBoxLayout
self.image_label = QLabel(self)
self.layout.addWidget(self.image_label)
self.image_paths = image_paths
self.current_image_index = 0
# start timer
self.timer = QTimer(self)
self.timer.timeout.connect(self.update_image)
self.timer.start(self.DURATION_MILLISECOND)
self.update_image()
def update_image(self):
current_image_path = self.image_paths[self.current_image_index]
pixmap = QPixmap(current_image_path)
self.image_label.setPixmap(pixmap)
self.current_image_index = (self.current_image_index + 1) % len(self.image_paths)
def showEvent(self, event):
super(ImageRotatingWidget, self).showEvent(event)
self.current_image_index = 0
current_image_path = self.image_paths[self.current_image_index]
pixmap = QPixmap(current_image_path)
self.image_label.setPixmap(pixmap)
self.current_image_index = (self.current_image_index + 1) % len(self.image_paths)
def get_maya_window():
# type: () -> QWidget
for obj in QApplication.topLevelWidgets():
if obj.objectName() == "MayaWindow":
return obj
else:
raise Exception("Maya main window not found")
def show_menu_tooltip_help(tootip_widget):
# type: (QWidget) -> None
"""Shows menu help widget on right side of given widget."""
global __MENU_HELP_EVENT_FILTERS__ # pylint: disable=global-statement
event_filter = HoverEventFilter(tootip_widget)
under_cursor_widget = QApplication.widgetAt(QCursor.pos())
under_cursor_widget.installEventFilter(event_filter)
__MENU_HELP_EVENT_FILTERS__.append(event_filter)
container_global_pos = under_cursor_widget.mapToGlobal(QPoint(0, 0))
help_widget_x = container_global_pos.x() + under_cursor_widget.width() + 2
help_widget_y = container_global_pos.y() - 20
tootip_widget.move(help_widget_x, help_widget_y)
tootip_widget.show()
def register_menu_help(module, menu_item_path, message):
# type: (ModuleType, Text, Text) -> None
"""Registers menu help widget to Maya menu."""
script_dir = os.path.dirname(os.path.abspath(module.__file__))
html = render_markdown(message, script_dir)
tootip_widget = HoverTooltipWidget(html)
target_action, _ = get_qt_object_at_path(menu_item_path)
# the target_widget is not the same as the widget when tear off.
# so, we get widget by cursor position. and discard the target_widget now.
target_action.hovered.connect(lambda: show_menu_tooltip_help(tootip_widget))
def get_qt_object_at_path(menu_item_path):
# type: (Text) -> Tuple[QWidgetAction, QWidget]
"""Retrieves QWidget from given Maya menu item name.
In Qt, menu item name is in format of "menu_item_path".
But in Maya, menu item name is in format of "menu|sub_menu|menu_item".
And the menu hierarchy represented in Qt is,
Case 1: Menu item is a menu
Parent (QMenu)
- MenuA action (QWidgetAction) # This contains some attributes for subsequently listed widgets
- MenuA widget (QWidget) # This is the actual widget that we want to get
- MenuB action (QWidgetAction)
- MenuB widget (QWidget)
...
Case 2: Menu item is a sub menu
Parent (QMenu)
- SubParent action (QWidgetAction)
- SubParent widget (QWidget)
- SubParent menu (QMenu)
- MenuA action (QWidgetAction) # This contains some attributes for subsequently listed widgets
- MenuA widget (QWidget) # This is the actual widget that we want to get
- MenuB action (QWidgetAction)
- MenuB widget (QWidget)
...
...
"""
def _find_recursive(parent, name, offset):
# type: (QObject, Text, int) -> Optional[Union[QWidget, QWidgetAction]]
for child in parent.children():
if child.objectName() == name:
if isinstance(child, QWidgetAction):
index = child.parentWidget().children().index(child)
target_widget = child.parentWidget().children()[index + offset]
return target_widget
else:
return child
else:
x = _find_recursive(child, name, offset)
if x:
return x
path = menu_item_path.replace("MayaWindow|", "") # Remove MayaWindow prefix
# Because the object name may not be unique,
# we need to find the parent menu widget first by hierarchy.
parent = get_maya_window()
for menu in path.split("|")[0:-1]:
p = _find_recursive(parent, menu, 2)
if p is not None:
parent = p
else:
print("Menu widget not found: {}, {}".format(parent, menu))
action = _find_recursive(parent, path.split("|")[-1], 0)
widget = _find_recursive(parent, path.split("|")[-1], 1)
if action is None or widget is None:
raise Exception("Menu widget not found: {}".format(menu_item_path))
if not isinstance(action, QWidgetAction):
raise Exception("Menu widget not found: {}".format(menu_item_path))
if not isinstance(widget, QWidget):
raise Exception("Menu widget not found: {}".format(menu_item_path))
return action, widget
# ----------------------------------------------------------------------------
# Internal functions
# ----------------------------------------------------------------------------
def _linenumber(m): # pylint: disable=invalid-name
# type: (Tuple[Text, Any]) -> int
try:
return m[1].linenumber
except AttributeError:
return 999999
def _generate_invoked_code(package_path, func_name):
return dedent("""
import {} as x
x.{}()
""".format(package_path, func_name))
def _is_menu_item(item):
# type: (Any) -> bool
try:
return hasattr(item, "is_maya_menu_item")
except AttributeError:
return False
# return isinstance(item, GmlCommandMenu)
def _get_annotation(func, as_one_line=False):
# type: (Callable[[Any], Any], bool) -> Text
doc = func.__doc__
if not doc:
return ""
doc = dedent(doc)
if as_one_line:
lines = [x.strip() for x in doc.splitlines(True)]
doc = "".join(lines)
return doc
def render_markdown(md_text, script_directory):
# type: (Text, Text) -> Text
"""Renders markdown text to HTML."""
import markdown
if sys.version_info[0] == 2:
try:
md_text = md_text.decode("cp932")
except UnicodeEncodeError:
pass
html = markdown.markdown(md_text)
resolved_html = re.sub(
r'(src|href)="/?(.*)"',
r'\1="{}/\2"'.format(script_directory.replace(os.sep, "/")),
html
)
resolved_html = resolved_html.replace(os.sep, "/")
return resolved_html
class GmlCommandMenu(object):
"""Base class that represents a command menu item."""
pass
# ----------------------------------------------------------------------------
# Inject menu tooltip to Maya menu
# ----------------------------------------------------------------------------
def inject_annotation_as_menu_tooltip_to_maya_menu(main_menu_name):
# type: (Text) -> None
"""Injects help tooltip to Maya menu items."""
import maya.mel as mel
import gml_maya.menu as m
# Get menu items
kids = [] # type: List[Text]
kids = cmds.menu(main_menu_name, q=True, itemArray=True) # type: ignore
if not kids:
command = cmds.menu(main_menu_name, q=True, postMenuCommand=True) # type: ignore
mel.eval(command)
kids = cmds.menu(main_menu_name, q=True, itemArray=True) # type: ignore
if not kids:
raise Exception("Failed to get menu items: {}".format(main_menu_name))
if not isinstance(kids, list):
kids = [kids]
# Register menu help
for kid in kids:
path = main_menu_name + "|" + kid
annotation = cmds.menuItem(path, q=True, annotation=True) # type: Any
# if not isinstance(annotation, str):
# logger.error("Failed to get annotation: {}".format(path))
# continue
try:
inject_annotation_as_menu_tooltip_to_maya_menu(path)
except RuntimeError:
pass
if not annotation.strip():
continue
register_menu_help(m, path, annotation)
def inject_menu_tooltip_to_maya_menu(main_menu_name, label, message):
import gml_maya.menu as m
kids = cmds.menu(main_menu_name, q=True, itemArray=True) or [] # type: ignore
for kid in kids:
menu_item_path = main_menu_name + "|" + kid
l = cmds.menuItem(menu_item_path, q=True, label=True)
print(menu_item_path, l, label, label in l)
if label in l:
break
else:
raise Exception("Menu item not found: {}".format(label))
register_menu_help(m, menu_item_path, message)
# ----------------------------------------------------------------------------
# Debug
# ----------------------------------------------------------------------------
def _debug_print_all_menus():
def recursive(parent):
for child in parent.children():
print(parent, child.objectName(), child)
recursive(child)
recursive(get_maya_window())
if __name__ == "__main__":
# _debug_print_all_menus()
menu_items = [
"MayaWindow|mainFileMenu",
# "MayaWindow|mainEditMenu",
# "MayaWindow|mainCreateMenu",
# "MayaWindow|mainSelectMenu",
# "MayaWindow|mainModifyMenu",
# "MayaWindow|mainDisplayMenu",
# "MayaWindow|mainWindowMenu",
# "MayaWindow|mainMeshMenu",
# "MayaWindow|mainEditMeshMenu",
# "MayaWindow|mainMeshToolsMenu",
# "MayaWindow|mainMeshDisplayMenu",
# "MayaWindow|mainCurvesMenu",
]
for item in menu_items:
inject_annotation_as_menu_tooltip_to_maya_menu(item)
message = r"""
###Add Divisions
Splits selected polygon components (edges or faces) into smaller components. Add Divisions is useful when you need to add detail to an existing polygon mesh in either a global or localized manner. Polygon faces can be divided into three-sided (triangles) or four-sided (quadrangles) faces. Edges can be subdivided so that the number of sides on a face is increased.
Select Edit Mesh > Add Divisions > to set the Add Divisions Options.
Note: The contents of the Add Divisions Options window changes depending on what component selection mode you are currently in.
"""
inject_menu_tooltip_to_maya_menu("MayaWindow|mainEditMeshMenu", "Add Divisions", message)
message = r"""
###Bevel
A Bevel expands each selected vertex and edge into a new face, rounding the edges of a polygon mesh. You can position these new faces at an offset from the original edge or scale them towards the original face center using the Bevel Options.
Performing a bevel operation on any mesh produces predictable results. In the following example, several corner edges are beveled. All of the beveled edges are equal in length, and the new segments are parallel. To circularize part of a mesh
![](images/bevel1.png)
![](images/bevel2.png)
"""
inject_menu_tooltip_to_maya_menu("MayaWindow|mainEditMeshMenu", "Bevel", message)
message = r"""
###Circularize components
Circularizing vertices, edges, or faces reorganizes them into a perfect circle using the selection's center as the circle's center. This is useful when you want to build structures that jut out of existing shapes. See Circularize command.
Note: Although circularize technically always creates a circular shape, you can use it to create other basic shapes by selecting a different number of components (see below).
To circularize part of a mesh
Select a set of vertices, edges, or faces on the mesh.
For vertices: You must select at least three vertices. The number of selected vertices will determine the shape of the circularized area (i.e. three creates a triangle, four creates a square, five creates a pentagon, etc).
For edges: Circularize is performed on each closed edge loop.
For faces: Circularize is performed on each set of faces that share at least one common edge.
Select Edit Mesh > Circularize from the Modeling menu set, or Shift + right-click and select Circularize Components.
The selected components are converted into a circle.
Adjust the options in the in-view editor to change the appearance of the circularization. In particular:
Use Normal Offset to push the new circle away or toward the original object.
Use Alignment to define whether or not the circle conforms to the original object surface.
Use Supporting Edges to convert resulting n-gons into tris.
Averages the directions of vertex normals. This affects the appearance of shaded polygons.
![](images/circularize.png)
"""
inject_menu_tooltip_to_maya_menu("MayaWindow|mainEditMeshMenu", "Circularize", message)
import sys
import os
import maya.cmds as cmds
import gml_maya.menu as menu
import gml_maya.decorator as deco
@menu.command_item("Reset bindpose", divider="Skinning")
def reset_bindpose():
"""
### Reset Bindpose
- select bone
- execute this command
![](images/reset_bindpose.png)
"""
import maya.cmds as cmds
selection = cmds.ls(sl=True, long=True)
bones = cmds.ls(sl=True) or []
bindposes = list(set(cmds.listConnections(bones, d=True, type="dagPose")))
cmds.delete(bindposes)
cmds.dagPose(bp=True, save=True)
def register(parent):
# type: (Text) -> None
import personal_maya.modeling.menu as m
menu.register_module_menu(m, "Modeling", parent)
def deregister():
# type: () -> None
import personal_maya.modeling.menu as m
menu.deregister_module_menu(m)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment