From 94791ba2ebc854d02acbbc3197fb5d21d047872b Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Wed, 8 Jul 2026 08:20:30 +0900 Subject: [PATCH 01/25] AnimPicker: Modernize module (Phase 1); fix curve<->picker round-trip Closes #598 Behavior-preserving cleanup of the anim_picker module (internal version -> 2.0.0), the base for the planned decomposition and feature work. - Storage: picker data on the PICKER_DATAS node is now clean JSON (json.dumps/ json.loads) instead of a stringified Python literal read back with eval(); no arbitrary code execution on load. BREAKING: pickers stored only in a scene node with no external .pkr are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected). - Fix #598 "Picker shape from curve not working": store the real tab name on the extraction group so the round-trip survives Maya node renaming (default -> default1, spaces, duplicates) instead of silently dropping edited data; guard shape-less transforms during curves->picker; remove the json true->True replace hack that corrupted names/paths containing "true"; warn on unmatched tabs. - Fix latent bugs: .format() typo, a setAttr that never hid the data node, an assertion that never fired on referenced nodes, an always-false dict type check. - Python 3 only: drop __future__ imports, super(Class, self) -> super(), remove module-reload NameError guards. - Logging via mgear.log instead of print/sys.stderr (user-facing menu prints kept). - SelectionCheck migrated from Maya API 1.0 to maya.api.OpenMaya (API 2.0). - Remove commented-out PySide2 import blocks, stale TODOs, and dead code. --- release/scripts/mgear/anim_picker/__init__.py | 6 - release/scripts/mgear/anim_picker/gui.py | 116 ++++++++++-------- .../mgear/anim_picker/handlers/__init__.py | 6 - .../anim_picker/handlers/action_handlers.py | 19 +-- .../anim_picker/handlers/file_handlers.py | 17 +-- .../anim_picker/handlers/maya_handlers.py | 85 +++++++------ .../anim_picker/handlers/mode_handlers.py | 6 - .../anim_picker/handlers/python_handlers.py | 13 +- release/scripts/mgear/anim_picker/menu.py | 5 - .../scripts/mgear/anim_picker/picker_node.py | 45 ++++--- release/scripts/mgear/anim_picker/version.py | 6 +- .../mgear/anim_picker/widgets/basic.py | 12 +- .../anim_picker/widgets/overlay_widgets.py | 18 +-- .../anim_picker/widgets/picker_widgets.py | 17 +-- releaseLog.rst | 10 ++ 15 files changed, 177 insertions(+), 204 deletions(-) diff --git a/release/scripts/mgear/anim_picker/__init__.py b/release/scripts/mgear/anim_picker/__init__.py index 574a2882..de036d9e 100644 --- a/release/scripts/mgear/anim_picker/__init__.py +++ b/release/scripts/mgear/anim_picker/__init__.py @@ -1,9 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - - from . import gui from . import version diff --git a/release/scripts/mgear/anim_picker/gui.py b/release/scripts/mgear/anim_picker/gui.py index bce78060..7d377ca6 100644 --- a/release/scripts/mgear/anim_picker/gui.py +++ b/release/scripts/mgear/anim_picker/gui.py @@ -1,8 +1,3 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - # python import os import copy @@ -24,17 +19,9 @@ from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore - -# from mgear.vendor.Qt import QtOpenGL from mgear.vendor.Qt import QtCompat from mgear.vendor.Qt import QtWidgets -# debugging -# from PySide2 import QtGui -# from PySide2 import QtCore -# from PySide2 import QtOpenGL -# from PySide2 import QtWidgets - # module from . import menu from . import version @@ -47,10 +34,7 @@ from .handlers import __SELECTION__ # constants ------------------------------------------------------------------- -try: - _CLIPBOARD -except NameError as e: - _CLIPBOARD = [] +_CLIPBOARD = [] ANIM_PICKER_TITLE = "Anim Picker {ap_version} | mGear {m_version}" @@ -149,9 +133,7 @@ def eventFilter(self, QObject, event): self.deleteLater() except RuntimeError: pass - return super(APPassthroughEventFilter, self).eventFilter( - QObject, event - ) + return super().eventFilter(QObject, event) class OrderedGraphicsScene(QtWidgets.QGraphicsScene): @@ -174,9 +156,9 @@ def __init__(self, parent=None): self.set_default_size() self._z_index = 0 - def set_size(self, width, heith): + def set_size(self, width, height): """Will set scene size with proper center position""" - self.setSceneRect(-width / 2, -heith / 2, width, heith) + self.setSceneRect(-width / 2, -height / 2, width, height) def set_default_size(self): self.set_size( @@ -343,7 +325,6 @@ def __init__(self, namespace=None, main_window=None): self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter) - # TODO # Set selection mode self.setRubberBandSelectionMode(QtCore.Qt.IntersectsItemBoundingRect) self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) @@ -1012,7 +993,10 @@ def set_background(self, path=None): path = self.apply_background_fallback_logic(path) # Check that path exists if not (path and os.path.exists(path)): - print("# background image not found: '{}'".format(path)) + mgear.log( + "anim_picker: background image not found: '{}'".format(path), + mgear.sev_warning, + ) return self.background_image_path = path @@ -1191,7 +1175,7 @@ def get_data(self): # Add background to data if self.background_image_path: bg_fp = r"{}".format(self.background_image_path) - data["background"] = json.dumps(bg_fp).replace('"', "") + data["background"] = bg_fp data["background_size"] = self.get_background_size().toTuple() # Add items to data @@ -1300,10 +1284,20 @@ def convert_picker_to_curves(self): grp = pm.group(em=True, n=PICKER_EXTRACTION_NAME) attribute.lockAttribute(grp) - # deletes existing tab group and recreates it - if pm.objExists(tab["name"]): - pm.delete(tab["name"]) + # Delete a previous extraction group for this tab, matched by the + # stored tab name rather than the node name: Maya may rename the group + # (e.g. "default" -> "default1", or spaces -> "_"), so the node name is + # not a reliable key. + for existing in grp.listRelatives() or []: + if ( + pm.hasAttr(existing, "tabName") + and existing.tabName.get() == tab["name"] + ): + pm.delete(existing) picker_grp = pm.group(em=True, n=tab["name"], p=grp) + # Store the real tab name so the round-trip does not depend on the + # (possibly Maya-mangled) group node name. + attribute.addAttribute(picker_grp, "tabName", "string", tab["name"]) picker_grp.sy >> picker_grp.sx attribute.lockAttribute( picker_grp, ["tz", "rx", "ry", "rz", "sx", "sz", "v"] @@ -1432,7 +1426,11 @@ def delete_extraction_grp(self): try: pm.delete(PICKER_EXTRACTION_NAME) except Exception as e: - print(e) + mgear.log( + "anim_picker: failed to delete extraction group: " + "{}".format(e), + mgear.sev_warning, + ) def convert_curves_to_picker(self): """get the information from the created picker curves and reset the @@ -1442,7 +1440,13 @@ def convert_curves_to_picker(self): new_data = {"tabs": []} for tab_grp in grp.listRelatives(): - new_data["tabs"].append({"name": tab_grp.name()}) + # Recover the real tab name (the group node may have been renamed + # by Maya, e.g. "default" -> "default1"). + if pm.hasAttr(tab_grp, "tabName"): + tab_name = tab_grp.tabName.get() + else: + tab_name = tab_grp.name() + new_data["tabs"].append({"name": tab_name}) new_data["tabs"][-1]["data"] = {"items": []} bg_imagePlane = tab_grp.listRelatives(type="imagePlane", ad=True) @@ -1454,11 +1458,11 @@ def convert_curves_to_picker(self): bg_imagePlane[0].height.get(), ] - for item_curve in [ - ic - for ic in tab_grp.listRelatives() - if ic.getShape().type() != "imagePlane" - ]: + for item_curve in tab_grp.listRelatives(): + shape = item_curve.getShape() + if shape is None or shape.type() == "imagePlane": + continue + item_data = {} # color @@ -1508,18 +1512,31 @@ def convert_curves_to_picker(self): # Create a lookup dictionary for fast matching new_data_lookup = {d["name"]: d for d in new_data["tabs"] if "name" in d} + # Surface converted tabs that don't match any current picker tab, so + # the merge below does not silently drop their edited data. + current_names = { + d.get("name") for d in data.get("tabs", []) if "name" in d + } + for converted_name in new_data_lookup: + if converted_name not in current_names: + mgear.log( + "anim_picker: converted tab '{}' has no matching tab in " + "the current picker; its data was not applied".format( + converted_name + ), + mgear.sev_warning, + ) + # Replace the matching dictionaries in data updated_data = {"tabs": []} updated_data["tabs"] = [ new_data_lookup.get(d.get("name"), d) if "name" in d else d for d in data["tabs"] ] - data_node = pm.PyNode(str(data_node)) - data_node.picker_datas.set(lock=False) - data_node.picker_datas.set( - json.dumps(updated_data).replace("true", "True") - ) - data_node.picker_datas.set(lock=True) + # Write through the DataNode chokepoint (JSON + version stamp + + # lock handling all centralized in picker_node.py) + data_node.set_data(updated_data) + data_node.write_data(to_node=True) self.main_window.refresh() @@ -1688,7 +1705,7 @@ class MainDockWindow(QtWidgets.QWidget): ) def __init__(self, parent=None, edit=False, dockable=False): - super(MainDockWindow, self).__init__(parent=parent) + super().__init__(parent=parent) self.window_parent = parent self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) self.setWindowFlags(QtCore.Qt.Window) @@ -2047,7 +2064,7 @@ def showEvent(self, *args, **kwargs): return # Default close - super(MainDockWindow, self).showEvent(*args, **kwargs) + super().showEvent(*args, **kwargs) # Force char load self.refresh() @@ -2069,7 +2086,7 @@ def resizeEvent(self, event): self.load_widget.resize(size) - return super(MainDockWindow, self).resizeEvent(event) + return super().resizeEvent(event) def show_about_infos(self): """Open animation picker about and help infos""" @@ -2358,7 +2375,7 @@ def selection_change_event(self, *args): # version of the anim picker ui that uses MayaQWidgetDockableMixin for docking class MainDockableWindow(MayaQWidgetDockableMixin, MainDockWindow): def __init__(self, parent=None, edit=False, dockable=True): - super(MainDockableWindow, self).__init__(parent=parent) + super().__init__(parent=parent) # ============================================================================= @@ -2376,10 +2393,11 @@ def load(edit=False, dockable=False): """ - # NOTE: if instedad with set dockable to false the window doesn't get - # parented to Maya UI - # TODO: Dockable breaks the interface when docks. For the moment this - # option is not available from the menu + # NOTE: if instead we set dockable to false the window doesn't get + # parented to Maya UI. + # Deferred (feature phase): dockable mode breaks the interface when + # docked, so it is intentionally not exposed from the menu yet. See the + # anim_picker refactor roadmap (Phase 3+: "Fix + re-enable dockable"). if dockable: ANIM_PKR_UI = MainDockableWindow( parent=None, edit=edit, dockable=dockable diff --git a/release/scripts/mgear/anim_picker/handlers/__init__.py b/release/scripts/mgear/anim_picker/handlers/__init__.py index c9662bda..efb63b1b 100644 --- a/release/scripts/mgear/anim_picker/handlers/__init__.py +++ b/release/scripts/mgear/anim_picker/handlers/__init__.py @@ -1,9 +1,3 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - - from . import mode_handlers from . import maya_handlers diff --git a/release/scripts/mgear/anim_picker/handlers/action_handlers.py b/release/scripts/mgear/anim_picker/handlers/action_handlers.py index 2844a60a..8a1f16e8 100644 --- a/release/scripts/mgear/anim_picker/handlers/action_handlers.py +++ b/release/scripts/mgear/anim_picker/handlers/action_handlers.py @@ -1,16 +1,10 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - - from mgear.core import anim_utils from mgear.core.anim_utils import stripNamespace from mgear.core import pyqt from mgear.vendor.Qt import QtWidgets -# from PySide2 import QtWidgets +import mgear class SpaceChangeList(QtWidgets.QMenu): @@ -28,7 +22,7 @@ class SpaceChangeList(QtWidgets.QMenu): def __init__( self, namespace, ui_host, combo_attr, ctl, self_widget, *args, **kwargs ): - super(SpaceChangeList, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.namespace = namespace if namespace: ui_host = namespace + ":" + ui_host @@ -125,12 +119,11 @@ def show_space_chage_list( pyqt.position_window(ql) ql.exec_() except Exception as e: - print( - "Could not build space change list for ctl {}. Rig components renamed?".format( - ctl - ) + mgear.log( + "Could not build space change list for ctl {}. Rig components " + "renamed? Error: {}".format(ctl, e), + mgear.sev_error, ) - print("Error: " + str(e)) return diff --git a/release/scripts/mgear/anim_picker/handlers/file_handlers.py b/release/scripts/mgear/anim_picker/handlers/file_handlers.py index 9a3485bf..46515368 100644 --- a/release/scripts/mgear/anim_picker/handlers/file_handlers.py +++ b/release/scripts/mgear/anim_picker/handlers/file_handlers.py @@ -1,13 +1,8 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - - import os import json import functools +import mgear from mgear.core import pyqt from mgear.core import string from mgear.vendor.Qt import QtWidgets @@ -25,7 +20,10 @@ def _importData(file_path): data = json.load(f) return data except Exception as e: - print(e) + mgear.log( + "anim_picker: failed to read '{}': {}".format(file_path, e), + mgear.sev_error, + ) def _exportData(data, file_path): @@ -33,7 +31,10 @@ def _exportData(data, file_path): with open(file_path, "w") as f: json.dump(data, f, sort_keys=False, indent=4) except Exception as e: - print(e) + mgear.log( + "anim_picker: failed to write '{}': {}".format(file_path, e), + mgear.sev_error, + ) def replace_token_with_path(file_path): diff --git a/release/scripts/mgear/anim_picker/handlers/maya_handlers.py b/release/scripts/mgear/anim_picker/handlers/maya_handlers.py index bfd135f0..5a8f4a26 100644 --- a/release/scripts/mgear/anim_picker/handlers/maya_handlers.py +++ b/release/scripts/mgear/anim_picker/handlers/maya_handlers.py @@ -1,12 +1,9 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - -import sys +import json from maya import cmds -from maya import OpenMaya +from maya.api import OpenMaya + +import mgear def get_flattened_nodes(nodes): @@ -49,7 +46,10 @@ def select_nodes(nodes, namespace=None, modifier=None): # skip invalid nodes if not cmds.objExists(node): - sys.stderr.write("node '{}' not found, skipping\n".format(node)) + mgear.log( + "node '{}' not found, skipping".format(node), + mgear.sev_warning, + ) continue # Set case @@ -88,32 +88,42 @@ def reset_node_attributes(node, attr="rigBindPose"): """Will reset attribute to stored values""" # Sanity check if not cmds.objExists(node): - msg = "reset_node_attributes -> '{}' not found, skipping".format(node) - sys.stderr.write(msg) + mgear.log( + "reset_node_attributes -> '{}' not found, skipping".format(node), + mgear.sev_warning, + ) return # Check for attribute if not cmds.attributeQuery(attr, n=node, ex=True): - msg = "reset_node_attributes -> '{}' has no attribute named \ - '{}', skipping".format( - node, attr + mgear.log( + "reset_node_attributes -> '{}' has no attribute named '{}', " + "skipping".format(node, attr), + mgear.sev_warning, ) - sys.stderr.write(msg) return - # Get attributes dictionary + # Get attributes dictionary (stored as JSON) str_values = cmds.getAttr("{}.{}".format(node, attr)) if not str_values: return - attr_values = eval(str_values) + try: + attr_values = json.loads(str_values) + except (ValueError, TypeError) as exc: + mgear.log( + "reset_node_attributes -> stored data for node '{}' is not " + "valid JSON ({})".format(node, exc), + mgear.sev_warning, + ) + return # Check type - if not type(attr_values) == {}: - msg = "reset_node_attributes -> stored data for node '{}' are not a \ - dictionary".format( - node + if not isinstance(attr_values, dict): + mgear.log( + "reset_node_attributes -> stored data for node '{}' is not a " + "dictionary".format(node), + mgear.sev_warning, ) - sys.stderr.write(msg) return # Apply values @@ -126,11 +136,11 @@ def reset_node_attributes(node, attr="rigBindPose"): try: cmds.setAttr("{}.{}".format(node, attr_key), attr_values[attr_key]) except Exception: - msg = "reset_node_attributes -> failed to set attribute '{}.{}' \ - to {}".format( - node, attr, str(attr_values[attr_key]) + mgear.log( + "reset_node_attributes -> failed to set attribute '{}.{}' " + "to {}".format(node, attr, str(attr_values[attr_key])), + mgear.sev_warning, ) - sys.stderr.write(msg) return True @@ -141,36 +151,31 @@ def __init__(self): def update(self): """Will update selection data""" - # Get current selection - self.sel.clear() - OpenMaya.MGlobal.getActiveSelectionList(self.sel) + # Get current selection (API 2.0 returns a new list) + self.sel = OpenMaya.MGlobal.getActiveSelectionList() @staticmethod def get_node_mobject(node): """Will return node mobject if possible""" # Sanity check if not cmds.objExists(node): - return + return None - # Cast node to MSelectionList + # Cast node to MSelectionList and return its mobject nodes = OpenMaya.MSelectionList() - OpenMaya.MGlobal.getSelectionListByName(node, nodes) - - # Get node mobject - mobject = OpenMaya.MObject() - nodes.getDependNode(0, mobject) - return mobject + nodes.add(node) + return nodes.getDependNode(0) @classmethod def get_node_mdagpath(cls, node): """Return node MDagPath if possible""" mobject = cls.get_node_mobject(node) - if not mobject: - return + if mobject is None: + return None # Abort if not a Dag object if not mobject.hasFn(OpenMaya.MFn.kDagNode): - return + return None return OpenMaya.MDagPath.getAPathTo(mobject) @@ -181,5 +186,5 @@ def is_selected(self, node): if not node: return False - # Check if node is in selection lest + # Check if node is in selection list return self.sel.hasItem(node) diff --git a/release/scripts/mgear/anim_picker/handlers/mode_handlers.py b/release/scripts/mgear/anim_picker/handlers/mode_handlers.py index db8d1aa3..c86421b2 100644 --- a/release/scripts/mgear/anim_picker/handlers/mode_handlers.py +++ b/release/scripts/mgear/anim_picker/handlers/mode_handlers.py @@ -1,9 +1,3 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - - class EditMode(object): """UI edition status mode handler""" diff --git a/release/scripts/mgear/anim_picker/handlers/python_handlers.py b/release/scripts/mgear/anim_picker/handlers/python_handlers.py index d7e7e275..51f4c2cf 100644 --- a/release/scripts/mgear/anim_picker/handlers/python_handlers.py +++ b/release/scripts/mgear/anim_picker/handlers/python_handlers.py @@ -1,10 +1,4 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - - -import pprint +import mgear def safe_code_exec(cmd, env=None): @@ -14,4 +8,7 @@ def safe_code_exec(cmd, env=None): try: exec(cmd, env) except Exception as e: - pprint.pprint(e) + mgear.log( + "anim_picker: custom action failed: {}".format(e), + mgear.sev_error, + ) diff --git a/release/scripts/mgear/anim_picker/menu.py b/release/scripts/mgear/anim_picker/menu.py index 483d3a35..d7ac0805 100644 --- a/release/scripts/mgear/anim_picker/menu.py +++ b/release/scripts/mgear/anim_picker/menu.py @@ -1,8 +1,3 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - from maya import cmds import mgear.pymaya as pm diff --git a/release/scripts/mgear/anim_picker/picker_node.py b/release/scripts/mgear/anim_picker/picker_node.py index 351ab2f3..54aeb6ff 100644 --- a/release/scripts/mgear/anim_picker/picker_node.py +++ b/release/scripts/mgear/anim_picker/picker_node.py @@ -1,17 +1,13 @@ -from __future__ import absolute_import -from __future__ import division -from __future__ import print_function -from __future__ import unicode_literals - # python -import sys import os +import json # dcc from maya import cmds import mgear.pymaya as pm -# anim picker +# mgear +import mgear import mgear.anim_picker from .handlers import maya_handlers from .handlers import file_handlers @@ -109,11 +105,10 @@ def is_referenced(self): return cmds.referenceQuery(self.name, inr=True) def _assert_not_referenced(self): - assert ( - self.is_referenced - ), "Data node '{}' is referenced, and can not \ - be modified.".format( - self.name + assert not self.is_referenced(), ( + "Data node '{}' is referenced, and can not be modified.".format( + self.name + ) ) def create(self, node=None): @@ -131,7 +126,7 @@ def create(self, node=None): if node: if node.hasAttr("picker_datas_node"): pm.displayWarning( - "{} have anim picker data".formant(node.name()) + "{} have anim picker data".format(node.name()) ) else: node = node.name() @@ -139,13 +134,16 @@ def create(self, node=None): else: # Abort if node already exists if cmds.objExists(self.name): - sys.stderr.write("node '{}' already exists.".format(self.name)) + mgear.log( + "node '{}' already exists.".format(self.name), + mgear.sev_warning, + ) return self.name # Create data node (render sphere for outliner "icon") shp = cmds.createNode("renderSphere") cmds.setAttr("{}.radius".format(shp), 0) - cmds.setAttr("{}.v".format(shp, 0)) + cmds.setAttr("{}.v".format(shp), 0) # Rename data node node = cmds.listRelatives(shp, p=True)[0] @@ -230,19 +228,28 @@ def write_data( file_handlers.write_data_file(file_path, data=data) self._set_str_attr(self.__FILE_ATTR__, value=file_path) - # Write data to node attribute + # Write data to node attribute as JSON if to_node: - self._set_str_attr(self.__DATAS_ATTR__, value=data) + self._set_str_attr(self.__DATAS_ATTR__, value=json.dumps(data)) + self.set_version() def read_data_from_node(self): """Read data from data node or data file""" # Init data dict data = {} - # Get data from attribute + # Get data from attribute (stored as JSON) attr_data = self._get_attr(self.__DATAS_ATTR__) if attr_data: - data = eval(attr_data) + try: + data = json.loads(attr_data) + except (ValueError, TypeError) as exc: + mgear.log( + "anim_picker: could not parse picker data on '{}' as " + "JSON, ignoring legacy data ({})".format(self.name, exc), + mgear.sev_warning, + ) + data = {} return data diff --git a/release/scripts/mgear/anim_picker/version.py b/release/scripts/mgear/anim_picker/version.py index c13dc680..8a522c93 100644 --- a/release/scripts/mgear/anim_picker/version.py +++ b/release/scripts/mgear/anim_picker/version.py @@ -1,6 +1,6 @@ -VERSION_MAJOR = 1 -VERSION_MINOR = 3 -VERSION_PATCH = 2 +VERSION_MAJOR = 2 +VERSION_MINOR = 0 +VERSION_PATCH = 0 version_info = (VERSION_MAJOR, VERSION_MINOR, VERSION_PATCH) version = "%i.%i.%i" % version_info diff --git a/release/scripts/mgear/anim_picker/widgets/basic.py b/release/scripts/mgear/anim_picker/widgets/basic.py index c614ff01..73d9a8a5 100644 --- a/release/scripts/mgear/anim_picker/widgets/basic.py +++ b/release/scripts/mgear/anim_picker/widgets/basic.py @@ -1,8 +1,3 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - # python import os @@ -14,11 +9,6 @@ from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets -# debugging -# from PySide2 import QtGui -# from PySide2 import QtCore -# from PySide2 import QtWidgets - # module from mgear.core import pyqt from mgear.anim_picker.handlers import __EDIT_MODE__ @@ -427,7 +417,7 @@ class BackgroundOptionsDialog(QtWidgets.QDialog): """minimal ui for adjusting the background image""" def __init__(self, tabWidget, parent=None): - super(BackgroundOptionsDialog, self).__init__(parent) + super().__init__(parent) self.setWindowTitle("Set background size") self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) diff --git a/release/scripts/mgear/anim_picker/widgets/overlay_widgets.py b/release/scripts/mgear/anim_picker/widgets/overlay_widgets.py index 9fe76e79..a72eb733 100644 --- a/release/scripts/mgear/anim_picker/widgets/overlay_widgets.py +++ b/release/scripts/mgear/anim_picker/widgets/overlay_widgets.py @@ -1,8 +1,3 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - # python import os @@ -10,26 +5,19 @@ import maya.cmds as cmds # mgear +import mgear from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets from mgear.core import string -# debugging -# from PySide2 import QtGui -# from PySide2 import QtCore -# from PySide2 import QtWidgets - # module from mgear.anim_picker import picker_node from mgear.anim_picker.widgets import basic from mgear.anim_picker.handlers import file_handlers # constants ------------------------------------------------------------------- -try: - _LAST_USED_DIRECTORY -except NameError as e: - _LAST_USED_DIRECTORY = None +_LAST_USED_DIRECTORY = None class OverlayWidget(QtWidgets.QWidget): @@ -381,7 +369,7 @@ def get_namespaces(self): def check_selection(self, index): if self.namespace_cbox.currentText() == "-Refresh-": self.update_namespaces() - print("Namespaces refreshed...") + mgear.log("anim_picker: namespaces refreshed", mgear.sev_info) def load_namespace_options(self): layout = QtWidgets.QHBoxLayout() diff --git a/release/scripts/mgear/anim_picker/widgets/picker_widgets.py b/release/scripts/mgear/anim_picker/widgets/picker_widgets.py index 4102be61..e0b8708d 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_widgets.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_widgets.py @@ -1,9 +1,3 @@ -from __future__ import print_function -from __future__ import absolute_import -from __future__ import division -from __future__ import unicode_literals - - import re import copy import uuid @@ -19,11 +13,6 @@ from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets -# debugging -# from PySide2 import QtGui -# from PySide2 import QtCore -# from PySide2 import QtWidgets - from mgear.anim_picker.widgets import basic from mgear.anim_picker.handlers import ( __EDIT_MODE__, @@ -1473,8 +1462,6 @@ def setY(self, value=0): def shape(self): """Return default handle square shape based on specified size""" path = QtGui.QPainterPath() - # TODO some ints are being set to negative, make sure it survived the - # pep8 rectangle = QtCore.QRectF( QtCore.QPointF(-self.size / 2.0, self.size / 2.0), QtCore.QPointF(self.size / 2.0, -self.size / 2.0), @@ -1923,7 +1910,7 @@ def hoverEnterEvent(self, event=None): if __EDIT_MODE__.get(): text = "\n".join(self.get_controls()) self.setToolTip(text) - super(PickerItem, self).hoverEnterEvent(event) + super().hoverEnterEvent(event) def mouseMoveEvent_offset(self, event): self.setPos(event.scenePos() + self.cursor_delta) @@ -1936,7 +1923,7 @@ def mouseMoveEvent(self, event): item.mouseMoveEvent_offset(event) for item in self.currently_selected ] - super(PickerItem, self).mouseMoveEvent(gfx_event) + super().mouseMoveEvent(gfx_event) def mousePressEvent(self, event): """Event called on mouse press""" diff --git a/releaseLog.rst b/releaseLog.rst index e278132e..7dc41506 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -2,6 +2,16 @@ Release Log =========== +5.3.4 +------ +**Enhancements** + * Anim Picker: Modernize the module (Phase 1 cleanup) — Python-3-only idioms, mgear.log-based logging, Maya API 2.0 selection handling, and dead-code / debug-comment removal; internal anim_picker version bumped to 2.0.0 + * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) + +**Bug Fix** + * Anim Picker: Fix "Picker shape from curve not working" #598 — remove the JSON-to-Python-literal replace hack that corrupted control names/paths containing "true", and guard shape-less transforms during curves-to-picker conversion + * Anim Picker: Fix latent bugs in picker_node / maya_handlers — a .format() typo, a setAttr that never hid the data node, an assertion that never fired on referenced nodes, and an always-false dictionary type check + 5.3.3 ------ **Enhancements** From 8db73a744245cc7072c38ce08e3cf7a94d2c9e6d Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Wed, 8 Jul 2026 10:21:32 +0900 Subject: [PATCH 02/25] AnimPicker: Decompose gui.py and picker_widgets.py; extract PickerItemData (Phase 2) Behavior-preserving decomposition of the two anim_picker God-files into focused modules, on top of the Phase 1 cleanup. Public API, importable names, Shifter's MAYA_OVERRIDE_COLOR, and the .pkr / PICKER_DATAS formats are unchanged. - gui.py (2361 -> 40 lines, shim) split into constants.py, scene.py, view.py, tab_widget.py, main_window.py. - widgets/picker_widgets.py (2942 -> 53 lines, shim) split into widgets/graphics.py, widgets/picker_item.py, and widgets/dialogs/ (script_dialog, search_replace_dialog, handles_window, item_options, copy_paste_dialog). - New Qt/Maya-free widgets/item_model.py (PickerItemData); PickerItem.get_data / set_data delegate to its to_dict / from_dict, preserving the exact schema round-trip. - gui.py and picker_widgets.py kept as compatibility shims re-exporting the same objects (isinstance identity preserved). Import cycles broken with lazy imports (copy_paste_dialog -> PickerItem, tab_widget -> MainDockWindow). - Curve<->picker conversion kept in view.py; picker_curves.py extraction deferred. --- .../scripts/mgear/anim_picker/constants.py | 60 + release/scripts/mgear/anim_picker/gui.py | 2444 +------------- .../scripts/mgear/anim_picker/main_window.py | 779 +++++ release/scripts/mgear/anim_picker/scene.py | 179 + .../scripts/mgear/anim_picker/tab_widget.py | 170 + release/scripts/mgear/anim_picker/view.py | 1267 +++++++ .../anim_picker/widgets/dialogs/__init__.py | 0 .../widgets/dialogs/copy_paste_dialog.py | 211 ++ .../widgets/dialogs/handles_window.py | 107 + .../widgets/dialogs/item_options.py | 932 ++++++ .../widgets/dialogs/script_dialog.py | 180 + .../widgets/dialogs/search_replace_dialog.py | 85 + .../mgear/anim_picker/widgets/graphics.py | 447 +++ .../mgear/anim_picker/widgets/item_model.py | 107 + .../mgear/anim_picker/widgets/picker_item.py | 1051 ++++++ .../anim_picker/widgets/picker_widgets.py | 2974 +---------------- 16 files changed, 5661 insertions(+), 5332 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/constants.py create mode 100644 release/scripts/mgear/anim_picker/main_window.py create mode 100644 release/scripts/mgear/anim_picker/scene.py create mode 100644 release/scripts/mgear/anim_picker/tab_widget.py create mode 100644 release/scripts/mgear/anim_picker/view.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/__init__.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/copy_paste_dialog.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/search_replace_dialog.py create mode 100644 release/scripts/mgear/anim_picker/widgets/graphics.py create mode 100644 release/scripts/mgear/anim_picker/widgets/item_model.py create mode 100644 release/scripts/mgear/anim_picker/widgets/picker_item.py diff --git a/release/scripts/mgear/anim_picker/constants.py b/release/scripts/mgear/anim_picker/constants.py new file mode 100644 index 00000000..03008ba6 --- /dev/null +++ b/release/scripts/mgear/anim_picker/constants.py @@ -0,0 +1,60 @@ +# mgear +import mgear + + +ANIM_PICKER_TITLE = "Anim Picker {ap_version} | mGear {m_version}" + +# maya color index +MAYA_OVERRIDE_COLOR = { + 0: [48, 48, 48], + 1: [0, 0, 0], + 2: [13, 13, 13], + 3: [81, 81, 81], + 4: [84, 0, 5], + 5: [0, 0, 30], + 6: [0, 0, 255], + 7: [0, 16, 2], + 8: [5, 0, 14], + 9: [147, 0, 147], + 10: [65, 17, 8], + 11: [13, 4, 3], + 12: [81, 5, 0], + 13: [255, 0, 0], + 14: [0, 255, 0], + 15: [0, 13, 81], + 16: [255, 255, 255], + 17: [255, 255, 0], + 18: [32, 183, 255], + 19: [14, 255, 93], + 20: [255, 111, 111], + 21: [198, 105, 49], + 22: [255, 255, 32], + 23: [0, 81, 23], + 24: [91, 37, 8], + 25: [87, 91, 8], + 26: [35, 91, 8], + 27: [8, 91, 28], + 28: [8, 91, 91], + 29: [8, 35, 91], + 30: [41, 8, 91], + 31: [91, 8, 37], +} + +GROUPBOX_BG_CSS = """QGroupBox {{ + background-color: rgba{color}; + border: 0px solid rgba{color}; +}}""" + + +_mgear_version = mgear.getVersion() + +PICKER_EXTRACTION_NAME = "pickerData_extraction" +ANIM_PICKER_RELATIVE_IMAGES = "ANIM_PICKER_RELATIVE_IMAGES" + +""" +/animpickers/characterA/publish/pkr/charact.pkr +/animpickers/characterA/publish/pkr/../images +examples "../images", "../../images", "" +""" +# default image location is assumed same as .pkr file +DEFAULT_RELATIVE_IMAGES_PATH = "" diff --git a/release/scripts/mgear/anim_picker/gui.py b/release/scripts/mgear/anim_picker/gui.py index 7d377ca6..f7735609 100644 --- a/release/scripts/mgear/anim_picker/gui.py +++ b/release/scripts/mgear/anim_picker/gui.py @@ -1,2410 +1,40 @@ -# python -import os -import copy -import json -from functools import partial +"""Compatibility shim for the anim picker gui. -# dcc -from maya import cmds -import mgear.pymaya as pm - -from maya.app.general.mayaMixin import MayaQWidgetDockableMixin - -# mgear -import mgear -from mgear.core import pyqt -from mgear.core import attribute -from mgear.core import callbackManager - - -from mgear.vendor.Qt import QtGui -from mgear.vendor.Qt import QtCore -from mgear.vendor.Qt import QtCompat -from mgear.vendor.Qt import QtWidgets - -# module -from . import menu -from . import version -from . import picker_node -from .widgets import basic -from .widgets import picker_widgets -from .widgets import overlay_widgets - -from .handlers import __EDIT_MODE__ -from .handlers import __SELECTION__ - -# constants ------------------------------------------------------------------- -_CLIPBOARD = [] - -ANIM_PICKER_TITLE = "Anim Picker {ap_version} | mGear {m_version}" - -# maya color index -MAYA_OVERRIDE_COLOR = { - 0: [48, 48, 48], - 1: [0, 0, 0], - 2: [13, 13, 13], - 3: [81, 81, 81], - 4: [84, 0, 5], - 5: [0, 0, 30], - 6: [0, 0, 255], - 7: [0, 16, 2], - 8: [5, 0, 14], - 9: [147, 0, 147], - 10: [65, 17, 8], - 11: [13, 4, 3], - 12: [81, 5, 0], - 13: [255, 0, 0], - 14: [0, 255, 0], - 15: [0, 13, 81], - 16: [255, 255, 255], - 17: [255, 255, 0], - 18: [32, 183, 255], - 19: [14, 255, 93], - 20: [255, 111, 111], - 21: [198, 105, 49], - 22: [255, 255, 32], - 23: [0, 81, 23], - 24: [91, 37, 8], - 25: [87, 91, 8], - 26: [35, 91, 8], - 27: [8, 91, 28], - 28: [8, 91, 91], - 29: [8, 35, 91], - 30: [41, 8, 91], - 31: [91, 8, 37], -} - -GROUPBOX_BG_CSS = """QGroupBox {{ - background-color: rgba{color}; - border: 0px solid rgba{color}; -}}""" - - -_mgear_version = mgear.getVersion() - -PICKER_EXTRACTION_NAME = "pickerData_extraction" -ANIM_PICKER_RELATIVE_IMAGES = "ANIM_PICKER_RELATIVE_IMAGES" - -""" -/animpickers/characterA/publish/pkr/charact.pkr -/animpickers/characterA/publish/pkr/../images -examples "../images", "../../images", "" +The classes and constants formerly defined here were split into focused +modules during the Phase 2 decomposition (constants, scene, view, tab_widget, +main_window). This module re-exports the same objects so existing imports keep +working unchanged -- notably +``from mgear.anim_picker.gui import MAYA_OVERRIDE_COLOR`` (used by Shifter). """ -# default image location is assumed same as .pkr file -DEFAULT_RELATIVE_IMAGES_PATH = "" - - -# ============================================================================= -# Classes -# ============================================================================= - - -class APPassthroughEventFilter(QtCore.QObject): - """AnimPicker eventFilter for MayaMainWindow when enabling - click passthrough for the GUI. - """ - - # Animpicker gui reference - APUI = None - - def eventFilter(self, QObject, event): - """Filter for changing the windowFlags on the animPicker gui""" - modifiers = None - if QtCompat.isValid(self.APUI): - modifiers = QtWidgets.QApplication.queryKeyboardModifiers() - auto_state = self.APUI.auto_opacity_btn.isChecked() - flag_state = self.APUI.testAttribute( - QtCore.Qt.WA_TransparentForMouseEvents - ) - if auto_state and modifiers == QtCore.Qt.ShiftModifier: - # if the window is passthrough enabled - if flag_state: - pos = QtGui.QCursor().pos() - widgetRect = self.APUI.geometry() - if widgetRect.contains(pos): - self.APUI.set_mouseEvent_passthrough(False) - # if the window is passthrough enabled and the feature disabled - elif flag_state and not menu.get_option_var_passthrough_state(): - self.APUI.set_mouseEvent_passthrough(False) - else: - pass - else: - try: - self.deleteLater() - except RuntimeError: - pass - return super().eventFilter(QObject, event) - - -class OrderedGraphicsScene(QtWidgets.QGraphicsScene): - """ - Custom QGraphicsScene with x/y axis line options for origin - feedback in edition mode - (provides a center reference to work from, view will fit what ever - is the content in use mode). - - Had to add z_index support since there was a little z - conflict when "moving" items to back/front in edit mode - """ - - __DEFAULT_SCENE_WIDTH__ = 6000 - __DEFAULT_SCENE_HEIGHT__ = 6000 - - def __init__(self, parent=None): - QtWidgets.QGraphicsScene.__init__(self, parent=parent) - - self.set_default_size() - self._z_index = 0 - - def set_size(self, width, height): - """Will set scene size with proper center position""" - self.setSceneRect(-width / 2, -height / 2, width, height) - - def set_default_size(self): - self.set_size( - self.__DEFAULT_SCENE_WIDTH__, self.__DEFAULT_SCENE_HEIGHT__ - ) - - def get_bounding_rect(self, margin=0, selection=False): - """ - Return scene content bounding box with specified margin - Warning: In edit mode, will return default scene rectangle - """ - # Return default size in edit mode - # if __EDIT_MODE__.get(): - # return self.sceneRect() - - # Get item boundingBox - if selection: - sel_items = self.get_selected_items() - if not sel_items: - return - scene_rect = QtCore.QRectF() - - # init coordinates with the first element - rec = sel_items[0].boundingRect().getCoords() - x1 = rec[0] + sel_items[0].x() - y1 = rec[1] + sel_items[0].y() - x2 = rec[2] + sel_items[0].x() - y2 = rec[3] + sel_items[0].y() - - for item in sel_items[1:]: - rec = item.boundingRect().getCoords() - if (rec[0] + item.x()) < x1: - x1 = rec[0] + item.x() - if (rec[1] + item.y()) < y1: - y1 = rec[1] + item.y() - if (rec[2] + item.x()) > x2: - x2 = rec[2] + item.x() - if (rec[3] + item.y()) > y2: - y2 = rec[3] + item.y() - scene_rect.setCoords(x1, y1, x2, y2) - - else: - scene_rect = self.itemsBoundingRect() - - # Stop here if no margin - if not margin: - return scene_rect - - # Add margin - scene_rect.setX(scene_rect.x() - margin) - scene_rect.setY(scene_rect.y() - margin) - scene_rect.setWidth(scene_rect.width() + margin) - scene_rect.setHeight(scene_rect.height() + margin) - - return scene_rect - - def clear(self): - """Reset default z index on clear""" - QtWidgets.QGraphicsScene.clear(self) - self._z_index = 0 - - def set_picker_items(self, items): - """Will set picker items""" - self.clear() - for item in items: - QtWidgets.QGraphicsScene.addItem(self, item) - self.set_z_value(item) - self.add_axis_lines() - - def get_picker_items(self): - """Will return all scenes' picker items""" - picker_items = [] - # Filter picker items (from handles etc) - for item in list(self.items()): - if not isinstance(item, picker_widgets.PickerItem): - continue - picker_items.append(item) - return picker_items - - def picker_at(self, scene_pos, transform): - item_at = self.itemAt(scene_pos, transform) - if isinstance(item_at, picker_widgets.PickerItem): - return item_at - elif item_at and not isinstance(item_at, picker_widgets.PickerItem): - return item_at.parentItem() - else: - return None - - def get_picker_by_uuid(self, picker_uuid): - """pickers have UUID's for hashing in dictionaries. search via uuid - - Args: - picker_uuid (str): uuid - - Returns: - PickerIteem: instance of matching picker - """ - for picker in self.get_picker_items(): - if picker.uuid == picker_uuid: - return picker - return None - - def get_selected_items(self): - return [ - item for item in self.get_picker_items() if item.polygon.selected - ] - - def clear_picker_selection(self): - for picker in self.get_picker_items(): - picker.set_selected_state(False) - self.update() - - def select_picker_items(self, picker_items, event=None): - if event is None: - modifiers = None - else: - modifiers = event.modifiers() - - # Shift cases (toggle) - if modifiers == QtCore.Qt.ShiftModifier: - for picker in picker_items: - picker.set_selected_state(True) - - # Controls case - elif modifiers == QtCore.Qt.ControlModifier: - for picker in picker_items: - picker.set_selected_state(False) - - # Alt case (remove) - # elif modifiers == QtCore.Qt.AltModifier: - else: - self.clear_picker_selection() - for picker in picker_items: - picker.set_selected_state(True) - - def set_z_value(self, item): - """set proper z index for item""" - item.setZValue(self._z_index) - self._z_index += 1 - - def addItem(self, item): - """Overload to keep axis on top""" - QtWidgets.QGraphicsScene.addItem(self, item) - self.set_z_value(item) - - -class GraphicViewWidget(QtWidgets.QGraphicsView): - """Graphic view widget that display the "polygons" picker items""" - - __DEFAULT_SCENE_WIDTH__ = 6000 - __DEFAULT_SCENE_HEIGHT__ = 6000 - - def __init__(self, namespace=None, main_window=None): - QtWidgets.QGraphicsView.__init__(self) - - self.setScene(OrderedGraphicsScene(parent=self)) - - self.namespace = namespace - self.main_window = main_window - self.setParent(self.main_window) - - # Scale view in Y for positive Y values (maya-like) - self.scale(1, -1) - - self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter) - - # Set selection mode - self.setRubberBandSelectionMode(QtCore.Qt.IntersectsItemBoundingRect) - self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) - self.scene_mouse_origin = QtCore.QPointF() - self.drag_active = False - self.pan_active = False - self.zoom_active = False - self.auto_frame_active = True - - # Disable scroll bars - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - - # Set background color - brush = QtGui.QBrush(QtGui.QColor(70, 70, 70, 255)) - self.setBackgroundBrush(brush) - self.background_image = None - self.background_image_path = None - self.bg_ui = None - - self.fit_margin = 8 - - # # undo list --------------------------------------------------------- - self.undo_move_order = [] - self.undo_move_order_index = -1 - - def get_center_pos(self): - return self.mapToScene( - QtCore.QPoint(self.width() / 2, self.height() / 2) - ) - - def mousePressEvent(self, event): - self.modified_select = False - self.item_selected = False - self.__move_prompt = False - QtWidgets.QGraphicsView.mousePressEvent(self, event) - if event.buttons() == QtCore.Qt.MouseButton.LeftButton: - self.scene_mouse_origin = self.mapToScene(event.pos()) - # Get current viewport transformation - transform = self.viewportTransform() - scene_pos = self.mapToScene(event.pos()) - # Clear selection if no picker item below mouse - picker_at = self.scene().picker_at(scene_pos, transform) or [] - if picker_at: - if __EDIT_MODE__.get(): - self.item_selected = True - # undo --------------------------------------------------- - self.__move_prompt = False - # open undo chunk - self.tmp_picker_pos_info = {} - pickers = self.scene().get_selected_items() - if picker_at not in pickers: - pickers.append(picker_at) - for picker in pickers: - pt = [picker.x(), picker.y(), picker.rotation()] - self.tmp_picker_pos_info[picker.uuid] = pt - # undo --------------------------------------------------- - if event.modifiers(): - # this allows for shift selecting in edit - self.modified_select = False - else: - self.modified_select = True - picker_widgets.select_picker_controls([picker_at], event) - else: - self.modified_select = False - if not event.modifiers(): - self.scene().clear_picker_selection() - cmds.select(cl=True) - - elif event.buttons() == QtCore.Qt.MouseButton.MiddleButton: - self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag) - self.pan_active = True - self.scene_mouse_origin = self.mapToScene(event.pos()) - - # zoom support added for the mouse, for those pen/tablet users - elif ( - event.buttons() == QtCore.Qt.MouseButton.RightButton - and event.modifiers() == QtCore.Qt.AltModifier - ): - self.zoom_active = True - self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag) - self.scene_mouse_origin = self.mapToGlobal(event.pos()) - cursor_pos = QtGui.QVector2D( - self.mapToGlobal(self.scene_mouse_origin) - ) - screen = QtWidgets.QApplication.instance().primaryScreen() - rect = screen.availableGeometry() - self.top_left_pos = QtGui.QVector2D(rect.topLeft()) - self.zoom_delta = self.top_left_pos.distanceToPoint(cursor_pos) - self.setTransformationAnchor( - QtWidgets.QGraphicsView.AnchorViewCenter - ) - - def mouseMoveEvent(self, event): - result = QtWidgets.QGraphicsView.mouseMoveEvent(self, event) - - if ( - event.buttons() == QtCore.Qt.MouseButton.LeftButton - and not self.item_selected - ): - self.drag_active = True - - # undo --------------------------------------------------------------- - if ( - __EDIT_MODE__.get() - and event.buttons() == QtCore.Qt.MouseButton.LeftButton - and self.item_selected - ): - # confirm undo move chunck, a picker has been moved - self.__move_prompt = True - # undo ---------------------------------------------------------------- - - if self.pan_active: - current_center = self.get_center_pos() - scene_paning = self.mapToScene(event.pos()) - - new_center = current_center - ( - scene_paning - self.scene_mouse_origin - ) - self.centerOn(new_center) - - if self.zoom_active: - cursor_pos = QtGui.QVector2D(self.mapToGlobal(event.pos())) - current_delta = self.top_left_pos.distanceToPoint(cursor_pos) - - factor = 1.05 - if current_delta < self.zoom_delta: - factor = 0.95 - - # Apply zoom - self.scale(factor, factor) - self.zoom_delta = current_delta - - return result - - def mouseReleaseEvent(self, event): - """Overload to clear selection on empty area""" - result = QtWidgets.QGraphicsView.mouseReleaseEvent(self, event) - if ( - not self.drag_active - and event.button() == QtCore.Qt.MouseButton.LeftButton - and not self.modified_select - ): - self.modified_select = False - scene_pos = self.mapToScene(event.pos()) - - # Get current viewport transformation - transform = self.viewportTransform() - - # Clear selection if no picker item below mouse - picker_at = self.scene().picker_at(scene_pos, transform) or [] - if not picker_at: - if not event.modifiers(): - self.scene().clear_picker_selection() - cmds.select(cl=True) - elif picker_at and event.modifiers() == QtCore.Qt.AltModifier: - picker_at.select_associated_controls() - self.scene().select_picker_items([picker_at], event) - else: - self.scene().select_picker_items([picker_at], event) - - # add moved pickers to undo_move_order list --------------------------- - if not self.drag_active and self.__move_prompt: - for picker_uuid in list(self.tmp_picker_pos_info.keys()): - picker = self.scene().get_picker_by_uuid(picker_uuid) - if picker is None: - continue - pt = [picker.x(), picker.y(), picker.rotation()] - self.tmp_picker_pos_info[picker_uuid].extend(pt) - if self.undo_move_order_index in [-1]: - self.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - else: - self.undo_move_order = self.undo_move_order[ - : self.undo_move_order_index - ] - self.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - self.undo_move_order_index = -1 - self.__move_prompt = None - self.tmp_picker_pos_info = {} - # undo ---------------------------------------------------------------- - - # Area selection - if ( - self.drag_active - and event.button() == QtCore.Qt.MouseButton.LeftButton - ): - scene_drag_end = self.mapToScene(event.pos()) - - sel_area = QtCore.QRectF(self.scene_mouse_origin, scene_drag_end) - transform = self.viewportTransform() - if not sel_area.size().isNull(): - items = self.scene().items( - sel_area, - QtCore.Qt.IntersectsItemShape, - QtCore.Qt.AscendingOrder, - deviceTransform=transform, - ) - - picker_items = [] - for item in items: - if not isinstance(item, picker_widgets.PickerItem): - continue - picker_items.append(item) - if __EDIT_MODE__.get(): - self.scene().select_picker_items(picker_items) - if event.modifiers() == QtCore.Qt.AltModifier: - ctrls = [] - for x in picker_items: - ctrls.extend(x.get_controls()) - cmds.select(cmds.ls(ctrls)) - else: - picker_widgets.select_picker_controls(picker_items, event) - - # Middle mouse view panning - if ( - self.pan_active - and event.button() == QtCore.Qt.MouseButton.MiddleButton - ): - current_center = self.get_center_pos() - scene_drag_end = self.mapToScene(event.pos()) - - new_center = current_center - ( - scene_drag_end - self.scene_mouse_origin - ) - self.centerOn(new_center) - self.pan_active = False - self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) - - # zoom support added for the mouse, for those pen/tablet users - if ( - self.zoom_active - and event.button() == QtCore.Qt.MouseButton.RightButton - ): - self.zoom_active = False - self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) - - self.drag_active = False - return result - - def wheelEvent(self, event): - """Wheel event to add zoom support""" - if self.window().testAttribute(QtCore.Qt.WA_TransparentForMouseEvents): - return False - self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse) - - # Run default event - # QtWidgets.QGraphicsView.wheelEvent(self, event) - - # Define zoom factor - factor = 1.1 - if event.angleDelta().y() < 0: - factor = 0.9 - - # Apply zoom - self.scale(factor, factor) - - # undo -------------------------------------------------------------------- - def undo_move(self): - """go through (reversed) the undo_move_order list and move pickers - back to their previously stored location - """ - undo_len = len(self.undo_move_order) - if undo_len == 0: - return - - if self.undo_move_order_index == -1: - self.undo_move_order_index = undo_len - elif self.undo_move_order_index == 0: - return - if self.undo_move_order_index > 0: - self.undo_move_order_index = self.undo_move_order_index - 1 - undo_items = self.undo_move_order[self.undo_move_order_index].items() - for picker_uuid, undo_pos in undo_items: - picker = self.scene().get_picker_by_uuid(picker_uuid) - if not picker: - continue - picker.setPos(undo_pos[0], undo_pos[1]) - picker.setRotation(undo_pos[2]) - - def redo_move(self): - """go through the undo_move_order restoring picker locations""" - undo_len = len(self.undo_move_order) - if undo_len == 0: - return - - if self.undo_move_order_index == -1: - return - if self.undo_move_order_index < undo_len: - undo_index = self.undo_move_order[self.undo_move_order_index] - for picker_uuid, undo_pos in undo_index.items(): - picker = self.scene().get_picker_by_uuid(picker_uuid) - if not picker: - continue - picker.setPos(undo_pos[3], undo_pos[4]) - picker.setRotation(undo_pos[5]) - self.undo_move_order_index = self.undo_move_order_index + 1 - else: - self.undo_move_order_index = -1 - - def keyPressEvent(self, event): - """keyboard press event override for custom shortcuts - - Args: - event (QtCore.QEvent): keyboard event - """ - if __EDIT_MODE__.get(): - modifiers = event.modifiers() - if ( - modifiers == QtCore.Qt.ControlModifier - and event.key() == QtCore.Qt.Key_Z - ): - self.undo_move() - event.accept() - elif ( - modifiers == QtCore.Qt.ControlModifier - and event.key() == QtCore.Qt.Key_Y - ): - self.redo_move() - event.accept() - else: - event.ignore() - else: - event.ignore() - - # undo -------------------------------------------------------------------- - - def contextMenuEvent(self, event, mapped_pos=None): - """Right click menu options""" - if event.modifiers() == QtCore.Qt.AltModifier: - # alt may indicate zooming enabled so no menu - return - # Item area - picker_item = [ - item for item in self.get_picker_items() if item._hovered - ] - if picker_item: - # Run default method that call on childs - mapped_pos = event.globalPos() - evnt_type = QtGui.QContextMenuEvent.Mouse - contextEvent = QtGui.QContextMenuEvent(evnt_type, mapped_pos) - return picker_item[0].contextMenuEvent(contextEvent) - - # Init context menu - menu = QtWidgets.QMenu(self) - - # Build Edit move options - if __EDIT_MODE__.get(): - mapped_pos = self.mapToScene(event.pos()) - add_action = QtWidgets.QAction("Add Item", None) - add_action.triggered.connect( - partial(self.add_picker_item_gui, mapped_pos) - ) - menu.addAction(add_action) - - add_action1 = QtWidgets.QAction("Add with selected", None) - add_action1.triggered.connect( - partial(self.add_picker_item_selected, mapped_pos) - ) - menu.addAction(add_action1) - - add_action2 = QtWidgets.QAction("Add item per selected", None) - add_action2.triggered.connect( - partial(self.add_picker_item_per_selected, mapped_pos) - ) - menu.addAction(add_action2) - - toggle_handles_action = QtWidgets.QAction( - "Toggle all handles", None - ) - func = self.toggle_all_handles_event - toggle_handles_action.triggered.connect(func) - menu.addAction(toggle_handles_action) - - menu.addSeparator() - - # this copy is currently only supported when not hovering a picker - copy_action = QtWidgets.QAction("Copy (Multi)", None) - copy_action.triggered.connect(self.copy_event) - menu.addAction(copy_action) - - # this paste is only supported when not hovering - paste_action = QtWidgets.QAction("Paste (Multi)", None) - paste_action.triggered.connect(self.paste_event) - menu.addAction(paste_action) - - menu.addSeparator() - - background_action = QtWidgets.QAction("Set background image", None) - background_action.triggered.connect(self.set_background_event) - menu.addAction(background_action) - - background_size_action = QtWidgets.QAction("Background Size", None) - background_size_action.triggered.connect(self.background_options) - menu.addAction(background_size_action) - - reset_background_action = QtWidgets.QAction( - "Remove background", None - ) - func = self.reset_background_event - reset_background_action.triggered.connect(func) - menu.addAction(reset_background_action) - - menu.addSeparator() - - msg = "Convert to nurbs curves" - convert_picker_to_curves = QtWidgets.QAction(msg, None) - func = self.convert_picker_to_curves - convert_picker_to_curves.triggered.connect(func) - menu.addAction(convert_picker_to_curves) - - msg = "Convert to picker data" - convert_curves_to_picker = QtWidgets.QAction(msg, None) - func = self.convert_curves_to_picker - convert_curves_to_picker.triggered.connect(func) - menu.addAction(convert_curves_to_picker) - - msg = "Delete picker nurbs curves" - delete_extraction_grp = QtWidgets.QAction(msg, None) - func = self.delete_extraction_grp - delete_extraction_grp.triggered.connect(func) - menu.addAction(delete_extraction_grp) - - menu.addSeparator() - - if __EDIT_MODE__.get_main(): - toggle_mode_action = QtWidgets.QAction("Toggle Mode", None) - toggle_mode_action.triggered.connect(self.toggle_mode_event) - menu.addAction(toggle_mode_action) - - menu.addSeparator() - - # Common actions - reset_view_action = QtWidgets.QAction("Reset view", None) - reset_view_action.triggered.connect(self.fit_scene_content) - menu.addAction(reset_view_action) - frame_selection_view_action = QtWidgets.QAction( - "Frame Selection", None - ) - frame_selection_view_action.triggered.connect( - self.fit_selection_content - ) - menu.addAction(frame_selection_view_action) - - auto_frame_selection_view_action = QtWidgets.QAction( - "Auto Frame view", None - ) - auto_frame_selection_view_action.setCheckable(True) - auto_frame_selection_view_action.setChecked(self.auto_frame_active) - auto_frame_selection_view_action.triggered.connect( - self.set_auto_frame_view - ) - menu.addAction(auto_frame_selection_view_action) - - # Open context menu under mouse - menu.exec_(event.globalPos()) - - def resizeEvent(self, *args, **kwargs): - """Overload to force scale scene content to fit view""" - # Fit scene content to view - if self.auto_frame_active: - self.fit_scene_content() - - # Run default resizeEvent - return QtWidgets.QGraphicsView.resizeEvent(self, *args, **kwargs) - - def fit_scene_content(self): - """Will fit scene content to view, by scaling it""" - scene_rect = self.scene().get_bounding_rect(margin=self.fit_margin) - self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) - - def set_auto_frame_view(self): - """Enable auto fit when a resize event happens""" - # Fit scene content to view - if not self.auto_frame_active: - self.fit_scene_content() - self.auto_frame_active = not self.auto_frame_active - - def fit_selection_content(self): - """Will fit the selected item to view, by scaling it""" - scene_rect = self.scene().get_bounding_rect( - margin=self.fit_margin, selection=True - ) - if scene_rect: - self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) - - def get_color_picker_override(self, picker, ctrl): - """Get the maya override color and return picker equivelant - - Args: - picker (PickerItem): pickeritem class - ctrl (str): name of the control - - Returns: - list: [R, G, B, Alpha] - """ - node = ctrl - if cmds.nodeType(ctrl) == "transform": - node = cmds.listRelatives(ctrl, shapes=True)[0] - if not cmds.getAttr("{}.overrideEnabled".format(node)): - return [0, 0, 0, 255] - if cmds.getAttr("{}.overrideRGBColors".format(node)): - r_color = cmds.getAttr("{}.overrideColorR".format(node)) - g_color = cmds.getAttr("{}.overrideColorG".format(node)) - b_color = cmds.getAttr("{}.overrideColorB".format(node)) - return [r_color * 255, g_color * 255, b_color * 255, 255] - else: - override_index = cmds.getAttr("{}.overrideColor".format(node)) - color_rgb = MAYA_OVERRIDE_COLOR[override_index] - return [color_rgb[0], color_rgb[1], color_rgb[2], 255] - - def add_picker_item(self, event=None): - """Add new PickerItem to current view""" - ctrl = picker_widgets.PickerItem( - main_window=self.main_window, namespace=self.namespace - ) - ctrl.setParent(self) - self.scene().addItem(ctrl) - - # Move ctrl - if event: - ctrl.setPos(event.pos()) - else: - ctrl.setPos(0, 0) - - return ctrl - - def add_picker_item_gui(self, mouse_pos=None): - """Create picker item at the position of the mouse - - Args: - mouse_pos (QPosition, optional): mouse position - """ - ctrl = self.add_picker_item() - ctrl.setPos(mouse_pos) - - def add_picker_item_selected(self, mouse_pos=None): - """Add new PickerItem to current view""" - ctrl = self.add_picker_item() - data = {} - selected = cmds.ls(sl=True) or [] - data["controls"] = selected - ctrl.set_data(data) - ctrl.set_selected_state(True) - if selected: - colors_rgb = self.get_color_picker_override(ctrl, selected[0]) - ctrl.set_color(color=colors_rgb) - if mouse_pos: - ctrl.setPos(mouse_pos) - - return ctrl - - def add_picker_item_per_selected(self, mouse_pos=None): - """Add new PickerItem to current view""" - selection = cmds.ls(sl=True) or [] - if not selection: - return - created_ctrls = [] - if mouse_pos: - x_start = mouse_pos.x() - y_start = mouse_pos.y() - else: - x_start = 0 - y_start = 0 - y_increment = -35 - for selected in selection: - ctrl = self.add_picker_item() - data = {} - data["controls"] = [selected] - data["position"] = [x_start, y_start] - colors_rgb = self.get_color_picker_override(ctrl, selected) - ctrl.set_color(color=colors_rgb) - y_start = y_start + y_increment - ctrl.set_data(data) - ctrl.set_selected_state(True) - created_ctrls.append(ctrl) - - return created_ctrls - - def copy_event(self): - """reset the clipboard and populate the list with picker data for paste""" - global _CLIPBOARD - _CLIPBOARD = [] - selected_pickers = self.scene().get_selected_items() - for picker in selected_pickers: - _CLIPBOARD.append(picker.get_data()) - - def paste_event(self): - """create new anim pickers based off the data in the clipboard - Make new pickers selected - """ - global _CLIPBOARD - [ - x.set_selected_state(False) - for x in self.scene().get_selected_items() - ] - for data in _CLIPBOARD: - ctrl = self.add_picker_item(event=None) - ctrl.set_data(data) - ctrl.set_selected_state(True) - - def toggle_all_handles_event(self, event=None): - new_status = None - for item in list(self.scene().items()): - # Skip non picker items - if not isinstance(item, picker_widgets.PickerItem): - continue - - # Get first status - if new_status is None: - new_status = not item.get_edit_status() - - # Set item status - item.set_edit_status(new_status) - - def toggle_mode_event(self, event=None): - """Will toggle UI edition mode""" - if not self.main_window: - return - - # Check for possible data change/loss - if __EDIT_MODE__.get(): - if not self.main_window.check_for_data_change(): - return - - # Toggle mode - __EDIT_MODE__.toggle() - - # Reset size to default - self.main_window.reset_default_size() - self.main_window.refresh() - - def apply_background_fallback_logic(self, path): - # test if the original path exists - if os.path.exists(path): - return path - # check the data node for the "source_file_path" that is added when - # pkr is loaded from file - data = self.window().get_current_data_node().read_data_from_node() - pkr_path = data.get("source_file_path", None) - if not pkr_path or pkr_path is None: - return path - # looking in the neighboring directories for images dir - pkr_dir = os.path.dirname(pkr_path) - rel_path_token = os.environ.get( - ANIM_PICKER_RELATIVE_IMAGES, DEFAULT_RELATIVE_IMAGES_PATH - ) - base_name = os.path.basename(path) - relative_image_path = os.path.realpath( - os.path.join(pkr_dir, rel_path_token, base_name) - ) - # only return if path exists - if os.path.exists(relative_image_path): - return relative_image_path - else: - return path - - def set_background(self, path=None): - """Set tab index widget background image""" - if not path: - return - path = os.path.abspath(r"{}".format(path)) - path = self.apply_background_fallback_logic(path) - # Check that path exists - if not (path and os.path.exists(path)): - mgear.log( - "anim_picker: background image not found: '{}'".format(path), - mgear.sev_warning, - ) - return - - self.background_image_path = path - - # Load image and mirror it vertically - self.background_image = QtGui.QImage(path).mirrored(False, True) - - # Set scene size to background picture - width = self.background_image.width() - height = self.background_image.height() - - self.scene().set_size(width, height) - - # Update display - self.fit_scene_content() - - def background_options(self): - tabWidget = self.parent().parent() - # Delete old window - if self.bg_ui: - try: - self.bg_ui.close() - self.bg_ui.deleteLater() - except Exception: - pass - if not tabWidget.currentWidget().get_background(0): - cmds.warning("Current view has no background!") - return - self.bg_ui = basic.BackgroundOptionsDialog(tabWidget, self) - self.bg_ui.show() - self.bg_ui.raise_() - - def set_background_event(self, event=None): - """Set background image pick dialog window""" - # Open file dialog - img_dir = basic.get_images_folder_path() - file_path = QtWidgets.QFileDialog.getOpenFileName( - self, "Pick a background", img_dir - ) - - # Filter return result (based on qt version) - if isinstance(file_path, tuple): - file_path = file_path[0] - - # Abort on cancel - if not file_path: - return - - # Set background - self.set_background(file_path) - - def reset_background_event(self, event=None): - """Reset background to default""" - self.background_image = None - self.background_image_path = None - self.scene().set_default_size() - - # Update display - self.fit_scene_content() - - def resize_background_image( - self, width, height, keepAspectRatio=False, auto_update=True - ): - """resize the background image if one is set - - Args: - width (int): desired width - height (int): desired height - keepAspectRatio (bool, optional): scale image to fit aspect ratio - auto_update (bool, optional): update the scene view - - Returns: - None: none - """ - if not self.background_image: - return - - current_width = self.background_image.size().width() - current_height = self.background_image.size().height() - if current_width == width and current_height == height: - return - - if keepAspectRatio: - if current_width != width: - aspect_size = self.background_image.scaledToWidth(width).size() - width, height = aspect_size.width(), aspect_size.height() - elif current_height != height: - aspect_size = self.background_image.scaledToHeight( - height - ).size() - width, height = aspect_size.width(), aspect_size.height() - # TODO find if this is the most efficient way to achieve this - self.background_image = self.background_image.scaled(width, height) - - if auto_update: - self.scene().set_size(width, height) - # Update display - self.fit_scene_content() - - def set_background_width(self, width, keepAspectRatio=True): - """convenience function for setting width on bg image - - Args: - width (int): desired width - keepAspectRatio (bool, optional): force aspect ration - - Returns: - None: None - """ - if not self.background_image: - return - current_height = self.background_image.size().height() - self.resize_background_image( - width, current_height, keepAspectRatio=keepAspectRatio - ) - - def set_background_height(self, height, keepAspectRatio=True): - """convenience function for setting height on bg image - - Args: - height (int): desired height - keepAspectRatio (bool, optional): force aspect ration - - Returns: - None: None - """ - if not self.background_image: - return - current_width = self.background_image.size().width() - self.resize_background_image( - current_width, height, keepAspectRatio=keepAspectRatio - ) - - def get_background_size(self): - """get bg image in Qt.QSize - - Returns: - Qt.QSize: current size of bg - """ - bg_image = self.get_background(0) - if bg_image: - return bg_image.size() - else: - return QtCore.QSize(0, 0) - - def get_background(self, index): - """Return background for tab index""" - return self.background_image - - def clear(self): - """Clear view, by replacing scene with a new one""" - old_scene = self.scene() - self.setScene(OrderedGraphicsScene(parent=self)) - old_scene.deleteLater() - - def get_picker_items(self): - """Return scene picker items in proper order (back to front)""" - items = [] - for item in list(self.scene().items()): - # Skip non picker graphic items - if not isinstance(item, picker_widgets.PickerItem): - continue - - # Add picker item to filtered list - items.append(item) - - # Reverse list order (to return back to front) - items.reverse() - - return items - - def get_data(self): - """Return view data""" - data = {} - - # Add background to data - if self.background_image_path: - bg_fp = r"{}".format(self.background_image_path) - data["background"] = bg_fp - data["background_size"] = self.get_background_size().toTuple() - - # Add items to data - items = [] - for item in self.get_picker_items(): - items.append(item.get_data()) - if items: - data["items"] = items - - return data - - def set_data(self, data): - """Set/load view data""" - self.clear() - - # Set backgraound picture - background = data.get("background", None) - background_size = data.get("background_size", None) - if background: - self.set_background(background) - if background_size: - self.resize_background_image( - background_size[0], background_size[1] - ) - - # Add items to view - for item_data in data.get("items", []): - item = self.add_picker_item() - item.set_data(item_data) - - def drawBackground(self, painter, rect): - """Default method override to draw view custom background image""" - # Run default method - result = QtWidgets.QGraphicsView.drawBackground(self, painter, rect) - - # Stop here if view has no background - if not self.background_image: - return result - - # Draw background image - painter.drawImage( - self.sceneRect(), - self.background_image, - QtCore.QRectF(self.background_image.rect()), - ) - - return result - - def drawForeground(self, painter, rect): - """Default method override to draw origin axis in edit mode""" - # Run default method - result = QtWidgets.QGraphicsView.drawForeground(self, painter, rect) - - # Paint axis in edit mode - if __EDIT_MODE__.get(): - self.draw_overlay_axis(painter, rect) - - return result - - def draw_overlay_axis(self, painter, rect): - """Draw x and y origin axis""" - # Set Pen - pen = QtGui.QPen( - QtGui.QColor(160, 160, 160, 120), 1, QtCore.Qt.DashLine - ) - painter.setPen(pen) - - # Get event rect in scene coordinates - # Draw x line - if rect.y() < 0 and (rect.height() - rect.y()) > 0: - x_line = QtCore.QLine(rect.x(), 0, rect.width() + rect.x(), 0) - painter.drawLine(x_line) - - # Draw y line - if rect.x() < 0 and (rect.width() - rect.x()) > 0: - y_line = QtCore.QLineF(0, rect.y(), 0, rect.height() + rect.y()) - painter.drawLine(y_line) - - def convert_picker_to_curves(self): - """Convert the pickernodes from the view into maya curves for easier - editing. - - Returns: - n/a: n/a - - http://forum.mgear-framework.com/t/sharing-a-couple-of-functions-i-wrote-for-anim-picker/1717 - """ - data = self.main_window.get_character_data() - - tab_index = self.main_window.tab_widget.currentIndex() - tab_name = self.main_window.tab_widget.tabText(tab_index) - - tab = None - # only focus on the current tab - for _tab in data["tabs"]: - if _tab["name"] == tab_name: - tab = _tab - if not tab: - cmds.warning("No data in picker tab: {}".format(tab_name)) - return - - # lets not create multiple picker group nodes - if pm.objExists(PICKER_EXTRACTION_NAME): - grp = pm.PyNode(PICKER_EXTRACTION_NAME) - else: - grp = pm.group(em=True, n=PICKER_EXTRACTION_NAME) - attribute.lockAttribute(grp) - - # Delete a previous extraction group for this tab, matched by the - # stored tab name rather than the node name: Maya may rename the group - # (e.g. "default" -> "default1", or spaces -> "_"), so the node name is - # not a reliable key. - for existing in grp.listRelatives() or []: - if ( - pm.hasAttr(existing, "tabName") - and existing.tabName.get() == tab["name"] - ): - pm.delete(existing) - picker_grp = pm.group(em=True, n=tab["name"], p=grp) - # Store the real tab name so the round-trip does not depend on the - # (possibly Maya-mangled) group node name. - attribute.addAttribute(picker_grp, "tabName", "string", tab["name"]) - picker_grp.sy >> picker_grp.sx - attribute.lockAttribute( - picker_grp, ["tz", "rx", "ry", "rz", "sx", "sz", "v"] - ) - - if "background" in tab["data"]: - attribute.addAttribute( - picker_grp, - "backgroundAlpha", - "float", - 0.5, - minValue=0, - maxValue=1, - ) - attribute.addAttribute( - picker_grp, - "backgroundWidth", - "long", - tab["data"]["background_size"][0], - minValue=1, - ) - attribute.addAttribute( - picker_grp, - "backgroundHeight", - "long", - tab["data"]["background_size"][1], - minValue=1, - ) - ip = pm.imagePlane(n="{}_background".format(tab["name"])) - ip[0].tz.set(-1) - ip[0].overrideEnabled.set(1) - ip[0].overrideDisplayType.set(2) - pm.parent(ip[0], picker_grp) - - picker_grp.backgroundAlpha >> ip[1].alphaGain - picker_grp.backgroundWidth >> ip[1].width - picker_grp.backgroundHeight >> ip[1].height - - ip[1].imageName.set(tab["data"]["background"]) - - if "items" in tab["data"]: - for item in tab["data"]["items"]: - handles = item["handles"] - pos_x, pos_y = item["position"] - rot_z = item["rotation"] - - if len(handles) > 2: - item_curve = pm.circle( - d=1, s=len(item["handles"]), ch=False - )[0] - - for i, (x, y) in enumerate(handles): - item_curve.getShape().controlPoints[i].set(x, y, 0) - item_curve.getShape().controlPoints[i + 1].set( - handles[0][0], handles[0][1], 0 - ) - - # special case for circles - elif len(handles) == 2: - item_curve = pm.curve( - p=[ - [handles[0][0], handles[0][1], 0.0], - [handles[1][0], handles[1][1], 0.0], - ], - d=1, - ) - poci = pm.createNode("pointOnCurveInfo") - item_curve.getShape().worldSpace >> poci.inputCurve - curve_len = pm.arclen(item_curve, ch=True) - - display_curve = pm.circle(d=3, s=6, ch=False)[0] - pm.parent(display_curve, item_curve) - display_curve.getShape().overrideEnabled.set(1) - display_curve.getShape().overrideDisplayType.set(2) - display_curve.inheritsTransform.set(0) - - curve_len.arcLength >> display_curve.sx - curve_len.arcLength >> display_curve.sy - curve_len.arcLength >> display_curve.sz - poci.position >> display_curve.t - - pm.parent(item_curve, picker_grp) - - q_color = QtGui.QColor(*item["color"]) - attribute.addColorAttribute( - item_curve, "color", q_color.getRgbF()[:3] - ) - attribute.addAttribute( - item_curve, - "alpha", - "long", - item["color"][3], - minValue=0, - maxValue=255, - ) - - item_curve.t.set(pos_x, pos_y, 0) - item_curve.rz.set(rot_z) - item_curve.displayHandle.set(1) - item_curve.getShape().dispCV.set(1) - item_curve.overrideEnabled.set(1) - item_curve.overrideRGBColors.set(1) - item_curve.color >> item_curve.overrideColorRGB - item_curve.scalePivot >> item_curve.selectHandle - attribute.lockAttribute( - item_curve, ["tz", "rx", "ry", "sz", "v"] - ) - - # this will save all the data that is not needed for display - # purposes to an attr - ignore_list = ("position", "rotation", "handles", "color") - item_data = {} - - for key in item.keys(): - if key not in ignore_list: - item_data[key] = item[key] - - if item_data: - attribute.addAttribute( - item_curve, "itemData", "string", json.dumps(item_data) - ) - item_curve.itemData.set(lock=True) - - def delete_extraction_grp(self): - """delete extraction group""" - try: - pm.delete(PICKER_EXTRACTION_NAME) - except Exception as e: - mgear.log( - "anim_picker: failed to delete extraction group: " - "{}".format(e), - mgear.sev_warning, - ) - - def convert_curves_to_picker(self): - """get the information from the created picker curves and reset the - information on the picker data node the anim picker operates on - """ - grp = pm.PyNode(PICKER_EXTRACTION_NAME) - new_data = {"tabs": []} - - for tab_grp in grp.listRelatives(): - # Recover the real tab name (the group node may have been renamed - # by Maya, e.g. "default" -> "default1"). - if pm.hasAttr(tab_grp, "tabName"): - tab_name = tab_grp.tabName.get() - else: - tab_name = tab_grp.name() - new_data["tabs"].append({"name": tab_name}) - new_data["tabs"][-1]["data"] = {"items": []} - bg_imagePlane = tab_grp.listRelatives(type="imagePlane", ad=True) - - if bg_imagePlane: - image_name = bg_imagePlane[0].imageName.get() - new_data["tabs"][-1]["data"]["background"] = image_name - new_data["tabs"][-1]["data"]["background_size"] = [ - bg_imagePlane[0].width.get(), - bg_imagePlane[0].height.get(), - ] - - for item_curve in tab_grp.listRelatives(): - shape = item_curve.getShape() - if shape is None or shape.type() == "imagePlane": - continue - - item_data = {} - - # color - q_color = QtGui.QColor() - q_color.setRgbF(*item_curve.color.get()) - q_color.setAlpha(item_curve.alpha.get()) - item_data["color"] = q_color.getRgb() - - # position and rotation - item_piv = pm.dt.Point( - item_curve.getPivots(worldSpace=True)[0] - ) - piv_offset = item_piv * item_curve.worldInverseMatrix.get() - item_pos = item_piv * tab_grp.worldInverseMatrix.get() - - item_data["position"] = [item_pos.x, item_pos.y] - item_data["rotation"] = item_curve.rz.get() - - # handles - handles = [] - item_scale = [item_curve.sx.get(), item_curve.sy.get()] - for cv in item_curve.cv: - x, y = cv.getPosition(space="object")[:2] - handles.append( - [ - (x - piv_offset.x) * item_scale[0], - (y - piv_offset.y) * item_scale[1], - ] - ) - - # if the first and last points are the same then ignore the - # last one. - if handles[0] == handles[-1]: - handles = handles[:-1] - item_data["handles"] = handles - - if pm.hasAttr(item_curve, "itemData"): - item_data.update(json.loads(item_curve.itemData.get())) - - new_data["tabs"][-1]["data"]["items"].append(item_data) - - data_node = self.main_window.get_current_data_node() - if not (data_node and data_node.exists()): - return True - data = self.main_window.get_character_data() - # update original data to avoid deletion of the non edited tabs - # Create a lookup dictionary for fast matching - new_data_lookup = {d["name"]: d for d in new_data["tabs"] if "name" in d} - - # Surface converted tabs that don't match any current picker tab, so - # the merge below does not silently drop their edited data. - current_names = { - d.get("name") for d in data.get("tabs", []) if "name" in d - } - for converted_name in new_data_lookup: - if converted_name not in current_names: - mgear.log( - "anim_picker: converted tab '{}' has no matching tab in " - "the current picker; its data was not applied".format( - converted_name - ), - mgear.sev_warning, - ) - - # Replace the matching dictionaries in data - updated_data = {"tabs": []} - updated_data["tabs"] = [ - new_data_lookup.get(d.get("name"), d) if "name" in d else d - for d in data["tabs"] - ] - # Write through the DataNode chokepoint (JSON + version stamp + - # lock handling all centralized in picker_node.py) - data_node.set_data(updated_data) - data_node.write_data(to_node=True) - - self.main_window.refresh() - - -class ContextMenuTabWidget(QtWidgets.QTabWidget): - """Custom tab widget with specific context menu support""" - - def __init__(self, parent, main_window=None, *args, **kwargs): - QtWidgets.QTabWidget.__init__(self, parent, *args, **kwargs) - self.main_window = main_window - - def contextMenuEvent(self, event): - """Right click menu options""" - # Abort out of edit mode - if not __EDIT_MODE__.get(): - return - - # Init context menu - menu = QtWidgets.QMenu(self) - - # Build context menu - rename_action = QtWidgets.QAction("Rename", None) - rename_action.triggered.connect(self.rename_event) - menu.addAction(rename_action) - - add_action = QtWidgets.QAction("Add Tab", None) - add_action.triggered.connect(self.add_tab_event) - menu.addAction(add_action) - - remove_action = QtWidgets.QAction("Remove Tab", None) - remove_action.triggered.connect(self.remove_tab_event) - menu.addAction(remove_action) - - move_forward_action = QtWidgets.QAction("Move Tab >>>", None) - move_forward_action.triggered.connect(self.move_forward_tab_event) - menu.addAction(move_forward_action) - - move_back_action = QtWidgets.QAction("Move Tab <<<", None) - move_back_action.triggered.connect(self.move_back_tab_event) - menu.addAction(move_back_action) - - # Open context menu under mouse - menu.exec_(self.mapToGlobal(event.pos())) - - def fit_contents(self): - """Will resize views content to match views size""" - for i in range(self.count()): - widget = self.widget(i) - if not isinstance(widget, GraphicViewWidget): - continue - widget.fit_scene_content() - - def move_back_tab_event(self): - current_index = self.currentIndex() - if current_index > 0: - self.tabBar().moveTab(current_index, current_index - 1) - self.setCurrentIndex(current_index - 1) - - def move_forward_tab_event(self): - current_index = self.currentIndex() - if current_index < self.count() - 1: - self.tabBar().moveTab(current_index, current_index + 1) - self.setCurrentIndex(current_index + 1) - - def rename_event(self): - """Will open dialog to rename tab""" - # Get current tab index - index = self.currentIndex() - - # Open input window - name, ok = QtWidgets.QInputDialog.getText( - self, - "Tab name", - "New name", - QtWidgets.QLineEdit.Normal, - self.tabText(index), - ) - if not (ok and name): - return - - # Update influence name - self.setTabText(index, name) - - def add_tab_event(self): - """Will open dialog to get tab name and create a new tab""" - # Open input window - name, ok = QtWidgets.QInputDialog.getText( - self, "Create new tab", "Tab name", QtWidgets.QLineEdit.Normal, "" - ) - if not (ok and name): - return - - # Add tab - self.addTab(GraphicViewWidget(main_window=self.main_window), name) - - # Set new tab active - self.setCurrentIndex(self.count() - 1) - - def remove_tab_event(self): - """Will remove tab from widget""" - # Get current tab index - index = self.currentIndex() - - # Open confirmation - reply = QtWidgets.QMessageBox.question( - self, - "Delete", - "Delete tab '{}'?".format(self.tabText(index)), - QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, - QtWidgets.QMessageBox.No, - ) - if reply == QtWidgets.QMessageBox.No: - return - - # Remove tab - self.removeTab(index) - - def get_namespace(self): - """Return data_node namespace""" - # Proper parent - if self.main_window and isinstance(self.main_window, MainDockWindow): - return self.main_window.get_current_namespace() - - return None - - def get_current_picker_items(self): - """Return all picker items for current active tab""" - return self.currentWidget().get_picker_items() - - def get_all_picker_items(self): - """Returns all picker items for all tabs""" - items = [] - for i in range(self.count()): - items.extend(self.widget(i).get_picker_items()) - return items - - def get_data(self): - """Will return all tabs data""" - data = [] - for i in range(self.count()): - name = str(self.tabText(i)) - tab_data = self.widget(i).get_data() - data.append({"name": name, "data": tab_data}) - return data - - def set_data(self, data): - """Will, set/load tabs data""" - self.clear() - for tab in data: - view = GraphicViewWidget( - namespace=self.get_namespace(), main_window=self.main_window - ) - # changed name to default1 as maya wont let you make a group called - # 'default' for curve extraction. - self.addTab(view, tab.get("name", "default1")) - - tab_content = tab.get("data", None) - if tab_content: - view.set_data(tab_content) - - -class MainDockWindow(QtWidgets.QWidget): - __OBJ_NAME__ = "ctrl_picker_window" - __TITLE__ = ANIM_PICKER_TITLE.format( - m_version=_mgear_version, ap_version=version.version - ) - - def __init__(self, parent=None, edit=False, dockable=False): - super().__init__(parent=parent) - self.window_parent = parent - self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) - self.setWindowFlags(QtCore.Qt.Window) - self.ready = False - - # Window size - # (default size to provide a 450/700 for tab area and proper img size) - self.default_width = pyqt.dpi_scale(476) - self.default_height = pyqt.dpi_scale(837) - - # Default vars - self.status = False - self.childs = [] - self.script_jobs = [] - - __EDIT_MODE__.set_init(edit) - self.is_dockable = dockable - - # Setup ui - self.cb_manager = callbackManager.CallbackManager() - self.setup() - - # experimental passthrough feature - self.original_flags = self.windowFlags() - self.passthrough_eventFilter_installed = False - self.ap_eventFilter = APPassthroughEventFilter() - self.ap_eventFilter.APUI = self - - def setup(self): - """Setup interface""" - # Main window setting - # Setting object name makes docking not useable? da fuck - # self.setObjectName(self.__OBJ_NAME__) - self.setWindowTitle(self.__TITLE__) - - # Add main widget and vertical layout - self.main_vertical_layout = QtWidgets.QVBoxLayout() - self.setLayout(self.main_vertical_layout) - - # Add window fields - self.add_character_selector() - self.add_tab_widget() - - # if the window is not dockable we can control the opacity - # MayaQWidgetDockableMixin overrides setWindowsOpacity - self.auto_opacity_btn = QtWidgets.QPushButton("") - if not self.is_dockable: - opacity_layout = QtWidgets.QHBoxLayout() - self.opacity_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) - self.opacity_slider.setRange(10, 100) - self.opacity_slider.setValue(100) - self.opacity_slider.valueChanged.connect(self.change_opacity) - self.auto_opacity_btn = QtWidgets.QPushButton("Auto opacity") - self.auto_opacity_btn.setCheckable(True) - self.auto_opacity_btn.toggled.connect(self.change_opacity) - self.auto_opacity_btn.toggled.connect( - self.toggle_passthrough_eventFilter - ) - self.installEventFilter(self) - opacity_layout.addWidget(self.opacity_slider) - opacity_layout.addWidget(self.auto_opacity_btn) - self.main_vertical_layout.addLayout(opacity_layout) - - self.add_overlays() - self.resize(self.default_width, self.default_height) - # Creating is done (workaround for signals being fired - # off before everything is created) - self.ready = True - - def toggle_passthrough_eventFilter(self): - """enable the eventFilter for changing the AP gui windowFlags state""" - # this feature is beta and is off by default - if ( - menu.get_option_var_passthrough_state() == 0 - or not self.window_parent - ): - return - if self.auto_opacity_btn.isChecked(): - self.window_parent.installEventFilter(self.ap_eventFilter) - self.passthrough_eventFilter_installed = True - else: - self.window_parent.removeEventFilter(self.ap_eventFilter) - self.passthrough_eventFilter_installed = False - - def set_mouseEvent_passthrough(self, state): - """set the state of the passthrough feature for anim picker - - Args: - state (bool): enable or disable - """ - if state and self.passthrough_eventFilter_installed: - self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True) - self.setWindowFlags( - self.original_flags & QtCore.Qt.WA_TransparentForMouseEvents - ) - self.show() - elif state and not self.passthrough_eventFilter_installed: - self.toggle_passthrough_eventFilter() - else: - self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, False) - self.setWindowFlags(self.original_flags) - self.show() - - def eventFilter(self, QObject, event): - """event filter for general override - current use - -Auto opacityfilter - -hide the GraphicsView for compatibility with MacOs - - Args: - QObject (QObject): the object getting the event - event (QEvent): event type - - Returns: - bool: accepting event or not - """ - modifiers = None - if self.auto_opacity_btn.isChecked(): - modifiers = QtWidgets.QApplication.queryKeyboardModifiers() - - if event.type() == QtCore.QEvent.Type.Enter: - shift_state = modifiers == QtCore.Qt.ShiftModifier - flag_state = self.testAttribute( - QtCore.Qt.WA_TransparentForMouseEvents - ) - if self.auto_opacity_btn.isChecked(): - if flag_state and shift_state: - self.setWindowOpacity(100) - return True - elif not flag_state: - self.setWindowOpacity(100) - return True - else: - if event.type() == QtCore.QEvent.Type.Leave: - opacity_state = self.auto_opacity_btn.isChecked() - flag_state = self.testAttribute( - QtCore.Qt.WA_TransparentForMouseEvents - ) - if opacity_state: - pos = QtGui.QCursor().pos() - widgetRect = self.geometry() - if not widgetRect.contains(pos): - self.change_opacity() - # check the option var if it is enabled - if menu.get_option_var_passthrough_state(): - self.set_mouseEvent_passthrough(True) - elif flag_state and not opacity_state: - self.set_mouseEvent_passthrough(False) - - # QtCore.QEvent.Type.ScreenChangeInternal - # hide main tab widget for os compatibility - if QObject in getattr(self, "overlays", []): - if event.type() == QtCore.QEvent.Type.Show: - self.tab_widget.hide() - return True - elif event.type() == QtCore.QEvent.Type.Hide: - self.tab_widget.show() - return True - - return False - - def change_opacity(self): - """Change the windows opacity""" - opacity_value = self.opacity_slider.value() - self.setWindowOpacity(opacity_value / 100.0) - - def reset_default_size(self): - """Reset window size to default""" - self.resize(self.default_width, self.default_height) - - def toggle_character_selector(self, *args): - """Toggle the visibility of the character select widget""" - if self.character_box.isChecked(): - self.char_select_widget.show() - else: - self.char_select_widget.hide() - - def add_character_selector(self): - """Add Character comboBox selector""" - # Create group box - self.character_box = QtWidgets.QGroupBox("Character Selector") - bg_color = self.palette().color(QtGui.QPalette.Window).getRgb() - cc_style_sheet = GROUPBOX_BG_CSS.format(color=bg_color) - self.character_box.setStyleSheet(cc_style_sheet) - self.character_box.setContentsMargins(0, 0, 0, 0) - self.character_box.setMinimumHeight(0) - self.character_box.setMaximumHeight(pyqt.dpi_scale(80)) - self.character_box.setCheckable(True) - self.character_box.setChecked(True) - self.character_box.clicked.connect(self.toggle_character_selector) - - self.char_select_widget = QtWidgets.QWidget() - self.char_select_widget.setContentsMargins(0, 5, 0, 0) - tmp_layout = QtWidgets.QHBoxLayout(self.character_box) - tmp_layout.setSpacing(0) - tmp_layout.addWidget(self.char_select_widget) - - # Create layout - layout = QtWidgets.QHBoxLayout(self.char_select_widget) - - # Create character picture widget - self.pic_widget = basic.SnapshotWidget() - - box_layout = QtWidgets.QVBoxLayout() - layout.addLayout(box_layout) - layout.addWidget(self.pic_widget, QtCore.Qt.AlignCenter) - - # Add combo box - self.char_selector_cb = basic.CallbackComboBox( - callback=self.selector_change_event - ) - box_layout.addWidget(self.char_selector_cb) - - # Init combo box data - self.char_selector_cb.nodes = [] - - # Add option buttons - btns_layout = QtWidgets.QHBoxLayout() - box_layout.addLayout(btns_layout) - - # Add horizont spacer - spacer = QtWidgets.QSpacerItem( - 10, - 0, - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Minimum, - ) - btns_layout.addItem(spacer) - - # sync checkbox - self.checkbox = QtWidgets.QCheckBox("Sync Namespace") - if not __EDIT_MODE__.get(): - btns_layout.addWidget(self.checkbox) - - # About btn - about_btn = basic.CallbackButton(callback=self.show_about_infos) - about_btn.setText("?") - about_btn.setToolTip("Show help/about informations") - btns_layout.addWidget(about_btn) - - # laod btn - load_btn = basic.CallbackButton(callback=self.show_load_widget) - load_btn.setText("Load") - load_btn.setToolTip("Load from file") - btns_layout.addWidget(load_btn) - - # Refresh button - self.char_refresh_btn = basic.CallbackButton(callback=self.refresh) - self.char_refresh_btn.setText("Refresh") - btns_layout.addWidget(self.char_refresh_btn) - - # Edit buttons - self.new_char_btn = None - self.save_char_btn = None - if __EDIT_MODE__.get(): - # Add New button - self.new_char_btn = basic.CallbackButton( - callback=self.new_character - ) - self.new_char_btn.setText("New") - self.new_char_btn.setFixedWidth(pyqt.dpi_scale(40)) - - btns_layout.addWidget(self.new_char_btn) - - # Add Save button - self.save_char_btn = basic.CallbackButton( - callback=self.save_character - ) - self.save_char_btn.setText("Save") - self.save_char_btn.setFixedWidth(pyqt.dpi_scale(40)) - - btns_layout.addWidget(self.save_char_btn) - self.main_vertical_layout.addWidget(self.character_box) - - def add_tab_widget(self, name="default"): - """Add control display field""" - self.tab_widget = ContextMenuTabWidget(self, main_window=self) - self.main_vertical_layout.addWidget(self.tab_widget) - - # Add default first tab - view = GraphicViewWidget(main_window=self) - self.tab_widget.addTab(view, name) - - # ensure the tab retains its size when hidden - sp_retain = self.tab_widget.sizePolicy() - sp_retain.setRetainSizeWhenHidden(True) - self.tab_widget.setSizePolicy(sp_retain) - - def add_overlays(self): - """Add transparent overlay widgets""" - self.about_widget = overlay_widgets.AboutOverlayWidget(self) - self.load_widget = overlay_widgets.LoadOverlayWidget(self) - self.save_widget = overlay_widgets.SaveOverlayWidget(self) - self.overlays = [self.about_widget, self.load_widget, self.save_widget] - - # specificaly hiding and showing the main layer for OS compatibility - for layer in self.overlays: - layer.installEventFilter(self) - - def get_picker_items(self): - """Return picker items for current active tab""" - return self.tab_widget.get_current_picker_items() - - def get_all_picker_items(self): - """Return all picker items for current picker""" - return self.tab_widget.get_all_picker_items() - - def dockCloseEventTriggered(self): - self.close() - - def closeEvent(self, evnt): - self.close() - - def close(self): - """Overwriting close event to close child windows too""" - # Delete script jobs - self.cb_manager.removeAllManagedCB() - # Close childs - for child in self.childs: - try: - child.close() - except Exception: - pass - - # Close ctrls options windows - for item in self.get_all_picker_items(): - try: - if not item.edit_window: - continue - item.edit_window.close() - except Exception: - pass - - try: - self.window_parent.removeEventFilter(self.ap_eventFilter) - except Exception: - pass - - # Default close - # mayaMixin bug that i need to correct for - corrected_for_dashes = self.objectName().replace("_", "-") - corrected_for_initial_dash = corrected_for_dashes.replace("-", "_", 1) - work_name = "{}WorkspaceControl".format(corrected_for_initial_dash) - try: - cmds.workspaceControl(work_name, e=True, close=True) - except ValueError: - pass - except RuntimeError: - pass - self.deleteLater() - - def showEvent(self, *args, **kwargs): - """Default showEvent overload""" - # Prevent firing this event before the window is set up - if not self.ready: - return - - # Default close - super().showEvent(*args, **kwargs) - - # Force char load - self.refresh() - - # Add script jobs - self.add_callback() - - def resizeEvent(self, event): - """Resize about overlay on resize event""" - # Prevent firing this event before the window is set up - if not self.ready: - return - - size = self.size() - - self.about_widget.resize(size) - - self.save_widget.resize(size) - - self.load_widget.resize(size) - - return super().resizeEvent(event) - - def show_about_infos(self): - """Open animation picker about and help infos""" - self.about_widget.show() - - def show_load_widget(self): - """Open animation picker about and help infos""" - self.load_widget.show() - - # ========================================================================= - # Character selector handlers --- - def selector_change_event(self, index): - """Will load data node relative to selector index""" - self.load_character() - - def populate_char_selector(self): - """Will populate char selector combo box""" - # Get char nodes - nodes = picker_node.get_nodes() - self.char_selector_cb.nodes = nodes - - # Empty combo box - self.char_selector_cb.clear() - - # Populate - for data_node in nodes: - # text = data_node.get_namespace() or data_node.name - text = data_node.name - self.char_selector_cb.addItem(text) - - # Set elements active status - self.set_field_status() - - def set_field_status(self): - """Will toggle elements active status""" - # Define status from node list - self.status = False - if self.char_selector_cb.count(): - self.status = True - - # Set status - self.char_selector_cb.setEnabled(self.status) - self.tab_widget.setEnabled(self.status) - if self.save_char_btn: - self.save_char_btn.setEnabled(self.status) - - # Reset tabs - if not self.status: - self.load_default_tabs() - - def load_default_tabs(self): - """Will reset and load default empty tabs""" - self.tab_widget.clear() - self.tab_widget.addTab(GraphicViewWidget(main_window=self), "None") - - def refresh(self): - """Refresh char selector and window""" - # Get current active node - current_node = None - data_node = self.get_current_data_node() - if data_node and data_node.exists(): - current_node = data_node.name - - # Check/abort on possible data changes - if __EDIT_MODE__.get() and current_node: - if not self.check_for_data_change(): - return - - # Re-populate selector - self.populate_char_selector() - - # Set proper index - if current_node: - self.make_node_active(current_node) - - # Refresh selection check - self.selection_change_event() - - # Force view resize - self.tab_widget.fit_contents() - - # Set focus on view - self.tab_widget.currentWidget().setFocus() - - def load_from_sel_node(self): - """Will try to load character for selected node""" - sel = cmds.ls(sl=True) - if not sel: - return - data_node = picker_node.get_node_for_object(sel[0]) - if not data_node: - return - self.make_node_active(data_node.name) - - def make_node_active(self, data_node): - """Will set character selector to specified data_node""" - index = 0 - for i in range(len(self.char_selector_cb.nodes)): - node = self.char_selector_cb.nodes[i] - if not data_node == node.name or data_node == node: - continue - index = i - break - self.char_selector_cb.setCurrentIndex(index) - - def new_character(self): - """ - Will create a new data node, and init a new window - (edit mode only) - """ - # Open input window - name, ok = QtWidgets.QInputDialog.getText( - self, - "New character", - "Node name", - QtWidgets.QLineEdit.Normal, - "PICKER_DATA", - ) - if not (ok and name): - return - - # Check for possible data changes/loss - if not self.check_for_data_change(): - return - - # Create new data node - data_node = picker_node.DataNode(name=str(name)) - data_node.create() - self.refresh() - self.make_node_active(data_node) - - # ========================================================================= - # Data --- - def check_for_data_change(self): - """ - Check if data changed - If changes are detected will ask user if he wants to proceed any - way and loose thoses changes - Return user answer - """ - # Get current data node - data_node = self.get_current_data_node() - if not (data_node and data_node.exists()): - return True - - # Return true if no changes were detected - if data_node == self.get_character_data(): - return True - - # Open question window - msg = "Any changes will be lost, proceed any way ?" - answer = QtWidgets.QMessageBox.question( - self, - "Changes detected", - msg, - buttons=QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, - ) - return answer == QtWidgets.QMessageBox.Yes - - def get_current_namespace(self): - return self.get_current_data_node().get_namespace() - - def get_current_data_node(self): - """Return current character data node""" - # Empty list case - if not self.char_selector_cb.count(): - return None - - # Return node from combo box index - index = self.char_selector_cb.currentIndex() - return self.char_selector_cb.nodes[index] - - def load_character(self): - """Load currently selected data node""" - # Get DataNode - data_node = self.get_current_data_node() - if not data_node: - return - picker_data = data_node.get_data() - - # Load snapshot - path = picker_data.get("snapshot", None) - self.pic_widget.set_background(path) - - # load tabs - tabs_data = picker_data.get("tabs", {}) - self.tab_widget.set_data(tabs_data) - - # Default tab - if not self.tab_widget.count(): - self.tab_widget.addTab( - GraphicViewWidget(main_window=self), "default" - ) - else: - # Return to first tab - self.tab_widget.setCurrentIndex(0) - - # Fit content - self.tab_widget.fit_contents() - - # Update selection states - self.selection_change_event() - - def save_character(self): - """Save data to current selected data_node""" - # Get DataNode - data_node = self.get_current_data_node() - assert data_node, "No data_node found/selected" - - # Block save in anim mode - if not __EDIT_MODE__.get(): - QtWidgets.QMessageBox.warning( - self, "Warning", "Save is not permited in anim mode" - ) - return - - # Block save on referenced nodes - if data_node.is_referenced(): - msg = "Save is not permited on referenced nodes" - QtWidgets.QMessageBox.warning(self, "Warning", msg) - return - - self.save_widget.show() - - def get_character_data(self): - """Return window data""" - picker_data = {} - - # Add snapshot path data - snapshot_data = self.pic_widget.get_data() - if snapshot_data: - picker_data["snapshot"] = snapshot_data - - # Add tabs data - tabs_data = self.tab_widget.get_data() - if tabs_data: - picker_data["tabs"] = tabs_data - - return picker_data - - # ========================================================================= - # Script jobs handling --- - def add_callback(self): - """Will add maya scripts job events""" - # Clear any existing scrip jobs - self.cb_manager.removeAllManagedCB() - - # Add selection change event - self.cb_manager.selectionChangedCB( - "anim_picker_selection", self.selection_change_event - ) - # Add scene open event - self.cb_manager.newSceneCB( - "anim_picker_newScene", self.selection_change_event - ) - - def selection_change_event(self, *args): - """ - Event called with a script job from maya on selection change. - Will properly parse poly_ctrls associated node, and set border - visible if content is selected - """ - # Abort in Edit mode - if __EDIT_MODE__.get(): - return - - # Update selection data - __SELECTION__.update() - - # sync with namespce - if not __EDIT_MODE__.get(): - sel = pm.selected() - sync = self.checkbox.isChecked() - if sel and sync: - ns = sel[0].namespace() - if ns: - for i, n in enumerate(self.char_selector_cb.nodes): - if ns in str(n): - self.char_selector_cb.setCurrentIndex(i) - break - # Update controls for active tab - for item in self.get_picker_items(): - item.run_selection_check() - - -# version of the anim picker ui that uses MayaQWidgetDockableMixin for docking -class MainDockableWindow(MayaQWidgetDockableMixin, MainDockWindow): - def __init__(self, parent=None, edit=False, dockable=True): - super().__init__(parent=parent) - - -# ============================================================================= -# Load user interface function -# ============================================================================= -def load(edit=False, dockable=False): - """To launch the ui and not get the same instance - - Returns: - Anim_picker: instance - - Args: - edit (bool, optional): Description - dockable (bool, optional): Description - - """ - - # NOTE: if instead we set dockable to false the window doesn't get - # parented to Maya UI. - # Deferred (feature phase): dockable mode breaks the interface when - # docked, so it is intentionally not exposed from the menu yet. See the - # anim_picker refactor roadmap (Phase 3+: "Fix + re-enable dockable"). - if dockable: - ANIM_PKR_UI = MainDockableWindow( - parent=None, edit=edit, dockable=dockable - ) - ANIM_PKR_UI.show(dockable=True) - else: - ANIM_PKR_UI = MainDockWindow(parent=pyqt.get_main_window(), edit=edit) - ANIM_PKR_UI.show() - return ANIM_PKR_UI +from mgear.anim_picker.constants import ANIM_PICKER_TITLE +from mgear.anim_picker.constants import MAYA_OVERRIDE_COLOR +from mgear.anim_picker.constants import GROUPBOX_BG_CSS +from mgear.anim_picker.constants import PICKER_EXTRACTION_NAME +from mgear.anim_picker.constants import ANIM_PICKER_RELATIVE_IMAGES +from mgear.anim_picker.constants import DEFAULT_RELATIVE_IMAGES_PATH + +from mgear.anim_picker.scene import OrderedGraphicsScene +from mgear.anim_picker.view import GraphicViewWidget +from mgear.anim_picker.tab_widget import ContextMenuTabWidget +from mgear.anim_picker.main_window import APPassthroughEventFilter +from mgear.anim_picker.main_window import MainDockWindow +from mgear.anim_picker.main_window import MainDockableWindow +from mgear.anim_picker.main_window import load + + +__all__ = [ + "ANIM_PICKER_TITLE", + "MAYA_OVERRIDE_COLOR", + "GROUPBOX_BG_CSS", + "PICKER_EXTRACTION_NAME", + "ANIM_PICKER_RELATIVE_IMAGES", + "DEFAULT_RELATIVE_IMAGES_PATH", + "OrderedGraphicsScene", + "GraphicViewWidget", + "ContextMenuTabWidget", + "APPassthroughEventFilter", + "MainDockWindow", + "MainDockableWindow", + "load", +] diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py new file mode 100644 index 00000000..110f85cc --- /dev/null +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -0,0 +1,779 @@ +"""Main dockable window and launcher for the anim picker. + +Extracted from gui.py during the Phase 2 decomposition. Also hosts the +passthrough event filter used by the main window. +""" + +from maya import cmds +import mgear.pymaya as pm + +from maya.app.general.mayaMixin import MayaQWidgetDockableMixin + +from mgear.core import pyqt +from mgear.core import callbackManager +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtCompat +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker import menu +from mgear.anim_picker import version +from mgear.anim_picker import picker_node +from mgear.anim_picker.constants import ANIM_PICKER_TITLE +from mgear.anim_picker.constants import GROUPBOX_BG_CSS +from mgear.anim_picker.constants import _mgear_version +from mgear.anim_picker.view import GraphicViewWidget +from mgear.anim_picker.tab_widget import ContextMenuTabWidget +from mgear.anim_picker.widgets import basic +from mgear.anim_picker.widgets import overlay_widgets +from mgear.anim_picker.handlers import __EDIT_MODE__ +from mgear.anim_picker.handlers import __SELECTION__ + + +class APPassthroughEventFilter(QtCore.QObject): + """AnimPicker eventFilter for MayaMainWindow when enabling + click passthrough for the GUI. + """ + + # Animpicker gui reference + APUI = None + + def eventFilter(self, QObject, event): + """Filter for changing the windowFlags on the animPicker gui""" + modifiers = None + if QtCompat.isValid(self.APUI): + modifiers = QtWidgets.QApplication.queryKeyboardModifiers() + auto_state = self.APUI.auto_opacity_btn.isChecked() + flag_state = self.APUI.testAttribute( + QtCore.Qt.WA_TransparentForMouseEvents + ) + if auto_state and modifiers == QtCore.Qt.ShiftModifier: + # if the window is passthrough enabled + if flag_state: + pos = QtGui.QCursor().pos() + widgetRect = self.APUI.geometry() + if widgetRect.contains(pos): + self.APUI.set_mouseEvent_passthrough(False) + # if the window is passthrough enabled and the feature disabled + elif flag_state and not menu.get_option_var_passthrough_state(): + self.APUI.set_mouseEvent_passthrough(False) + else: + pass + else: + try: + self.deleteLater() + except RuntimeError: + pass + return super().eventFilter(QObject, event) + + +class MainDockWindow(QtWidgets.QWidget): + __OBJ_NAME__ = "ctrl_picker_window" + __TITLE__ = ANIM_PICKER_TITLE.format( + m_version=_mgear_version, ap_version=version.version + ) + + def __init__(self, parent=None, edit=False, dockable=False): + super().__init__(parent=parent) + self.window_parent = parent + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + self.setWindowFlags(QtCore.Qt.Window) + self.ready = False + + # Window size + # (default size to provide a 450/700 for tab area and proper img size) + self.default_width = pyqt.dpi_scale(476) + self.default_height = pyqt.dpi_scale(837) + + # Default vars + self.status = False + self.childs = [] + self.script_jobs = [] + + __EDIT_MODE__.set_init(edit) + self.is_dockable = dockable + + # Setup ui + self.cb_manager = callbackManager.CallbackManager() + self.setup() + + # experimental passthrough feature + self.original_flags = self.windowFlags() + self.passthrough_eventFilter_installed = False + self.ap_eventFilter = APPassthroughEventFilter() + self.ap_eventFilter.APUI = self + + def setup(self): + """Setup interface""" + # Main window setting + # Setting object name makes docking not useable? da fuck + # self.setObjectName(self.__OBJ_NAME__) + self.setWindowTitle(self.__TITLE__) + + # Add main widget and vertical layout + self.main_vertical_layout = QtWidgets.QVBoxLayout() + self.setLayout(self.main_vertical_layout) + + # Add window fields + self.add_character_selector() + self.add_tab_widget() + + # if the window is not dockable we can control the opacity + # MayaQWidgetDockableMixin overrides setWindowsOpacity + self.auto_opacity_btn = QtWidgets.QPushButton("") + if not self.is_dockable: + opacity_layout = QtWidgets.QHBoxLayout() + self.opacity_slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) + self.opacity_slider.setRange(10, 100) + self.opacity_slider.setValue(100) + self.opacity_slider.valueChanged.connect(self.change_opacity) + self.auto_opacity_btn = QtWidgets.QPushButton("Auto opacity") + self.auto_opacity_btn.setCheckable(True) + self.auto_opacity_btn.toggled.connect(self.change_opacity) + self.auto_opacity_btn.toggled.connect( + self.toggle_passthrough_eventFilter + ) + self.installEventFilter(self) + opacity_layout.addWidget(self.opacity_slider) + opacity_layout.addWidget(self.auto_opacity_btn) + self.main_vertical_layout.addLayout(opacity_layout) + + self.add_overlays() + self.resize(self.default_width, self.default_height) + # Creating is done (workaround for signals being fired + # off before everything is created) + self.ready = True + + def toggle_passthrough_eventFilter(self): + """enable the eventFilter for changing the AP gui windowFlags state""" + # this feature is beta and is off by default + if ( + menu.get_option_var_passthrough_state() == 0 + or not self.window_parent + ): + return + if self.auto_opacity_btn.isChecked(): + self.window_parent.installEventFilter(self.ap_eventFilter) + self.passthrough_eventFilter_installed = True + else: + self.window_parent.removeEventFilter(self.ap_eventFilter) + self.passthrough_eventFilter_installed = False + + def set_mouseEvent_passthrough(self, state): + """set the state of the passthrough feature for anim picker + + Args: + state (bool): enable or disable + """ + if state and self.passthrough_eventFilter_installed: + self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True) + self.setWindowFlags( + self.original_flags & QtCore.Qt.WA_TransparentForMouseEvents + ) + self.show() + elif state and not self.passthrough_eventFilter_installed: + self.toggle_passthrough_eventFilter() + else: + self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, False) + self.setWindowFlags(self.original_flags) + self.show() + + def eventFilter(self, QObject, event): + """event filter for general override + current use + -Auto opacityfilter + -hide the GraphicsView for compatibility with MacOs + + Args: + QObject (QObject): the object getting the event + event (QEvent): event type + + Returns: + bool: accepting event or not + """ + modifiers = None + if self.auto_opacity_btn.isChecked(): + modifiers = QtWidgets.QApplication.queryKeyboardModifiers() + + if event.type() == QtCore.QEvent.Type.Enter: + shift_state = modifiers == QtCore.Qt.ShiftModifier + flag_state = self.testAttribute( + QtCore.Qt.WA_TransparentForMouseEvents + ) + if self.auto_opacity_btn.isChecked(): + if flag_state and shift_state: + self.setWindowOpacity(100) + return True + elif not flag_state: + self.setWindowOpacity(100) + return True + else: + if event.type() == QtCore.QEvent.Type.Leave: + opacity_state = self.auto_opacity_btn.isChecked() + flag_state = self.testAttribute( + QtCore.Qt.WA_TransparentForMouseEvents + ) + if opacity_state: + pos = QtGui.QCursor().pos() + widgetRect = self.geometry() + if not widgetRect.contains(pos): + self.change_opacity() + # check the option var if it is enabled + if menu.get_option_var_passthrough_state(): + self.set_mouseEvent_passthrough(True) + elif flag_state and not opacity_state: + self.set_mouseEvent_passthrough(False) + + # QtCore.QEvent.Type.ScreenChangeInternal + # hide main tab widget for os compatibility + if QObject in getattr(self, "overlays", []): + if event.type() == QtCore.QEvent.Type.Show: + self.tab_widget.hide() + return True + elif event.type() == QtCore.QEvent.Type.Hide: + self.tab_widget.show() + return True + + return False + + def change_opacity(self): + """Change the windows opacity""" + opacity_value = self.opacity_slider.value() + self.setWindowOpacity(opacity_value / 100.0) + + def reset_default_size(self): + """Reset window size to default""" + self.resize(self.default_width, self.default_height) + + def toggle_character_selector(self, *args): + """Toggle the visibility of the character select widget""" + if self.character_box.isChecked(): + self.char_select_widget.show() + else: + self.char_select_widget.hide() + + def add_character_selector(self): + """Add Character comboBox selector""" + # Create group box + self.character_box = QtWidgets.QGroupBox("Character Selector") + bg_color = self.palette().color(QtGui.QPalette.Window).getRgb() + cc_style_sheet = GROUPBOX_BG_CSS.format(color=bg_color) + self.character_box.setStyleSheet(cc_style_sheet) + self.character_box.setContentsMargins(0, 0, 0, 0) + self.character_box.setMinimumHeight(0) + self.character_box.setMaximumHeight(pyqt.dpi_scale(80)) + self.character_box.setCheckable(True) + self.character_box.setChecked(True) + self.character_box.clicked.connect(self.toggle_character_selector) + + self.char_select_widget = QtWidgets.QWidget() + self.char_select_widget.setContentsMargins(0, 5, 0, 0) + tmp_layout = QtWidgets.QHBoxLayout(self.character_box) + tmp_layout.setSpacing(0) + tmp_layout.addWidget(self.char_select_widget) + + # Create layout + layout = QtWidgets.QHBoxLayout(self.char_select_widget) + + # Create character picture widget + self.pic_widget = basic.SnapshotWidget() + + box_layout = QtWidgets.QVBoxLayout() + layout.addLayout(box_layout) + layout.addWidget(self.pic_widget, QtCore.Qt.AlignCenter) + + # Add combo box + self.char_selector_cb = basic.CallbackComboBox( + callback=self.selector_change_event + ) + box_layout.addWidget(self.char_selector_cb) + + # Init combo box data + self.char_selector_cb.nodes = [] + + # Add option buttons + btns_layout = QtWidgets.QHBoxLayout() + box_layout.addLayout(btns_layout) + + # Add horizont spacer + spacer = QtWidgets.QSpacerItem( + 10, + 0, + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Minimum, + ) + btns_layout.addItem(spacer) + + # sync checkbox + self.checkbox = QtWidgets.QCheckBox("Sync Namespace") + if not __EDIT_MODE__.get(): + btns_layout.addWidget(self.checkbox) + + # About btn + about_btn = basic.CallbackButton(callback=self.show_about_infos) + about_btn.setText("?") + about_btn.setToolTip("Show help/about informations") + btns_layout.addWidget(about_btn) + + # laod btn + load_btn = basic.CallbackButton(callback=self.show_load_widget) + load_btn.setText("Load") + load_btn.setToolTip("Load from file") + btns_layout.addWidget(load_btn) + + # Refresh button + self.char_refresh_btn = basic.CallbackButton(callback=self.refresh) + self.char_refresh_btn.setText("Refresh") + btns_layout.addWidget(self.char_refresh_btn) + + # Edit buttons + self.new_char_btn = None + self.save_char_btn = None + if __EDIT_MODE__.get(): + # Add New button + self.new_char_btn = basic.CallbackButton( + callback=self.new_character + ) + self.new_char_btn.setText("New") + self.new_char_btn.setFixedWidth(pyqt.dpi_scale(40)) + + btns_layout.addWidget(self.new_char_btn) + + # Add Save button + self.save_char_btn = basic.CallbackButton( + callback=self.save_character + ) + self.save_char_btn.setText("Save") + self.save_char_btn.setFixedWidth(pyqt.dpi_scale(40)) + + btns_layout.addWidget(self.save_char_btn) + self.main_vertical_layout.addWidget(self.character_box) + + def add_tab_widget(self, name="default"): + """Add control display field""" + self.tab_widget = ContextMenuTabWidget(self, main_window=self) + self.main_vertical_layout.addWidget(self.tab_widget) + + # Add default first tab + view = GraphicViewWidget(main_window=self) + self.tab_widget.addTab(view, name) + + # ensure the tab retains its size when hidden + sp_retain = self.tab_widget.sizePolicy() + sp_retain.setRetainSizeWhenHidden(True) + self.tab_widget.setSizePolicy(sp_retain) + + def add_overlays(self): + """Add transparent overlay widgets""" + self.about_widget = overlay_widgets.AboutOverlayWidget(self) + self.load_widget = overlay_widgets.LoadOverlayWidget(self) + self.save_widget = overlay_widgets.SaveOverlayWidget(self) + self.overlays = [self.about_widget, self.load_widget, self.save_widget] + + # specificaly hiding and showing the main layer for OS compatibility + for layer in self.overlays: + layer.installEventFilter(self) + + def get_picker_items(self): + """Return picker items for current active tab""" + return self.tab_widget.get_current_picker_items() + + def get_all_picker_items(self): + """Return all picker items for current picker""" + return self.tab_widget.get_all_picker_items() + + def dockCloseEventTriggered(self): + self.close() + + def closeEvent(self, evnt): + self.close() + + def close(self): + """Overwriting close event to close child windows too""" + # Delete script jobs + self.cb_manager.removeAllManagedCB() + # Close childs + for child in self.childs: + try: + child.close() + except Exception: + pass + + # Close ctrls options windows + for item in self.get_all_picker_items(): + try: + if not item.edit_window: + continue + item.edit_window.close() + except Exception: + pass + + try: + self.window_parent.removeEventFilter(self.ap_eventFilter) + except Exception: + pass + + # Default close + # mayaMixin bug that i need to correct for + corrected_for_dashes = self.objectName().replace("_", "-") + corrected_for_initial_dash = corrected_for_dashes.replace("-", "_", 1) + work_name = "{}WorkspaceControl".format(corrected_for_initial_dash) + try: + cmds.workspaceControl(work_name, e=True, close=True) + except ValueError: + pass + except RuntimeError: + pass + self.deleteLater() + + def showEvent(self, *args, **kwargs): + """Default showEvent overload""" + # Prevent firing this event before the window is set up + if not self.ready: + return + + # Default close + super().showEvent(*args, **kwargs) + + # Force char load + self.refresh() + + # Add script jobs + self.add_callback() + + def resizeEvent(self, event): + """Resize about overlay on resize event""" + # Prevent firing this event before the window is set up + if not self.ready: + return + + size = self.size() + + self.about_widget.resize(size) + + self.save_widget.resize(size) + + self.load_widget.resize(size) + + return super().resizeEvent(event) + + def show_about_infos(self): + """Open animation picker about and help infos""" + self.about_widget.show() + + def show_load_widget(self): + """Open animation picker about and help infos""" + self.load_widget.show() + + # ========================================================================= + # Character selector handlers --- + def selector_change_event(self, index): + """Will load data node relative to selector index""" + self.load_character() + + def populate_char_selector(self): + """Will populate char selector combo box""" + # Get char nodes + nodes = picker_node.get_nodes() + self.char_selector_cb.nodes = nodes + + # Empty combo box + self.char_selector_cb.clear() + + # Populate + for data_node in nodes: + # text = data_node.get_namespace() or data_node.name + text = data_node.name + self.char_selector_cb.addItem(text) + + # Set elements active status + self.set_field_status() + + def set_field_status(self): + """Will toggle elements active status""" + # Define status from node list + self.status = False + if self.char_selector_cb.count(): + self.status = True + + # Set status + self.char_selector_cb.setEnabled(self.status) + self.tab_widget.setEnabled(self.status) + if self.save_char_btn: + self.save_char_btn.setEnabled(self.status) + + # Reset tabs + if not self.status: + self.load_default_tabs() + + def load_default_tabs(self): + """Will reset and load default empty tabs""" + self.tab_widget.clear() + self.tab_widget.addTab(GraphicViewWidget(main_window=self), "None") + + def refresh(self): + """Refresh char selector and window""" + # Get current active node + current_node = None + data_node = self.get_current_data_node() + if data_node and data_node.exists(): + current_node = data_node.name + + # Check/abort on possible data changes + if __EDIT_MODE__.get() and current_node: + if not self.check_for_data_change(): + return + + # Re-populate selector + self.populate_char_selector() + + # Set proper index + if current_node: + self.make_node_active(current_node) + + # Refresh selection check + self.selection_change_event() + + # Force view resize + self.tab_widget.fit_contents() + + # Set focus on view + self.tab_widget.currentWidget().setFocus() + + def load_from_sel_node(self): + """Will try to load character for selected node""" + sel = cmds.ls(sl=True) + if not sel: + return + data_node = picker_node.get_node_for_object(sel[0]) + if not data_node: + return + self.make_node_active(data_node.name) + + def make_node_active(self, data_node): + """Will set character selector to specified data_node""" + index = 0 + for i in range(len(self.char_selector_cb.nodes)): + node = self.char_selector_cb.nodes[i] + if not data_node == node.name or data_node == node: + continue + index = i + break + self.char_selector_cb.setCurrentIndex(index) + + def new_character(self): + """ + Will create a new data node, and init a new window + (edit mode only) + """ + # Open input window + name, ok = QtWidgets.QInputDialog.getText( + self, + "New character", + "Node name", + QtWidgets.QLineEdit.Normal, + "PICKER_DATA", + ) + if not (ok and name): + return + + # Check for possible data changes/loss + if not self.check_for_data_change(): + return + + # Create new data node + data_node = picker_node.DataNode(name=str(name)) + data_node.create() + self.refresh() + self.make_node_active(data_node) + + # ========================================================================= + # Data --- + def check_for_data_change(self): + """ + Check if data changed + If changes are detected will ask user if he wants to proceed any + way and loose thoses changes + Return user answer + """ + # Get current data node + data_node = self.get_current_data_node() + if not (data_node and data_node.exists()): + return True + + # Return true if no changes were detected + if data_node == self.get_character_data(): + return True + + # Open question window + msg = "Any changes will be lost, proceed any way ?" + answer = QtWidgets.QMessageBox.question( + self, + "Changes detected", + msg, + buttons=QtWidgets.QMessageBox.No | QtWidgets.QMessageBox.Yes, + ) + return answer == QtWidgets.QMessageBox.Yes + + def get_current_namespace(self): + return self.get_current_data_node().get_namespace() + + def get_current_data_node(self): + """Return current character data node""" + # Empty list case + if not self.char_selector_cb.count(): + return None + + # Return node from combo box index + index = self.char_selector_cb.currentIndex() + return self.char_selector_cb.nodes[index] + + def load_character(self): + """Load currently selected data node""" + # Get DataNode + data_node = self.get_current_data_node() + if not data_node: + return + picker_data = data_node.get_data() + + # Load snapshot + path = picker_data.get("snapshot", None) + self.pic_widget.set_background(path) + + # load tabs + tabs_data = picker_data.get("tabs", {}) + self.tab_widget.set_data(tabs_data) + + # Default tab + if not self.tab_widget.count(): + self.tab_widget.addTab( + GraphicViewWidget(main_window=self), "default" + ) + else: + # Return to first tab + self.tab_widget.setCurrentIndex(0) + + # Fit content + self.tab_widget.fit_contents() + + # Update selection states + self.selection_change_event() + + def save_character(self): + """Save data to current selected data_node""" + # Get DataNode + data_node = self.get_current_data_node() + assert data_node, "No data_node found/selected" + + # Block save in anim mode + if not __EDIT_MODE__.get(): + QtWidgets.QMessageBox.warning( + self, "Warning", "Save is not permited in anim mode" + ) + return + + # Block save on referenced nodes + if data_node.is_referenced(): + msg = "Save is not permited on referenced nodes" + QtWidgets.QMessageBox.warning(self, "Warning", msg) + return + + self.save_widget.show() + + def get_character_data(self): + """Return window data""" + picker_data = {} + + # Add snapshot path data + snapshot_data = self.pic_widget.get_data() + if snapshot_data: + picker_data["snapshot"] = snapshot_data + + # Add tabs data + tabs_data = self.tab_widget.get_data() + if tabs_data: + picker_data["tabs"] = tabs_data + + return picker_data + + # ========================================================================= + # Script jobs handling --- + def add_callback(self): + """Will add maya scripts job events""" + # Clear any existing scrip jobs + self.cb_manager.removeAllManagedCB() + + # Add selection change event + self.cb_manager.selectionChangedCB( + "anim_picker_selection", self.selection_change_event + ) + # Add scene open event + self.cb_manager.newSceneCB( + "anim_picker_newScene", self.selection_change_event + ) + + def selection_change_event(self, *args): + """ + Event called with a script job from maya on selection change. + Will properly parse poly_ctrls associated node, and set border + visible if content is selected + """ + # Abort in Edit mode + if __EDIT_MODE__.get(): + return + + # Update selection data + __SELECTION__.update() + + # sync with namespce + if not __EDIT_MODE__.get(): + sel = pm.selected() + sync = self.checkbox.isChecked() + if sel and sync: + ns = sel[0].namespace() + if ns: + for i, n in enumerate(self.char_selector_cb.nodes): + if ns in str(n): + self.char_selector_cb.setCurrentIndex(i) + break + # Update controls for active tab + for item in self.get_picker_items(): + item.run_selection_check() + + +# version of the anim picker ui that uses MayaQWidgetDockableMixin for docking +class MainDockableWindow(MayaQWidgetDockableMixin, MainDockWindow): + def __init__(self, parent=None, edit=False, dockable=True): + super().__init__(parent=parent) + + +# ============================================================================= +# Load user interface function +# ============================================================================= +def load(edit=False, dockable=False): + """To launch the ui and not get the same instance + + Returns: + Anim_picker: instance + + Args: + edit (bool, optional): Description + dockable (bool, optional): Description + + """ + + # NOTE: if instead we set dockable to false the window doesn't get + # parented to Maya UI. + # Deferred (feature phase): dockable mode breaks the interface when + # docked, so it is intentionally not exposed from the menu yet. See the + # anim_picker refactor roadmap (Phase 3+: "Fix + re-enable dockable"). + if dockable: + ANIM_PKR_UI = MainDockableWindow( + parent=None, edit=edit, dockable=dockable + ) + ANIM_PKR_UI.show(dockable=True) + else: + ANIM_PKR_UI = MainDockWindow(parent=pyqt.get_main_window(), edit=edit) + ANIM_PKR_UI.show() + + return ANIM_PKR_UI diff --git a/release/scripts/mgear/anim_picker/scene.py b/release/scripts/mgear/anim_picker/scene.py new file mode 100644 index 00000000..f07e7e52 --- /dev/null +++ b/release/scripts/mgear/anim_picker/scene.py @@ -0,0 +1,179 @@ +"""Ordered graphics scene for the anim picker. + +Extracted from gui.py during the Phase 2 decomposition. +""" + +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import picker_widgets +from mgear.anim_picker.handlers import __EDIT_MODE__ + + +class OrderedGraphicsScene(QtWidgets.QGraphicsScene): + """ + Custom QGraphicsScene with x/y axis line options for origin + feedback in edition mode + (provides a center reference to work from, view will fit what ever + is the content in use mode). + + Had to add z_index support since there was a little z + conflict when "moving" items to back/front in edit mode + """ + + __DEFAULT_SCENE_WIDTH__ = 6000 + __DEFAULT_SCENE_HEIGHT__ = 6000 + + def __init__(self, parent=None): + QtWidgets.QGraphicsScene.__init__(self, parent=parent) + + self.set_default_size() + self._z_index = 0 + + def set_size(self, width, height): + """Will set scene size with proper center position""" + self.setSceneRect(-width / 2, -height / 2, width, height) + + def set_default_size(self): + self.set_size( + self.__DEFAULT_SCENE_WIDTH__, self.__DEFAULT_SCENE_HEIGHT__ + ) + + def get_bounding_rect(self, margin=0, selection=False): + """ + Return scene content bounding box with specified margin + Warning: In edit mode, will return default scene rectangle + """ + # Return default size in edit mode + # if __EDIT_MODE__.get(): + # return self.sceneRect() + + # Get item boundingBox + if selection: + sel_items = self.get_selected_items() + if not sel_items: + return + scene_rect = QtCore.QRectF() + + # init coordinates with the first element + rec = sel_items[0].boundingRect().getCoords() + x1 = rec[0] + sel_items[0].x() + y1 = rec[1] + sel_items[0].y() + x2 = rec[2] + sel_items[0].x() + y2 = rec[3] + sel_items[0].y() + + for item in sel_items[1:]: + rec = item.boundingRect().getCoords() + if (rec[0] + item.x()) < x1: + x1 = rec[0] + item.x() + if (rec[1] + item.y()) < y1: + y1 = rec[1] + item.y() + if (rec[2] + item.x()) > x2: + x2 = rec[2] + item.x() + if (rec[3] + item.y()) > y2: + y2 = rec[3] + item.y() + scene_rect.setCoords(x1, y1, x2, y2) + + else: + scene_rect = self.itemsBoundingRect() + + # Stop here if no margin + if not margin: + return scene_rect + + # Add margin + scene_rect.setX(scene_rect.x() - margin) + scene_rect.setY(scene_rect.y() - margin) + scene_rect.setWidth(scene_rect.width() + margin) + scene_rect.setHeight(scene_rect.height() + margin) + + return scene_rect + + def clear(self): + """Reset default z index on clear""" + QtWidgets.QGraphicsScene.clear(self) + self._z_index = 0 + + def set_picker_items(self, items): + """Will set picker items""" + self.clear() + for item in items: + QtWidgets.QGraphicsScene.addItem(self, item) + self.set_z_value(item) + self.add_axis_lines() + + def get_picker_items(self): + """Will return all scenes' picker items""" + picker_items = [] + # Filter picker items (from handles etc) + for item in list(self.items()): + if not isinstance(item, picker_widgets.PickerItem): + continue + picker_items.append(item) + return picker_items + + def picker_at(self, scene_pos, transform): + item_at = self.itemAt(scene_pos, transform) + if isinstance(item_at, picker_widgets.PickerItem): + return item_at + elif item_at and not isinstance(item_at, picker_widgets.PickerItem): + return item_at.parentItem() + else: + return None + + def get_picker_by_uuid(self, picker_uuid): + """pickers have UUID's for hashing in dictionaries. search via uuid + + Args: + picker_uuid (str): uuid + + Returns: + PickerIteem: instance of matching picker + """ + for picker in self.get_picker_items(): + if picker.uuid == picker_uuid: + return picker + return None + + def get_selected_items(self): + return [ + item for item in self.get_picker_items() if item.polygon.selected + ] + + def clear_picker_selection(self): + for picker in self.get_picker_items(): + picker.set_selected_state(False) + self.update() + + def select_picker_items(self, picker_items, event=None): + if event is None: + modifiers = None + else: + modifiers = event.modifiers() + + # Shift cases (toggle) + if modifiers == QtCore.Qt.ShiftModifier: + for picker in picker_items: + picker.set_selected_state(True) + + # Controls case + elif modifiers == QtCore.Qt.ControlModifier: + for picker in picker_items: + picker.set_selected_state(False) + + # Alt case (remove) + # elif modifiers == QtCore.Qt.AltModifier: + else: + self.clear_picker_selection() + for picker in picker_items: + picker.set_selected_state(True) + + def set_z_value(self, item): + """set proper z index for item""" + item.setZValue(self._z_index) + self._z_index += 1 + + def addItem(self, item): + """Overload to keep axis on top""" + QtWidgets.QGraphicsScene.addItem(self, item) + self.set_z_value(item) diff --git a/release/scripts/mgear/anim_picker/tab_widget.py b/release/scripts/mgear/anim_picker/tab_widget.py new file mode 100644 index 00000000..dd151f8e --- /dev/null +++ b/release/scripts/mgear/anim_picker/tab_widget.py @@ -0,0 +1,170 @@ +"""Tab widget with picker context menu for the anim picker. + +Extracted from gui.py during the Phase 2 decomposition. +""" + +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.view import GraphicViewWidget +from mgear.anim_picker.handlers import __EDIT_MODE__ + + +class ContextMenuTabWidget(QtWidgets.QTabWidget): + """Custom tab widget with specific context menu support""" + + def __init__(self, parent, main_window=None, *args, **kwargs): + QtWidgets.QTabWidget.__init__(self, parent, *args, **kwargs) + self.main_window = main_window + + def contextMenuEvent(self, event): + """Right click menu options""" + # Abort out of edit mode + if not __EDIT_MODE__.get(): + return + + # Init context menu + menu = QtWidgets.QMenu(self) + + # Build context menu + rename_action = QtWidgets.QAction("Rename", None) + rename_action.triggered.connect(self.rename_event) + menu.addAction(rename_action) + + add_action = QtWidgets.QAction("Add Tab", None) + add_action.triggered.connect(self.add_tab_event) + menu.addAction(add_action) + + remove_action = QtWidgets.QAction("Remove Tab", None) + remove_action.triggered.connect(self.remove_tab_event) + menu.addAction(remove_action) + + move_forward_action = QtWidgets.QAction("Move Tab >>>", None) + move_forward_action.triggered.connect(self.move_forward_tab_event) + menu.addAction(move_forward_action) + + move_back_action = QtWidgets.QAction("Move Tab <<<", None) + move_back_action.triggered.connect(self.move_back_tab_event) + menu.addAction(move_back_action) + + # Open context menu under mouse + menu.exec_(self.mapToGlobal(event.pos())) + + def fit_contents(self): + """Will resize views content to match views size""" + for i in range(self.count()): + widget = self.widget(i) + if not isinstance(widget, GraphicViewWidget): + continue + widget.fit_scene_content() + + def move_back_tab_event(self): + current_index = self.currentIndex() + if current_index > 0: + self.tabBar().moveTab(current_index, current_index - 1) + self.setCurrentIndex(current_index - 1) + + def move_forward_tab_event(self): + current_index = self.currentIndex() + if current_index < self.count() - 1: + self.tabBar().moveTab(current_index, current_index + 1) + self.setCurrentIndex(current_index + 1) + + def rename_event(self): + """Will open dialog to rename tab""" + # Get current tab index + index = self.currentIndex() + + # Open input window + name, ok = QtWidgets.QInputDialog.getText( + self, + "Tab name", + "New name", + QtWidgets.QLineEdit.Normal, + self.tabText(index), + ) + if not (ok and name): + return + + # Update influence name + self.setTabText(index, name) + + def add_tab_event(self): + """Will open dialog to get tab name and create a new tab""" + # Open input window + name, ok = QtWidgets.QInputDialog.getText( + self, "Create new tab", "Tab name", QtWidgets.QLineEdit.Normal, "" + ) + if not (ok and name): + return + + # Add tab + self.addTab(GraphicViewWidget(main_window=self.main_window), name) + + # Set new tab active + self.setCurrentIndex(self.count() - 1) + + def remove_tab_event(self): + """Will remove tab from widget""" + # Get current tab index + index = self.currentIndex() + + # Open confirmation + reply = QtWidgets.QMessageBox.question( + self, + "Delete", + "Delete tab '{}'?".format(self.tabText(index)), + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, + QtWidgets.QMessageBox.No, + ) + if reply == QtWidgets.QMessageBox.No: + return + + # Remove tab + self.removeTab(index) + + def get_namespace(self): + """Return data_node namespace""" + # Lazy import to avoid a circular import with main_window.py + from mgear.anim_picker.main_window import MainDockWindow + + # Proper parent + if self.main_window and isinstance(self.main_window, MainDockWindow): + return self.main_window.get_current_namespace() + + return None + + def get_current_picker_items(self): + """Return all picker items for current active tab""" + return self.currentWidget().get_picker_items() + + def get_all_picker_items(self): + """Returns all picker items for all tabs""" + items = [] + for i in range(self.count()): + items.extend(self.widget(i).get_picker_items()) + return items + + def get_data(self): + """Will return all tabs data""" + data = [] + for i in range(self.count()): + name = str(self.tabText(i)) + tab_data = self.widget(i).get_data() + data.append({"name": name, "data": tab_data}) + return data + + def set_data(self, data): + """Will, set/load tabs data""" + self.clear() + for tab in data: + view = GraphicViewWidget( + namespace=self.get_namespace(), main_window=self.main_window + ) + # changed name to default1 as maya wont let you make a group called + # 'default' for curve extraction. + self.addTab(view, tab.get("name", "default1")) + + tab_content = tab.get("data", None) + if tab_content: + view.set_data(tab_content) diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py new file mode 100644 index 00000000..aab75be1 --- /dev/null +++ b/release/scripts/mgear/anim_picker/view.py @@ -0,0 +1,1267 @@ +"""Graphics view widget (picker canvas) for the anim picker. + +Extracted from gui.py during the Phase 2 decomposition. +""" + +import os +import copy +import json +from functools import partial + +from maya import cmds +import mgear.pymaya as pm + +import mgear +from mgear.core import attribute +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.scene import OrderedGraphicsScene +from mgear.anim_picker.constants import MAYA_OVERRIDE_COLOR +from mgear.anim_picker.constants import PICKER_EXTRACTION_NAME +from mgear.anim_picker.constants import ANIM_PICKER_RELATIVE_IMAGES +from mgear.anim_picker.constants import DEFAULT_RELATIVE_IMAGES_PATH +from mgear.anim_picker.widgets import basic +from mgear.anim_picker.widgets import picker_widgets +from mgear.anim_picker.handlers import __EDIT_MODE__ + + +# module clipboard shared across views (copy/paste of picker items) +_CLIPBOARD = [] + + +class GraphicViewWidget(QtWidgets.QGraphicsView): + """Graphic view widget that display the "polygons" picker items""" + + __DEFAULT_SCENE_WIDTH__ = 6000 + __DEFAULT_SCENE_HEIGHT__ = 6000 + + def __init__(self, namespace=None, main_window=None): + QtWidgets.QGraphicsView.__init__(self) + + self.setScene(OrderedGraphicsScene(parent=self)) + + self.namespace = namespace + self.main_window = main_window + self.setParent(self.main_window) + + # Scale view in Y for positive Y values (maya-like) + self.scale(1, -1) + + self.setResizeAnchor(QtWidgets.QGraphicsView.AnchorViewCenter) + + # Set selection mode + self.setRubberBandSelectionMode(QtCore.Qt.IntersectsItemBoundingRect) + self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) + self.scene_mouse_origin = QtCore.QPointF() + self.drag_active = False + self.pan_active = False + self.zoom_active = False + self.auto_frame_active = True + + # Disable scroll bars + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + # Set background color + brush = QtGui.QBrush(QtGui.QColor(70, 70, 70, 255)) + self.setBackgroundBrush(brush) + self.background_image = None + self.background_image_path = None + self.bg_ui = None + + self.fit_margin = 8 + + # # undo list --------------------------------------------------------- + self.undo_move_order = [] + self.undo_move_order_index = -1 + + def get_center_pos(self): + return self.mapToScene( + QtCore.QPoint(self.width() / 2, self.height() / 2) + ) + + def mousePressEvent(self, event): + self.modified_select = False + self.item_selected = False + self.__move_prompt = False + QtWidgets.QGraphicsView.mousePressEvent(self, event) + if event.buttons() == QtCore.Qt.MouseButton.LeftButton: + self.scene_mouse_origin = self.mapToScene(event.pos()) + # Get current viewport transformation + transform = self.viewportTransform() + scene_pos = self.mapToScene(event.pos()) + # Clear selection if no picker item below mouse + picker_at = self.scene().picker_at(scene_pos, transform) or [] + if picker_at: + if __EDIT_MODE__.get(): + self.item_selected = True + # undo --------------------------------------------------- + self.__move_prompt = False + # open undo chunk + self.tmp_picker_pos_info = {} + pickers = self.scene().get_selected_items() + if picker_at not in pickers: + pickers.append(picker_at) + for picker in pickers: + pt = [picker.x(), picker.y(), picker.rotation()] + self.tmp_picker_pos_info[picker.uuid] = pt + # undo --------------------------------------------------- + if event.modifiers(): + # this allows for shift selecting in edit + self.modified_select = False + else: + self.modified_select = True + picker_widgets.select_picker_controls([picker_at], event) + else: + self.modified_select = False + if not event.modifiers(): + self.scene().clear_picker_selection() + cmds.select(cl=True) + + elif event.buttons() == QtCore.Qt.MouseButton.MiddleButton: + self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag) + self.pan_active = True + self.scene_mouse_origin = self.mapToScene(event.pos()) + + # zoom support added for the mouse, for those pen/tablet users + elif ( + event.buttons() == QtCore.Qt.MouseButton.RightButton + and event.modifiers() == QtCore.Qt.AltModifier + ): + self.zoom_active = True + self.setDragMode(QtWidgets.QGraphicsView.ScrollHandDrag) + self.scene_mouse_origin = self.mapToGlobal(event.pos()) + cursor_pos = QtGui.QVector2D( + self.mapToGlobal(self.scene_mouse_origin) + ) + screen = QtWidgets.QApplication.instance().primaryScreen() + rect = screen.availableGeometry() + self.top_left_pos = QtGui.QVector2D(rect.topLeft()) + self.zoom_delta = self.top_left_pos.distanceToPoint(cursor_pos) + self.setTransformationAnchor( + QtWidgets.QGraphicsView.AnchorViewCenter + ) + + def mouseMoveEvent(self, event): + result = QtWidgets.QGraphicsView.mouseMoveEvent(self, event) + + if ( + event.buttons() == QtCore.Qt.MouseButton.LeftButton + and not self.item_selected + ): + self.drag_active = True + + # undo --------------------------------------------------------------- + if ( + __EDIT_MODE__.get() + and event.buttons() == QtCore.Qt.MouseButton.LeftButton + and self.item_selected + ): + # confirm undo move chunck, a picker has been moved + self.__move_prompt = True + # undo ---------------------------------------------------------------- + + if self.pan_active: + current_center = self.get_center_pos() + scene_paning = self.mapToScene(event.pos()) + + new_center = current_center - ( + scene_paning - self.scene_mouse_origin + ) + self.centerOn(new_center) + + if self.zoom_active: + cursor_pos = QtGui.QVector2D(self.mapToGlobal(event.pos())) + current_delta = self.top_left_pos.distanceToPoint(cursor_pos) + + factor = 1.05 + if current_delta < self.zoom_delta: + factor = 0.95 + + # Apply zoom + self.scale(factor, factor) + self.zoom_delta = current_delta + + return result + + def mouseReleaseEvent(self, event): + """Overload to clear selection on empty area""" + result = QtWidgets.QGraphicsView.mouseReleaseEvent(self, event) + if ( + not self.drag_active + and event.button() == QtCore.Qt.MouseButton.LeftButton + and not self.modified_select + ): + self.modified_select = False + scene_pos = self.mapToScene(event.pos()) + + # Get current viewport transformation + transform = self.viewportTransform() + + # Clear selection if no picker item below mouse + picker_at = self.scene().picker_at(scene_pos, transform) or [] + if not picker_at: + if not event.modifiers(): + self.scene().clear_picker_selection() + cmds.select(cl=True) + elif picker_at and event.modifiers() == QtCore.Qt.AltModifier: + picker_at.select_associated_controls() + self.scene().select_picker_items([picker_at], event) + else: + self.scene().select_picker_items([picker_at], event) + + # add moved pickers to undo_move_order list --------------------------- + if not self.drag_active and self.__move_prompt: + for picker_uuid in list(self.tmp_picker_pos_info.keys()): + picker = self.scene().get_picker_by_uuid(picker_uuid) + if picker is None: + continue + pt = [picker.x(), picker.y(), picker.rotation()] + self.tmp_picker_pos_info[picker_uuid].extend(pt) + if self.undo_move_order_index in [-1]: + self.undo_move_order.append( + copy.deepcopy(self.tmp_picker_pos_info) + ) + else: + self.undo_move_order = self.undo_move_order[ + : self.undo_move_order_index + ] + self.undo_move_order.append( + copy.deepcopy(self.tmp_picker_pos_info) + ) + self.undo_move_order_index = -1 + self.__move_prompt = None + self.tmp_picker_pos_info = {} + # undo ---------------------------------------------------------------- + + # Area selection + if ( + self.drag_active + and event.button() == QtCore.Qt.MouseButton.LeftButton + ): + scene_drag_end = self.mapToScene(event.pos()) + + sel_area = QtCore.QRectF(self.scene_mouse_origin, scene_drag_end) + transform = self.viewportTransform() + if not sel_area.size().isNull(): + items = self.scene().items( + sel_area, + QtCore.Qt.IntersectsItemShape, + QtCore.Qt.AscendingOrder, + deviceTransform=transform, + ) + + picker_items = [] + for item in items: + if not isinstance(item, picker_widgets.PickerItem): + continue + picker_items.append(item) + if __EDIT_MODE__.get(): + self.scene().select_picker_items(picker_items) + if event.modifiers() == QtCore.Qt.AltModifier: + ctrls = [] + for x in picker_items: + ctrls.extend(x.get_controls()) + cmds.select(cmds.ls(ctrls)) + else: + picker_widgets.select_picker_controls(picker_items, event) + + # Middle mouse view panning + if ( + self.pan_active + and event.button() == QtCore.Qt.MouseButton.MiddleButton + ): + current_center = self.get_center_pos() + scene_drag_end = self.mapToScene(event.pos()) + + new_center = current_center - ( + scene_drag_end - self.scene_mouse_origin + ) + self.centerOn(new_center) + self.pan_active = False + self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) + + # zoom support added for the mouse, for those pen/tablet users + if ( + self.zoom_active + and event.button() == QtCore.Qt.MouseButton.RightButton + ): + self.zoom_active = False + self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) + + self.drag_active = False + return result + + def wheelEvent(self, event): + """Wheel event to add zoom support""" + if self.window().testAttribute(QtCore.Qt.WA_TransparentForMouseEvents): + return False + self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse) + + # Run default event + # QtWidgets.QGraphicsView.wheelEvent(self, event) + + # Define zoom factor + factor = 1.1 + if event.angleDelta().y() < 0: + factor = 0.9 + + # Apply zoom + self.scale(factor, factor) + + # undo -------------------------------------------------------------------- + def undo_move(self): + """go through (reversed) the undo_move_order list and move pickers + back to their previously stored location + """ + undo_len = len(self.undo_move_order) + if undo_len == 0: + return + + if self.undo_move_order_index == -1: + self.undo_move_order_index = undo_len + elif self.undo_move_order_index == 0: + return + if self.undo_move_order_index > 0: + self.undo_move_order_index = self.undo_move_order_index - 1 + undo_items = self.undo_move_order[self.undo_move_order_index].items() + for picker_uuid, undo_pos in undo_items: + picker = self.scene().get_picker_by_uuid(picker_uuid) + if not picker: + continue + picker.setPos(undo_pos[0], undo_pos[1]) + picker.setRotation(undo_pos[2]) + + def redo_move(self): + """go through the undo_move_order restoring picker locations""" + undo_len = len(self.undo_move_order) + if undo_len == 0: + return + + if self.undo_move_order_index == -1: + return + if self.undo_move_order_index < undo_len: + undo_index = self.undo_move_order[self.undo_move_order_index] + for picker_uuid, undo_pos in undo_index.items(): + picker = self.scene().get_picker_by_uuid(picker_uuid) + if not picker: + continue + picker.setPos(undo_pos[3], undo_pos[4]) + picker.setRotation(undo_pos[5]) + self.undo_move_order_index = self.undo_move_order_index + 1 + else: + self.undo_move_order_index = -1 + + def keyPressEvent(self, event): + """keyboard press event override for custom shortcuts + + Args: + event (QtCore.QEvent): keyboard event + """ + if __EDIT_MODE__.get(): + modifiers = event.modifiers() + if ( + modifiers == QtCore.Qt.ControlModifier + and event.key() == QtCore.Qt.Key_Z + ): + self.undo_move() + event.accept() + elif ( + modifiers == QtCore.Qt.ControlModifier + and event.key() == QtCore.Qt.Key_Y + ): + self.redo_move() + event.accept() + else: + event.ignore() + else: + event.ignore() + + # undo -------------------------------------------------------------------- + + def contextMenuEvent(self, event, mapped_pos=None): + """Right click menu options""" + if event.modifiers() == QtCore.Qt.AltModifier: + # alt may indicate zooming enabled so no menu + return + # Item area + picker_item = [ + item for item in self.get_picker_items() if item._hovered + ] + if picker_item: + # Run default method that call on childs + mapped_pos = event.globalPos() + evnt_type = QtGui.QContextMenuEvent.Mouse + contextEvent = QtGui.QContextMenuEvent(evnt_type, mapped_pos) + return picker_item[0].contextMenuEvent(contextEvent) + + # Init context menu + menu = QtWidgets.QMenu(self) + + # Build Edit move options + if __EDIT_MODE__.get(): + mapped_pos = self.mapToScene(event.pos()) + add_action = QtWidgets.QAction("Add Item", None) + add_action.triggered.connect( + partial(self.add_picker_item_gui, mapped_pos) + ) + menu.addAction(add_action) + + add_action1 = QtWidgets.QAction("Add with selected", None) + add_action1.triggered.connect( + partial(self.add_picker_item_selected, mapped_pos) + ) + menu.addAction(add_action1) + + add_action2 = QtWidgets.QAction("Add item per selected", None) + add_action2.triggered.connect( + partial(self.add_picker_item_per_selected, mapped_pos) + ) + menu.addAction(add_action2) + + toggle_handles_action = QtWidgets.QAction( + "Toggle all handles", None + ) + func = self.toggle_all_handles_event + toggle_handles_action.triggered.connect(func) + menu.addAction(toggle_handles_action) + + menu.addSeparator() + + # this copy is currently only supported when not hovering a picker + copy_action = QtWidgets.QAction("Copy (Multi)", None) + copy_action.triggered.connect(self.copy_event) + menu.addAction(copy_action) + + # this paste is only supported when not hovering + paste_action = QtWidgets.QAction("Paste (Multi)", None) + paste_action.triggered.connect(self.paste_event) + menu.addAction(paste_action) + + menu.addSeparator() + + background_action = QtWidgets.QAction("Set background image", None) + background_action.triggered.connect(self.set_background_event) + menu.addAction(background_action) + + background_size_action = QtWidgets.QAction("Background Size", None) + background_size_action.triggered.connect(self.background_options) + menu.addAction(background_size_action) + + reset_background_action = QtWidgets.QAction( + "Remove background", None + ) + func = self.reset_background_event + reset_background_action.triggered.connect(func) + menu.addAction(reset_background_action) + + menu.addSeparator() + + msg = "Convert to nurbs curves" + convert_picker_to_curves = QtWidgets.QAction(msg, None) + func = self.convert_picker_to_curves + convert_picker_to_curves.triggered.connect(func) + menu.addAction(convert_picker_to_curves) + + msg = "Convert to picker data" + convert_curves_to_picker = QtWidgets.QAction(msg, None) + func = self.convert_curves_to_picker + convert_curves_to_picker.triggered.connect(func) + menu.addAction(convert_curves_to_picker) + + msg = "Delete picker nurbs curves" + delete_extraction_grp = QtWidgets.QAction(msg, None) + func = self.delete_extraction_grp + delete_extraction_grp.triggered.connect(func) + menu.addAction(delete_extraction_grp) + + menu.addSeparator() + + if __EDIT_MODE__.get_main(): + toggle_mode_action = QtWidgets.QAction("Toggle Mode", None) + toggle_mode_action.triggered.connect(self.toggle_mode_event) + menu.addAction(toggle_mode_action) + + menu.addSeparator() + + # Common actions + reset_view_action = QtWidgets.QAction("Reset view", None) + reset_view_action.triggered.connect(self.fit_scene_content) + menu.addAction(reset_view_action) + frame_selection_view_action = QtWidgets.QAction( + "Frame Selection", None + ) + frame_selection_view_action.triggered.connect( + self.fit_selection_content + ) + menu.addAction(frame_selection_view_action) + + auto_frame_selection_view_action = QtWidgets.QAction( + "Auto Frame view", None + ) + auto_frame_selection_view_action.setCheckable(True) + auto_frame_selection_view_action.setChecked(self.auto_frame_active) + auto_frame_selection_view_action.triggered.connect( + self.set_auto_frame_view + ) + menu.addAction(auto_frame_selection_view_action) + + # Open context menu under mouse + menu.exec_(event.globalPos()) + + def resizeEvent(self, *args, **kwargs): + """Overload to force scale scene content to fit view""" + # Fit scene content to view + if self.auto_frame_active: + self.fit_scene_content() + + # Run default resizeEvent + return QtWidgets.QGraphicsView.resizeEvent(self, *args, **kwargs) + + def fit_scene_content(self): + """Will fit scene content to view, by scaling it""" + scene_rect = self.scene().get_bounding_rect(margin=self.fit_margin) + self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) + + def set_auto_frame_view(self): + """Enable auto fit when a resize event happens""" + # Fit scene content to view + if not self.auto_frame_active: + self.fit_scene_content() + self.auto_frame_active = not self.auto_frame_active + + def fit_selection_content(self): + """Will fit the selected item to view, by scaling it""" + scene_rect = self.scene().get_bounding_rect( + margin=self.fit_margin, selection=True + ) + if scene_rect: + self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) + + def get_color_picker_override(self, picker, ctrl): + """Get the maya override color and return picker equivelant + + Args: + picker (PickerItem): pickeritem class + ctrl (str): name of the control + + Returns: + list: [R, G, B, Alpha] + """ + node = ctrl + if cmds.nodeType(ctrl) == "transform": + node = cmds.listRelatives(ctrl, shapes=True)[0] + if not cmds.getAttr("{}.overrideEnabled".format(node)): + return [0, 0, 0, 255] + if cmds.getAttr("{}.overrideRGBColors".format(node)): + r_color = cmds.getAttr("{}.overrideColorR".format(node)) + g_color = cmds.getAttr("{}.overrideColorG".format(node)) + b_color = cmds.getAttr("{}.overrideColorB".format(node)) + return [r_color * 255, g_color * 255, b_color * 255, 255] + else: + override_index = cmds.getAttr("{}.overrideColor".format(node)) + color_rgb = MAYA_OVERRIDE_COLOR[override_index] + return [color_rgb[0], color_rgb[1], color_rgb[2], 255] + + def add_picker_item(self, event=None): + """Add new PickerItem to current view""" + ctrl = picker_widgets.PickerItem( + main_window=self.main_window, namespace=self.namespace + ) + ctrl.setParent(self) + self.scene().addItem(ctrl) + + # Move ctrl + if event: + ctrl.setPos(event.pos()) + else: + ctrl.setPos(0, 0) + + return ctrl + + def add_picker_item_gui(self, mouse_pos=None): + """Create picker item at the position of the mouse + + Args: + mouse_pos (QPosition, optional): mouse position + """ + ctrl = self.add_picker_item() + ctrl.setPos(mouse_pos) + + def add_picker_item_selected(self, mouse_pos=None): + """Add new PickerItem to current view""" + ctrl = self.add_picker_item() + data = {} + selected = cmds.ls(sl=True) or [] + data["controls"] = selected + ctrl.set_data(data) + ctrl.set_selected_state(True) + if selected: + colors_rgb = self.get_color_picker_override(ctrl, selected[0]) + ctrl.set_color(color=colors_rgb) + if mouse_pos: + ctrl.setPos(mouse_pos) + + return ctrl + + def add_picker_item_per_selected(self, mouse_pos=None): + """Add new PickerItem to current view""" + selection = cmds.ls(sl=True) or [] + if not selection: + return + created_ctrls = [] + if mouse_pos: + x_start = mouse_pos.x() + y_start = mouse_pos.y() + else: + x_start = 0 + y_start = 0 + y_increment = -35 + for selected in selection: + ctrl = self.add_picker_item() + data = {} + data["controls"] = [selected] + data["position"] = [x_start, y_start] + colors_rgb = self.get_color_picker_override(ctrl, selected) + ctrl.set_color(color=colors_rgb) + y_start = y_start + y_increment + ctrl.set_data(data) + ctrl.set_selected_state(True) + created_ctrls.append(ctrl) + + return created_ctrls + + def copy_event(self): + """reset the clipboard and populate the list with picker data for paste""" + global _CLIPBOARD + _CLIPBOARD = [] + selected_pickers = self.scene().get_selected_items() + for picker in selected_pickers: + _CLIPBOARD.append(picker.get_data()) + + def paste_event(self): + """create new anim pickers based off the data in the clipboard + Make new pickers selected + """ + global _CLIPBOARD + [ + x.set_selected_state(False) + for x in self.scene().get_selected_items() + ] + for data in _CLIPBOARD: + ctrl = self.add_picker_item(event=None) + ctrl.set_data(data) + ctrl.set_selected_state(True) + + def toggle_all_handles_event(self, event=None): + new_status = None + for item in list(self.scene().items()): + # Skip non picker items + if not isinstance(item, picker_widgets.PickerItem): + continue + + # Get first status + if new_status is None: + new_status = not item.get_edit_status() + + # Set item status + item.set_edit_status(new_status) + + def toggle_mode_event(self, event=None): + """Will toggle UI edition mode""" + if not self.main_window: + return + + # Check for possible data change/loss + if __EDIT_MODE__.get(): + if not self.main_window.check_for_data_change(): + return + + # Toggle mode + __EDIT_MODE__.toggle() + + # Reset size to default + self.main_window.reset_default_size() + self.main_window.refresh() + + def apply_background_fallback_logic(self, path): + # test if the original path exists + if os.path.exists(path): + return path + # check the data node for the "source_file_path" that is added when + # pkr is loaded from file + data = self.window().get_current_data_node().read_data_from_node() + pkr_path = data.get("source_file_path", None) + if not pkr_path or pkr_path is None: + return path + # looking in the neighboring directories for images dir + pkr_dir = os.path.dirname(pkr_path) + rel_path_token = os.environ.get( + ANIM_PICKER_RELATIVE_IMAGES, DEFAULT_RELATIVE_IMAGES_PATH + ) + base_name = os.path.basename(path) + relative_image_path = os.path.realpath( + os.path.join(pkr_dir, rel_path_token, base_name) + ) + # only return if path exists + if os.path.exists(relative_image_path): + return relative_image_path + else: + return path + + def set_background(self, path=None): + """Set tab index widget background image""" + if not path: + return + path = os.path.abspath(r"{}".format(path)) + path = self.apply_background_fallback_logic(path) + # Check that path exists + if not (path and os.path.exists(path)): + mgear.log( + "anim_picker: background image not found: '{}'".format(path), + mgear.sev_warning, + ) + return + + self.background_image_path = path + + # Load image and mirror it vertically + self.background_image = QtGui.QImage(path).mirrored(False, True) + + # Set scene size to background picture + width = self.background_image.width() + height = self.background_image.height() + + self.scene().set_size(width, height) + + # Update display + self.fit_scene_content() + + def background_options(self): + tabWidget = self.parent().parent() + # Delete old window + if self.bg_ui: + try: + self.bg_ui.close() + self.bg_ui.deleteLater() + except Exception: + pass + if not tabWidget.currentWidget().get_background(0): + cmds.warning("Current view has no background!") + return + self.bg_ui = basic.BackgroundOptionsDialog(tabWidget, self) + self.bg_ui.show() + self.bg_ui.raise_() + + def set_background_event(self, event=None): + """Set background image pick dialog window""" + # Open file dialog + img_dir = basic.get_images_folder_path() + file_path = QtWidgets.QFileDialog.getOpenFileName( + self, "Pick a background", img_dir + ) + + # Filter return result (based on qt version) + if isinstance(file_path, tuple): + file_path = file_path[0] + + # Abort on cancel + if not file_path: + return + + # Set background + self.set_background(file_path) + + def reset_background_event(self, event=None): + """Reset background to default""" + self.background_image = None + self.background_image_path = None + self.scene().set_default_size() + + # Update display + self.fit_scene_content() + + def resize_background_image( + self, width, height, keepAspectRatio=False, auto_update=True + ): + """resize the background image if one is set + + Args: + width (int): desired width + height (int): desired height + keepAspectRatio (bool, optional): scale image to fit aspect ratio + auto_update (bool, optional): update the scene view + + Returns: + None: none + """ + if not self.background_image: + return + + current_width = self.background_image.size().width() + current_height = self.background_image.size().height() + if current_width == width and current_height == height: + return + + if keepAspectRatio: + if current_width != width: + aspect_size = self.background_image.scaledToWidth(width).size() + width, height = aspect_size.width(), aspect_size.height() + elif current_height != height: + aspect_size = self.background_image.scaledToHeight( + height + ).size() + width, height = aspect_size.width(), aspect_size.height() + # TODO find if this is the most efficient way to achieve this + self.background_image = self.background_image.scaled(width, height) + + if auto_update: + self.scene().set_size(width, height) + # Update display + self.fit_scene_content() + + def set_background_width(self, width, keepAspectRatio=True): + """convenience function for setting width on bg image + + Args: + width (int): desired width + keepAspectRatio (bool, optional): force aspect ration + + Returns: + None: None + """ + if not self.background_image: + return + current_height = self.background_image.size().height() + self.resize_background_image( + width, current_height, keepAspectRatio=keepAspectRatio + ) + + def set_background_height(self, height, keepAspectRatio=True): + """convenience function for setting height on bg image + + Args: + height (int): desired height + keepAspectRatio (bool, optional): force aspect ration + + Returns: + None: None + """ + if not self.background_image: + return + current_width = self.background_image.size().width() + self.resize_background_image( + current_width, height, keepAspectRatio=keepAspectRatio + ) + + def get_background_size(self): + """get bg image in Qt.QSize + + Returns: + Qt.QSize: current size of bg + """ + bg_image = self.get_background(0) + if bg_image: + return bg_image.size() + else: + return QtCore.QSize(0, 0) + + def get_background(self, index): + """Return background for tab index""" + return self.background_image + + def clear(self): + """Clear view, by replacing scene with a new one""" + old_scene = self.scene() + self.setScene(OrderedGraphicsScene(parent=self)) + old_scene.deleteLater() + + def get_picker_items(self): + """Return scene picker items in proper order (back to front)""" + items = [] + for item in list(self.scene().items()): + # Skip non picker graphic items + if not isinstance(item, picker_widgets.PickerItem): + continue + + # Add picker item to filtered list + items.append(item) + + # Reverse list order (to return back to front) + items.reverse() + + return items + + def get_data(self): + """Return view data""" + data = {} + + # Add background to data + if self.background_image_path: + bg_fp = r"{}".format(self.background_image_path) + data["background"] = bg_fp + data["background_size"] = self.get_background_size().toTuple() + + # Add items to data + items = [] + for item in self.get_picker_items(): + items.append(item.get_data()) + if items: + data["items"] = items + + return data + + def set_data(self, data): + """Set/load view data""" + self.clear() + + # Set backgraound picture + background = data.get("background", None) + background_size = data.get("background_size", None) + if background: + self.set_background(background) + if background_size: + self.resize_background_image( + background_size[0], background_size[1] + ) + + # Add items to view + for item_data in data.get("items", []): + item = self.add_picker_item() + item.set_data(item_data) + + def drawBackground(self, painter, rect): + """Default method override to draw view custom background image""" + # Run default method + result = QtWidgets.QGraphicsView.drawBackground(self, painter, rect) + + # Stop here if view has no background + if not self.background_image: + return result + + # Draw background image + painter.drawImage( + self.sceneRect(), + self.background_image, + QtCore.QRectF(self.background_image.rect()), + ) + + return result + + def drawForeground(self, painter, rect): + """Default method override to draw origin axis in edit mode""" + # Run default method + result = QtWidgets.QGraphicsView.drawForeground(self, painter, rect) + + # Paint axis in edit mode + if __EDIT_MODE__.get(): + self.draw_overlay_axis(painter, rect) + + return result + + def draw_overlay_axis(self, painter, rect): + """Draw x and y origin axis""" + # Set Pen + pen = QtGui.QPen( + QtGui.QColor(160, 160, 160, 120), 1, QtCore.Qt.DashLine + ) + painter.setPen(pen) + + # Get event rect in scene coordinates + # Draw x line + if rect.y() < 0 and (rect.height() - rect.y()) > 0: + x_line = QtCore.QLine(rect.x(), 0, rect.width() + rect.x(), 0) + painter.drawLine(x_line) + + # Draw y line + if rect.x() < 0 and (rect.width() - rect.x()) > 0: + y_line = QtCore.QLineF(0, rect.y(), 0, rect.height() + rect.y()) + painter.drawLine(y_line) + + def convert_picker_to_curves(self): + """Convert the pickernodes from the view into maya curves for easier + editing. + + Returns: + n/a: n/a + + http://forum.mgear-framework.com/t/sharing-a-couple-of-functions-i-wrote-for-anim-picker/1717 + """ + data = self.main_window.get_character_data() + + tab_index = self.main_window.tab_widget.currentIndex() + tab_name = self.main_window.tab_widget.tabText(tab_index) + + tab = None + # only focus on the current tab + for _tab in data["tabs"]: + if _tab["name"] == tab_name: + tab = _tab + if not tab: + cmds.warning("No data in picker tab: {}".format(tab_name)) + return + + # lets not create multiple picker group nodes + if pm.objExists(PICKER_EXTRACTION_NAME): + grp = pm.PyNode(PICKER_EXTRACTION_NAME) + else: + grp = pm.group(em=True, n=PICKER_EXTRACTION_NAME) + attribute.lockAttribute(grp) + + # Delete a previous extraction group for this tab, matched by the + # stored tab name rather than the node name: Maya may rename the group + # (e.g. "default" -> "default1", or spaces -> "_"), so the node name is + # not a reliable key. + for existing in grp.listRelatives() or []: + if ( + pm.hasAttr(existing, "tabName") + and existing.tabName.get() == tab["name"] + ): + pm.delete(existing) + picker_grp = pm.group(em=True, n=tab["name"], p=grp) + # Store the real tab name so the round-trip does not depend on the + # (possibly Maya-mangled) group node name. + attribute.addAttribute(picker_grp, "tabName", "string", tab["name"]) + picker_grp.sy >> picker_grp.sx + attribute.lockAttribute( + picker_grp, ["tz", "rx", "ry", "rz", "sx", "sz", "v"] + ) + + if "background" in tab["data"]: + attribute.addAttribute( + picker_grp, + "backgroundAlpha", + "float", + 0.5, + minValue=0, + maxValue=1, + ) + attribute.addAttribute( + picker_grp, + "backgroundWidth", + "long", + tab["data"]["background_size"][0], + minValue=1, + ) + attribute.addAttribute( + picker_grp, + "backgroundHeight", + "long", + tab["data"]["background_size"][1], + minValue=1, + ) + ip = pm.imagePlane(n="{}_background".format(tab["name"])) + ip[0].tz.set(-1) + ip[0].overrideEnabled.set(1) + ip[0].overrideDisplayType.set(2) + pm.parent(ip[0], picker_grp) + + picker_grp.backgroundAlpha >> ip[1].alphaGain + picker_grp.backgroundWidth >> ip[1].width + picker_grp.backgroundHeight >> ip[1].height + + ip[1].imageName.set(tab["data"]["background"]) + + if "items" in tab["data"]: + for item in tab["data"]["items"]: + handles = item["handles"] + pos_x, pos_y = item["position"] + rot_z = item["rotation"] + + if len(handles) > 2: + item_curve = pm.circle( + d=1, s=len(item["handles"]), ch=False + )[0] + + for i, (x, y) in enumerate(handles): + item_curve.getShape().controlPoints[i].set(x, y, 0) + item_curve.getShape().controlPoints[i + 1].set( + handles[0][0], handles[0][1], 0 + ) + + # special case for circles + elif len(handles) == 2: + item_curve = pm.curve( + p=[ + [handles[0][0], handles[0][1], 0.0], + [handles[1][0], handles[1][1], 0.0], + ], + d=1, + ) + poci = pm.createNode("pointOnCurveInfo") + item_curve.getShape().worldSpace >> poci.inputCurve + curve_len = pm.arclen(item_curve, ch=True) + + display_curve = pm.circle(d=3, s=6, ch=False)[0] + pm.parent(display_curve, item_curve) + display_curve.getShape().overrideEnabled.set(1) + display_curve.getShape().overrideDisplayType.set(2) + display_curve.inheritsTransform.set(0) + + curve_len.arcLength >> display_curve.sx + curve_len.arcLength >> display_curve.sy + curve_len.arcLength >> display_curve.sz + poci.position >> display_curve.t + + pm.parent(item_curve, picker_grp) + + q_color = QtGui.QColor(*item["color"]) + attribute.addColorAttribute( + item_curve, "color", q_color.getRgbF()[:3] + ) + attribute.addAttribute( + item_curve, + "alpha", + "long", + item["color"][3], + minValue=0, + maxValue=255, + ) + + item_curve.t.set(pos_x, pos_y, 0) + item_curve.rz.set(rot_z) + item_curve.displayHandle.set(1) + item_curve.getShape().dispCV.set(1) + item_curve.overrideEnabled.set(1) + item_curve.overrideRGBColors.set(1) + item_curve.color >> item_curve.overrideColorRGB + item_curve.scalePivot >> item_curve.selectHandle + attribute.lockAttribute( + item_curve, ["tz", "rx", "ry", "sz", "v"] + ) + + # this will save all the data that is not needed for display + # purposes to an attr + ignore_list = ("position", "rotation", "handles", "color") + item_data = {} + + for key in item.keys(): + if key not in ignore_list: + item_data[key] = item[key] + + if item_data: + attribute.addAttribute( + item_curve, "itemData", "string", json.dumps(item_data) + ) + item_curve.itemData.set(lock=True) + + def delete_extraction_grp(self): + """delete extraction group""" + try: + pm.delete(PICKER_EXTRACTION_NAME) + except Exception as e: + mgear.log( + "anim_picker: failed to delete extraction group: " + "{}".format(e), + mgear.sev_warning, + ) + + def convert_curves_to_picker(self): + """get the information from the created picker curves and reset the + information on the picker data node the anim picker operates on + """ + grp = pm.PyNode(PICKER_EXTRACTION_NAME) + new_data = {"tabs": []} + + for tab_grp in grp.listRelatives(): + # Recover the real tab name (the group node may have been renamed + # by Maya, e.g. "default" -> "default1"). + if pm.hasAttr(tab_grp, "tabName"): + tab_name = tab_grp.tabName.get() + else: + tab_name = tab_grp.name() + new_data["tabs"].append({"name": tab_name}) + new_data["tabs"][-1]["data"] = {"items": []} + bg_imagePlane = tab_grp.listRelatives(type="imagePlane", ad=True) + + if bg_imagePlane: + image_name = bg_imagePlane[0].imageName.get() + new_data["tabs"][-1]["data"]["background"] = image_name + new_data["tabs"][-1]["data"]["background_size"] = [ + bg_imagePlane[0].width.get(), + bg_imagePlane[0].height.get(), + ] + + for item_curve in tab_grp.listRelatives(): + shape = item_curve.getShape() + if shape is None or shape.type() == "imagePlane": + continue + + item_data = {} + + # color + q_color = QtGui.QColor() + q_color.setRgbF(*item_curve.color.get()) + q_color.setAlpha(item_curve.alpha.get()) + item_data["color"] = q_color.getRgb() + + # position and rotation + item_piv = pm.dt.Point( + item_curve.getPivots(worldSpace=True)[0] + ) + piv_offset = item_piv * item_curve.worldInverseMatrix.get() + item_pos = item_piv * tab_grp.worldInverseMatrix.get() + + item_data["position"] = [item_pos.x, item_pos.y] + item_data["rotation"] = item_curve.rz.get() + + # handles + handles = [] + item_scale = [item_curve.sx.get(), item_curve.sy.get()] + for cv in item_curve.cv: + x, y = cv.getPosition(space="object")[:2] + handles.append( + [ + (x - piv_offset.x) * item_scale[0], + (y - piv_offset.y) * item_scale[1], + ] + ) + + # if the first and last points are the same then ignore the + # last one. + if handles[0] == handles[-1]: + handles = handles[:-1] + item_data["handles"] = handles + + if pm.hasAttr(item_curve, "itemData"): + item_data.update(json.loads(item_curve.itemData.get())) + + new_data["tabs"][-1]["data"]["items"].append(item_data) + + data_node = self.main_window.get_current_data_node() + if not (data_node and data_node.exists()): + return True + data = self.main_window.get_character_data() + # update original data to avoid deletion of the non edited tabs + # Create a lookup dictionary for fast matching + new_data_lookup = {d["name"]: d for d in new_data["tabs"] if "name" in d} + + # Surface converted tabs that don't match any current picker tab, so + # the merge below does not silently drop their edited data. + current_names = { + d.get("name") for d in data.get("tabs", []) if "name" in d + } + for converted_name in new_data_lookup: + if converted_name not in current_names: + mgear.log( + "anim_picker: converted tab '{}' has no matching tab in " + "the current picker; its data was not applied".format( + converted_name + ), + mgear.sev_warning, + ) + + # Replace the matching dictionaries in data + updated_data = {"tabs": []} + updated_data["tabs"] = [ + new_data_lookup.get(d.get("name"), d) if "name" in d else d + for d in data["tabs"] + ] + # Write through the DataNode chokepoint (JSON + version stamp + + # lock handling all centralized in picker_node.py) + data_node.set_data(updated_data) + data_node.write_data(to_node=True) + + self.main_window.refresh() diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/__init__.py b/release/scripts/mgear/anim_picker/widgets/dialogs/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/copy_paste_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/copy_paste_dialog.py new file mode 100644 index 00000000..b5322a4a --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/copy_paste_dialog.py @@ -0,0 +1,211 @@ +"""Picker item copy/paste dialog and state handling. + +Extracted from picker_widgets.py during the Phase 2 decomposition. +PickerItem is imported lazily inside the methods that need it to avoid a +circular import with picker_item.py. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import basic + + +class State(object): + """State object, for easy state handling""" + + def __init__(self, state, name=False): + self.state = state + self.name = name + + def __lt__(self, other): + """Override for "sort" function""" + return self.name < other.name + + def get(self): + return self.state + + def set(self, state): + self.state = state + + +class DataCopyDialog(QtWidgets.QDialog): + """PickerItem data copying dialog handler""" + + __DATA__ = {} + + __STATES__ = [] + __DO_POS__ = State(False, "position") + __STATES__.append(__DO_POS__) + __DO_ROT__ = State(False, "rotation") + __STATES__.append(__DO_ROT__) + __DO_COLOR__ = State(True, "color") + __STATES__.append(__DO_COLOR__) + __DO_ACTION_MODE__ = State(True, "action_mode") + __STATES__.append(__DO_ACTION_MODE__) + __DO_ACTION_SCRIPT__ = State(True, "action_script") + __STATES__.append(__DO_ACTION_SCRIPT__) + __DO_HANDLES__ = State(True, "handles") + __STATES__.append(__DO_HANDLES__) + __DO_TEXT__ = State(True, "text") + __STATES__.append(__DO_TEXT__) + __DO_TEXT_SIZE__ = State(True, "text_size") + __STATES__.append(__DO_TEXT_SIZE__) + __DO_TEXT_COLOR__ = State(True, "text_color") + __STATES__.append(__DO_TEXT_COLOR__) + __DO_CTRLS__ = State(True, "controls") + __STATES__.append(__DO_CTRLS__) + __DO_MENUS__ = State(True, "menus") + __STATES__.append(__DO_MENUS__) + + def __init__(self, parent=None): + QtWidgets.QDialog.__init__(self, parent) + self.apply = False + self.setup() + + def setup(self): + """Build/Setup the dialog window""" + self.setWindowTitle("Copy/Paste") + + # Add layout + self.main_layout = QtWidgets.QVBoxLayout(self) + + # Add data field options + for state in self.__STATES__: + label_name = state.name.capitalize().replace("_", " ") + cb = basic.CallbackCheckBoxWidget( + callback=self.check_box_event, + value=state.get(), + label=label_name, + state_obj=state, + ) + self.main_layout.addWidget(cb) + + # Add buttons + btn_layout = QtWidgets.QHBoxLayout() + self.main_layout.addLayout(btn_layout) + + ok_btn = basic.CallbackButton(callback=self.accept_event) + ok_btn.setText("Ok") + btn_layout.addWidget(ok_btn) + + cancel_btn = basic.CallbackButton(callback=self.cancel_event) + cancel_btn.setText("Cancel") + btn_layout.addWidget(cancel_btn) + + def check_box_event(self, value=False, state_obj=None): + """Update state object value on checkbox state change event""" + state_obj.set(value) + + def accept_event(self): + """Accept button event""" + self.apply = True + + self.accept() + self.close() + + def cancel_event(self): + """Cancel button event""" + self.apply = False + self.close() + + @classmethod + def options(cls, item=None): + """ + Default method used to run the dialog input window + Will open the dialog window and return input texts. + """ + win = cls() + win.exec_() + win.raise_() + + if not win.apply: + return + # win.set(item) + + @staticmethod + def set_pos(item=None, x=True, y=True): + """Set the position date for a specific picker item + + Args: + item (object, optional): picker object item + x (bool, optional): if true will set X position + y (bool, optional): if true will set Y position + """ + # Sanity check + # Lazy import to avoid a circular import with picker_item.py + from mgear.anim_picker.widgets.picker_item import PickerItem + + msg = "Item is not an PickerItem instance" + assert isinstance(item, PickerItem), msg + assert DataCopyDialog.__DATA__, "No stored data to paste" + + keys = [] + keys.append("position") + + # Build valid data + data = {} + for key in keys: + if key not in DataCopyDialog.__DATA__: + continue + data[key] = DataCopyDialog.__DATA__[key] + + # Get picker item data + item_data = item.get_data() + + if x: + data["position"][1] = item_data["position"][1] + if y: + data["position"][0] = item_data["position"][0] + item.set_data(data) + + @staticmethod + def set(item=None): + """Set the data to specific picker item + + Args: + item (object, optional): Picker object + """ + # Sanity check + # Lazy import to avoid a circular import with picker_item.py + from mgear.anim_picker.widgets.picker_item import PickerItem + + msg = "Item is not an PickerItem instance" + assert isinstance(item, PickerItem), msg + assert DataCopyDialog.__DATA__, "No stored data to paste" + + # Filter data keys to copy + keys = [] + for state in DataCopyDialog.__STATES__: + if not state.get(): + continue + keys.append(state.name) + + # Build valid data + data = {} + for key in keys: + if key not in DataCopyDialog.__DATA__: + continue + data[key] = DataCopyDialog.__DATA__[key] + + # Get picker item data + item.set_data(data) + + @staticmethod + def get(item=None): + """Will get and store data for specified item""" + # Sanity check + # Lazy import to avoid a circular import with picker_item.py + from mgear.anim_picker.widgets.picker_item import PickerItem + + msg = "Item is not an PickerItem instance" + assert isinstance(item, PickerItem), msg + + # Get picker item data + data = item.get_data() + + # Store data + DataCopyDialog.__DATA__ = data + + return data diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py b/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py new file mode 100644 index 00000000..f49c476f --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py @@ -0,0 +1,107 @@ +"""Handle-position editor window for the anim picker. + +Extracted from picker_widgets.py during the Phase 2 decomposition. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import basic + + +class HandlesPositionWindow(QtWidgets.QMainWindow): + """Whild window to edit picker item handles local positions""" + + __OBJ_NAME__ = "picker_item_handles_window" + __TITLE__ = "Handles positions" + + __DEFAULT_WIDTH__ = 250 + __DEFAULT_HEIGHT__ = 300 + + def __init__(self, parent=None, picker_item=None): + QtWidgets.QMainWindow.__init__(self, parent=None) + + self.picker_item = picker_item + + # Run setup + self.setup() + + def setup(self): + """Setup window elements""" + # Main window setting + self.setObjectName(self.__OBJ_NAME__) + self.setWindowTitle(self.__TITLE__) + self.resize(self.__DEFAULT_WIDTH__, self.__DEFAULT_HEIGHT__) + + # Create main widget + self.main_widget = QtWidgets.QWidget(self) + self.main_layout = QtWidgets.QVBoxLayout(self.main_widget) + + self.setCentralWidget(self.main_widget) + + # Add content + self.add_position_table() + self.add_option_buttons() + + # Populate table + self.populate_table() + + def add_position_table(self): + self.table = QtWidgets.QTableWidget(self) + + self.table.setColumnCount(2) + self.table.setHorizontalHeaderLabels(["X", "Y"]) + + self.main_layout.addWidget(self.table) + + def add_option_buttons(self): + """Add window option buttons""" + # Refresh button + self.refresh_button = basic.CallbackButton(callback=self.refresh_event) + self.refresh_button.setText("Refresh") + self.main_layout.addWidget(self.refresh_button) + + def refresh_event(self): + """Refresh table event""" + self.populate_table() + + def populate_table(self): + """Populate table with X/Y handles position items""" + # Clear table + while self.table.rowCount(): + self.table.removeRow(0) + + # Abort if no pickeritem specified + if not self.picker_item: + return + + # Parse handles + handles = self.picker_item.get_handles() + for i in range(len(handles)): + self.table.insertRow(i) + spin_box = basic.CallBackDoubleSpinBox( + callback=handles[i].setX, value=handles[i].x(), min=-999 + ) + self.table.setCellWidget(i, 0, spin_box) + + spin_box = basic.CallBackDoubleSpinBox( + callback=handles[i].setY, value=handles[i].y(), min=-999 + ) + self.table.setCellWidget(i, 1, spin_box) + + def display_handles_index(self, status=True): + """Display related picker handles index""" + for handle in self.picker_item.get_handles(): + handle.enable_index_draw(status) + + def closeEvent(self, *args, **kwargs): + self.display_handles_index(status=False) + return QtWidgets.QMainWindow.closeEvent(self, *args, **kwargs) + + def show(self, *args, **kwargs): + """Override default show function to display related picker + handles index + """ + self.display_handles_index(status=True) + return QtWidgets.QMainWindow.show(self, *args, **kwargs) diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py b/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py new file mode 100644 index 00000000..6626c7a8 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py @@ -0,0 +1,932 @@ +"""Picker item options editor window. + +Extracted from picker_widgets.py during the Phase 2 decomposition. +""" + +import copy + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import basic +from mgear.anim_picker.widgets.dialogs.script_dialog import ( + CustomScriptEditDialog, +) +from mgear.anim_picker.widgets.dialogs.script_dialog import ( + CustomMenuEditDialog, +) +from mgear.anim_picker.widgets.dialogs.handles_window import ( + HandlesPositionWindow, +) + + +class ItemOptionsWindow(QtWidgets.QMainWindow): + """Child window to edit shape options""" + + __OBJ_NAME__ = "ctrl_picker_edit_window" + __TITLE__ = "Picker Item Options" + + # ---------------------------------------------------------------------- + # constructor + def __init__(self, parent=None, picker_item=None): + QtWidgets.QMainWindow.__init__(self, parent=parent) + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + self.picker_item = picker_item + + # undo ---------------------------------------------------------------- + self.main_view = self.picker_item.scene().parent() + self.tmp_picker_pos_info = {} + self.tmp_picker_pos_info[picker_item.uuid] = [ + picker_item.x(), + picker_item.y(), + picker_item.rotation(), + ] + # undo ---------------------------------------------------------------- + + # Define size + self.default_width = 270 + self.default_height = 140 + + # Run setup + self.setup() + + # Other + self.handles_window = None + self.event_disabled = False + + def setup(self): + """Setup window elements""" + # Main window setting + self.setObjectName(self.__OBJ_NAME__) + self.setWindowTitle(self.__TITLE__) + self.resize(self.default_width, self.default_height) + + # Set size policies + sizePolicy = QtWidgets.QSizePolicy( + QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed + ) + sizePolicy.setHeightForWidth(self.sizePolicy().hasHeightForWidth()) + self.setSizePolicy(sizePolicy) + + # Create main widget + self.main_widget = QtWidgets.QWidget(self) + self.main_layout = QtWidgets.QHBoxLayout(self.main_widget) + + self.left_layout = QtWidgets.QVBoxLayout() + self.main_layout.addLayout(self.left_layout) + + self.right_layout = QtWidgets.QHBoxLayout() + self.main_layout.addLayout(self.right_layout) + + self.control_layout = QtWidgets.QVBoxLayout() + self.control_layout.setContentsMargins(0, 0, 0, 0) + self.right_layout.addLayout(self.control_layout) + + self.setCentralWidget(self.main_widget) + + # Add content + self.add_main_options() + self.add_position_options() + self.add_rotation_options() + self.add_color_options() + self.add_scale_options() + self.add_text_options() + self.add_action_mode_field() + self.add_target_control_field() + self.add_custom_menus_field() + + # Add layouts stretch + self.left_layout.addStretch() + + # Udpate fields + self._update_shape_infos() + self._update_position_infos() + self._update_color_infos() + self._update_text_infos() + self._update_ctrls_infos() + self._update_menus_infos() + + def closeEvent(self, *args, **kwargs): + """Overwriting close event to close child windows too""" + # Close child windows + if self.handles_window: + try: + self.handles_window.close() + except Exception: + pass + + # undo ---------------------------------------------------------------- + current_position = [ + self.picker_item.x(), + self.picker_item.y(), + self.picker_item.rotation(), + ] + + orig_position = self.tmp_picker_pos_info.get( + self.picker_item.uuid, None + ) + if orig_position is not None and orig_position != current_position: + self.tmp_picker_pos_info[self.picker_item.uuid].extend( + current_position + ) + if self.main_view.undo_move_order_index in [-1]: + self.main_view.undo_move_order.append( + copy.deepcopy(self.tmp_picker_pos_info) + ) + else: + self.main_view.undo_move_order = self.undo_move_order[ + : self.main_view.undo_move_order_index + ] + self.main_view.undo_move_order.append( + copy.deepcopy(self.tmp_picker_pos_info) + ) + self.undo_move_order_index = -1 + self.tmp_picker_pos_info = {} + # undo ---------------------------------------------------------------- + + QtWidgets.QMainWindow.closeEvent(self, *args, **kwargs) + + def _update_shape_infos(self): + self.event_disabled = True + self.handles_cb.setChecked(self.picker_item.get_edit_status()) + self.count_sb.setValue(self.picker_item.point_count) + self.event_disabled = False + + def _update_position_infos(self): + self.event_disabled = True + position = self.picker_item.pos() + self.pos_x_sb.setValue(position.x()) + self.pos_y_sb.setValue(position.y()) + self.event_disabled = False + + def _update_color_infos(self): + self.event_disabled = True + self._set_color_button(self.picker_item.get_color()) + self.alpha_sb.setValue(self.picker_item.get_color().alpha()) + self.event_disabled = False + + def _update_text_infos(self): + self.event_disabled = True + + # Retrieve et set text field + text = self.picker_item.get_text() + if text: + self.text_field.setText(text) + + # Set text color fields + self._set_text_color_button(self.picker_item.get_text_color()) + self.text_alpha_sb.setValue(self.picker_item.get_text_color().alpha()) + self.event_disabled = False + + def _update_ctrls_infos(self): + self._populate_ctrl_list_widget() + + def _update_menus_infos(self): + self._populate_menu_list_widget() + + def add_main_options(self): + """Add vertex count option""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Main Properties") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Add edit check box + func = self.handles_cb_event + self.handles_cb = basic.CallbackCheckBoxWidget(callback=func) + self.handles_cb.setText("Show handles") + + layout.addWidget(self.handles_cb) + + # Add point count spin box + spin_layout = QtWidgets.QHBoxLayout() + + spin_label = QtWidgets.QLabel() + spin_label.setText("Vtx Count") + spin_layout.addWidget(spin_label) + + point_count = self.picker_item.edit_point_count + self.count_sb = basic.CallBackSpinBox( + callback=point_count, value=self.picker_item.point_count + ) + self.count_sb.setMinimum(2) + spin_layout.addWidget(self.count_sb) + + layout.addLayout(spin_layout) + + # Add handles position button + handle_position = self.edit_handles_position_event + handles_button = basic.CallbackButton(callback=handle_position) + handles_button.setText("Handles Positions") + layout.addWidget(handles_button) + + # Add to main layout + self.left_layout.addWidget(group_box) + + def add_position_options(self): + """Add position field for precise control positioning""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Position") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Get bary-center + position = self.picker_item.pos() + + # Add X position spin box + spin_layout = QtWidgets.QHBoxLayout() + + spin_label = QtWidgets.QLabel() + spin_label.setText("X") + spin_layout.addWidget(spin_label) + + edit_pos_event = self.edit_position_event + self.pos_x_sb = basic.CallBackDoubleSpinBox( + callback=edit_pos_event, value=position.x(), min=-9999 + ) + spin_layout.addWidget(self.pos_x_sb) + + layout.addLayout(spin_layout) + + # Add Y position spin box + spin_layout = QtWidgets.QHBoxLayout() + + label = QtWidgets.QLabel() + label.setText("Y") + spin_layout.addWidget(label) + + self.pos_y_sb = basic.CallBackDoubleSpinBox( + callback=edit_pos_event, value=position.y(), min=-9999 + ) + spin_layout.addWidget(self.pos_y_sb) + + layout.addLayout(spin_layout) + + # Add to main layout + self.left_layout.addWidget(group_box) + + def add_rotation_options(self): + """Add rotation group box options""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Rotation") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Add alpha spin box + spin_layout = QtWidgets.QHBoxLayout() + layout.addLayout(spin_layout) + + label = QtWidgets.QLabel() + label.setText("Angle") + spin_layout.addWidget(label) + + self.rotate_sb = QtWidgets.QDoubleSpinBox() + self.rotate_sb.setValue(15) + self.rotate_sb.setSingleStep(5) + spin_layout.addWidget(self.rotate_sb) + + # Add rotate buttons + btn_layout = QtWidgets.QHBoxLayout() + layout.addLayout(btn_layout) + + btn = basic.CallbackButton(callback=self.rotate_event, rotMinus=True) + btn.setText("Rot-") + btn_layout.addWidget(btn) + + btn = basic.CallbackButton(callback=self.reset_rotate_event) + btn.setText("Reset") + btn_layout.addWidget(btn) + + btn = basic.CallbackButton(callback=self.rotate_event, rotPlus=True) + btn.setText("Rot+") + btn_layout.addWidget(btn) + + # Add to main left layout + self.left_layout.addWidget(group_box) + + def _set_color_button(self, color): + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Button, color) + self.color_button.setPalette(palette) + self.color_button.setAutoFillBackground(True) + + def _set_text_color_button(self, color): + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Button, color) + self.text_color_button.setPalette(palette) + self.text_color_button.setAutoFillBackground(True) + + def add_color_options(self): + """Add color edition field for polygon""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Color options") + + # Add layout + layout = QtWidgets.QHBoxLayout(group_box) + + # Add color button + self.color_button = basic.CallbackButton( + callback=self.change_color_event + ) + + layout.addWidget(self.color_button) + + # Add alpha spin box + layout.addStretch() + + label = QtWidgets.QLabel() + label.setText("Alpha") + layout.addWidget(label) + + alpha_event = self.change_color_alpha_event + alpha_value = self.picker_item.get_color().alpha() + self.alpha_sb = basic.CallBackSpinBox( + callback=alpha_event, value=alpha_value, max=255 + ) + layout.addWidget(self.alpha_sb) + + # Add to main layout + self.left_layout.addWidget(group_box) + + def add_text_options(self): + """Add text option fields""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Text options") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Add Caption text field + self.text_field = basic.CallbackLineEdit(self.set_text_event) + layout.addWidget(self.text_field) + + # Add size factor spin box + spin_layout = QtWidgets.QHBoxLayout() + + spin_label = QtWidgets.QLabel() + spin_label.setText("Size factor") + spin_layout.addWidget(spin_label) + + text_size = self.picker_item.get_text_size() + value_sb = basic.CallBackDoubleSpinBox( + callback=self.edit_text_size_event, value=text_size + ) + spin_layout.addWidget(value_sb) + + layout.addLayout(spin_layout) + + # Add color layout + color_layout = QtWidgets.QHBoxLayout(group_box) + + # Add color button + color_event = self.change_text_color_event + self.text_color_button = basic.CallbackButton(callback=color_event) + + color_layout.addWidget(self.text_color_button) + + # Add alpha spin box + color_layout.addStretch() + + label = QtWidgets.QLabel() + label.setText("Alpha") + color_layout.addWidget(label) + + alpha_event = self.change_text_alpha_event + alpha_value = self.picker_item.get_text_color().alpha() + self.text_alpha_sb = basic.CallBackSpinBox( + callback=alpha_event, value=alpha_value, max=255 + ) + color_layout.addWidget(self.text_alpha_sb) + + # Add color layout to group box layout + layout.addLayout(color_layout) + + # Add to main layout + self.left_layout.addWidget(group_box) + + def add_scale_options(self): + """Add scale group box options""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Scale") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Add edit check box + self.worldspace_box = QtWidgets.QCheckBox() + self.worldspace_box.setText("World space") + + layout.addWidget(self.worldspace_box) + + # Add alpha spin box + spin_layout = QtWidgets.QHBoxLayout() + layout.addLayout(spin_layout) + + label = QtWidgets.QLabel() + label.setText("Factor") + spin_layout.addWidget(label) + + self.scale_sb = QtWidgets.QDoubleSpinBox() + self.scale_sb.setValue(1.1) + self.scale_sb.setSingleStep(0.05) + spin_layout.addWidget(self.scale_sb) + + # Add scale buttons + btn_layout = QtWidgets.QHBoxLayout() + layout.addLayout(btn_layout) + + btn = basic.CallbackButton(callback=self.scale_event, x=True) + btn.setText("X") + btn_layout.addWidget(btn) + + btn = basic.CallbackButton(callback=self.scale_event, y=True) + btn.setText("Y") + btn_layout.addWidget(btn) + + btn = basic.CallbackButton(callback=self.scale_event, x=True, y=True) + btn.setText("XY") + btn_layout.addWidget(btn) + + # Add to main left layout + self.left_layout.addWidget(group_box) + + def add_action_mode_field(self): + """Add custom action mode field group box""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Action Mode") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Add default select mode radio button + custom_mode = not self.picker_item.get_custom_action_mode() + default_rad = basic.CallbackRadioButtonWidget( + "default", self.mode_radio_event, checked=custom_mode + ) + default_rad.setText("Default action (select)") + default_rad.setToolTip( + "Run default selection action on related controls" + ) + layout.addWidget(default_rad) + + # Add custom action script radio button + action_mode = self.picker_item.get_custom_action_mode() + custom_rad = basic.CallbackRadioButtonWidget( + "custom", self.mode_radio_event, checked=action_mode + ) + custom_rad.setText("Custom action (script)") + custom_rad.setToolTip("Change mode to run a custom action script") + layout.addWidget(custom_rad) + + # Add edit custom script button + custom_script = self.edit_custom_action_script + custom_script_btn = basic.CallbackButton(callback=custom_script) + custom_script_btn.setText("Edit Action script") + custom_script_btn.setToolTip("Open custom action script edit window") + layout.addWidget(custom_script_btn) + + self.control_layout.addWidget(group_box) + + def add_target_control_field(self): + """Add target control association group box""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Control Association") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Init list object + ctrl_name = self.edit_ctrl_name_event + self.control_list = basic.CallbackListWidget(callback=ctrl_name) + self.control_list.setToolTip( + "Associated controls/objects that will be\ + selected when clicking picker item" + ) + layout.addWidget(self.control_list) + + # Add buttons + btn_layout1 = QtWidgets.QHBoxLayout() + layout.addLayout(btn_layout1) + + btn = basic.CallbackButton(callback=self.add_selected_controls_event) + btn.setText("Add Selection") + btn.setToolTip("Add selected controls to list") + btn.setMinimumWidth(75) + btn_layout1.addWidget(btn) + + btn = basic.CallbackButton(callback=self.remove_controls_event) + btn.setText("Remove") + btn.setToolTip("Remove selected controls") + btn.setMinimumWidth(75) + btn_layout1.addWidget(btn) + + btn = basic.CallbackButton(callback=self.search_replace_controls_event) + btn.setText("Search & Replace") + btn.setToolTip("Will search and replace all controls names") + layout.addWidget(btn) + + self.control_layout.addWidget(group_box) + + def add_custom_menus_field(self): + """Add custom menu management groupe box""" + # Create group box + group_box = QtWidgets.QGroupBox() + group_box.setTitle("Custom Menus") + + # Add layout + layout = QtWidgets.QVBoxLayout(group_box) + + # Init list object + self.menus_list = basic.CallbackListWidget( + callback=self.edit_menu_event + ) + self.menus_list.setToolTip( + "Custom action menus that will be accessible through right clicking the picker item in animation mode" + ) + layout.addWidget(self.menus_list) + + # Add buttons + btn_layout1 = QtWidgets.QHBoxLayout() + layout.addLayout(btn_layout1) + + btn = basic.CallbackButton(callback=self.new_menu_event) + btn.setText("New") + btn.setMinimumWidth(60) + btn_layout1.addWidget(btn) + + btn = basic.CallbackButton(callback=self.remove_menus_event) + btn.setText("Remove") + btn.setMinimumWidth(60) + btn_layout1.addWidget(btn) + + self.right_layout.addWidget(group_box) + + # ========================================================================= + # Events + def handles_cb_event(self, value=False): + """Toggle edit mode for shape""" + self.picker_item.set_edit_status(value) + + def edit_handles_position_event(self): + + # Delete old window + if self.handles_window: + try: + self.handles_window.close() + self.handles_window.deleteLater() + except Exception: + pass + + # Init new window + picker_item = self.picker_item + self.handles_window = HandlesPositionWindow( + parent=self, picker_item=picker_item + ) + + # Show window + self.handles_window.show() + self.handles_window.raise_() + + def edit_position_event(self, value=0): + """Will move polygon based on new values""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + x = self.pos_x_sb.value() + y = self.pos_y_sb.value() + + self.picker_item.setPos(QtCore.QPointF(x, y)) + + def change_color_alpha_event(self, value=255): + """Will edit the polygon transparency alpha value""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + # Get current color + color = self.picker_item.get_color() + color.setAlpha(value) + + # Update color + self.picker_item.set_color(color) + + def change_color_event(self): + """Will edit polygon color based on new values""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + # Open color picker dialog + picker_color = self.picker_item.get_color() + color = QtWidgets.QColorDialog.getColor( + initial=picker_color, parent=self + ) + + # Abort on invalid color (cancel button) + if not color.isValid(): + return + + # Update button color + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Button, color) + self.color_button.setPalette(palette) + + # Edit new color alpha + alpha = self.picker_item.get_color().alpha() + color.setAlpha(alpha) + + # Update color + self.picker_item.set_color(color) + + def rotate_event(self, rotMinus=None, rotPlus=None): + """Will rotate polygon based on angle value from spin box""" + # Get rotate angle value + rotate_angle = self.rotate_sb.value() + + # Build kwargs + kwargs = {"angle": 0.0} + if rotMinus: + kwargs["angle"] = rotate_angle + if rotPlus: + kwargs["angle"] = rotate_angle * -1 + + # Apply rotation + self.picker_item.rotate_shape(**kwargs) + + def reset_rotate_event(self): + self.picker_item.reset_rotation() + + def scale_event(self, x=False, y=False): + """Will scale polygon on specified axis based on scale factor + value from spin box + """ + # Get scale factor value + scale_factor = self.scale_sb.value() + + # Build kwargs + kwargs = {"x": 1.0, "y": 1.0} + if x: + kwargs["x"] = scale_factor + if y: + kwargs["y"] = scale_factor + + # Check space + if self.worldspace_box.isChecked(): + kwargs["world"] = True + + # Apply scale + self.picker_item.scale_shape(**kwargs) + + def set_text_event(self, text=None): + """Will set polygon text to field""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + text = str(text) + self.picker_item.set_text(text) + + def edit_text_size_event(self, value=1): + """Will edit text size factor""" + self.picker_item.set_text_size(value) + + def change_text_alpha_event(self, value=255): + """Will edit the polygon transparency alpha value""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + # Get current color + color = self.picker_item.get_text_color() + color.setAlpha(value) + + # Update color + self.picker_item.set_text_color(color) + + def change_text_color_event(self): + """Will edit polygon color based on new values""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + # Open color picker dialog + picker_color = self.picker_item.get_text_color() + color = QtWidgets.QColorDialog.getColor( + initial=picker_color, parent=self + ) + + # Abort on invalid color (cancel button) + if not color.isValid(): + return + + # Update button color + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Button, color) + self.text_color_button.setPalette(palette) + + # Edit new color alpha + alpha = self.picker_item.get_text_color().alpha() + color.setAlpha(alpha) + + # Update color + self.picker_item.set_text_color(color) + + # ========================================================================= + # Custom action management + def mode_radio_event(self, mode): + """Action mode change event""" + # Skip if event is disabled (updating ui value) + if self.event_disabled: + return + + if mode == "default": + self.picker_item.custom_action = False + + elif mode == "custom": + self.picker_item.custom_action = True + + def edit_custom_action_script(self): + + # Open input window + action_script = self.picker_item.custom_action_script + cmd, ok = CustomScriptEditDialog.get( + cmd=action_script, item=self.picker_item + ) + if not (ok and cmd): + return + + self.picker_item.set_custom_action_script(cmd) + + # ========================================================================= + # Control management + def _populate_ctrl_list_widget(self): + """Will update/populate list with current shape ctrls""" + # Empty list + self.control_list.clear() + + # Populate node list + controls = self.picker_item.get_controls() + for i in range(len(controls)): + item = basic.CtrlListWidgetItem(index=i) + item.setText(controls[i]) + self.control_list.addItem(item) + + # if controls: + # self.control_list.setCurrentRow(0) + + def edit_ctrl_name_event(self, item=None): + """Double click event on associated ctrls list""" + if not item: + return + + # Open input window + line_normal = QtWidgets.QLineEdit.Normal + name, ok = QtWidgets.QInputDialog.getText( + self, + "Ctrl name", + "New name", + mode=line_normal, + text=str(item.text()), + ) + if not (ok and name): + return + + # Update influence name + new_name = item.setText(name) + if new_name: + self.update_shape_controls_list() + + # Deselect item + self.control_list.clearSelection() + + def add_selected_controls_event(self): + """Will add maya selected object to control list""" + self.picker_item.add_selected_controls() + + # Update display + self._populate_ctrl_list_widget() + + def remove_controls_event(self): + """Will remove selected item list from stored controls""" + # Get selected item + items = self.control_list.selectedItems() + assert items, "no list item selected" + + # Remove item from list + for item in items: + self.picker_item.remove_control(item.node()) + + # Update display + self._populate_ctrl_list_widget() + + def search_replace_controls_event(self): + """Will search and replace controls names for related picker item""" + if self.picker_item.search_and_replace_controls(): + self._populate_ctrl_list_widget() + + def get_controls_from_list(self): + """Return the controls from list widget""" + ctrls = [] + for i in range(self.control_list.count()): + item = self.control_list.item(i) + ctrls.append(item.node()) + return ctrls + + def update_shape_controls_list(self): + """Update shape stored control list""" + ctrls = self.get_controls_from_list() + self.picker_item.set_control_list(ctrls) + + # ========================================================================= + # Menus management + def _add_menu_item(self, text=None): + """Add a menu item to menu list widget""" + item = QtWidgets.QListWidgetItem() + item.index = self.menus_list.count() + if text: + item.setText(text) + self.menus_list.addItem(item) + return item + + def _populate_menu_list_widget(self): + """Populate list widget with menu data""" + # Empty list + self.menus_list.clear() + + # Populate node list + menus_data = self.picker_item.get_custom_menus() + for i in range(len(menus_data)): + self._add_menu_item(text=menus_data[i][0]) + + def _update_menu_data(self, index, name, cmd): + """Update custom menu data""" + menu_data = self.picker_item.get_custom_menus() + if index > len(menu_data) - 1: + menu_data.append([name, cmd]) + else: + menu_data[index] = [name, cmd] + self.picker_item.set_custom_menus(menu_data) + + def edit_menu_event(self, item=None): + """Double click event on associated menu list""" + if not item: + return + + name, cmd = self.picker_item.get_custom_menus()[item.index] + + # Open input window + name, cmd, ok = CustomMenuEditDialog.get( + name=name, cmd=cmd, item=self.picker_item + ) + if not (ok and name and cmd): + return + + # Update menu display name + item.setText(name) + + # Update menu data + self._update_menu_data(item.index, name, cmd) + + # Deselect item + self.menus_list.clearSelection() + + def new_menu_event(self): + """Add new custom menu btn event""" + # Open input window + name, cmd, ok = CustomMenuEditDialog.get(item=self.picker_item) + if not (ok and name and cmd): + return + + # Update menu display name + item = self._add_menu_item(text=name) + + # Update menu data + self._update_menu_data(item.index, name, cmd) + + def remove_menus_event(self): + """Remove custom menu btn event""" + # Get selected item + items = self.menus_list.selectedItems() + assert items, "no list item selected" + + # Remove item from list + menu_data = self.picker_item.get_custom_menus() + for i in range(len(items)): + menu_data.pop(items[i].index - i) + self.picker_item.set_custom_menus(menu_data) + + # Update display + self._populate_menu_list_widget() diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py new file mode 100644 index 00000000..7eb3bfd8 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py @@ -0,0 +1,180 @@ +"""Custom script / menu edit dialogs for the anim picker. + +Extracted from picker_widgets.py during the Phase 2 decomposition. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import basic +from mgear.anim_picker.handlers import python_handlers + + +SCRIPT_DOC_HEADER = """ +# Variable reference for custom script execution on pickers. +# Use the following variables in your code to access related data: +# __CONTROLS__ for picker item associated controls (will return sets and not content). +# __FLATCONTROLS__ for associated controls and control set content. +# __NAMESPACE__ for current picker namespace +# __INIT__ use 'if not' statement to avoid code execution on creation. +# __SELF__ to get access to the PickerItem() instace. (Change color, size, etc) + +""" + + +class CustomScriptEditDialog(QtWidgets.QDialog): + """Custom python script window (used for custom picker item + action and context menu) + """ + + __TITLE__ = "Custom script" + + def __init__(self, parent=None, cmd=None, item=None): + QtWidgets.QDialog.__init__(self, parent) + + self.cmd = cmd + self.picker_item = item + + self.apply = False + self.setup() + + def setup(self): + """Build/Setup the dialog window""" + self.setWindowTitle(self.__TITLE__) + + # Add layout + self.main_layout = QtWidgets.QVBoxLayout(self) + + # Add cmd txt field + self.cmd_widget = QtWidgets.QTextEdit() + if self.cmd: + text = self.cmd + else: + text = SCRIPT_DOC_HEADER + self.cmd_widget.setText(text) + newCursor = self.cmd_widget.textCursor() + newCursor.movePosition(QtGui.QTextCursor.End) + self.cmd_widget.setTextCursor(newCursor) + self.main_layout.addWidget(self.cmd_widget) + + # Add buttons + btn_layout = QtWidgets.QHBoxLayout() + self.main_layout.addLayout(btn_layout) + + ok_btn = basic.CallbackButton(callback=self.accept_event) + ok_btn.setText("Ok") + btn_layout.addWidget(ok_btn) + + cancel_btn = basic.CallbackButton(callback=self.cancel_event) + cancel_btn.setText("Cancel") + btn_layout.addWidget(cancel_btn) + + run_btn = basic.CallbackButton(callback=self.run_event) + run_btn.setText("Run") + btn_layout.addWidget(run_btn) + + self.resize(500, 600) + + def accept_event(self): + """Accept button event""" + self.apply = True + + self.accept() + self.close() + + def cancel_event(self): + """Cancel button event""" + self.apply = False + self.close() + + def run_event(self): + """Run event button""" + cmd_str = str(self.cmd_widget.toPlainText()) + + if self.picker_item: + python_handlers.safe_code_exec( + cmd_str, env=self.picker_item.get_exec_env() + ) + else: + python_handlers.safe_code_exec(cmd_str) + + def get_values(self): + """Return dialog window result values""" + cmd_str = str(self.cmd_widget.toPlainText()) + + return cmd_str, self.apply + + @classmethod + def get(cls, cmd=None, item=None): + """ + Default method used to run the dialog input window + Will open the dialog window and return input texts. + """ + win = cls(cmd=cmd, item=item) + win.exec_() + win.raise_() + return win.get_values() + + +class CustomMenuEditDialog(CustomScriptEditDialog): + """Custom python script window for picker item context menu""" + + __TITLE__ = "Custom Menu" + + def __init__(self, parent=None, name=None, cmd=None, item=None): + + self.name = name + CustomScriptEditDialog.__init__( + self, parent=parent, cmd=cmd, item=item + ) + + def setup(self): + """Add name field to default window setup""" + # Run default setup + CustomScriptEditDialog.setup(self) + + # Add name line edit + name_layout = QtWidgets.QHBoxLayout(self) + + label = QtWidgets.QLabel() + label.setText("Name") + name_layout.addWidget(label) + + self.name_widget = QtWidgets.QLineEdit() + if self.name: + self.name_widget.setText(self.name) + name_layout.addWidget(self.name_widget) + + self.main_layout.insertLayout(0, name_layout) + + def accept_event(self): + """Accept button event, check for name""" + if not self.name_widget.text(): + QtWidgets.QMessageBox.warning( + self, "Warning", "You need to specify a menu name" + ) + return + + self.apply = True + + self.accept() + self.close() + + def get_values(self): + """Return dialog window result values""" + name_str = str(self.name_widget.text()) + cmd_str = str(self.cmd_widget.toPlainText()) + + return name_str, cmd_str, self.apply + + @classmethod + def get(cls, name=None, cmd=None, item=None): + """ + Default method used to run the dialog input window + Will open the dialog window and return input texts. + """ + win = cls(name=name, cmd=cmd, item=item) + win.exec_() + win.raise_() + return win.get_values() diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/search_replace_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/search_replace_dialog.py new file mode 100644 index 00000000..23e4f5a6 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/search_replace_dialog.py @@ -0,0 +1,85 @@ +"""Search-and-replace dialog for the anim picker. + +Extracted from picker_widgets.py during the Phase 2 decomposition. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import basic + + +class SearchAndReplaceDialog(QtWidgets.QDialog): + """Search and replace dialog window""" + + __SEARCH_STR__ = "_L" + __REPLACE_STR__ = "_R" + + def __init__(self, parent=None): + QtWidgets.QDialog.__init__(self, parent) + + self.apply = False + self.setup() + + def setup(self): + """Build/Setup the dialog window""" + self.setWindowTitle("Search And Replace") + + # Add layout + self.main_layout = QtWidgets.QVBoxLayout(self) + + # Add line edits + self.search_widget = QtWidgets.QLineEdit() + self.search_widget.setText(self.__SEARCH_STR__) + self.main_layout.addWidget(self.search_widget) + + self.replace_widget = QtWidgets.QLineEdit() + self.replace_widget.setText(self.__REPLACE_STR__) + self.main_layout.addWidget(self.replace_widget) + + # Add buttons + btn_layout = QtWidgets.QHBoxLayout() + self.main_layout.addLayout(btn_layout) + + ok_btn = basic.CallbackButton(callback=self.accept_event) + ok_btn.setText("Ok") + btn_layout.addWidget(ok_btn) + + cancel_btn = basic.CallbackButton(callback=self.cancel_event) + cancel_btn.setText("Cancel") + btn_layout.addWidget(cancel_btn) + + ok_btn.setFocus() + + def accept_event(self): + """Accept button event""" + self.apply = True + + self.accept() + self.close() + + def cancel_event(self): + """Cancel button event""" + self.apply = False + self.close() + + def get_values(self): + """Return field values and button choice""" + search_str = str(self.search_widget.text()) + replace_str = str(self.replace_widget.text()) + if self.apply: + SearchAndReplaceDialog.__SEARCH_STR__ = search_str + SearchAndReplaceDialog.__REPLACE_STR__ = replace_str + return search_str, replace_str, self.apply + + @classmethod + def get(cls): + """ + Default method used to run the dialog input window + Will open the dialog window and return input texts. + """ + win = cls() + win.exec_() + win.raise_() + return win.get_values() diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py new file mode 100644 index 00000000..3dd8af59 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -0,0 +1,447 @@ +"""Graphics primitives for the anim picker. + +Polygon / handle / text QGraphics items, extracted from picker_widgets.py +during the Phase 2 decomposition. Qt-only, no picker/dialog dependencies. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + + +class DefaultPolygon(QtWidgets.QGraphicsObject): + """Default polygon class, with move and hover support""" + + __DEFAULT_COLOR__ = QtGui.QColor(0, 0, 0, 255) + + def __init__(self, parent=None): + QtWidgets.QGraphicsObject.__init__(self, parent=parent) + + if parent: + self.setParent(parent) + + # Hover feedback + self.setAcceptHoverEvents(True) + self._hovered = False + + # Init default + self.color = self.__DEFAULT_COLOR__ + + def hoverEnterEvent(self, event=None): + """Lightens background color on mose over""" + QtWidgets.QGraphicsObject.hoverEnterEvent(self, event) + self._hovered = True + self.update() + + def hoverLeaveEvent(self, event=None): + """Resets mouse over background color""" + QtWidgets.QGraphicsObject.hoverLeaveEvent(self, event) + self._hovered = False + self.update() + + def boundingRect(self): + """ + Needed override: + Returns the bounding rectangle for the graphic item + """ + return self.shape().boundingRect() + + def itemChange(self, change, value): + """itemChange update behavior""" + # Catch position update + if change == QtWidgets.QGraphicsItem.ItemPositionChange: + # Force scene update to prevent "ghosts" + # (ghost happen when the previous polygon is out of + # the new bounding rect when updating) + if self.scene(): + self.scene().update() + + # Run default action + return QtWidgets.QGraphicsObject.itemChange(self, change, value) + + def get_color(self): + """Get polygon color""" + return QtGui.QColor(self.color) + + def set_color(self, color=None): + """Set polygon color""" + if not color: + color = QtGui.QColor(0, 0, 0, 255) + elif isinstance(color, (list, tuple)): + color = QtGui.QColor(*color) + + msg = "input color '{}' is invalid".format(color) + assert isinstance(color, QtGui.QColor), msg + + self.color = color + self.update() + + return color + + +class PointHandle(DefaultPolygon): + """Handle polygon object to move picker polygon cvs""" + + __DEFAULT_COLOR__ = QtGui.QColor(30, 30, 30, 200) + + def __init__(self, x=0, y=0, size=8, color=None, parent=None, index=0): + + DefaultPolygon.__init__(self, parent) + + # Make movable + self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable) + self.setFlag(QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges) + self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) + + # Set values + self.setPos(x, y) + self.index = index + self.size = size + self.set_color() + self.draw_index = False + + # Hide by default + self.setVisible(False) + + # Add index element + self.index = PointHandleIndex(parent=self, index=index) + + # ========================================================================= + # Default python methods + # ========================================================================= + def _new_pos_handle_copy(self, pos): + """Return a new PointHandle isntance with same attributes + but different position + """ + new_handle = PointHandle( + x=pos.x(), + y=pos.y(), + size=self.size, + color=self.color, + parent=self.parentObject(), + ) + return new_handle + + def _get_pos_for_input(self, other): + if isinstance(other, PointHandle): + return other.pos() + return other + + def __add__(self, other): + other = self._get_pos_for_input(other) + new_pos = self.pos() + other + return self._new_pos_handle_copy(new_pos) + + def __sub__(self, other): + other = self._get_pos_for_input(other) + new_pos = self.pos() - other + return self._new_pos_handle_copy(new_pos) + + def __div__(self, other): + other = self._get_pos_for_input(other) + new_pos = self.pos() / other + return self._new_pos_handle_copy(new_pos) + + def __mul__(self, other): + other = self._get_pos_for_input(other) + new_pos = self.pos() / other + return self._new_pos_handle_copy(new_pos) + + # ========================================================================= + # QT OVERRIDES + # ========================================================================= + def setX(self, value=0): + """Override to support keyword argument for spin_box callback""" + DefaultPolygon.setX(self, value) + + def setY(self, value=0): + """Override to support keyword argument for spin_box callback""" + DefaultPolygon.setY(self, value) + + # ========================================================================= + # Graphic item methods + # ========================================================================= + def shape(self): + """Return default handle square shape based on specified size""" + path = QtGui.QPainterPath() + rectangle = QtCore.QRectF( + QtCore.QPointF(-self.size / 2.0, self.size / 2.0), + QtCore.QPointF(self.size / 2.0, -self.size / 2.0), + ) + # path.addRect(rectangle) + path.addEllipse(rectangle) + return path + + def paint(self, painter, options, widget=None): + """Paint graphic item""" + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + # Get polygon path + path = self.shape() + + # Set node background color + brush = QtGui.QBrush(self.color) + if self._hovered: + brush = QtGui.QBrush(self.color.lighter(500)) + + # Paint background + painter.fillPath(path, brush) + + border_pen = QtGui.QPen(QtGui.QColor(200, 200, 200, 255)) + painter.setPen(border_pen) + + # Paint Borders + painter.drawPath(path) + + # if not edit_mode: return + # Paint center cross + cross_size = self.size / 2 - 2 + painter.setPen(QtGui.QColor(0, 0, 0, 180)) + painter.drawLine(-cross_size, 0, cross_size, 0) + painter.drawLine(0, cross_size, 0, -cross_size) + + def mirror_x_position(self): + """will mirror local x position value""" + self.setX(-1 * self.x()) + + def scale_pos(self, x=1.0, y=1.0): + """Scale handle local position""" + self.setPos(self.pos().x() * x, self.pos().y() * y) + self.update() + + def enable_index_draw(self, status=False): + self.index.setVisible(status) + + def set_index(self, index): + self.index.setText(index) + + def get_index(self): + return int(self.index.text()) + + +class Polygon(DefaultPolygon): + """ + Picker controls visual graphic object + (inherits from QtWidgets.QGraphicsObject rather + than QtWidgets.QGraphicsItem for signal support) + """ + + __DEFAULT_COLOR__ = QtGui.QColor(200, 200, 200, 180) + __DEFAULT_SELECT_COLOR__ = QtGui.QColor(230, 230, 230, 240) + + def __init__(self, parent=None, points=[], color=None): + + DefaultPolygon.__init__(self, parent=parent) + self.points = points + self.set_color(Polygon.__DEFAULT_COLOR__) + + self._edit_status = False + self.selected = False + + def set_edit_status(self, status=False): + self._edit_status = status + self.update() + + def shape(self): + """Override function to return proper "hit box", + and compute shape only once. + """ + path = QtGui.QPainterPath() + + # Polygon case + if len(self.points) > 2: + # Define polygon points for closed loop + shp_points = [] + for handle in self.points: + shp_points.append(handle.pos()) + shp_points.append(self.points[0].pos()) + + # Draw polygon + polygon = QtGui.QPolygonF(shp_points) + + # Update path + path.addPolygon(polygon) + + # Circle case + else: + center = self.points[0].pos() + radius = QtGui.QVector2D( + self.points[0].pos() - self.points[1].pos() + ).length() + + # Update path + path.addEllipse( + center.x() - radius, + center.y() - radius, + radius * 2, + radius * 2, + ) + + return path + + def paint(self, painter, options, widget=None): + """Paint graphic item""" + # Set render quality + painter.setRenderHint(QtGui.QPainter.Antialiasing) + + # Get polygon path + path = self.shape() + + # Background color + color = QtGui.QColor(self.color) + if self._hovered: + color = color.lighter(130) + brush = QtGui.QBrush(color) + + painter.fillPath(path, brush) + + # Add white layer color overlay on selected state + if self.selected: + color = QtGui.QColor(255, 255, 255, 50) + brush = QtGui.QBrush(color) + painter.fillPath(path, brush) + + # Border status feedback + border_pen = QtGui.QPen(self.__DEFAULT_SELECT_COLOR__) + border_pen.setWidthF(2) + + if self.selected: + painter.setPen(border_pen) + painter.drawPath(path) + + elif self._hovered: + border_pen.setStyle(QtCore.Qt.DashLine) + painter.setPen(border_pen) + painter.drawPath(path) + + # Stop her if not in edit mode + if not self._edit_status: + return + + # Paint center cross + painter.setRenderHints(QtGui.QPainter.Antialiasing, False) + painter.setPen(QtGui.QColor(0, 0, 0, 180)) + painter.drawLine(-5, 0, 5, 0) + painter.drawLine(0, 5, 0, -5) + + def set_selected_state(self, state): + """Will set border color feedback based on selection state""" + # Do nothing on same state + if state == self.selected: + return + + # Change state, and update + self.selected = state + self.update() + + def set_color(self, color): + # Run default method + color = DefaultPolygon.set_color(self, color) + + # Store new color as default + Polygon.__DEFAULT_COLOR__ = color + + +class PointHandleIndex(QtWidgets.QGraphicsSimpleTextItem): + """Point handle index text element""" + + __DEFAULT_COLOR__ = QtGui.QColor(130, 50, 50, 255) + + def __init__(self, parent=None, scene=None, index=0): + QtWidgets.QGraphicsSimpleTextItem.__init__(self, parent, scene) + + # Init defaults + self.set_size() + self.set_color(PointHandleIndex.__DEFAULT_COLOR__) + self.setPos(QtCore.QPointF(-9, -14)) + self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) + + # Hide by default + self.setVisible(False) + + self.setText(index) + + def set_size(self, value=8.0): + """Set pointSizeF for text""" + font = self.font() + font.setPointSizeF(value) + self.setFont(font) + + def set_color(self, color=None): + """Set text color""" + if not color: + return + brush = self.brush() + brush.setColor(color) + self.setBrush(brush) + + def setText(self, text): + """Override default setText method to force unicode on int index input""" + return QtWidgets.QGraphicsSimpleTextItem.setText(self, str(text)) + + +class GraphicText(QtWidgets.QGraphicsSimpleTextItem): + """Picker item text element""" + + __DEFAULT_COLOR__ = QtGui.QColor(30, 30, 30, 255) + + def __init__(self, parent=None, scene=None): + QtWidgets.QGraphicsSimpleTextItem.__init__(self, parent, scene) + + # Counter view scale + self.scale_transform = QtGui.QTransform().scale(1, -1) + self.setTransform(self.scale_transform) + + # Init default size + self.set_size() + self.set_color(GraphicText.__DEFAULT_COLOR__) + + def set_text(self, text): + """ + Set current text + (Will center text on parent too) + """ + self.setText(text) + self.center_on_parent() + + def get_text(self): + """Return element text""" + return str(self.text()) + + def set_size(self, value=10.0): + """Set pointSizeF for text""" + font = self.font() + font.setPointSizeF(value) + self.setFont(font) + self.center_on_parent() + + def get_size(self): + """Return text pointSizeF""" + return self.font().pointSizeF() + + def get_color(self): + """Return text color""" + return self.brush().color() + + def set_color(self, color=None): + """Set text color""" + if not color: + return + brush = self.brush() + brush.setColor(color) + self.setBrush(brush) + + # Store new color as default color + GraphicText.__DEFAULT_COLOR__ = color + + def center_on_parent(self): + """ + Center text on parent item + (Since by default the text start on the bottom left corner) + """ + center_pos = self.boundingRect().center() + # self.setPos(-center_pos * self.scale_transform) + scale_xy = QtCore.QPointF(center_pos.x(), center_pos.y() * -1) + self.setPos(-scale_xy) + + diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py new file mode 100644 index 00000000..078959e7 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -0,0 +1,107 @@ +"""Qt/Maya-free data model for a picker item. + +`PickerItemData` mirrors the picker-item dictionary schema used by ``.pkr`` +files and the ``PICKER_DATAS`` node, and is the serialization authority for a +``PickerItem`` (``get_data`` / ``set_data`` delegate to ``to_dict`` / +``from_dict``). It has no Qt or Maya dependency, so it can be constructed and +round-tripped for import/export without a running DCC. + +Fields map 1:1 to the schema keys: + color RGBA tuple + position [x, y] + rotation float + handles list of [x, y] + action_mode bool (custom action script mode) + action_script str + controls list of control names + menus list of custom menu data + text str + text_size float + text_color RGBA tuple +""" + + +class PickerItemData(object): + """Serializable data model for a single picker item.""" + + def __init__(self): + self.color = None + self.position = [0, 0] + self.rotation = 0.0 + self.handles = [] + self.action_mode = False + self.action_script = None + self.controls = [] + self.menus = [] + self.text = None + self.text_size = None + self.text_color = None + + @classmethod + def from_dict(cls, data): + """Build a model from a picker-item dictionary. + + Args: + data (dict): picker item data (may be a partial subset, as used by + copy/paste). + + Returns: + PickerItemData: populated model. + """ + model = cls() + data = data or {} + + if "color" in data: + model.color = tuple(data["color"]) + if "position" in data: + model.position = list(data.get("position", [0, 0])) + if "rotation" in data: + model.rotation = data.get("rotation") + if "handles" in data: + model.handles = [list(handle) for handle in data["handles"]] + if data.get("action_mode", False): + model.action_mode = True + model.action_script = data.get("action_script", None) + if "controls" in data: + model.controls = list(data["controls"]) + if "menus" in data: + model.menus = data["menus"] + if "text" in data: + model.text = data["text"] + model.text_size = data.get("text_size") + if "text_color" in data: + model.text_color = tuple(data["text_color"]) + + return model + + def to_dict(self): + """Serialize the model back to the picker-item dictionary schema. + + Only the keys that the legacy ``PickerItem.get_data`` emitted are + produced, in the same order, so the round-trip is byte-compatible. + + Returns: + dict: picker item data. + """ + data = {} + data["color"] = self.color + data["position"] = list(self.position) + data["rotation"] = self.rotation + data["handles"] = [list(handle) for handle in self.handles] + + if self.action_mode: + data["action_mode"] = True + data["action_script"] = self.action_script + + if self.controls: + data["controls"] = self.controls + + if self.menus: + data["menus"] = self.menus + + if self.text: + data["text"] = self.text + data["text_size"] = self.text_size + data["text_color"] = self.text_color + + return data diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py new file mode 100644 index 00000000..27d72d9f --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -0,0 +1,1051 @@ +"""Picker item graphic object and selection helper. + +Extracted from picker_widgets.py during the Phase 2 decomposition. +""" + +import re +import copy +import uuid + +from math import pi +from math import sin +from math import cos + +import maya.cmds as cmds + +from mgear.core import pyqt +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets.graphics import DefaultPolygon +from mgear.anim_picker.widgets.graphics import PointHandle +from mgear.anim_picker.widgets.graphics import Polygon +from mgear.anim_picker.widgets.graphics import GraphicText +from mgear.anim_picker.widgets.dialogs.item_options import ItemOptionsWindow +from mgear.anim_picker.widgets.dialogs.search_replace_dialog import ( + SearchAndReplaceDialog, +) +from mgear.anim_picker.widgets.dialogs.copy_paste_dialog import DataCopyDialog +from mgear.anim_picker.widgets.item_model import PickerItemData +from mgear.anim_picker.handlers import __EDIT_MODE__ +from mgear.anim_picker.handlers import __SELECTION__ +from mgear.anim_picker.handlers import python_handlers +from mgear.anim_picker.handlers import maya_handlers + + +def select_picker_controls(picker_items, event, modifiers=None): + if __EDIT_MODE__.get(): + return + if modifiers: + modifiers = modifiers + else: + modifiers = event.modifiers() + modifier = None + + # Shift cases (toggle) + if modifiers == QtCore.Qt.ShiftModifier: + modifier = "shift" + + # Controls case + if modifiers == QtCore.Qt.ControlModifier: + modifier = "control" + + # Alt case (remove) + if modifiers == QtCore.Qt.AltModifier: + modifier = "alt" + + picker_controls = [] + for pItem in picker_items: + picker_controls.extend(pItem.get_controls()) + maya_handlers.select_nodes(picker_controls, modifier=modifier) + + + +class PickerItem(DefaultPolygon): + """Main picker graphic item container""" + + def __init__( + self, parent=None, point_count=4, namespace=None, main_window=None + ): + DefaultPolygon.__init__(self, parent=parent) + self.point_count = point_count + + self.setPos(25, 30) + + # Make item movable + if __EDIT_MODE__.get(): + self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable) + self.setFlag(QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges) + + # Default vars + self.namespace = namespace + self.main_window = main_window + self._edit_status = False + self.edit_window = None + + # Add polygon + self.polygon = Polygon(parent=self) + + # Add text + self.text = GraphicText(parent=self) + + # Add handles + self.handles = [] + self.set_handles(self.get_default_handles()) + + # Controls vars + self.controls = [] + self.custom_menus = [] + + # Custom action + self.custom_action = False + self.custom_action_script = None + + # uuid & undo + self.uuid = uuid.uuid4() + + def shape(self): + path = QtGui.QPainterPath() + + if self.polygon: + path.addPath(self.polygon.shape()) + + # Stop here in default mode + if not self._edit_status: + return path + + # Add handles to shape + for handle in self.handles: + path.addPath(handle.mapToParent(handle.shape())) + + return path + + def paint(self, painter, *args, **kwargs): + pass + # for debug only + # # Set render quality + # painter.setRenderHint(QtGui.QPainter.Antialiasing) + + # # Get polygon path + # path = self.shape() + + # # Set node background color + # brush = QtGui.QBrush(QtGui.QColor(0,0,200,255)) + + # # Paint background + # painter.fillPath(path, brush) + + # border_pen = QtGui.QPen(QtGui.QColor(0,200,0,255)) + # painter.setPen(border_pen) + + # # Paint Borders + # painter.drawPath(path) + + def get_default_handles(self): + """ + Generate default point handles coordinate for polygon + (on circle) + """ + unit_scale = 20 + handles = [] + + # Circle case + if self.point_count == 2: + handle_a = PointHandle(x=0.0, y=0.0, parent=self, index=1) + handle_b = PointHandle( + x=1.0 * unit_scale, y=0.0, parent=self, index=2 + ) + handles = [handle_a, handle_b] + + else: + # Define angle step + angle_step = pi * 2 / self.point_count + + # Generate point coordinates + for i in range(0, self.point_count): + x = sin(i * angle_step + pi / self.point_count) * unit_scale + y = cos(i * angle_step + pi / self.point_count) * unit_scale + handle = PointHandle(x=x, y=y, parent=self, index=i + 1) + handles.append(handle) + + return handles + + def edit_point_count(self, value=4): + """ + Change/edit the number of points for the polygon + (that will reset the shape) + """ + # Update point count + self.point_count = value + + # Reset points + points = self.get_default_handles() + self.set_handles(points) + + def get_handles(self): + """Return picker item handles""" + return self.handles + + def set_handles(self, handles=[]): + """Set polygon handles points""" + # Remove existing handles + for handle in self.handles: + handle.setParent(None) + handle.deleteLater() + + # Parse input type + new_handles = [] + # start index at 1 since table Widget raw are indexed at 1 + index = 1 + + for handle in handles: + if isinstance(handle, (list, tuple)): + handle = PointHandle( + x=handle[0], y=handle[1], parent=self, index=index + ) + elif hasattr(handle, "x") and hasattr(handle, "y"): + handle = PointHandle( + x=handle.x(), y=handle.y(), parent=self, index=index + ) + new_handles.append(handle) + index += 1 + + # Update handles list + self.handles = new_handles + self.polygon.points = new_handles + + # Set current visibility status + for handle in self.handles: + handle.setVisible(self.get_edit_status()) + + # Set new point count + self.point_count = len(self.handles) + + # ========================================================================= + # Mouse events --- + def hoverEnterEvent(self, event=None): + """Update tooltip on hoover with associated controls in edit mode""" + if __EDIT_MODE__.get(): + text = "\n".join(self.get_controls()) + self.setToolTip(text) + super().hoverEnterEvent(event) + + def mouseMoveEvent_offset(self, event): + self.setPos(event.scenePos() + self.cursor_delta) + + def mouseMoveEvent(self, event): + gfx_event = event + if event.buttons() == QtCore.Qt.LeftButton and __EDIT_MODE__.get(): + if self.currently_selected: + [ + item.mouseMoveEvent_offset(event) + for item in self.currently_selected + ] + super().mouseMoveEvent(gfx_event) + + def mousePressEvent(self, event): + """Event called on mouse press""" + # Simply run default event in edit mode, and exit + if __EDIT_MODE__.get(): + self.get_delta_from_point(event.pos()) + # this allows for maintaining offset while dragging multiple + self.currently_selected = [ + item + for item in self.parent().get_picker_items() + if item.polygon.selected + ] + if self.currently_selected: + if self in self.currently_selected: + self.currently_selected.remove(self) + [ + item.get_delta_from_point(event.scenePos()) + for item in self.currently_selected + ] + return DefaultPolygon.mousePressEvent(self, event) + + # Run selection on left mouse button event + if event.buttons() == QtCore.Qt.LeftButton: + # Run custom script action + if self.get_custom_action_mode(): + self.mouse_press_custom_action(event) + # Run default selection action + else: + select_picker_controls([self], event, modifiers=None) + + # Set focus to maya window + maya_window = pyqt.maya_main_window() + if maya_window: + maya_window.setFocus() + + def mouse_press_select_event(self, event, modifiers=None): + """ + Default select event on mouse press. + Will select associated controls + """ + # Get keyboard modifier + # Simply run default event in edit mode, and exit + if __EDIT_MODE__.get(): + return + if modifiers: + modifiers = modifiers + else: + modifiers = event.modifiers() + modifier = None + + # Shift cases (toggle) + if modifiers == QtCore.Qt.ShiftModifier: + modifier = "shift" + + # Controls case + if modifiers == QtCore.Qt.ControlModifier: + modifier = "control" + + # Alt case (remove) + if modifiers == QtCore.Qt.AltModifier: + modifier = "alt" + + # Call action + self.select_associated_controls(modifier=modifier) + + def mouse_press_custom_action(self, event): + """Custom script action on mouse press""" + # Run custom action script with picker item environnement + python_handlers.safe_code_exec( + self.get_custom_action_script(), env=self.get_exec_env() + ) + + def mouseDoubleClickEvent(self, event): + """Event called when mouse is double clicked""" + if not __EDIT_MODE__.get(): + return + + self.edit_options() + + def contextMenuEvent(self, event): + """Right click menu options""" + # Context menu for edition mode + if __EDIT_MODE__.get(): + self.edit_context_menu(event) + + # Context menu for default mode + else: + self.default_context_menu(event) + + # Force call release method + # self.mouseReleaseEvent(event) + return True + + def edit_context_menu(self, event): + """Context menu (right click) in edition mode""" + # Init context menu + menu = QtWidgets.QMenu(self.parent()) + + # Build edit context menu + options_action = QtWidgets.QAction("Options", None) + options_action.triggered.connect(self.edit_options) + menu.addAction(options_action) + + handles_action = QtWidgets.QAction("Toggle handles", None) + handles_action.triggered.connect(self.toggle_edit_status) + menu.addAction(handles_action) + + menu.addSeparator() + + # Shape options menu + shape_menu = QtWidgets.QMenu(menu) + shape_menu.setTitle("Shape") + + move_action = QtWidgets.QAction("Move to center", None) + move_action.triggered.connect(self.move_to_center) + shape_menu.addAction(move_action) + + shp_mirror_action = QtWidgets.QAction("Mirror shape", None) + shp_mirror_action.triggered.connect(self.mirror_shape) + shape_menu.addAction(shp_mirror_action) + + color_mirror_action = QtWidgets.QAction("Mirror color", None) + color_mirror_action.triggered.connect(self.mirror_color) + shape_menu.addAction(color_mirror_action) + + menu.addMenu(shape_menu) + + move_back_action = QtWidgets.QAction("Move to back", None) + move_back_action.triggered.connect(self.move_to_back) + menu.addAction(move_back_action) + + move_front_action = QtWidgets.QAction("Move to front", None) + move_front_action.triggered.connect(self.move_to_front) + menu.addAction(move_front_action) + + menu.addSeparator() + + # Copy handling + copy_action = QtWidgets.QAction("Copy", None) + copy_action.triggered.connect(self.copy_event) + menu.addAction(copy_action) + + paste_action = QtWidgets.QAction("Paste", None) + if DataCopyDialog.__DATA__: + paste_action.triggered.connect(self.past_event) + else: + paste_action.setEnabled(False) + menu.addAction(paste_action) + + paste_options_action = QtWidgets.QAction("Paste Options", None) + if DataCopyDialog.__DATA__: + paste_options_action.triggered.connect(self.past_option_event) + else: + paste_options_action.setEnabled(False) + menu.addAction(paste_options_action) + + menu.addSeparator() + + # Paste position actions + paste_x_action = QtWidgets.QAction("Paste Pos X", None) + if DataCopyDialog.__DATA__: + paste_x_action.triggered.connect(self.past_x_event) + else: + paste_x_action.setEnabled(False) + menu.addAction(paste_x_action) + + paste_y_action = QtWidgets.QAction("Paste Pos Y", None) + if DataCopyDialog.__DATA__: + paste_y_action.triggered.connect(self.past_y_event) + else: + paste_y_action.setEnabled(False) + menu.addAction(paste_y_action) + + menu.addSeparator() + + # Duplicate options + duplicate_action = QtWidgets.QAction("Duplicate", None) + duplicate_action.triggered.connect(self.duplicate_selected) + menu.addAction(duplicate_action) + + mirror_dup_action = QtWidgets.QAction("Duplicate/mirror", None) + mirror_dup_action.triggered.connect(self.duplicate_and_mirror_selected) + menu.addAction(mirror_dup_action) + + menu.addSeparator() + + # Delete + remove_action = QtWidgets.QAction("Remove", None) + remove_action.triggered.connect(self.remove_selected) + menu.addAction(remove_action) + + menu.addSeparator() + + # Control association + ctrls_menu = QtWidgets.QMenu(menu) + ctrls_menu.setTitle("Ctrls Association") + + select_action = QtWidgets.QAction("Select", None) + select_action.triggered.connect(self.select_associated_controls) + ctrls_menu.addAction(select_action) + + select_all_action = QtWidgets.QAction("Select all", None) + select_all_action.triggered.connect( + self.select_all_associated_controls + ) + ctrls_menu.addAction(select_all_action) + + replace_action = QtWidgets.QAction("Replace with selection", None) + replace_action.triggered.connect(self.replace_controls_selection) + ctrls_menu.addAction(replace_action) + + menu.addMenu(ctrls_menu) + + # Open context menu under mouse + # offset position to prevent accidental mouse release on menu + # OFFSET + offseted_pos = event.pos() + QtCore.QPoint(5, 0) + menu.exec_(offseted_pos) + return True + + def default_context_menu(self, event): + """Context menu (right click) out of edition mode (animation)""" + # Init context menu + menu = QtWidgets.QMenu(self.parent()) + + # Add reset action + # reset_action = QtWidgets.QAction("Reset", None) + # reset_action.triggered.connect(self.active_control.reset_to_bind_pose) + # menu.addAction(reset_action) + + # Add custom actions + actions = self._get_custom_action_menus() + for action in actions: + menu.addAction(action) + + # Abort on empty menu + if menu.isEmpty(): + return + + # Open context menu under mouse + # offset position to prevent accidental mouse release on menu + offseted_pos = event.pos() + QtCore.QPoint(5, 0) + # scene_pos = self.mapToScene(offseted_pos) + # view_pos = self.parent().mapFromScene(scene_pos) + # screen_pos = self.parent().mapToGlobal(view_pos) + menu.exec_(offseted_pos) + + def get_init_env(self): + env = self.get_exec_env() + env["__INIT__"] = True + + return env + + def get_exec_env(self): + """ + Will return proper environnement dictionnary for eval execs + (Will provide related controls as __CONTROLS__ + and __NAMESPACE__ variables) + """ + # Init env + env = {} + + # Add controls vars + env["__CONTROLS__"] = self.get_controls() + ctrls = self.get_controls() + env["__FLATCONTROLS__"] = maya_handlers.get_flattened_nodes(ctrls) + env["__NAMESPACE__"] = self.get_namespace() + env["__SELF__"] = self + env["__INIT__"] = False + + return env + + def _get_custom_action_menus(self): + # Init action list to fix loop problem where qmenu only + # show last action when using the same variable name ... + actions = [] + + # Define custom exec cmd wrapper + def wrapper(cmd): + def custom_eval(*args, **kwargs): + python_handlers.safe_code_exec(cmd, env=self.get_exec_env()) + + return custom_eval + + # Get active controls custom menus + custom_data = self.get_custom_menus() + if not custom_data: + return actions + + # Build menu + for i in range(len(custom_data)): + actions.append(QtWidgets.QAction(custom_data[i][0], None)) + actions[i].triggered.connect(wrapper(custom_data[i][1])) + + return actions + + # ========================================================================= + # Edit picker item options --- + def edit_options(self): + """Open Edit options window""" + # Delete old window + if self.edit_window: + try: + self.edit_window.close() + self.edit_window.deleteLater() + except Exception: + pass + + # Init new window + self.edit_window = ItemOptionsWindow( + parent=self.main_window, picker_item=self + ) + + # Show window + self.edit_window.show() + self.edit_window.raise_() + + def set_edit_status(self, status): + """Set picker item edit status (handle visibility etc.)""" + self._edit_status = status + + for handle in self.handles: + handle.setVisible(status) + + self.polygon.set_edit_status(status) + + def get_edit_status(self): + return self._edit_status + + def toggle_edit_status(self): + """Will toggle handle visibility status""" + self.set_edit_status(not self._edit_status) + + # ========================================================================= + # Properties methods --- + def get_color(self): + """Get polygon color""" + return self.polygon.get_color() + + def set_color(self, color=None): + """Set polygon color""" + self.polygon.set_color(color) + + # ========================================================================= + # Text handling --- + def get_text(self): + return self.text.get_text() + + def set_text(self, text): + self.text.set_text(text) + + def get_text_color(self): + return self.text.get_color() + + def set_text_color(self, color): + self.text.set_color(color) + + def get_text_size(self): + return self.text.get_size() + + def set_text_size(self, size): + self.text.set_size(size) + + # ========================================================================= + # Scene Placement --- + def move_to_front(self): + """Move picker item to scene front""" + # Get current scene + scene = self.scene() + + # Move to temp scene + tmp_scene = QtWidgets.QGraphicsScene() + tmp_scene.addItem(self) + + # Add to current scene (will be put on top) + scene.addItem(self) + + # Clean + tmp_scene.deleteLater() + + def move_to_back(self): + """Move picker item to background level behind other items""" + # Get picker Items + picker_items = self.scene().get_picker_items() + + # Reverse list since items are returned front to back + picker_items.reverse() + + # Move current item to front of list (back) + picker_items.remove(self) + picker_items.insert(0, self) + + # Move each item in proper oder to front of scene + # That will add them in the proper order to the scene + for item in picker_items: + item.move_to_front() + + def move_to_center(self): + """Move picker item to pos 0,0""" + self.setPos(0, 0) + + def remove_selected(self): + selected_pickers = self.scene().get_selected_items() + if self not in selected_pickers: + selected_pickers.append(self) + [picker.remove() for picker in selected_pickers] + + def remove(self): + self.scene().removeItem(self) + self.setParent(None) + self.deleteLater() + + def get_delta_from_point(self, point): + self.cursor_delta = self.pos() - point + return self.cursor_delta + + # ========================================================================= + # Ducplicate and mirror methods --- + def mirror_position(self): + """Mirror picker position (on X axis)""" + self.setX(-1 * self.pos().x()) + + def mirror_rotation(self, angle=None): + """Mirror picker rotation angle""" + if not angle: + angle = self.rotation() + + if angle > 360: + angle = angle - 360 + + mirror_angle = abs(angle - 360) + + self.setRotation(mirror_angle) + self.update() + + def mirror_shape(self): + """Will mirror polygon handles position on X axis""" + for handle in self.handles: + handle.mirror_x_position() + + def mirror_color(self): + """Will reverse red/bleu rgb values for the polygon color""" + old_color = self.get_color() + new_color = QtGui.QColor( + old_color.blue(), + old_color.green(), + old_color.red(), + ) + new_color.setAlpha(old_color.alpha()) + self.set_color(new_color) + + def duplicate_selected(self, *args, **kwargs): + selected_pickers = self.scene().get_selected_items() + if self not in selected_pickers: + selected_pickers.append(self) + new_pickers = [] + for picker in selected_pickers: + new_picker = picker.duplicate() + offset_x = (picker.boundingRect().width()) + 5 + new_pos = QtCore.QPointF( + picker.pos().x() + offset_x, picker.pos().y() + ) + new_picker.setPos(new_pos) + new_pickers.append(new_picker) + self.scene().select_picker_items(new_pickers) + + def duplicate(self, *args, **kwargs): + """Will create a new picker item and copy data over.""" + # Create new picker item + new_item = PickerItem() + new_item.setParent(self.parent()) + self.scene().addItem(new_item) + + # Copy data over + data = copy.deepcopy(self.get_data()) + new_item.set_data(data) + + return new_item + + def duplicate_and_mirror_selected(self): + selected_pickers = self.scene().get_selected_items() + if self not in selected_pickers: + selected_pickers.append(self) + + search = None + replace = None + new_pickers = [] + for picker in selected_pickers: + if picker.get_controls() and not search and not replace: + search, replace, ok = SearchAndReplaceDialog.get() + if not ok: + break + new_picker = picker.duplicate_and_mirror(search, replace) + new_pickers.append(new_picker) + self.scene().select_picker_items(new_pickers) + + def duplicate_and_mirror(self, search=None, replace=None): + """Duplicate and mirror picker item""" + new_item = self.duplicate() + new_item.mirror_color() + new_item.mirror_position() + new_item.mirror_shape() + + angle = self.rotation() + new_item.mirror_rotation(angle) + + if self.get_controls(): + new_item.search_and_replace_controls( + search=search, replace=replace + ) + return new_item + + def paste_pos(self, x=True, y=False): + """Paste the position x and y of a picker + + Args: + x (bool, optional): if true paste X position + y (bool, optional): if true paste Y position + """ + selected_pickers = self.scene().get_selected_items() + if self not in selected_pickers: + selected_pickers.append(self) + for picker in selected_pickers: + DataCopyDialog.set_pos(picker, x, y) + + def copy_event(self): + """Store pickerItem data for copy/paste support""" + DataCopyDialog.get(self) + + def past_event(self): + """Apply previously stored pickerItem data""" + selected_pickers = self.scene().get_selected_items() + if self not in selected_pickers: + selected_pickers.append(self) + for picker in selected_pickers: + DataCopyDialog.set(picker) + + def past_x_event(self): + """Paste X position""" + self.paste_pos(x=True, y=False) + + def past_y_event(self): + """Paste Y position""" + self.paste_pos(x=False, y=True) + + def past_option_event(self): + """Will open Paste option dialog window""" + DataCopyDialog.options(self) + + # ========================================================================= + # Transforms --- + def scale_shape(self, x=1.0, y=1.0, world=False): + """Will scale shape based on axis x/y factors""" + # Scale handles + for handle in self.handles: + handle.scale_pos(x, y) + + # Scale position + if world: + self.setPos(self.pos().x() * x, self.pos().y() * y) + + self.update() + + def rotate_shape(self, angle): + """Rotate shape based on item center""" + angle = self.rotation() + angle + if angle > 360: + angle = angle - 360 + + self.setRotation(angle) + self.update() + + def reset_rotation(self): + """Reset rotation""" + self.setRotation(0) + self.update() + + # ========================================================================= + # Custom action handling --- + def get_custom_action_mode(self): + return self.custom_action + + def set_custom_action_mode(self, state): + self.custom_action = state + + def set_custom_action_script(self, cmd): + self.custom_action_script = cmd + + def get_custom_action_script(self): + return self.custom_action_script + + # ========================================================================= + # Controls handling --- + def get_namespace(self): + """Will return associated namespace""" + return self.namespace + + def set_control_list(self, ctrls=[]): + """Update associated control list""" + self.controls = ctrls + + def get_controls(self, with_namespace=True): + """Return associated controls""" + # Returned controls without namespace (as data stored) + if not with_namespace: + return self.controls + + # Get namespace + namespace = self.get_namespace() + + # No namespace, return nodes + if not namespace: + return self.controls + + # Prefix nodes with namespace + nodes = [] + for node in self.controls: + nodes.append("{}:{}".format(namespace, node)) + + return nodes + + def append_control(self, ctrl): + """Add control to list""" + self.controls.append(ctrl) + + def remove_control(self, ctrl): + """Remove control from list""" + if ctrl not in self.controls: + return + self.controls.remove(ctrl) + + def search_and_replace_controls(self, search=None, replace=None): + """Will search and replace in associated controls names + + Args: + search (str, optional): search string + replace (str, optional): what to replace with + + Returns: + Bool: if successful + """ + # Open Search and replace dialog window + ok = True + if not search or not replace: + search, replace, ok = SearchAndReplaceDialog.get() + + if not ok: + return False + + # Parse controls + node_missing = False + controls = self.get_controls()[:] + for i, ctrl in enumerate(controls): + controls[i] = re.sub(search, replace, ctrl) + if not cmds.objExists(controls[i]): + node_missing = True + + # Print warning + if node_missing: + QtWidgets.QMessageBox.warning( + self.parent(), "Warning", "Some target controls do not exist" + ) + + # Update list + self.set_control_list(controls) + + return True + + def select_associated_controls(self, modifier=None): + """Will select maya associated controls""" + maya_handlers.select_nodes(self.get_controls(), modifier=modifier) + + def select_all_associated_controls(self, modifier=None): + """Will select maya associated controls""" + controls = [] + for picker in self.parent().scene().get_selected_items(): + controls.extend(picker.get_controls()) + maya_handlers.select_nodes(controls, modifier=modifier) + + def replace_controls_selection(self): + """Will replace controls association with current selection""" + self.set_control_list([]) + self.add_selected_controls() + + def add_selected_controls(self): + """Add selected controls to control list""" + # Get selection + sel = cmds.ls(sl=True) + + # Add to stored list + for ctrl in sel: + if ctrl in self.get_controls(): + continue + self.append_control(ctrl) + + def is_selected(self): + """ + Will return True if a related control is currently selected + (Only works with polygon that have a single associated maya_node) + """ + # Get controls associated nodes + controls = self.get_controls() + + # Abort if not single control polygon + if not len(controls) == 1: + return False + + # Check + return __SELECTION__.is_selected(controls[0]) + + def set_selected_state(self, state): + """Will set border color feedback based on selection state""" + self.polygon.set_selected_state(state) + + def run_selection_check(self): + """Will set selection state based on selection status""" + self.set_selected_state(self.is_selected()) + + # ========================================================================= + # Custom menus handling --- + def set_custom_menus(self, menus): + """Set custom menu list for current poly data""" + self.custom_menus = list(menus) + + def get_custom_menus(self): + """Return current menu list for current poly data""" + return self.custom_menus + + # ========================================================================= + # Data handling --- + def set_data(self, data): + """Set picker item from data dictionary. + + Values are parsed through the Qt-free ``PickerItemData`` model, while + the per-key presence checks are preserved so partial data (as sent by + copy/paste) only updates the keys it carries. + """ + model = PickerItemData.from_dict(data) + + # Set color + if "color" in data: + self.set_color(QtGui.QColor(*model.color)) + + # Set position + if "position" in data: + self.setPos(*model.position) + + # Set rotation + if "rotation" in data: + self.setRotation(model.rotation) + + # Set handles + if "handles" in data: + self.set_handles(model.handles) + + # Set text (read size/color from the dict to preserve the exact + # legacy behavior when a partial paste carries "text" alone) + if "text" in data: + self.set_text(data["text"]) + self.set_text_size(data["text_size"]) + self.set_text_color(QtGui.QColor(*data["text_color"])) + + # Set action mode + if model.action_mode: + self.set_custom_action_mode(True) + self.set_custom_action_script(model.action_script) + python_handlers.safe_code_exec( + self.get_custom_action_script(), env=self.get_init_env() + ) + + # Set controls + if "controls" in data: + self.set_control_list(model.controls) + + # Set custom menus + if "menus" in data: + self.set_custom_menus(model.menus) + + def get_data(self): + """Get picker item data in dictionary form. + + Reads the item state into the Qt-free ``PickerItemData`` model and + serializes it back to the schema dict. + """ + model = PickerItemData() + model.color = self.get_color().getRgb() + model.position = [self.x(), self.y()] + model.rotation = self.rotation() + model.handles = [[handle.x(), handle.y()] for handle in self.handles] + + if self.get_custom_action_mode(): + model.action_mode = True + model.action_script = self.get_custom_action_script() + + if self.get_controls(): + model.controls = self.get_controls(with_namespace=False) + + if self.get_custom_menus(): + model.menus = self.get_custom_menus() + + if self.get_text(): + model.text = self.get_text() + model.text_size = self.get_text_size() + model.text_color = self.get_text_color().getRgb() + + return model.to_dict() diff --git a/release/scripts/mgear/anim_picker/widgets/picker_widgets.py b/release/scripts/mgear/anim_picker/widgets/picker_widgets.py index e0b8708d..78464030 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_widgets.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_widgets.py @@ -1,2929 +1,53 @@ -import re -import copy -import uuid - -from math import pi -from math import sin -from math import cos - -import maya.cmds as cmds - -from mgear.core import pyqt -from mgear.vendor.Qt import QtGui -from mgear.vendor.Qt import QtCore -from mgear.vendor.Qt import QtWidgets - -from mgear.anim_picker.widgets import basic -from mgear.anim_picker.handlers import ( - __EDIT_MODE__, - __SELECTION__, - python_handlers, - maya_handlers, -) - -# constants ------------------------------------------------------------------- -SCRIPT_DOC_HEADER = """ -# Variable reference for custom script execution on pickers. -# Use the following variables in your code to access related data: -# __CONTROLS__ for picker item associated controls (will return sets and not content). -# __FLATCONTROLS__ for associated controls and control set content. -# __NAMESPACE__ for current picker namespace -# __INIT__ use 'if not' statement to avoid code execution on creation. -# __SELF__ to get access to the PickerItem() instace. (Change color, size, etc) +"""Compatibility shim for the anim picker widgets. +The classes formerly defined here were split into focused modules during the +Phase 2 decomposition. This module re-exports the same objects so existing +imports (e.g. ``picker_widgets.PickerItem``, +``picker_widgets.select_picker_controls``, and +``isinstance(x, picker_widgets.PickerItem)``) keep working unchanged. """ +from mgear.anim_picker.widgets.graphics import DefaultPolygon +from mgear.anim_picker.widgets.graphics import PointHandle +from mgear.anim_picker.widgets.graphics import Polygon +from mgear.anim_picker.widgets.graphics import PointHandleIndex +from mgear.anim_picker.widgets.graphics import GraphicText -# ============================================================================= -# general functions -# ============================================================================= -def select_picker_controls(picker_items, event, modifiers=None): - if __EDIT_MODE__.get(): - return - if modifiers: - modifiers = modifiers - else: - modifiers = event.modifiers() - modifier = None - - # Shift cases (toggle) - if modifiers == QtCore.Qt.ShiftModifier: - modifier = "shift" - - # Controls case - if modifiers == QtCore.Qt.ControlModifier: - modifier = "control" - - # Alt case (remove) - if modifiers == QtCore.Qt.AltModifier: - modifier = "alt" - - picker_controls = [] - for pItem in picker_items: - picker_controls.extend(pItem.get_controls()) - maya_handlers.select_nodes(picker_controls, modifier=modifier) - - -# ============================================================================= -# classes -# ============================================================================= -class CustomScriptEditDialog(QtWidgets.QDialog): - """Custom python script window (used for custom picker item - action and context menu) - """ - - __TITLE__ = "Custom script" - - def __init__(self, parent=None, cmd=None, item=None): - QtWidgets.QDialog.__init__(self, parent) - - self.cmd = cmd - self.picker_item = item - - self.apply = False - self.setup() - - def setup(self): - """Build/Setup the dialog window""" - self.setWindowTitle(self.__TITLE__) - - # Add layout - self.main_layout = QtWidgets.QVBoxLayout(self) - - # Add cmd txt field - self.cmd_widget = QtWidgets.QTextEdit() - if self.cmd: - text = self.cmd - else: - text = SCRIPT_DOC_HEADER - self.cmd_widget.setText(text) - newCursor = self.cmd_widget.textCursor() - newCursor.movePosition(QtGui.QTextCursor.End) - self.cmd_widget.setTextCursor(newCursor) - self.main_layout.addWidget(self.cmd_widget) - - # Add buttons - btn_layout = QtWidgets.QHBoxLayout() - self.main_layout.addLayout(btn_layout) - - ok_btn = basic.CallbackButton(callback=self.accept_event) - ok_btn.setText("Ok") - btn_layout.addWidget(ok_btn) - - cancel_btn = basic.CallbackButton(callback=self.cancel_event) - cancel_btn.setText("Cancel") - btn_layout.addWidget(cancel_btn) - - run_btn = basic.CallbackButton(callback=self.run_event) - run_btn.setText("Run") - btn_layout.addWidget(run_btn) - - self.resize(500, 600) - - def accept_event(self): - """Accept button event""" - self.apply = True - - self.accept() - self.close() - - def cancel_event(self): - """Cancel button event""" - self.apply = False - self.close() - - def run_event(self): - """Run event button""" - cmd_str = str(self.cmd_widget.toPlainText()) - - if self.picker_item: - python_handlers.safe_code_exec( - cmd_str, env=self.picker_item.get_exec_env() - ) - else: - python_handlers.safe_code_exec(cmd_str) - - def get_values(self): - """Return dialog window result values""" - cmd_str = str(self.cmd_widget.toPlainText()) - - return cmd_str, self.apply - - @classmethod - def get(cls, cmd=None, item=None): - """ - Default method used to run the dialog input window - Will open the dialog window and return input texts. - """ - win = cls(cmd=cmd, item=item) - win.exec_() - win.raise_() - return win.get_values() - - -class CustomMenuEditDialog(CustomScriptEditDialog): - """Custom python script window for picker item context menu""" - - __TITLE__ = "Custom Menu" - - def __init__(self, parent=None, name=None, cmd=None, item=None): - - self.name = name - CustomScriptEditDialog.__init__( - self, parent=parent, cmd=cmd, item=item - ) - - def setup(self): - """Add name field to default window setup""" - # Run default setup - CustomScriptEditDialog.setup(self) - - # Add name line edit - name_layout = QtWidgets.QHBoxLayout(self) - - label = QtWidgets.QLabel() - label.setText("Name") - name_layout.addWidget(label) - - self.name_widget = QtWidgets.QLineEdit() - if self.name: - self.name_widget.setText(self.name) - name_layout.addWidget(self.name_widget) - - self.main_layout.insertLayout(0, name_layout) - - def accept_event(self): - """Accept button event, check for name""" - if not self.name_widget.text(): - QtWidgets.QMessageBox.warning( - self, "Warning", "You need to specify a menu name" - ) - return - - self.apply = True - - self.accept() - self.close() - - def get_values(self): - """Return dialog window result values""" - name_str = str(self.name_widget.text()) - cmd_str = str(self.cmd_widget.toPlainText()) - - return name_str, cmd_str, self.apply - - @classmethod - def get(cls, name=None, cmd=None, item=None): - """ - Default method used to run the dialog input window - Will open the dialog window and return input texts. - """ - win = cls(name=name, cmd=cmd, item=item) - win.exec_() - win.raise_() - return win.get_values() - - -class SearchAndReplaceDialog(QtWidgets.QDialog): - """Search and replace dialog window""" - - __SEARCH_STR__ = "_L" - __REPLACE_STR__ = "_R" - - def __init__(self, parent=None): - QtWidgets.QDialog.__init__(self, parent) - - self.apply = False - self.setup() - - def setup(self): - """Build/Setup the dialog window""" - self.setWindowTitle("Search And Replace") - - # Add layout - self.main_layout = QtWidgets.QVBoxLayout(self) - - # Add line edits - self.search_widget = QtWidgets.QLineEdit() - self.search_widget.setText(self.__SEARCH_STR__) - self.main_layout.addWidget(self.search_widget) - - self.replace_widget = QtWidgets.QLineEdit() - self.replace_widget.setText(self.__REPLACE_STR__) - self.main_layout.addWidget(self.replace_widget) - - # Add buttons - btn_layout = QtWidgets.QHBoxLayout() - self.main_layout.addLayout(btn_layout) - - ok_btn = basic.CallbackButton(callback=self.accept_event) - ok_btn.setText("Ok") - btn_layout.addWidget(ok_btn) - - cancel_btn = basic.CallbackButton(callback=self.cancel_event) - cancel_btn.setText("Cancel") - btn_layout.addWidget(cancel_btn) - - ok_btn.setFocus() - - def accept_event(self): - """Accept button event""" - self.apply = True - - self.accept() - self.close() - - def cancel_event(self): - """Cancel button event""" - self.apply = False - self.close() - - def get_values(self): - """Return field values and button choice""" - search_str = str(self.search_widget.text()) - replace_str = str(self.replace_widget.text()) - if self.apply: - SearchAndReplaceDialog.__SEARCH_STR__ = search_str - SearchAndReplaceDialog.__REPLACE_STR__ = replace_str - return search_str, replace_str, self.apply - - @classmethod - def get(cls): - """ - Default method used to run the dialog input window - Will open the dialog window and return input texts. - """ - win = cls() - win.exec_() - win.raise_() - return win.get_values() - - -class ItemOptionsWindow(QtWidgets.QMainWindow): - """Child window to edit shape options""" - - __OBJ_NAME__ = "ctrl_picker_edit_window" - __TITLE__ = "Picker Item Options" - - # ---------------------------------------------------------------------- - # constructor - def __init__(self, parent=None, picker_item=None): - QtWidgets.QMainWindow.__init__(self, parent=parent) - self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) - self.picker_item = picker_item - - # undo ---------------------------------------------------------------- - self.main_view = self.picker_item.scene().parent() - self.tmp_picker_pos_info = {} - self.tmp_picker_pos_info[picker_item.uuid] = [ - picker_item.x(), - picker_item.y(), - picker_item.rotation(), - ] - # undo ---------------------------------------------------------------- - - # Define size - self.default_width = 270 - self.default_height = 140 - - # Run setup - self.setup() - - # Other - self.handles_window = None - self.event_disabled = False - - def setup(self): - """Setup window elements""" - # Main window setting - self.setObjectName(self.__OBJ_NAME__) - self.setWindowTitle(self.__TITLE__) - self.resize(self.default_width, self.default_height) - - # Set size policies - sizePolicy = QtWidgets.QSizePolicy( - QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed - ) - sizePolicy.setHeightForWidth(self.sizePolicy().hasHeightForWidth()) - self.setSizePolicy(sizePolicy) - - # Create main widget - self.main_widget = QtWidgets.QWidget(self) - self.main_layout = QtWidgets.QHBoxLayout(self.main_widget) - - self.left_layout = QtWidgets.QVBoxLayout() - self.main_layout.addLayout(self.left_layout) - - self.right_layout = QtWidgets.QHBoxLayout() - self.main_layout.addLayout(self.right_layout) - - self.control_layout = QtWidgets.QVBoxLayout() - self.control_layout.setContentsMargins(0, 0, 0, 0) - self.right_layout.addLayout(self.control_layout) - - self.setCentralWidget(self.main_widget) - - # Add content - self.add_main_options() - self.add_position_options() - self.add_rotation_options() - self.add_color_options() - self.add_scale_options() - self.add_text_options() - self.add_action_mode_field() - self.add_target_control_field() - self.add_custom_menus_field() - - # Add layouts stretch - self.left_layout.addStretch() - - # Udpate fields - self._update_shape_infos() - self._update_position_infos() - self._update_color_infos() - self._update_text_infos() - self._update_ctrls_infos() - self._update_menus_infos() - - def closeEvent(self, *args, **kwargs): - """Overwriting close event to close child windows too""" - # Close child windows - if self.handles_window: - try: - self.handles_window.close() - except Exception: - pass - - # undo ---------------------------------------------------------------- - current_position = [ - self.picker_item.x(), - self.picker_item.y(), - self.picker_item.rotation(), - ] - - orig_position = self.tmp_picker_pos_info.get( - self.picker_item.uuid, None - ) - if orig_position is not None and orig_position != current_position: - self.tmp_picker_pos_info[self.picker_item.uuid].extend( - current_position - ) - if self.main_view.undo_move_order_index in [-1]: - self.main_view.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - else: - self.main_view.undo_move_order = self.undo_move_order[ - : self.main_view.undo_move_order_index - ] - self.main_view.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - self.undo_move_order_index = -1 - self.tmp_picker_pos_info = {} - # undo ---------------------------------------------------------------- - - QtWidgets.QMainWindow.closeEvent(self, *args, **kwargs) - - def _update_shape_infos(self): - self.event_disabled = True - self.handles_cb.setChecked(self.picker_item.get_edit_status()) - self.count_sb.setValue(self.picker_item.point_count) - self.event_disabled = False - - def _update_position_infos(self): - self.event_disabled = True - position = self.picker_item.pos() - self.pos_x_sb.setValue(position.x()) - self.pos_y_sb.setValue(position.y()) - self.event_disabled = False - - def _update_color_infos(self): - self.event_disabled = True - self._set_color_button(self.picker_item.get_color()) - self.alpha_sb.setValue(self.picker_item.get_color().alpha()) - self.event_disabled = False - - def _update_text_infos(self): - self.event_disabled = True - - # Retrieve et set text field - text = self.picker_item.get_text() - if text: - self.text_field.setText(text) - - # Set text color fields - self._set_text_color_button(self.picker_item.get_text_color()) - self.text_alpha_sb.setValue(self.picker_item.get_text_color().alpha()) - self.event_disabled = False - - def _update_ctrls_infos(self): - self._populate_ctrl_list_widget() - - def _update_menus_infos(self): - self._populate_menu_list_widget() - - def add_main_options(self): - """Add vertex count option""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Main Properties") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Add edit check box - func = self.handles_cb_event - self.handles_cb = basic.CallbackCheckBoxWidget(callback=func) - self.handles_cb.setText("Show handles") - - layout.addWidget(self.handles_cb) - - # Add point count spin box - spin_layout = QtWidgets.QHBoxLayout() - - spin_label = QtWidgets.QLabel() - spin_label.setText("Vtx Count") - spin_layout.addWidget(spin_label) - - point_count = self.picker_item.edit_point_count - self.count_sb = basic.CallBackSpinBox( - callback=point_count, value=self.picker_item.point_count - ) - self.count_sb.setMinimum(2) - spin_layout.addWidget(self.count_sb) - - layout.addLayout(spin_layout) - - # Add handles position button - handle_position = self.edit_handles_position_event - handles_button = basic.CallbackButton(callback=handle_position) - handles_button.setText("Handles Positions") - layout.addWidget(handles_button) - - # Add to main layout - self.left_layout.addWidget(group_box) - - def add_position_options(self): - """Add position field for precise control positioning""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Position") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Get bary-center - position = self.picker_item.pos() - - # Add X position spin box - spin_layout = QtWidgets.QHBoxLayout() - - spin_label = QtWidgets.QLabel() - spin_label.setText("X") - spin_layout.addWidget(spin_label) - - edit_pos_event = self.edit_position_event - self.pos_x_sb = basic.CallBackDoubleSpinBox( - callback=edit_pos_event, value=position.x(), min=-9999 - ) - spin_layout.addWidget(self.pos_x_sb) - - layout.addLayout(spin_layout) - - # Add Y position spin box - spin_layout = QtWidgets.QHBoxLayout() - - label = QtWidgets.QLabel() - label.setText("Y") - spin_layout.addWidget(label) - - self.pos_y_sb = basic.CallBackDoubleSpinBox( - callback=edit_pos_event, value=position.y(), min=-9999 - ) - spin_layout.addWidget(self.pos_y_sb) - - layout.addLayout(spin_layout) - - # Add to main layout - self.left_layout.addWidget(group_box) - - def add_rotation_options(self): - """Add rotation group box options""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Rotation") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Add alpha spin box - spin_layout = QtWidgets.QHBoxLayout() - layout.addLayout(spin_layout) - - label = QtWidgets.QLabel() - label.setText("Angle") - spin_layout.addWidget(label) - - self.rotate_sb = QtWidgets.QDoubleSpinBox() - self.rotate_sb.setValue(15) - self.rotate_sb.setSingleStep(5) - spin_layout.addWidget(self.rotate_sb) - - # Add rotate buttons - btn_layout = QtWidgets.QHBoxLayout() - layout.addLayout(btn_layout) - - btn = basic.CallbackButton(callback=self.rotate_event, rotMinus=True) - btn.setText("Rot-") - btn_layout.addWidget(btn) - - btn = basic.CallbackButton(callback=self.reset_rotate_event) - btn.setText("Reset") - btn_layout.addWidget(btn) - - btn = basic.CallbackButton(callback=self.rotate_event, rotPlus=True) - btn.setText("Rot+") - btn_layout.addWidget(btn) - - # Add to main left layout - self.left_layout.addWidget(group_box) - - def _set_color_button(self, color): - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Button, color) - self.color_button.setPalette(palette) - self.color_button.setAutoFillBackground(True) - - def _set_text_color_button(self, color): - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Button, color) - self.text_color_button.setPalette(palette) - self.text_color_button.setAutoFillBackground(True) - - def add_color_options(self): - """Add color edition field for polygon""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Color options") - - # Add layout - layout = QtWidgets.QHBoxLayout(group_box) - - # Add color button - self.color_button = basic.CallbackButton( - callback=self.change_color_event - ) - - layout.addWidget(self.color_button) - - # Add alpha spin box - layout.addStretch() - - label = QtWidgets.QLabel() - label.setText("Alpha") - layout.addWidget(label) - - alpha_event = self.change_color_alpha_event - alpha_value = self.picker_item.get_color().alpha() - self.alpha_sb = basic.CallBackSpinBox( - callback=alpha_event, value=alpha_value, max=255 - ) - layout.addWidget(self.alpha_sb) - - # Add to main layout - self.left_layout.addWidget(group_box) - - def add_text_options(self): - """Add text option fields""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Text options") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Add Caption text field - self.text_field = basic.CallbackLineEdit(self.set_text_event) - layout.addWidget(self.text_field) - - # Add size factor spin box - spin_layout = QtWidgets.QHBoxLayout() - - spin_label = QtWidgets.QLabel() - spin_label.setText("Size factor") - spin_layout.addWidget(spin_label) - - text_size = self.picker_item.get_text_size() - value_sb = basic.CallBackDoubleSpinBox( - callback=self.edit_text_size_event, value=text_size - ) - spin_layout.addWidget(value_sb) - - layout.addLayout(spin_layout) - - # Add color layout - color_layout = QtWidgets.QHBoxLayout(group_box) - - # Add color button - color_event = self.change_text_color_event - self.text_color_button = basic.CallbackButton(callback=color_event) - - color_layout.addWidget(self.text_color_button) - - # Add alpha spin box - color_layout.addStretch() - - label = QtWidgets.QLabel() - label.setText("Alpha") - color_layout.addWidget(label) - - alpha_event = self.change_text_alpha_event - alpha_value = self.picker_item.get_text_color().alpha() - self.text_alpha_sb = basic.CallBackSpinBox( - callback=alpha_event, value=alpha_value, max=255 - ) - color_layout.addWidget(self.text_alpha_sb) - - # Add color layout to group box layout - layout.addLayout(color_layout) - - # Add to main layout - self.left_layout.addWidget(group_box) - - def add_scale_options(self): - """Add scale group box options""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Scale") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Add edit check box - self.worldspace_box = QtWidgets.QCheckBox() - self.worldspace_box.setText("World space") - - layout.addWidget(self.worldspace_box) - - # Add alpha spin box - spin_layout = QtWidgets.QHBoxLayout() - layout.addLayout(spin_layout) - - label = QtWidgets.QLabel() - label.setText("Factor") - spin_layout.addWidget(label) - - self.scale_sb = QtWidgets.QDoubleSpinBox() - self.scale_sb.setValue(1.1) - self.scale_sb.setSingleStep(0.05) - spin_layout.addWidget(self.scale_sb) - - # Add scale buttons - btn_layout = QtWidgets.QHBoxLayout() - layout.addLayout(btn_layout) - - btn = basic.CallbackButton(callback=self.scale_event, x=True) - btn.setText("X") - btn_layout.addWidget(btn) - - btn = basic.CallbackButton(callback=self.scale_event, y=True) - btn.setText("Y") - btn_layout.addWidget(btn) - - btn = basic.CallbackButton(callback=self.scale_event, x=True, y=True) - btn.setText("XY") - btn_layout.addWidget(btn) - - # Add to main left layout - self.left_layout.addWidget(group_box) - - def add_action_mode_field(self): - """Add custom action mode field group box""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Action Mode") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Add default select mode radio button - custom_mode = not self.picker_item.get_custom_action_mode() - default_rad = basic.CallbackRadioButtonWidget( - "default", self.mode_radio_event, checked=custom_mode - ) - default_rad.setText("Default action (select)") - default_rad.setToolTip( - "Run default selection action on related controls" - ) - layout.addWidget(default_rad) - - # Add custom action script radio button - action_mode = self.picker_item.get_custom_action_mode() - custom_rad = basic.CallbackRadioButtonWidget( - "custom", self.mode_radio_event, checked=action_mode - ) - custom_rad.setText("Custom action (script)") - custom_rad.setToolTip("Change mode to run a custom action script") - layout.addWidget(custom_rad) - - # Add edit custom script button - custom_script = self.edit_custom_action_script - custom_script_btn = basic.CallbackButton(callback=custom_script) - custom_script_btn.setText("Edit Action script") - custom_script_btn.setToolTip("Open custom action script edit window") - layout.addWidget(custom_script_btn) - - self.control_layout.addWidget(group_box) - - def add_target_control_field(self): - """Add target control association group box""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Control Association") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Init list object - ctrl_name = self.edit_ctrl_name_event - self.control_list = basic.CallbackListWidget(callback=ctrl_name) - self.control_list.setToolTip( - "Associated controls/objects that will be\ - selected when clicking picker item" - ) - layout.addWidget(self.control_list) - - # Add buttons - btn_layout1 = QtWidgets.QHBoxLayout() - layout.addLayout(btn_layout1) - - btn = basic.CallbackButton(callback=self.add_selected_controls_event) - btn.setText("Add Selection") - btn.setToolTip("Add selected controls to list") - btn.setMinimumWidth(75) - btn_layout1.addWidget(btn) - - btn = basic.CallbackButton(callback=self.remove_controls_event) - btn.setText("Remove") - btn.setToolTip("Remove selected controls") - btn.setMinimumWidth(75) - btn_layout1.addWidget(btn) - - btn = basic.CallbackButton(callback=self.search_replace_controls_event) - btn.setText("Search & Replace") - btn.setToolTip("Will search and replace all controls names") - layout.addWidget(btn) - - self.control_layout.addWidget(group_box) - - def add_custom_menus_field(self): - """Add custom menu management groupe box""" - # Create group box - group_box = QtWidgets.QGroupBox() - group_box.setTitle("Custom Menus") - - # Add layout - layout = QtWidgets.QVBoxLayout(group_box) - - # Init list object - self.menus_list = basic.CallbackListWidget( - callback=self.edit_menu_event - ) - self.menus_list.setToolTip( - "Custom action menus that will be accessible through right clicking the picker item in animation mode" - ) - layout.addWidget(self.menus_list) - - # Add buttons - btn_layout1 = QtWidgets.QHBoxLayout() - layout.addLayout(btn_layout1) - - btn = basic.CallbackButton(callback=self.new_menu_event) - btn.setText("New") - btn.setMinimumWidth(60) - btn_layout1.addWidget(btn) - - btn = basic.CallbackButton(callback=self.remove_menus_event) - btn.setText("Remove") - btn.setMinimumWidth(60) - btn_layout1.addWidget(btn) - - self.right_layout.addWidget(group_box) - - # ========================================================================= - # Events - def handles_cb_event(self, value=False): - """Toggle edit mode for shape""" - self.picker_item.set_edit_status(value) - - def edit_handles_position_event(self): - - # Delete old window - if self.handles_window: - try: - self.handles_window.close() - self.handles_window.deleteLater() - except Exception: - pass - - # Init new window - picker_item = self.picker_item - self.handles_window = HandlesPositionWindow( - parent=self, picker_item=picker_item - ) - - # Show window - self.handles_window.show() - self.handles_window.raise_() - - def edit_position_event(self, value=0): - """Will move polygon based on new values""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - x = self.pos_x_sb.value() - y = self.pos_y_sb.value() - - self.picker_item.setPos(QtCore.QPointF(x, y)) - - def change_color_alpha_event(self, value=255): - """Will edit the polygon transparency alpha value""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - # Get current color - color = self.picker_item.get_color() - color.setAlpha(value) - - # Update color - self.picker_item.set_color(color) - - def change_color_event(self): - """Will edit polygon color based on new values""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - # Open color picker dialog - picker_color = self.picker_item.get_color() - color = QtWidgets.QColorDialog.getColor( - initial=picker_color, parent=self - ) - - # Abort on invalid color (cancel button) - if not color.isValid(): - return - - # Update button color - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Button, color) - self.color_button.setPalette(palette) - - # Edit new color alpha - alpha = self.picker_item.get_color().alpha() - color.setAlpha(alpha) - - # Update color - self.picker_item.set_color(color) - - def rotate_event(self, rotMinus=None, rotPlus=None): - """Will rotate polygon based on angle value from spin box""" - # Get rotate angle value - rotate_angle = self.rotate_sb.value() - - # Build kwargs - kwargs = {"angle": 0.0} - if rotMinus: - kwargs["angle"] = rotate_angle - if rotPlus: - kwargs["angle"] = rotate_angle * -1 - - # Apply rotation - self.picker_item.rotate_shape(**kwargs) - - def reset_rotate_event(self): - self.picker_item.reset_rotation() - - def scale_event(self, x=False, y=False): - """Will scale polygon on specified axis based on scale factor - value from spin box - """ - # Get scale factor value - scale_factor = self.scale_sb.value() - - # Build kwargs - kwargs = {"x": 1.0, "y": 1.0} - if x: - kwargs["x"] = scale_factor - if y: - kwargs["y"] = scale_factor - - # Check space - if self.worldspace_box.isChecked(): - kwargs["world"] = True - - # Apply scale - self.picker_item.scale_shape(**kwargs) - - def set_text_event(self, text=None): - """Will set polygon text to field""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - text = str(text) - self.picker_item.set_text(text) - - def edit_text_size_event(self, value=1): - """Will edit text size factor""" - self.picker_item.set_text_size(value) - - def change_text_alpha_event(self, value=255): - """Will edit the polygon transparency alpha value""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - # Get current color - color = self.picker_item.get_text_color() - color.setAlpha(value) - - # Update color - self.picker_item.set_text_color(color) - - def change_text_color_event(self): - """Will edit polygon color based on new values""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - # Open color picker dialog - picker_color = self.picker_item.get_text_color() - color = QtWidgets.QColorDialog.getColor( - initial=picker_color, parent=self - ) - - # Abort on invalid color (cancel button) - if not color.isValid(): - return - - # Update button color - palette = QtGui.QPalette() - palette.setColor(QtGui.QPalette.Button, color) - self.text_color_button.setPalette(palette) - - # Edit new color alpha - alpha = self.picker_item.get_text_color().alpha() - color.setAlpha(alpha) - - # Update color - self.picker_item.set_text_color(color) - - # ========================================================================= - # Custom action management - def mode_radio_event(self, mode): - """Action mode change event""" - # Skip if event is disabled (updating ui value) - if self.event_disabled: - return - - if mode == "default": - self.picker_item.custom_action = False - - elif mode == "custom": - self.picker_item.custom_action = True - - def edit_custom_action_script(self): - - # Open input window - action_script = self.picker_item.custom_action_script - cmd, ok = CustomScriptEditDialog.get( - cmd=action_script, item=self.picker_item - ) - if not (ok and cmd): - return - - self.picker_item.set_custom_action_script(cmd) - - # ========================================================================= - # Control management - def _populate_ctrl_list_widget(self): - """Will update/populate list with current shape ctrls""" - # Empty list - self.control_list.clear() - - # Populate node list - controls = self.picker_item.get_controls() - for i in range(len(controls)): - item = basic.CtrlListWidgetItem(index=i) - item.setText(controls[i]) - self.control_list.addItem(item) - - # if controls: - # self.control_list.setCurrentRow(0) - - def edit_ctrl_name_event(self, item=None): - """Double click event on associated ctrls list""" - if not item: - return - - # Open input window - line_normal = QtWidgets.QLineEdit.Normal - name, ok = QtWidgets.QInputDialog.getText( - self, - "Ctrl name", - "New name", - mode=line_normal, - text=str(item.text()), - ) - if not (ok and name): - return - - # Update influence name - new_name = item.setText(name) - if new_name: - self.update_shape_controls_list() - - # Deselect item - self.control_list.clearSelection() - - def add_selected_controls_event(self): - """Will add maya selected object to control list""" - self.picker_item.add_selected_controls() - - # Update display - self._populate_ctrl_list_widget() - - def remove_controls_event(self): - """Will remove selected item list from stored controls""" - # Get selected item - items = self.control_list.selectedItems() - assert items, "no list item selected" - - # Remove item from list - for item in items: - self.picker_item.remove_control(item.node()) - - # Update display - self._populate_ctrl_list_widget() - - def search_replace_controls_event(self): - """Will search and replace controls names for related picker item""" - if self.picker_item.search_and_replace_controls(): - self._populate_ctrl_list_widget() - - def get_controls_from_list(self): - """Return the controls from list widget""" - ctrls = [] - for i in range(self.control_list.count()): - item = self.control_list.item(i) - ctrls.append(item.node()) - return ctrls - - def update_shape_controls_list(self): - """Update shape stored control list""" - ctrls = self.get_controls_from_list() - self.picker_item.set_control_list(ctrls) - - # ========================================================================= - # Menus management - def _add_menu_item(self, text=None): - """Add a menu item to menu list widget""" - item = QtWidgets.QListWidgetItem() - item.index = self.menus_list.count() - if text: - item.setText(text) - self.menus_list.addItem(item) - return item - - def _populate_menu_list_widget(self): - """Populate list widget with menu data""" - # Empty list - self.menus_list.clear() - - # Populate node list - menus_data = self.picker_item.get_custom_menus() - for i in range(len(menus_data)): - self._add_menu_item(text=menus_data[i][0]) - - def _update_menu_data(self, index, name, cmd): - """Update custom menu data""" - menu_data = self.picker_item.get_custom_menus() - if index > len(menu_data) - 1: - menu_data.append([name, cmd]) - else: - menu_data[index] = [name, cmd] - self.picker_item.set_custom_menus(menu_data) - - def edit_menu_event(self, item=None): - """Double click event on associated menu list""" - if not item: - return - - name, cmd = self.picker_item.get_custom_menus()[item.index] - - # Open input window - name, cmd, ok = CustomMenuEditDialog.get( - name=name, cmd=cmd, item=self.picker_item - ) - if not (ok and name and cmd): - return - - # Update menu display name - item.setText(name) - - # Update menu data - self._update_menu_data(item.index, name, cmd) - - # Deselect item - self.menus_list.clearSelection() - - def new_menu_event(self): - """Add new custom menu btn event""" - # Open input window - name, cmd, ok = CustomMenuEditDialog.get(item=self.picker_item) - if not (ok and name and cmd): - return - - # Update menu display name - item = self._add_menu_item(text=name) - - # Update menu data - self._update_menu_data(item.index, name, cmd) - - def remove_menus_event(self): - """Remove custom menu btn event""" - # Get selected item - items = self.menus_list.selectedItems() - assert items, "no list item selected" - - # Remove item from list - menu_data = self.picker_item.get_custom_menus() - for i in range(len(items)): - menu_data.pop(items[i].index - i) - self.picker_item.set_custom_menus(menu_data) - - # Update display - self._populate_menu_list_widget() - - -class HandlesPositionWindow(QtWidgets.QMainWindow): - """Whild window to edit picker item handles local positions""" - - __OBJ_NAME__ = "picker_item_handles_window" - __TITLE__ = "Handles positions" - - __DEFAULT_WIDTH__ = 250 - __DEFAULT_HEIGHT__ = 300 - - def __init__(self, parent=None, picker_item=None): - QtWidgets.QMainWindow.__init__(self, parent=None) - - self.picker_item = picker_item - - # Run setup - self.setup() - - def setup(self): - """Setup window elements""" - # Main window setting - self.setObjectName(self.__OBJ_NAME__) - self.setWindowTitle(self.__TITLE__) - self.resize(self.__DEFAULT_WIDTH__, self.__DEFAULT_HEIGHT__) - - # Create main widget - self.main_widget = QtWidgets.QWidget(self) - self.main_layout = QtWidgets.QVBoxLayout(self.main_widget) - - self.setCentralWidget(self.main_widget) - - # Add content - self.add_position_table() - self.add_option_buttons() - - # Populate table - self.populate_table() - - def add_position_table(self): - self.table = QtWidgets.QTableWidget(self) - - self.table.setColumnCount(2) - self.table.setHorizontalHeaderLabels(["X", "Y"]) - - self.main_layout.addWidget(self.table) - - def add_option_buttons(self): - """Add window option buttons""" - # Refresh button - self.refresh_button = basic.CallbackButton(callback=self.refresh_event) - self.refresh_button.setText("Refresh") - self.main_layout.addWidget(self.refresh_button) - - def refresh_event(self): - """Refresh table event""" - self.populate_table() - - def populate_table(self): - """Populate table with X/Y handles position items""" - # Clear table - while self.table.rowCount(): - self.table.removeRow(0) - - # Abort if no pickeritem specified - if not self.picker_item: - return - - # Parse handles - handles = self.picker_item.get_handles() - for i in range(len(handles)): - self.table.insertRow(i) - spin_box = basic.CallBackDoubleSpinBox( - callback=handles[i].setX, value=handles[i].x(), min=-999 - ) - self.table.setCellWidget(i, 0, spin_box) - - spin_box = basic.CallBackDoubleSpinBox( - callback=handles[i].setY, value=handles[i].y(), min=-999 - ) - self.table.setCellWidget(i, 1, spin_box) - - def display_handles_index(self, status=True): - """Display related picker handles index""" - for handle in self.picker_item.get_handles(): - handle.enable_index_draw(status) - - def closeEvent(self, *args, **kwargs): - self.display_handles_index(status=False) - return QtWidgets.QMainWindow.closeEvent(self, *args, **kwargs) - - def show(self, *args, **kwargs): - """Override default show function to display related picker - handles index - """ - self.display_handles_index(status=True) - return QtWidgets.QMainWindow.show(self, *args, **kwargs) - - -class DefaultPolygon(QtWidgets.QGraphicsObject): - """Default polygon class, with move and hover support""" - - __DEFAULT_COLOR__ = QtGui.QColor(0, 0, 0, 255) - - def __init__(self, parent=None): - QtWidgets.QGraphicsObject.__init__(self, parent=parent) - - if parent: - self.setParent(parent) - - # Hover feedback - self.setAcceptHoverEvents(True) - self._hovered = False - - # Init default - self.color = self.__DEFAULT_COLOR__ - - def hoverEnterEvent(self, event=None): - """Lightens background color on mose over""" - QtWidgets.QGraphicsObject.hoverEnterEvent(self, event) - self._hovered = True - self.update() - - def hoverLeaveEvent(self, event=None): - """Resets mouse over background color""" - QtWidgets.QGraphicsObject.hoverLeaveEvent(self, event) - self._hovered = False - self.update() - - def boundingRect(self): - """ - Needed override: - Returns the bounding rectangle for the graphic item - """ - return self.shape().boundingRect() - - def itemChange(self, change, value): - """itemChange update behavior""" - # Catch position update - if change == QtWidgets.QGraphicsItem.ItemPositionChange: - # Force scene update to prevent "ghosts" - # (ghost happen when the previous polygon is out of - # the new bounding rect when updating) - if self.scene(): - self.scene().update() - - # Run default action - return QtWidgets.QGraphicsObject.itemChange(self, change, value) - - def get_color(self): - """Get polygon color""" - return QtGui.QColor(self.color) - - def set_color(self, color=None): - """Set polygon color""" - if not color: - color = QtGui.QColor(0, 0, 0, 255) - elif isinstance(color, (list, tuple)): - color = QtGui.QColor(*color) - - msg = "input color '{}' is invalid".format(color) - assert isinstance(color, QtGui.QColor), msg - - self.color = color - self.update() - - return color - - -class PointHandle(DefaultPolygon): - """Handle polygon object to move picker polygon cvs""" - - __DEFAULT_COLOR__ = QtGui.QColor(30, 30, 30, 200) - - def __init__(self, x=0, y=0, size=8, color=None, parent=None, index=0): - - DefaultPolygon.__init__(self, parent) - - # Make movable - self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable) - self.setFlag(QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges) - self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) - - # Set values - self.setPos(x, y) - self.index = index - self.size = size - self.set_color() - self.draw_index = False - - # Hide by default - self.setVisible(False) - - # Add index element - self.index = PointHandleIndex(parent=self, index=index) - - # ========================================================================= - # Default python methods - # ========================================================================= - def _new_pos_handle_copy(self, pos): - """Return a new PointHandle isntance with same attributes - but different position - """ - new_handle = PointHandle( - x=pos.x(), - y=pos.y(), - size=self.size, - color=self.color, - parent=self.parentObject(), - ) - return new_handle - - def _get_pos_for_input(self, other): - if isinstance(other, PointHandle): - return other.pos() - return other - - def __add__(self, other): - other = self._get_pos_for_input(other) - new_pos = self.pos() + other - return self._new_pos_handle_copy(new_pos) - - def __sub__(self, other): - other = self._get_pos_for_input(other) - new_pos = self.pos() - other - return self._new_pos_handle_copy(new_pos) - - def __div__(self, other): - other = self._get_pos_for_input(other) - new_pos = self.pos() / other - return self._new_pos_handle_copy(new_pos) - - def __mul__(self, other): - other = self._get_pos_for_input(other) - new_pos = self.pos() / other - return self._new_pos_handle_copy(new_pos) - - # ========================================================================= - # QT OVERRIDES - # ========================================================================= - def setX(self, value=0): - """Override to support keyword argument for spin_box callback""" - DefaultPolygon.setX(self, value) - - def setY(self, value=0): - """Override to support keyword argument for spin_box callback""" - DefaultPolygon.setY(self, value) - - # ========================================================================= - # Graphic item methods - # ========================================================================= - def shape(self): - """Return default handle square shape based on specified size""" - path = QtGui.QPainterPath() - rectangle = QtCore.QRectF( - QtCore.QPointF(-self.size / 2.0, self.size / 2.0), - QtCore.QPointF(self.size / 2.0, -self.size / 2.0), - ) - # path.addRect(rectangle) - path.addEllipse(rectangle) - return path - - def paint(self, painter, options, widget=None): - """Paint graphic item""" - painter.setRenderHint(QtGui.QPainter.Antialiasing) - - # Get polygon path - path = self.shape() - - # Set node background color - brush = QtGui.QBrush(self.color) - if self._hovered: - brush = QtGui.QBrush(self.color.lighter(500)) - - # Paint background - painter.fillPath(path, brush) - - border_pen = QtGui.QPen(QtGui.QColor(200, 200, 200, 255)) - painter.setPen(border_pen) - - # Paint Borders - painter.drawPath(path) - - # if not edit_mode: return - # Paint center cross - cross_size = self.size / 2 - 2 - painter.setPen(QtGui.QColor(0, 0, 0, 180)) - painter.drawLine(-cross_size, 0, cross_size, 0) - painter.drawLine(0, cross_size, 0, -cross_size) - - def mirror_x_position(self): - """will mirror local x position value""" - self.setX(-1 * self.x()) - - def scale_pos(self, x=1.0, y=1.0): - """Scale handle local position""" - self.setPos(self.pos().x() * x, self.pos().y() * y) - self.update() - - def enable_index_draw(self, status=False): - self.index.setVisible(status) - - def set_index(self, index): - self.index.setText(index) - - def get_index(self): - return int(self.index.text()) - - -class Polygon(DefaultPolygon): - """ - Picker controls visual graphic object - (inherits from QtWidgets.QGraphicsObject rather - than QtWidgets.QGraphicsItem for signal support) - """ - - __DEFAULT_COLOR__ = QtGui.QColor(200, 200, 200, 180) - __DEFAULT_SELECT_COLOR__ = QtGui.QColor(230, 230, 230, 240) - - def __init__(self, parent=None, points=[], color=None): - - DefaultPolygon.__init__(self, parent=parent) - self.points = points - self.set_color(Polygon.__DEFAULT_COLOR__) - - self._edit_status = False - self.selected = False - - def set_edit_status(self, status=False): - self._edit_status = status - self.update() - - def shape(self): - """Override function to return proper "hit box", - and compute shape only once. - """ - path = QtGui.QPainterPath() - - # Polygon case - if len(self.points) > 2: - # Define polygon points for closed loop - shp_points = [] - for handle in self.points: - shp_points.append(handle.pos()) - shp_points.append(self.points[0].pos()) - - # Draw polygon - polygon = QtGui.QPolygonF(shp_points) - - # Update path - path.addPolygon(polygon) - - # Circle case - else: - center = self.points[0].pos() - radius = QtGui.QVector2D( - self.points[0].pos() - self.points[1].pos() - ).length() - - # Update path - path.addEllipse( - center.x() - radius, - center.y() - radius, - radius * 2, - radius * 2, - ) - - return path - - def paint(self, painter, options, widget=None): - """Paint graphic item""" - # Set render quality - painter.setRenderHint(QtGui.QPainter.Antialiasing) - - # Get polygon path - path = self.shape() - - # Background color - color = QtGui.QColor(self.color) - if self._hovered: - color = color.lighter(130) - brush = QtGui.QBrush(color) - - painter.fillPath(path, brush) - - # Add white layer color overlay on selected state - if self.selected: - color = QtGui.QColor(255, 255, 255, 50) - brush = QtGui.QBrush(color) - painter.fillPath(path, brush) - - # Border status feedback - border_pen = QtGui.QPen(self.__DEFAULT_SELECT_COLOR__) - border_pen.setWidthF(2) - - if self.selected: - painter.setPen(border_pen) - painter.drawPath(path) - - elif self._hovered: - border_pen.setStyle(QtCore.Qt.DashLine) - painter.setPen(border_pen) - painter.drawPath(path) - - # Stop her if not in edit mode - if not self._edit_status: - return - - # Paint center cross - painter.setRenderHints(QtGui.QPainter.Antialiasing, False) - painter.setPen(QtGui.QColor(0, 0, 0, 180)) - painter.drawLine(-5, 0, 5, 0) - painter.drawLine(0, 5, 0, -5) - - def set_selected_state(self, state): - """Will set border color feedback based on selection state""" - # Do nothing on same state - if state == self.selected: - return - - # Change state, and update - self.selected = state - self.update() - - def set_color(self, color): - # Run default method - color = DefaultPolygon.set_color(self, color) - - # Store new color as default - Polygon.__DEFAULT_COLOR__ = color - - -class PointHandleIndex(QtWidgets.QGraphicsSimpleTextItem): - """Point handle index text element""" - - __DEFAULT_COLOR__ = QtGui.QColor(130, 50, 50, 255) - - def __init__(self, parent=None, scene=None, index=0): - QtWidgets.QGraphicsSimpleTextItem.__init__(self, parent, scene) - - # Init defaults - self.set_size() - self.set_color(PointHandleIndex.__DEFAULT_COLOR__) - self.setPos(QtCore.QPointF(-9, -14)) - self.setFlag(QtWidgets.QGraphicsItem.ItemIgnoresTransformations) - - # Hide by default - self.setVisible(False) - - self.setText(index) - - def set_size(self, value=8.0): - """Set pointSizeF for text""" - font = self.font() - font.setPointSizeF(value) - self.setFont(font) - - def set_color(self, color=None): - """Set text color""" - if not color: - return - brush = self.brush() - brush.setColor(color) - self.setBrush(brush) - - def setText(self, text): - """Override default setText method to force unicode on int index input""" - return QtWidgets.QGraphicsSimpleTextItem.setText(self, str(text)) - - -class GraphicText(QtWidgets.QGraphicsSimpleTextItem): - """Picker item text element""" - - __DEFAULT_COLOR__ = QtGui.QColor(30, 30, 30, 255) - - def __init__(self, parent=None, scene=None): - QtWidgets.QGraphicsSimpleTextItem.__init__(self, parent, scene) - - # Counter view scale - self.scale_transform = QtGui.QTransform().scale(1, -1) - self.setTransform(self.scale_transform) - - # Init default size - self.set_size() - self.set_color(GraphicText.__DEFAULT_COLOR__) - - def set_text(self, text): - """ - Set current text - (Will center text on parent too) - """ - self.setText(text) - self.center_on_parent() - - def get_text(self): - """Return element text""" - return str(self.text()) - - def set_size(self, value=10.0): - """Set pointSizeF for text""" - font = self.font() - font.setPointSizeF(value) - self.setFont(font) - self.center_on_parent() - - def get_size(self): - """Return text pointSizeF""" - return self.font().pointSizeF() - - def get_color(self): - """Return text color""" - return self.brush().color() - - def set_color(self, color=None): - """Set text color""" - if not color: - return - brush = self.brush() - brush.setColor(color) - self.setBrush(brush) - - # Store new color as default color - GraphicText.__DEFAULT_COLOR__ = color - - def center_on_parent(self): - """ - Center text on parent item - (Since by default the text start on the bottom left corner) - """ - center_pos = self.boundingRect().center() - # self.setPos(-center_pos * self.scale_transform) - scale_xy = QtCore.QPointF(center_pos.x(), center_pos.y() * -1) - self.setPos(-scale_xy) - - -class PickerItem(DefaultPolygon): - """Main picker graphic item container""" - - def __init__( - self, parent=None, point_count=4, namespace=None, main_window=None - ): - DefaultPolygon.__init__(self, parent=parent) - self.point_count = point_count - - self.setPos(25, 30) - - # Make item movable - if __EDIT_MODE__.get(): - self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable) - self.setFlag(QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges) - - # Default vars - self.namespace = namespace - self.main_window = main_window - self._edit_status = False - self.edit_window = None - - # Add polygon - self.polygon = Polygon(parent=self) - - # Add text - self.text = GraphicText(parent=self) - - # Add handles - self.handles = [] - self.set_handles(self.get_default_handles()) - - # Controls vars - self.controls = [] - self.custom_menus = [] - - # Custom action - self.custom_action = False - self.custom_action_script = None - - # uuid & undo - self.uuid = uuid.uuid4() - - def shape(self): - path = QtGui.QPainterPath() - - if self.polygon: - path.addPath(self.polygon.shape()) - - # Stop here in default mode - if not self._edit_status: - return path - - # Add handles to shape - for handle in self.handles: - path.addPath(handle.mapToParent(handle.shape())) - - return path - - def paint(self, painter, *args, **kwargs): - pass - # for debug only - # # Set render quality - # painter.setRenderHint(QtGui.QPainter.Antialiasing) - - # # Get polygon path - # path = self.shape() - - # # Set node background color - # brush = QtGui.QBrush(QtGui.QColor(0,0,200,255)) - - # # Paint background - # painter.fillPath(path, brush) - - # border_pen = QtGui.QPen(QtGui.QColor(0,200,0,255)) - # painter.setPen(border_pen) - - # # Paint Borders - # painter.drawPath(path) - - def get_default_handles(self): - """ - Generate default point handles coordinate for polygon - (on circle) - """ - unit_scale = 20 - handles = [] - - # Circle case - if self.point_count == 2: - handle_a = PointHandle(x=0.0, y=0.0, parent=self, index=1) - handle_b = PointHandle( - x=1.0 * unit_scale, y=0.0, parent=self, index=2 - ) - handles = [handle_a, handle_b] - - else: - # Define angle step - angle_step = pi * 2 / self.point_count - - # Generate point coordinates - for i in range(0, self.point_count): - x = sin(i * angle_step + pi / self.point_count) * unit_scale - y = cos(i * angle_step + pi / self.point_count) * unit_scale - handle = PointHandle(x=x, y=y, parent=self, index=i + 1) - handles.append(handle) - - return handles - - def edit_point_count(self, value=4): - """ - Change/edit the number of points for the polygon - (that will reset the shape) - """ - # Update point count - self.point_count = value - - # Reset points - points = self.get_default_handles() - self.set_handles(points) - - def get_handles(self): - """Return picker item handles""" - return self.handles - - def set_handles(self, handles=[]): - """Set polygon handles points""" - # Remove existing handles - for handle in self.handles: - handle.setParent(None) - handle.deleteLater() - - # Parse input type - new_handles = [] - # start index at 1 since table Widget raw are indexed at 1 - index = 1 - - for handle in handles: - if isinstance(handle, (list, tuple)): - handle = PointHandle( - x=handle[0], y=handle[1], parent=self, index=index - ) - elif hasattr(handle, "x") and hasattr(handle, "y"): - handle = PointHandle( - x=handle.x(), y=handle.y(), parent=self, index=index - ) - new_handles.append(handle) - index += 1 - - # Update handles list - self.handles = new_handles - self.polygon.points = new_handles - - # Set current visibility status - for handle in self.handles: - handle.setVisible(self.get_edit_status()) - - # Set new point count - self.point_count = len(self.handles) - - # ========================================================================= - # Mouse events --- - def hoverEnterEvent(self, event=None): - """Update tooltip on hoover with associated controls in edit mode""" - if __EDIT_MODE__.get(): - text = "\n".join(self.get_controls()) - self.setToolTip(text) - super().hoverEnterEvent(event) - - def mouseMoveEvent_offset(self, event): - self.setPos(event.scenePos() + self.cursor_delta) - - def mouseMoveEvent(self, event): - gfx_event = event - if event.buttons() == QtCore.Qt.LeftButton and __EDIT_MODE__.get(): - if self.currently_selected: - [ - item.mouseMoveEvent_offset(event) - for item in self.currently_selected - ] - super().mouseMoveEvent(gfx_event) - - def mousePressEvent(self, event): - """Event called on mouse press""" - # Simply run default event in edit mode, and exit - if __EDIT_MODE__.get(): - self.get_delta_from_point(event.pos()) - # this allows for maintaining offset while dragging multiple - self.currently_selected = [ - item - for item in self.parent().get_picker_items() - if item.polygon.selected - ] - if self.currently_selected: - if self in self.currently_selected: - self.currently_selected.remove(self) - [ - item.get_delta_from_point(event.scenePos()) - for item in self.currently_selected - ] - return DefaultPolygon.mousePressEvent(self, event) - - # Run selection on left mouse button event - if event.buttons() == QtCore.Qt.LeftButton: - # Run custom script action - if self.get_custom_action_mode(): - self.mouse_press_custom_action(event) - # Run default selection action - else: - select_picker_controls([self], event, modifiers=None) - - # Set focus to maya window - maya_window = pyqt.maya_main_window() - if maya_window: - maya_window.setFocus() - - def mouse_press_select_event(self, event, modifiers=None): - """ - Default select event on mouse press. - Will select associated controls - """ - # Get keyboard modifier - # Simply run default event in edit mode, and exit - if __EDIT_MODE__.get(): - return - if modifiers: - modifiers = modifiers - else: - modifiers = event.modifiers() - modifier = None - - # Shift cases (toggle) - if modifiers == QtCore.Qt.ShiftModifier: - modifier = "shift" - - # Controls case - if modifiers == QtCore.Qt.ControlModifier: - modifier = "control" - - # Alt case (remove) - if modifiers == QtCore.Qt.AltModifier: - modifier = "alt" - - # Call action - self.select_associated_controls(modifier=modifier) - - def mouse_press_custom_action(self, event): - """Custom script action on mouse press""" - # Run custom action script with picker item environnement - python_handlers.safe_code_exec( - self.get_custom_action_script(), env=self.get_exec_env() - ) - - def mouseDoubleClickEvent(self, event): - """Event called when mouse is double clicked""" - if not __EDIT_MODE__.get(): - return - - self.edit_options() - - def contextMenuEvent(self, event): - """Right click menu options""" - # Context menu for edition mode - if __EDIT_MODE__.get(): - self.edit_context_menu(event) - - # Context menu for default mode - else: - self.default_context_menu(event) - - # Force call release method - # self.mouseReleaseEvent(event) - return True - - def edit_context_menu(self, event): - """Context menu (right click) in edition mode""" - # Init context menu - menu = QtWidgets.QMenu(self.parent()) - - # Build edit context menu - options_action = QtWidgets.QAction("Options", None) - options_action.triggered.connect(self.edit_options) - menu.addAction(options_action) - - handles_action = QtWidgets.QAction("Toggle handles", None) - handles_action.triggered.connect(self.toggle_edit_status) - menu.addAction(handles_action) - - menu.addSeparator() - - # Shape options menu - shape_menu = QtWidgets.QMenu(menu) - shape_menu.setTitle("Shape") - - move_action = QtWidgets.QAction("Move to center", None) - move_action.triggered.connect(self.move_to_center) - shape_menu.addAction(move_action) - - shp_mirror_action = QtWidgets.QAction("Mirror shape", None) - shp_mirror_action.triggered.connect(self.mirror_shape) - shape_menu.addAction(shp_mirror_action) - - color_mirror_action = QtWidgets.QAction("Mirror color", None) - color_mirror_action.triggered.connect(self.mirror_color) - shape_menu.addAction(color_mirror_action) - - menu.addMenu(shape_menu) - - move_back_action = QtWidgets.QAction("Move to back", None) - move_back_action.triggered.connect(self.move_to_back) - menu.addAction(move_back_action) - - move_front_action = QtWidgets.QAction("Move to front", None) - move_front_action.triggered.connect(self.move_to_front) - menu.addAction(move_front_action) - - menu.addSeparator() - - # Copy handling - copy_action = QtWidgets.QAction("Copy", None) - copy_action.triggered.connect(self.copy_event) - menu.addAction(copy_action) - - paste_action = QtWidgets.QAction("Paste", None) - if DataCopyDialog.__DATA__: - paste_action.triggered.connect(self.past_event) - else: - paste_action.setEnabled(False) - menu.addAction(paste_action) - - paste_options_action = QtWidgets.QAction("Paste Options", None) - if DataCopyDialog.__DATA__: - paste_options_action.triggered.connect(self.past_option_event) - else: - paste_options_action.setEnabled(False) - menu.addAction(paste_options_action) - - menu.addSeparator() - - # Paste position actions - paste_x_action = QtWidgets.QAction("Paste Pos X", None) - if DataCopyDialog.__DATA__: - paste_x_action.triggered.connect(self.past_x_event) - else: - paste_x_action.setEnabled(False) - menu.addAction(paste_x_action) - - paste_y_action = QtWidgets.QAction("Paste Pos Y", None) - if DataCopyDialog.__DATA__: - paste_y_action.triggered.connect(self.past_y_event) - else: - paste_y_action.setEnabled(False) - menu.addAction(paste_y_action) - - menu.addSeparator() - - # Duplicate options - duplicate_action = QtWidgets.QAction("Duplicate", None) - duplicate_action.triggered.connect(self.duplicate_selected) - menu.addAction(duplicate_action) - - mirror_dup_action = QtWidgets.QAction("Duplicate/mirror", None) - mirror_dup_action.triggered.connect(self.duplicate_and_mirror_selected) - menu.addAction(mirror_dup_action) - - menu.addSeparator() - - # Delete - remove_action = QtWidgets.QAction("Remove", None) - remove_action.triggered.connect(self.remove_selected) - menu.addAction(remove_action) - - menu.addSeparator() - - # Control association - ctrls_menu = QtWidgets.QMenu(menu) - ctrls_menu.setTitle("Ctrls Association") - - select_action = QtWidgets.QAction("Select", None) - select_action.triggered.connect(self.select_associated_controls) - ctrls_menu.addAction(select_action) - - select_all_action = QtWidgets.QAction("Select all", None) - select_all_action.triggered.connect( - self.select_all_associated_controls - ) - ctrls_menu.addAction(select_all_action) - - replace_action = QtWidgets.QAction("Replace with selection", None) - replace_action.triggered.connect(self.replace_controls_selection) - ctrls_menu.addAction(replace_action) - - menu.addMenu(ctrls_menu) - - # Open context menu under mouse - # offset position to prevent accidental mouse release on menu - # OFFSET - offseted_pos = event.pos() + QtCore.QPoint(5, 0) - menu.exec_(offseted_pos) - return True - - def default_context_menu(self, event): - """Context menu (right click) out of edition mode (animation)""" - # Init context menu - menu = QtWidgets.QMenu(self.parent()) - - # Add reset action - # reset_action = QtWidgets.QAction("Reset", None) - # reset_action.triggered.connect(self.active_control.reset_to_bind_pose) - # menu.addAction(reset_action) - - # Add custom actions - actions = self._get_custom_action_menus() - for action in actions: - menu.addAction(action) - - # Abort on empty menu - if menu.isEmpty(): - return - - # Open context menu under mouse - # offset position to prevent accidental mouse release on menu - offseted_pos = event.pos() + QtCore.QPoint(5, 0) - # scene_pos = self.mapToScene(offseted_pos) - # view_pos = self.parent().mapFromScene(scene_pos) - # screen_pos = self.parent().mapToGlobal(view_pos) - menu.exec_(offseted_pos) - - def get_init_env(self): - env = self.get_exec_env() - env["__INIT__"] = True - - return env - - def get_exec_env(self): - """ - Will return proper environnement dictionnary for eval execs - (Will provide related controls as __CONTROLS__ - and __NAMESPACE__ variables) - """ - # Init env - env = {} - - # Add controls vars - env["__CONTROLS__"] = self.get_controls() - ctrls = self.get_controls() - env["__FLATCONTROLS__"] = maya_handlers.get_flattened_nodes(ctrls) - env["__NAMESPACE__"] = self.get_namespace() - env["__SELF__"] = self - env["__INIT__"] = False - - return env - - def _get_custom_action_menus(self): - # Init action list to fix loop problem where qmenu only - # show last action when using the same variable name ... - actions = [] - - # Define custom exec cmd wrapper - def wrapper(cmd): - def custom_eval(*args, **kwargs): - python_handlers.safe_code_exec(cmd, env=self.get_exec_env()) - - return custom_eval - - # Get active controls custom menus - custom_data = self.get_custom_menus() - if not custom_data: - return actions - - # Build menu - for i in range(len(custom_data)): - actions.append(QtWidgets.QAction(custom_data[i][0], None)) - actions[i].triggered.connect(wrapper(custom_data[i][1])) - - return actions - - # ========================================================================= - # Edit picker item options --- - def edit_options(self): - """Open Edit options window""" - # Delete old window - if self.edit_window: - try: - self.edit_window.close() - self.edit_window.deleteLater() - except Exception: - pass - - # Init new window - self.edit_window = ItemOptionsWindow( - parent=self.main_window, picker_item=self - ) - - # Show window - self.edit_window.show() - self.edit_window.raise_() - - def set_edit_status(self, status): - """Set picker item edit status (handle visibility etc.)""" - self._edit_status = status - - for handle in self.handles: - handle.setVisible(status) - - self.polygon.set_edit_status(status) - - def get_edit_status(self): - return self._edit_status - - def toggle_edit_status(self): - """Will toggle handle visibility status""" - self.set_edit_status(not self._edit_status) - - # ========================================================================= - # Properties methods --- - def get_color(self): - """Get polygon color""" - return self.polygon.get_color() - - def set_color(self, color=None): - """Set polygon color""" - self.polygon.set_color(color) - - # ========================================================================= - # Text handling --- - def get_text(self): - return self.text.get_text() - - def set_text(self, text): - self.text.set_text(text) - - def get_text_color(self): - return self.text.get_color() - - def set_text_color(self, color): - self.text.set_color(color) - - def get_text_size(self): - return self.text.get_size() - - def set_text_size(self, size): - self.text.set_size(size) - - # ========================================================================= - # Scene Placement --- - def move_to_front(self): - """Move picker item to scene front""" - # Get current scene - scene = self.scene() - - # Move to temp scene - tmp_scene = QtWidgets.QGraphicsScene() - tmp_scene.addItem(self) - - # Add to current scene (will be put on top) - scene.addItem(self) - - # Clean - tmp_scene.deleteLater() - - def move_to_back(self): - """Move picker item to background level behind other items""" - # Get picker Items - picker_items = self.scene().get_picker_items() - - # Reverse list since items are returned front to back - picker_items.reverse() - - # Move current item to front of list (back) - picker_items.remove(self) - picker_items.insert(0, self) - - # Move each item in proper oder to front of scene - # That will add them in the proper order to the scene - for item in picker_items: - item.move_to_front() - - def move_to_center(self): - """Move picker item to pos 0,0""" - self.setPos(0, 0) - - def remove_selected(self): - selected_pickers = self.scene().get_selected_items() - if self not in selected_pickers: - selected_pickers.append(self) - [picker.remove() for picker in selected_pickers] - - def remove(self): - self.scene().removeItem(self) - self.setParent(None) - self.deleteLater() - - def get_delta_from_point(self, point): - self.cursor_delta = self.pos() - point - return self.cursor_delta - - # ========================================================================= - # Ducplicate and mirror methods --- - def mirror_position(self): - """Mirror picker position (on X axis)""" - self.setX(-1 * self.pos().x()) - - def mirror_rotation(self, angle=None): - """Mirror picker rotation angle""" - if not angle: - angle = self.rotation() - - if angle > 360: - angle = angle - 360 - - mirror_angle = abs(angle - 360) - - self.setRotation(mirror_angle) - self.update() - - def mirror_shape(self): - """Will mirror polygon handles position on X axis""" - for handle in self.handles: - handle.mirror_x_position() - - def mirror_color(self): - """Will reverse red/bleu rgb values for the polygon color""" - old_color = self.get_color() - new_color = QtGui.QColor( - old_color.blue(), - old_color.green(), - old_color.red(), - ) - new_color.setAlpha(old_color.alpha()) - self.set_color(new_color) - - def duplicate_selected(self, *args, **kwargs): - selected_pickers = self.scene().get_selected_items() - if self not in selected_pickers: - selected_pickers.append(self) - new_pickers = [] - for picker in selected_pickers: - new_picker = picker.duplicate() - offset_x = (picker.boundingRect().width()) + 5 - new_pos = QtCore.QPointF( - picker.pos().x() + offset_x, picker.pos().y() - ) - new_picker.setPos(new_pos) - new_pickers.append(new_picker) - self.scene().select_picker_items(new_pickers) - - def duplicate(self, *args, **kwargs): - """Will create a new picker item and copy data over.""" - # Create new picker item - new_item = PickerItem() - new_item.setParent(self.parent()) - self.scene().addItem(new_item) - - # Copy data over - data = copy.deepcopy(self.get_data()) - new_item.set_data(data) - - return new_item - - def duplicate_and_mirror_selected(self): - selected_pickers = self.scene().get_selected_items() - if self not in selected_pickers: - selected_pickers.append(self) - - search = None - replace = None - new_pickers = [] - for picker in selected_pickers: - if picker.get_controls() and not search and not replace: - search, replace, ok = SearchAndReplaceDialog.get() - if not ok: - break - new_picker = picker.duplicate_and_mirror(search, replace) - new_pickers.append(new_picker) - self.scene().select_picker_items(new_pickers) - - def duplicate_and_mirror(self, search=None, replace=None): - """Duplicate and mirror picker item""" - new_item = self.duplicate() - new_item.mirror_color() - new_item.mirror_position() - new_item.mirror_shape() - - angle = self.rotation() - new_item.mirror_rotation(angle) - - if self.get_controls(): - new_item.search_and_replace_controls( - search=search, replace=replace - ) - return new_item - - def paste_pos(self, x=True, y=False): - """Paste the position x and y of a picker - - Args: - x (bool, optional): if true paste X position - y (bool, optional): if true paste Y position - """ - selected_pickers = self.scene().get_selected_items() - if self not in selected_pickers: - selected_pickers.append(self) - for picker in selected_pickers: - DataCopyDialog.set_pos(picker, x, y) - - def copy_event(self): - """Store pickerItem data for copy/paste support""" - DataCopyDialog.get(self) - - def past_event(self): - """Apply previously stored pickerItem data""" - selected_pickers = self.scene().get_selected_items() - if self not in selected_pickers: - selected_pickers.append(self) - for picker in selected_pickers: - DataCopyDialog.set(picker) - - def past_x_event(self): - """Paste X position""" - self.paste_pos(x=True, y=False) - - def past_y_event(self): - """Paste Y position""" - self.paste_pos(x=False, y=True) - - def past_option_event(self): - """Will open Paste option dialog window""" - DataCopyDialog.options(self) - - # ========================================================================= - # Transforms --- - def scale_shape(self, x=1.0, y=1.0, world=False): - """Will scale shape based on axis x/y factors""" - # Scale handles - for handle in self.handles: - handle.scale_pos(x, y) - - # Scale position - if world: - self.setPos(self.pos().x() * x, self.pos().y() * y) - - self.update() - - def rotate_shape(self, angle): - """Rotate shape based on item center""" - angle = self.rotation() + angle - if angle > 360: - angle = angle - 360 - - self.setRotation(angle) - self.update() - - def reset_rotation(self): - """Reset rotation""" - self.setRotation(0) - self.update() - - # ========================================================================= - # Custom action handling --- - def get_custom_action_mode(self): - return self.custom_action - - def set_custom_action_mode(self, state): - self.custom_action = state - - def set_custom_action_script(self, cmd): - self.custom_action_script = cmd - - def get_custom_action_script(self): - return self.custom_action_script - - # ========================================================================= - # Controls handling --- - def get_namespace(self): - """Will return associated namespace""" - return self.namespace - - def set_control_list(self, ctrls=[]): - """Update associated control list""" - self.controls = ctrls - - def get_controls(self, with_namespace=True): - """Return associated controls""" - # Returned controls without namespace (as data stored) - if not with_namespace: - return self.controls - - # Get namespace - namespace = self.get_namespace() - - # No namespace, return nodes - if not namespace: - return self.controls - - # Prefix nodes with namespace - nodes = [] - for node in self.controls: - nodes.append("{}:{}".format(namespace, node)) - - return nodes - - def append_control(self, ctrl): - """Add control to list""" - self.controls.append(ctrl) - - def remove_control(self, ctrl): - """Remove control from list""" - if ctrl not in self.controls: - return - self.controls.remove(ctrl) - - def search_and_replace_controls(self, search=None, replace=None): - """Will search and replace in associated controls names - - Args: - search (str, optional): search string - replace (str, optional): what to replace with - - Returns: - Bool: if successful - """ - # Open Search and replace dialog window - ok = True - if not search or not replace: - search, replace, ok = SearchAndReplaceDialog.get() - - if not ok: - return False - - # Parse controls - node_missing = False - controls = self.get_controls()[:] - for i, ctrl in enumerate(controls): - controls[i] = re.sub(search, replace, ctrl) - if not cmds.objExists(controls[i]): - node_missing = True - - # Print warning - if node_missing: - QtWidgets.QMessageBox.warning( - self.parent(), "Warning", "Some target controls do not exist" - ) - - # Update list - self.set_control_list(controls) - - return True - - def select_associated_controls(self, modifier=None): - """Will select maya associated controls""" - maya_handlers.select_nodes(self.get_controls(), modifier=modifier) - - def select_all_associated_controls(self, modifier=None): - """Will select maya associated controls""" - controls = [] - for picker in self.parent().scene().get_selected_items(): - controls.extend(picker.get_controls()) - maya_handlers.select_nodes(controls, modifier=modifier) - - def replace_controls_selection(self): - """Will replace controls association with current selection""" - self.set_control_list([]) - self.add_selected_controls() - - def add_selected_controls(self): - """Add selected controls to control list""" - # Get selection - sel = cmds.ls(sl=True) - - # Add to stored list - for ctrl in sel: - if ctrl in self.get_controls(): - continue - self.append_control(ctrl) - - def is_selected(self): - """ - Will return True if a related control is currently selected - (Only works with polygon that have a single associated maya_node) - """ - # Get controls associated nodes - controls = self.get_controls() - - # Abort if not single control polygon - if not len(controls) == 1: - return False - - # Check - return __SELECTION__.is_selected(controls[0]) - - def set_selected_state(self, state): - """Will set border color feedback based on selection state""" - self.polygon.set_selected_state(state) - - def run_selection_check(self): - """Will set selection state based on selection status""" - self.set_selected_state(self.is_selected()) - - # ========================================================================= - # Custom menus handling --- - def set_custom_menus(self, menus): - """Set custom menu list for current poly data""" - self.custom_menus = list(menus) - - def get_custom_menus(self): - """Return current menu list for current poly data""" - return self.custom_menus - - # ========================================================================= - # Data handling --- - def set_data(self, data): - """Set picker item from data dictionary""" - # Set color - if "color" in data: - color = QtGui.QColor(*data["color"]) - self.set_color(color) - - # Set position - if "position" in data: - position = data.get("position", [0, 0]) - self.setPos(*position) - - # Set rotation - if "rotation" in data: - rotation = data.get("rotation") - self.setRotation(rotation) - - # Set handles - if "handles" in data: - self.set_handles(data["handles"]) - - # Set text - if "text" in data: - self.set_text(data["text"]) - self.set_text_size(data["text_size"]) - color = QtGui.QColor(*data["text_color"]) - self.set_text_color(color) - - # Set action mode - if data.get("action_mode", False): - self.set_custom_action_mode(True) - self.set_custom_action_script(data.get("action_script", None)) - python_handlers.safe_code_exec( - self.get_custom_action_script(), env=self.get_init_env() - ) - - # Set controls - if "controls" in data: - self.set_control_list(data["controls"]) - - # Set custom menus - if "menus" in data: - self.set_custom_menus(data["menus"]) - - def get_data(self): - """Get picker item data in dictionary form""" - # Init data dict - data = {} - - # Add polygon color - data["color"] = self.get_color().getRgb() - - # Add position - data["position"] = [self.x(), self.y()] - - # Add rotation - data["rotation"] = self.rotation() - - # Add handles datas - handles_data = [] - for handle in self.handles: - handles_data.append([handle.x(), handle.y()]) - data["handles"] = handles_data - - # Add mode data - if self.get_custom_action_mode(): - data["action_mode"] = True - data["action_script"] = self.get_custom_action_script() - - # Add controls data - if self.get_controls(): - data["controls"] = self.get_controls(with_namespace=False) - - # Add custom menus data - if self.get_custom_menus(): - data["menus"] = self.get_custom_menus() - - if self.get_text(): - data["text"] = self.get_text() - data["text_size"] = self.get_text_size() - data["text_color"] = self.get_text_color().getRgb() - - return data - - -class State(object): - """State object, for easy state handling""" - - def __init__(self, state, name=False): - self.state = state - self.name = name - - def __lt__(self, other): - """Override for "sort" function""" - return self.name < other.name - - def get(self): - return self.state - - def set(self, state): - self.state = state - - -class DataCopyDialog(QtWidgets.QDialog): - """PickerItem data copying dialog handler""" - - __DATA__ = {} - - __STATES__ = [] - __DO_POS__ = State(False, "position") - __STATES__.append(__DO_POS__) - __DO_ROT__ = State(False, "rotation") - __STATES__.append(__DO_ROT__) - __DO_COLOR__ = State(True, "color") - __STATES__.append(__DO_COLOR__) - __DO_ACTION_MODE__ = State(True, "action_mode") - __STATES__.append(__DO_ACTION_MODE__) - __DO_ACTION_SCRIPT__ = State(True, "action_script") - __STATES__.append(__DO_ACTION_SCRIPT__) - __DO_HANDLES__ = State(True, "handles") - __STATES__.append(__DO_HANDLES__) - __DO_TEXT__ = State(True, "text") - __STATES__.append(__DO_TEXT__) - __DO_TEXT_SIZE__ = State(True, "text_size") - __STATES__.append(__DO_TEXT_SIZE__) - __DO_TEXT_COLOR__ = State(True, "text_color") - __STATES__.append(__DO_TEXT_COLOR__) - __DO_CTRLS__ = State(True, "controls") - __STATES__.append(__DO_CTRLS__) - __DO_MENUS__ = State(True, "menus") - __STATES__.append(__DO_MENUS__) - - def __init__(self, parent=None): - QtWidgets.QDialog.__init__(self, parent) - self.apply = False - self.setup() - - def setup(self): - """Build/Setup the dialog window""" - self.setWindowTitle("Copy/Paste") - - # Add layout - self.main_layout = QtWidgets.QVBoxLayout(self) - - # Add data field options - for state in self.__STATES__: - label_name = state.name.capitalize().replace("_", " ") - cb = basic.CallbackCheckBoxWidget( - callback=self.check_box_event, - value=state.get(), - label=label_name, - state_obj=state, - ) - self.main_layout.addWidget(cb) - - # Add buttons - btn_layout = QtWidgets.QHBoxLayout() - self.main_layout.addLayout(btn_layout) - - ok_btn = basic.CallbackButton(callback=self.accept_event) - ok_btn.setText("Ok") - btn_layout.addWidget(ok_btn) - - cancel_btn = basic.CallbackButton(callback=self.cancel_event) - cancel_btn.setText("Cancel") - btn_layout.addWidget(cancel_btn) - - def check_box_event(self, value=False, state_obj=None): - """Update state object value on checkbox state change event""" - state_obj.set(value) - - def accept_event(self): - """Accept button event""" - self.apply = True - - self.accept() - self.close() - - def cancel_event(self): - """Cancel button event""" - self.apply = False - self.close() - - @classmethod - def options(cls, item=None): - """ - Default method used to run the dialog input window - Will open the dialog window and return input texts. - """ - win = cls() - win.exec_() - win.raise_() - - if not win.apply: - return - # win.set(item) - - @staticmethod - def set_pos(item=None, x=True, y=True): - """Set the position date for a specific picker item - - Args: - item (object, optional): picker object item - x (bool, optional): if true will set X position - y (bool, optional): if true will set Y position - """ - # Sanity check - msg = "Item is not an PickerItem instance" - assert isinstance(item, PickerItem), msg - assert DataCopyDialog.__DATA__, "No stored data to paste" - - keys = [] - keys.append("position") - - # Build valid data - data = {} - for key in keys: - if key not in DataCopyDialog.__DATA__: - continue - data[key] = DataCopyDialog.__DATA__[key] - - # Get picker item data - item_data = item.get_data() - - if x: - data["position"][1] = item_data["position"][1] - if y: - data["position"][0] = item_data["position"][0] - item.set_data(data) - - @staticmethod - def set(item=None): - """Set the data to specific picker item - - Args: - item (object, optional): Picker object - """ - # Sanity check - msg = "Item is not an PickerItem instance" - assert isinstance(item, PickerItem), msg - assert DataCopyDialog.__DATA__, "No stored data to paste" - - # Filter data keys to copy - keys = [] - for state in DataCopyDialog.__STATES__: - if not state.get(): - continue - keys.append(state.name) - - # Build valid data - data = {} - for key in keys: - if key not in DataCopyDialog.__DATA__: - continue - data[key] = DataCopyDialog.__DATA__[key] - - # Get picker item data - item.set_data(data) - - @staticmethod - def get(item=None): - """Will get and store data for specified item""" - # Sanity check - msg = "Item is not an PickerItem instance" - assert isinstance(item, PickerItem), msg - - # Get picker item data - data = item.get_data() - - # Store data - DataCopyDialog.__DATA__ = data - - return data +from mgear.anim_picker.widgets.dialogs.script_dialog import SCRIPT_DOC_HEADER +from mgear.anim_picker.widgets.dialogs.script_dialog import ( + CustomScriptEditDialog, +) +from mgear.anim_picker.widgets.dialogs.script_dialog import ( + CustomMenuEditDialog, +) +from mgear.anim_picker.widgets.dialogs.search_replace_dialog import ( + SearchAndReplaceDialog, +) +from mgear.anim_picker.widgets.dialogs.handles_window import ( + HandlesPositionWindow, +) +from mgear.anim_picker.widgets.dialogs.item_options import ItemOptionsWindow +from mgear.anim_picker.widgets.dialogs.copy_paste_dialog import State +from mgear.anim_picker.widgets.dialogs.copy_paste_dialog import DataCopyDialog + +from mgear.anim_picker.widgets.picker_item import select_picker_controls +from mgear.anim_picker.widgets.picker_item import PickerItem + + +__all__ = [ + "DefaultPolygon", + "PointHandle", + "Polygon", + "PointHandleIndex", + "GraphicText", + "SCRIPT_DOC_HEADER", + "CustomScriptEditDialog", + "CustomMenuEditDialog", + "SearchAndReplaceDialog", + "HandlesPositionWindow", + "ItemOptionsWindow", + "State", + "DataCopyDialog", + "select_picker_controls", + "PickerItem", +] From 2cfeedb9f4cbc225a07a5e2db042c89d693c7002 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Wed, 8 Jul 2026 12:54:46 +0900 Subject: [PATCH 03/25] AnimPicker: Backgrounds: Composite multi-image layers and flexible canvas #108 A tab background is now an ordered list of image layers instead of a single image. Each layer carries its own path, position (center), and size, drawn back-to-front. The scene rect spans the union of the background layers AND the picker items, floored at the default canvas, so pan and zoom reach all content and backgrounds can be larger and non-proportional (the former 6000 cap is removed). - New Qt/Maya-free BackgroundLayer model (widgets/background_model.py) as the serialization authority; get_data/set_data delegate to it. - BackgroundOptionsDialog becomes a layer manager (add/remove/reorder, per-layer position and size). - Maya curve round-trip creates one image plane per layer and reads them back. - Legacy single background/background_size pickers still load (mapped to one centered layer); writes emit a "backgrounds" list. --- release/scripts/mgear/anim_picker/scene.py | 13 + release/scripts/mgear/anim_picker/view.py | 435 +++++++++++------- .../anim_picker/widgets/background_model.py | 102 ++++ .../mgear/anim_picker/widgets/basic.py | 224 +++++++-- releaseLog.rst | 1 + 5 files changed, 567 insertions(+), 208 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/widgets/background_model.py diff --git a/release/scripts/mgear/anim_picker/scene.py b/release/scripts/mgear/anim_picker/scene.py index f07e7e52..75a0ecbb 100644 --- a/release/scripts/mgear/anim_picker/scene.py +++ b/release/scripts/mgear/anim_picker/scene.py @@ -34,11 +34,24 @@ def set_size(self, width, height): """Will set scene size with proper center position""" self.setSceneRect(-width / 2, -height / 2, width, height) + def set_rect(self, rect): + """Set the scene rect from a QRectF (fits the background layer union)""" + self.setSceneRect(rect) + def set_default_size(self): self.set_size( self.__DEFAULT_SCENE_WIDTH__, self.__DEFAULT_SCENE_HEIGHT__ ) + def default_rect(self): + """Return the default centered scene rect as a QRectF.""" + return QtCore.QRectF( + -self.__DEFAULT_SCENE_WIDTH__ / 2.0, + -self.__DEFAULT_SCENE_HEIGHT__ / 2.0, + self.__DEFAULT_SCENE_WIDTH__, + self.__DEFAULT_SCENE_HEIGHT__, + ) + def get_bounding_rect(self, margin=0, selection=False): """ Return scene content bounding box with specified margin diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index aab75be1..b3b3be3d 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -24,9 +24,25 @@ from mgear.anim_picker.constants import DEFAULT_RELATIVE_IMAGES_PATH from mgear.anim_picker.widgets import basic from mgear.anim_picker.widgets import picker_widgets +from mgear.anim_picker.widgets import background_model from mgear.anim_picker.handlers import __EDIT_MODE__ +class _LoadedBackground(object): + """View-side pairing of a ``BackgroundLayer`` model with its loaded image. + + The ``BackgroundLayer`` is the serialization authority (Qt/Maya-free); the + ``QImage`` is runtime-only state, decoded once and reused on every repaint. + """ + + def __init__(self, layer, image): + self.layer = layer + self.image = image + # Cached geometry, refreshed on layer mutation (never per paint). + self.rect = None + self.src_rect = None + + # module clipboard shared across views (copy/paste of picker items) _CLIPBOARD = [] @@ -67,8 +83,11 @@ def __init__(self, namespace=None, main_window=None): # Set background color brush = QtGui.QBrush(QtGui.QColor(70, 70, 70, 255)) self.setBackgroundBrush(brush) - self.background_image = None - self.background_image_path = None + # Ordered list of _LoadedBackground (back-to-front). Replaces the former + # single background_image / background_image_path state. + self.background_layers = [] + # Cached union rect of all layers, refreshed on mutation. + self._bounding_rect = None self.bg_ui = None self.fit_margin = 8 @@ -442,16 +461,18 @@ def contextMenuEvent(self, event, mapped_pos=None): menu.addSeparator() - background_action = QtWidgets.QAction("Set background image", None) + background_action = QtWidgets.QAction("Add background layer", None) background_action.triggered.connect(self.set_background_event) menu.addAction(background_action) - background_size_action = QtWidgets.QAction("Background Size", None) + background_size_action = QtWidgets.QAction( + "Background layers...", None + ) background_size_action.triggered.connect(self.background_options) menu.addAction(background_size_action) reset_background_action = QtWidgets.QAction( - "Remove background", None + "Remove all backgrounds", None ) func = self.reset_background_event reset_background_action.triggered.connect(func) @@ -711,33 +732,137 @@ def apply_background_fallback_logic(self, path): else: return path - def set_background(self, path=None): - """Set tab index widget background image""" - if not path: - return - path = os.path.abspath(r"{}".format(path)) + def _load_layer_image(self, layer): + """Resolve a layer's path and decode its (vertically mirrored) image. + + Fills the layer's natural size when unset and stores the resolved path + back on the model so re-saves point at a valid file. + + Args: + layer (BackgroundLayer): layer to load. + + Returns: + QtGui.QImage: the loaded image, or None if the path is missing. + """ + if not layer.path: + return None + path = os.path.abspath(r"{}".format(layer.path)) path = self.apply_background_fallback_logic(path) - # Check that path exists if not (path and os.path.exists(path)): mgear.log( "anim_picker: background image not found: '{}'".format(path), mgear.sev_warning, ) - return + return None + layer.path = path + image = QtGui.QImage(path).mirrored(False, True) + if not layer.size or not layer.size[0] or not layer.size[1]: + layer.size = [image.width(), image.height()] + return image + + def _layer_draw_size(self, loaded): + """Return the (width, height) a layer is drawn at (model or natural).""" + layer = loaded.layer + if layer.size and layer.size[0] and layer.size[1]: + return int(layer.size[0]), int(layer.size[1]) + if loaded.image is not None: + return loaded.image.width(), loaded.image.height() + return 0, 0 + + def _refresh_layer_geometry(self): + """Recompute and cache each layer's target/source rect and the union. + + Called on layer mutation (add/remove/move/resize/reposition) so the + paint path never allocates or recomputes rects per frame. + """ + bounding = None + for loaded in self.background_layers: + if loaded.image is None: + loaded.rect = None + loaded.src_rect = None + continue + width, height = self._layer_draw_size(loaded) + cx, cy = loaded.layer.position + loaded.rect = QtCore.QRectF( + cx - width / 2.0, cy - height / 2.0, width, height + ) + loaded.src_rect = QtCore.QRectF(loaded.image.rect()) + bounding = ( + loaded.rect + if bounding is None + else bounding.united(loaded.rect) + ) + self._bounding_rect = bounding - self.background_image_path = path + def _update_scene_rect(self): + """Size the scene rect to all content so pan/zoom can reach it. - # Load image and mirror it vertically - self.background_image = QtGui.QImage(path).mirrored(False, True) + The scene rect is the union of the background layers and the picker + items (the buttons), floored at the default canvas so panning stays + free and robust to items being moved while editing (issue #108). Only + the pan/zoom bounds are set here; the view is not refit. + """ + self._refresh_layer_geometry() + + # Union the background layer bounds with the picker items' extent so + # the canvas is not clamped to the image size (buttons stay reachable). + content = self._bounding_rect + items_rect = self.scene().itemsBoundingRect() + if not items_rect.isNull(): + content = ( + items_rect + if content is None + else content.united(items_rect) + ) - # Set scene size to background picture - width = self.background_image.width() - height = self.background_image.height() + if content is None: + self.scene().set_default_size() + return - self.scene().set_size(width, height) + margin = self.fit_margin + content = content.adjusted(-margin, -margin, margin, margin) + self.scene().set_rect(content.united(self.scene().default_rect())) - # Update display + def _update_scene_size(self): + """Recompute the scene rect and refit the view (load / layer edits).""" + self._update_scene_rect() self.fit_scene_content() + self.viewport().update() + + def set_background(self, path=None): + """Append a background image layer to this tab. + + Args: + path (str): image file path. + """ + if not path: + return + layer = background_model.BackgroundLayer() + layer.path = path + image = self._load_layer_image(layer) + if image is None: + return + self.background_layers.append(_LoadedBackground(layer, image)) + self._update_scene_size() + + def set_backgrounds(self, layers): + """Replace all layers from a list of BackgroundLayer models. + + Args: + layers (list): list of BackgroundLayer. + """ + self.background_layers = [] + for layer in layers: + image = self._load_layer_image(layer) + if image is None: + continue + self.background_layers.append(_LoadedBackground(layer, image)) + self._update_scene_size() + + def clear_backgrounds(self): + """Remove all background layers and restore the default canvas.""" + self.background_layers = [] + self._update_scene_size() def background_options(self): tabWidget = self.parent().parent() @@ -748,9 +873,6 @@ def background_options(self): self.bg_ui.deleteLater() except Exception: pass - if not tabWidget.currentWidget().get_background(0): - cmds.warning("Current view has no background!") - return self.bg_ui = basic.BackgroundOptionsDialog(tabWidget, self) self.bg_ui.show() self.bg_ui.raise_() @@ -775,102 +897,94 @@ def set_background_event(self, event=None): self.set_background(file_path) def reset_background_event(self, event=None): - """Reset background to default""" - self.background_image = None - self.background_image_path = None - self.scene().set_default_size() + """Reset background to default (clear all layers)""" + self.clear_backgrounds() + + def remove_background_layer(self, index): + """Remove the layer at index and refit the canvas.""" + if 0 <= index < len(self.background_layers): + self.background_layers.pop(index) + self._update_scene_size() + + def move_background_layer(self, index, new_index): + """Move the layer at index to new_index (changes draw order).""" + count = len(self.background_layers) + if not (0 <= index < count and 0 <= new_index < count): + return + loaded = self.background_layers.pop(index) + self.background_layers.insert(new_index, loaded) + self._update_scene_size() - # Update display - self.fit_scene_content() + def set_layer_position(self, index, x, y): + """Set the center position of the layer at index.""" + if 0 <= index < len(self.background_layers): + self.background_layers[index].layer.position = [x, y] + self._update_scene_size() def resize_background_image( - self, width, height, keepAspectRatio=False, auto_update=True + self, index, width, height, keepAspectRatio=False, auto_update=True ): - """resize the background image if one is set + """Resize the draw size of the layer at index. Args: - width (int): desired width - height (int): desired height - keepAspectRatio (bool, optional): scale image to fit aspect ratio - auto_update (bool, optional): update the scene view - - Returns: - None: none + index (int): layer index. + width (int): desired width. + height (int): desired height. + keepAspectRatio (bool, optional): keep the image's natural aspect. + auto_update (bool, optional): refit the scene view. """ - if not self.background_image: - return - - current_width = self.background_image.size().width() - current_height = self.background_image.size().height() - if current_width == width and current_height == height: + if not (0 <= index < len(self.background_layers)): return - - if keepAspectRatio: - if current_width != width: - aspect_size = self.background_image.scaledToWidth(width).size() - width, height = aspect_size.width(), aspect_size.height() - elif current_height != height: - aspect_size = self.background_image.scaledToHeight( - height - ).size() - width, height = aspect_size.width(), aspect_size.height() - # TODO find if this is the most efficient way to achieve this - self.background_image = self.background_image.scaled(width, height) - + loaded = self.background_layers[index] + if keepAspectRatio and loaded.image is not None: + natural = loaded.image.size() + nat_w = natural.width() or 1 + nat_h = natural.height() or 1 + current_w, current_h = self._layer_draw_size(loaded) + if width != current_w: + height = int(round(width * nat_h / float(nat_w))) + elif height != current_h: + width = int(round(height * nat_w / float(nat_h))) + loaded.layer.size = [int(width), int(height)] if auto_update: - self.scene().set_size(width, height) - # Update display - self.fit_scene_content() - - def set_background_width(self, width, keepAspectRatio=True): - """convenience function for setting width on bg image + self._update_scene_size() - Args: - width (int): desired width - keepAspectRatio (bool, optional): force aspect ration - - Returns: - None: None - """ - if not self.background_image: + def set_background_width(self, index, width, keepAspectRatio=True): + """Set the draw width of the layer at index.""" + if not (0 <= index < len(self.background_layers)): return - current_height = self.background_image.size().height() + _, current_height = self._layer_draw_size(self.background_layers[index]) self.resize_background_image( - width, current_height, keepAspectRatio=keepAspectRatio + index, width, current_height, keepAspectRatio=keepAspectRatio ) - def set_background_height(self, height, keepAspectRatio=True): - """convenience function for setting height on bg image - - Args: - height (int): desired height - keepAspectRatio (bool, optional): force aspect ration - - Returns: - None: None - """ - if not self.background_image: + def set_background_height(self, index, height, keepAspectRatio=True): + """Set the draw height of the layer at index.""" + if not (0 <= index < len(self.background_layers)): return - current_width = self.background_image.size().width() + current_width, _ = self._layer_draw_size(self.background_layers[index]) self.resize_background_image( - current_width, height, keepAspectRatio=keepAspectRatio + index, current_width, height, keepAspectRatio=keepAspectRatio ) - def get_background_size(self): - """get bg image in Qt.QSize + def get_background_layers(self): + """Return the ordered list of _LoadedBackground (back to front).""" + return self.background_layers - Returns: - Qt.QSize: current size of bg - """ - bg_image = self.get_background(0) - if bg_image: - return bg_image.size() - else: + def get_background_size(self): + """Return the union size of all layers as a Qt.QSize.""" + bounding = self._bounding_rect + if bounding is None: return QtCore.QSize(0, 0) + return QtCore.QSize( + int(round(bounding.width())), int(round(bounding.height())) + ) def get_background(self, index): - """Return background for tab index""" - return self.background_image + """Return the loaded image for the layer at index, or None.""" + if 0 <= index < len(self.background_layers): + return self.background_layers[index].image + return None def clear(self): """Clear view, by replacing scene with a new one""" @@ -898,11 +1012,12 @@ def get_data(self): """Return view data""" data = {} - # Add background to data - if self.background_image_path: - bg_fp = r"{}".format(self.background_image_path) - data["background"] = bg_fp - data["background_size"] = self.get_background_size().toTuple() + # Add background layers to data (new composite schema) + backgrounds = background_model.layers_to_data( + [loaded.layer for loaded in self.background_layers] + ) + if backgrounds: + data["backgrounds"] = backgrounds # Add items to data items = [] @@ -917,36 +1032,31 @@ def set_data(self, data): """Set/load view data""" self.clear() - # Set backgraound picture - background = data.get("background", None) - background_size = data.get("background_size", None) - if background: - self.set_background(background) - if background_size: - self.resize_background_image( - background_size[0], background_size[1] - ) + # Set background layers (accepts the new backgrounds list and the + # legacy single background/background_size keys). + self.set_backgrounds(background_model.layers_from_view_data(data)) # Add items to view for item_data in data.get("items", []): item = self.add_picker_item() item.set_data(item_data) + # Size the scene to include the items too, now that they exist. + self._update_scene_size() + def drawBackground(self, painter, rect): - """Default method override to draw view custom background image""" + """Draw the tab's background image layers (back to front)""" # Run default method result = QtWidgets.QGraphicsView.drawBackground(self, painter, rect) - # Stop here if view has no background - if not self.background_image: - return result - - # Draw background image - painter.drawImage( - self.sceneRect(), - self.background_image, - QtCore.QRectF(self.background_image.rect()), - ) + # Draw each layer from its cached geometry, culling those outside the + # exposed paint region. No per-paint allocation or recomputation. + for loaded in self.background_layers: + if loaded.image is None or loaded.rect is None: + continue + if not loaded.rect.intersects(rect): + continue + painter.drawImage(loaded.rect, loaded.image, loaded.src_rect) return result @@ -1029,7 +1139,10 @@ def convert_picker_to_curves(self): picker_grp, ["tz", "rx", "ry", "rz", "sx", "sz", "v"] ) - if "background" in tab["data"]: + # Create one image plane per background layer (composite backgrounds). + # Accepts both the new backgrounds list and the legacy single keys. + bg_layers = background_model.layers_from_view_data(tab["data"]) + if bg_layers: attribute.addAttribute( picker_grp, "backgroundAlpha", @@ -1038,31 +1151,24 @@ def convert_picker_to_curves(self): minValue=0, maxValue=1, ) - attribute.addAttribute( - picker_grp, - "backgroundWidth", - "long", - tab["data"]["background_size"][0], - minValue=1, - ) - attribute.addAttribute( - picker_grp, - "backgroundHeight", - "long", - tab["data"]["background_size"][1], - minValue=1, - ) - ip = pm.imagePlane(n="{}_background".format(tab["name"])) - ip[0].tz.set(-1) - ip[0].overrideEnabled.set(1) - ip[0].overrideDisplayType.set(2) - pm.parent(ip[0], picker_grp) - - picker_grp.backgroundAlpha >> ip[1].alphaGain - picker_grp.backgroundWidth >> ip[1].width - picker_grp.backgroundHeight >> ip[1].height - - ip[1].imageName.set(tab["data"]["background"]) + for i, layer in enumerate(bg_layers): + ip = pm.imagePlane( + n="{}_background_{}".format(tab["name"], i) + ) + # Stack planes behind the curves and by index so the draw + # order is recoverable on convert-back. + ip[0].tz.set(-1 - i) + ip[0].tx.set(layer.position[0]) + ip[0].ty.set(layer.position[1]) + ip[0].overrideEnabled.set(1) + ip[0].overrideDisplayType.set(2) + pm.parent(ip[0], picker_grp) + + picker_grp.backgroundAlpha >> ip[1].alphaGain + if layer.size and layer.size[0] and layer.size[1]: + ip[1].width.set(layer.size[0]) + ip[1].height.set(layer.size[1]) + ip[1].imageName.set(layer.path) if "items" in tab["data"]: for item in tab["data"]["items"]: @@ -1174,15 +1280,30 @@ def convert_curves_to_picker(self): tab_name = tab_grp.name() new_data["tabs"].append({"name": tab_name}) new_data["tabs"][-1]["data"] = {"items": []} - bg_imagePlane = tab_grp.listRelatives(type="imagePlane", ad=True) - - if bg_imagePlane: - image_name = bg_imagePlane[0].imageName.get() - new_data["tabs"][-1]["data"]["background"] = image_name - new_data["tabs"][-1]["data"]["background_size"] = [ - bg_imagePlane[0].width.get(), - bg_imagePlane[0].height.get(), - ] + # Collect all background image planes under the tab group and + # rebuild the composite backgrounds list. Draw order is recovered + # from the plane depth (tz), which convert_picker_to_curves stacks + # per layer index -- robust to Maya node renames. + bg_pairs = [] + for child in tab_grp.listRelatives() or []: + shape = child.getShape() + if shape is None or shape.type() != "imagePlane": + continue + bg_pairs.append((child, shape)) + # index 0 (backmost) was stacked at tz = -1, index 1 at -2, ... + bg_pairs.sort(key=lambda pair: pair[0].tz.get(), reverse=True) + + bg_layers = [] + for transform, shape in bg_pairs: + layer = background_model.BackgroundLayer() + layer.path = shape.imageName.get() + layer.position = [transform.tx.get(), transform.ty.get()] + layer.size = [shape.width.get(), shape.height.get()] + bg_layers.append(layer) + if bg_layers: + new_data["tabs"][-1]["data"][ + "backgrounds" + ] = background_model.layers_to_data(bg_layers) for item_curve in tab_grp.listRelatives(): shape = item_curve.getShape() diff --git a/release/scripts/mgear/anim_picker/widgets/background_model.py b/release/scripts/mgear/anim_picker/widgets/background_model.py new file mode 100644 index 00000000..0a198601 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/background_model.py @@ -0,0 +1,102 @@ +"""Qt/Maya-free data model for picker tab background layers. + +A picker tab background is an ordered list of image *layers* drawn back-to-front +(index 0 is the backmost layer). ``BackgroundLayer`` mirrors the per-layer +dictionary schema stored in ``.pkr`` files and the ``PICKER_DATAS`` node, and is +the serialization authority for the view's backgrounds (``get_data`` / ``set_data`` +delegate to ``layers_to_data`` / ``layers_from_view_data``). It has no Qt or Maya +dependency, so layers can be constructed and round-tripped for import/export +without a running DCC. + +Fields map 1:1 to the schema keys: + path image file path (absolute or relative) + position [cx, cy] layer center in the view's centered, Y-flipped scene space + size [w, h] draw size in scene units ([0, 0] means "use natural size") +""" + + +class BackgroundLayer(object): + """Serializable data model for a single background image layer.""" + + def __init__(self): + self.path = None + self.position = [0, 0] + self.size = [0, 0] + + @classmethod + def from_dict(cls, data): + """Build a layer from a background-layer dictionary. + + Args: + data (dict): background layer data. + + Returns: + BackgroundLayer: populated model. + """ + layer = cls() + data = data or {} + + if "path" in data: + layer.path = data["path"] + if "position" in data: + layer.position = list(data.get("position", [0, 0])) + if "size" in data: + layer.size = list(data.get("size", [0, 0])) + + return layer + + def to_dict(self): + """Serialize the layer back to the background-layer dictionary schema. + + Returns: + dict: background layer data with ``path``, ``position``, ``size``. + """ + return { + "path": self.path, + "position": list(self.position), + "size": list(self.size), + } + + +def layers_from_view_data(data): + """Build a list of ``BackgroundLayer`` from a tab's view data. + + Accepts the new ``backgrounds`` list, or the legacy single ``background`` / + ``background_size`` keys (mapped to one centered layer), or neither. + + Args: + data (dict): tab view data (may be partial). + + Returns: + list: list of ``BackgroundLayer`` (empty when there is no background). + """ + data = data or {} + + if "backgrounds" in data: + return [BackgroundLayer.from_dict(entry) for entry in data["backgrounds"]] + + # Legacy single-image compatibility: map to one centered layer so bundled + # templates and older user exports keep loading. + background = data.get("background", None) + if background: + layer = BackgroundLayer() + layer.path = background + layer.position = [0, 0] + size = data.get("background_size", None) + if size: + layer.size = list(size) + return [layer] + + return [] + + +def layers_to_data(layers): + """Serialize a list of ``BackgroundLayer`` to the ``backgrounds`` list form. + + Args: + layers (list): list of ``BackgroundLayer``. + + Returns: + list: list of background-layer dictionaries. + """ + return [layer.to_dict() for layer in layers] diff --git a/release/scripts/mgear/anim_picker/widgets/basic.py b/release/scripts/mgear/anim_picker/widgets/basic.py index 73d9a8a5..73726322 100644 --- a/release/scripts/mgear/anim_picker/widgets/basic.py +++ b/release/scripts/mgear/anim_picker/widgets/basic.py @@ -414,82 +414,204 @@ def get_data(self): class BackgroundOptionsDialog(QtWidgets.QDialog): - """minimal ui for adjusting the background image""" + """Layer manager for a tab's composite background. + + Lists the current tab's background layers and lets the user add, remove, + reorder, reposition, and resize each layer. No fixed size cap (issue #108). + """ def __init__(self, tabWidget, parent=None): super().__init__(parent) - self.setWindowTitle("Set background size") + self.setWindowTitle("Background layers") self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) self.tabWidget = tabWidget self.keep_aspect_ratio = True + # Guard to avoid write-back while the fields are being populated. + self._syncing = False - if not self.tabWidget.currentWidget().get_background(0): - cmds.warning("Current view has no background!") - return None + # Layer list + list actions + self.layer_list = QtWidgets.QListWidget() + self.add_button = QtWidgets.QPushButton("Add Layer") + self.remove_button = QtWidgets.QPushButton("Remove Layer") + self.up_button = QtWidgets.QPushButton("Move Up") + self.down_button = QtWidgets.QPushButton("Move Down") - width_label = QtWidgets.QLabel("Width") - self.width_box = QtWidgets.QSpinBox() - self.width_box.setRange(1, 6000) - height_label = QtWidgets.QLabel("Height") - self.height_box = QtWidgets.QSpinBox() - self.height_box.setRange(1, 6000) + # Per-layer fields self.aspect_button = QtWidgets.QPushButton("Maintain Aspect Ratio") self.aspect_button.setCheckable(True) self.aspect_button.setChecked(True) - self.reset_button = QtWidgets.QPushButton("Reset Size") + self.pos_x_box = QtWidgets.QSpinBox() + self.pos_y_box = QtWidgets.QSpinBox() + self.width_box = QtWidgets.QSpinBox() + self.height_box = QtWidgets.QSpinBox() + for box in (self.pos_x_box, self.pos_y_box): + box.setRange(-1000000, 1000000) + for box in (self.width_box, self.height_box): + box.setRange(1, 1000000) - self.main_layout = QtWidgets.QVBoxLayout(self) + self.build_layout() + self.connectSignals() + self.refresh_layer_list() - self.main_layout.addWidget(self.aspect_button) - self.main_layout.addWidget(width_label) - self.main_layout.addWidget(self.width_box) - self.main_layout.addWidget(height_label) - self.main_layout.addWidget(self.height_box) - self.main_layout.addWidget(self.reset_button) + def build_layout(self): + self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout.addWidget(self.layer_list) - self.update_ui_width_value() - self.update_ui_height_value() + list_buttons = QtWidgets.QHBoxLayout() + list_buttons.addWidget(self.add_button) + list_buttons.addWidget(self.remove_button) + list_buttons.addWidget(self.up_button) + list_buttons.addWidget(self.down_button) + self.main_layout.addLayout(list_buttons) - self.connectSignals() + self.main_layout.addWidget(self.aspect_button) + form = QtWidgets.QFormLayout() + form.addRow("X", self.pos_x_box) + form.addRow("Y", self.pos_y_box) + form.addRow("Width", self.width_box) + form.addRow("Height", self.height_box) + self.main_layout.addLayout(form) def connectSignals(self): + self.add_button.clicked.connect(self.add_layer) + self.remove_button.clicked.connect(self.remove_layer) + self.up_button.clicked.connect(self.move_up) + self.down_button.clicked.connect(self.move_down) + self.layer_list.currentRowChanged.connect(self.on_selection_changed) self.aspect_button.clicked.connect(self.toggle_aspect_value) - self.width_box.editingFinished.connect(self.update_background_width) - self.width_box.editingFinished.connect(self.update_ui_height_value) - self.height_box.editingFinished.connect(self.update_background_height) - self.height_box.editingFinished.connect(self.update_ui_width_value) - self.reset_button.clicked.connect(self.reset_size) - - def update_ui_width_value(self, *args): - if not self.keep_aspect_ratio: + self.pos_x_box.editingFinished.connect(self.apply_position) + self.pos_y_box.editingFinished.connect(self.apply_position) + self.width_box.editingFinished.connect(self.apply_width) + self.height_box.editingFinished.connect(self.apply_height) + + def gfx_view(self): + """Return the current tab's graphics view (or None).""" + return self.tabWidget.currentWidget() + + def current_index(self): + """Return the selected layer index (-1 if none).""" + return self.layer_list.currentRow() + + def refresh_layer_list(self): + """Rebuild the layer list from the view, preserving the selection.""" + view = self.gfx_view() + layers = view.get_background_layers() if view else [] + keep = self.current_index() + + self._syncing = True + self.layer_list.clear() + for loaded in layers: + name = os.path.basename(loaded.layer.path or "layer") + self.layer_list.addItem(name) + # Select inside the guard so currentRowChanged is suppressed; the + # explicit populate_fields() below then runs exactly once. + if layers: + row = keep if 0 <= keep < len(layers) else 0 + self.layer_list.setCurrentRow(row) + self._syncing = False + + self.populate_fields() + + def populate_fields(self): + """Load the selected layer's position/size into the fields.""" + view = self.gfx_view() + layers = view.get_background_layers() if view else [] + index = self.current_index() + has = 0 <= index < len(layers) + for widget in ( + self.pos_x_box, + self.pos_y_box, + self.width_box, + self.height_box, + self.remove_button, + self.up_button, + self.down_button, + ): + widget.setEnabled(has) + if not has: return - size = self.tabWidget.currentWidget().get_background_size() - self.width_box.setValue(size.width()) - def update_ui_height_value(self, *args): - if not self.keep_aspect_ratio: + layer = layers[index].layer + self._syncing = True + self.pos_x_box.setValue(int(layer.position[0])) + self.pos_y_box.setValue(int(layer.position[1])) + self.width_box.setValue(int(layer.size[0])) + self.height_box.setValue(int(layer.size[1])) + self._syncing = False + + def on_selection_changed(self, *args): + if self._syncing: return - size = self.tabWidget.currentWidget().get_background_size() - self.height_box.setValue(size.height()) + self.populate_fields() - def update_background_width(self): - gfx_view = self.tabWidget.currentWidget() - gfx_view.set_background_width( - int(self.width_box.text()), keepAspectRatio=self.keep_aspect_ratio - ) + def add_layer(self): + view = self.gfx_view() + if not view: + return + view.set_background_event() + self.refresh_layer_list() + count = self.layer_list.count() + if count: + self.layer_list.setCurrentRow(count - 1) + + def remove_layer(self): + index = self.current_index() + if index < 0: + return + self.gfx_view().remove_background_layer(index) + self.refresh_layer_list() - def update_background_height(self): - gfx_view = self.tabWidget.currentWidget() - gfx_view.set_background_height( - int(self.height_box.text()), keepAspectRatio=self.keep_aspect_ratio - ) + def move_up(self): + index = self.current_index() + if index <= 0: + return + self.gfx_view().move_background_layer(index, index - 1) + self.refresh_layer_list() + self.layer_list.setCurrentRow(index - 1) + + def move_down(self): + index = self.current_index() + if index < 0 or index >= self.layer_list.count() - 1: + return + self.gfx_view().move_background_layer(index, index + 1) + self.refresh_layer_list() + self.layer_list.setCurrentRow(index + 1) def toggle_aspect_value(self): self.keep_aspect_ratio = not self.keep_aspect_ratio - def reset_size(self): - path = self.tabWidget.currentWidget().background_image_path - self.tabWidget.currentWidget().set_background(path) - self.update_ui_width_value() - self.update_ui_height_value() + def apply_position(self): + if self._syncing: + return + index = self.current_index() + if index < 0: + return + self.gfx_view().set_layer_position( + index, self.pos_x_box.value(), self.pos_y_box.value() + ) + + def apply_width(self): + if self._syncing: + return + index = self.current_index() + if index < 0: + return + self.gfx_view().set_background_width( + index, self.width_box.value(), keepAspectRatio=self.keep_aspect_ratio + ) + # Reflect an aspect-adjusted height back into the fields. + self.populate_fields() + + def apply_height(self): + if self._syncing: + return + index = self.current_index() + if index < 0: + return + self.gfx_view().set_background_height( + index, + self.height_box.value(), + keepAspectRatio=self.keep_aspect_ratio, + ) + self.populate_fields() diff --git a/releaseLog.rst b/releaseLog.rst index 7dc41506..5a9d2eba 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Composite and flexible tab backgrounds — a tab background is now an ordered list of image layers, each with its own position and size, drawn back-to-front; the canvas spans both the background layers and the picker buttons (never smaller than the default), so pan and zoom reach all content and backgrounds can be larger and non-proportional #108 (the former 6000 size cap is removed). A new background-layer manager (right-click > Background layers...) adds / removes / reorders and repositions layers. Legacy single-image pickers still load (mapped to one layer); writes now emit a "backgrounds" list * Anim Picker: Modernize the module (Phase 1 cleanup) — Python-3-only idioms, mgear.log-based logging, Maya API 2.0 selection handling, and dead-code / debug-comment removal; internal anim_picker version bumped to 2.0.0 * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) From e858025405eb680be340ff59d8775af579bc65e5 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Wed, 8 Jul 2026 12:55:44 +0900 Subject: [PATCH 04/25] AnimPicker: Backgrounds: On-canvas move/scale manipulators with multi-select With the Background layers panel open (edit mode), background layers can be manipulated directly on the canvas: click / shift-click / marquee-select layers, drag the body to move, drag the bounding-box handles to scale (corner = both axes, edge = one, Shift keeps aspect). Multi-selected layers transform together as a group about the opposite handle; the panel selection and X/Y/W/H fields stay in sync. - widgets/background_transform.py: Qt/Maya-free group move/scale math (union_bounds, move_layers, scale_factors, scale_layers), unit-tested. - widgets/background_manipulator.py: BackgroundManipulator controller (selection, hit-test, overlay paint at constant screen size, drag lifecycle). - view.py: background-edit sub-mode routes mouse events to the manipulator and paints the overlay in drawForeground; panel-sync accessors. - BackgroundOptionsDialog: multi-selection, two-way canvas/panel sync, opens and closes the sub-mode. --- release/scripts/mgear/anim_picker/view.py | 158 +++++++++++++++ .../widgets/background_manipulator.py | 181 ++++++++++++++++++ .../widgets/background_transform.py | 138 +++++++++++++ .../mgear/anim_picker/widgets/basic.py | 58 ++++++ releaseLog.rst | 1 + 5 files changed, 536 insertions(+) create mode 100644 release/scripts/mgear/anim_picker/widgets/background_manipulator.py create mode 100644 release/scripts/mgear/anim_picker/widgets/background_transform.py diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index b3b3be3d..91467059 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -25,6 +25,7 @@ from mgear.anim_picker.widgets import basic from mgear.anim_picker.widgets import picker_widgets from mgear.anim_picker.widgets import background_model +from mgear.anim_picker.widgets import background_manipulator from mgear.anim_picker.handlers import __EDIT_MODE__ @@ -90,6 +91,13 @@ def __init__(self, namespace=None, main_window=None): self._bounding_rect = None self.bg_ui = None + # On-canvas background layer manipulation (active with the panel open). + self.background_edit = False + self.bg_manipulator = background_manipulator.BackgroundManipulator(self) + self._bg_dragging = False + self._bg_marquee_origin = None + self._bg_marquee_current = None + self.fit_margin = 8 # # undo list --------------------------------------------------------- @@ -102,6 +110,9 @@ def get_center_pos(self): ) def mousePressEvent(self, event): + # Background layer manipulation intercepts left-click when active. + if self.background_edit and self._bg_mouse_press(event): + return self.modified_select = False self.item_selected = False self.__move_prompt = False @@ -164,6 +175,9 @@ def mousePressEvent(self, event): ) def mouseMoveEvent(self, event): + # Background layer drag / marquee intercepts movement when active. + if self.background_edit and self._bg_mouse_move(event): + return result = QtWidgets.QGraphicsView.mouseMoveEvent(self, event) if ( @@ -207,6 +221,9 @@ def mouseMoveEvent(self, event): def mouseReleaseEvent(self, event): """Overload to clear selection on empty area""" + # Background layer drag / marquee release when active. + if self.background_edit and self._bg_mouse_release(event): + return result = QtWidgets.QGraphicsView.mouseReleaseEvent(self, event) if ( not self.drag_active @@ -864,6 +881,142 @@ def clear_backgrounds(self): self.background_layers = [] self._update_scene_size() + def enter_background_edit(self): + """Activate on-canvas background layer manipulation (panel open).""" + self.background_edit = True + self.setDragMode(QtWidgets.QGraphicsView.NoDrag) + self.viewport().update() + + def exit_background_edit(self): + """Deactivate manipulation and restore normal picker interaction. + + Only manipulation state is reset here; the ``bg_ui`` panel reference is + owned by ``background_options`` (which already tolerates a stale one). + """ + self.background_edit = False + self.bg_manipulator.clear() + self._bg_dragging = False + self._bg_marquee_origin = None + self._bg_marquee_current = None + self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) + self.viewport().update() + + def get_selected_bg_indices(self): + """Return the indices of the currently selected background layers.""" + return list(self.bg_manipulator.selected) + + def set_selected_bg_indices(self, indices): + """Set the selected background layers (driven by the panel list).""" + self.bg_manipulator.set_selected(indices) + self.viewport().update() + + def _notify_bg_selection(self): + """Tell the panel the canvas selection changed.""" + if self.bg_ui: + self.bg_ui.on_canvas_selection_changed() + + def _notify_bg_fields(self): + """Tell the panel to refresh its position/size fields.""" + if self.bg_ui: + self.bg_ui.refresh_active_fields() + + def _bg_mouse_press(self, event): + """Handle a background-edit left press. Return True if consumed.""" + if event.button() != QtCore.Qt.MouseButton.LeftButton: + return False + scene_pos = self.mapToScene(event.pos()) + x, y = scene_pos.x(), scene_pos.y() + + # Grab a handle or the body of the current selection first. + hit = self.bg_manipulator.hit_test(x, y) + if hit is not None: + mode = hit[0] + handle = hit[1] if mode == "handle" else None + self.bg_manipulator.begin_drag(mode, handle, x, y) + self._bg_dragging = True + return True + + # Otherwise select the layer under the cursor, or start a marquee. + additive = bool(event.modifiers() & QtCore.Qt.ShiftModifier) + index = self.bg_manipulator.layer_at(x, y) + if index is not None: + self.bg_manipulator.select(index, additive) + self._notify_bg_selection() + self.viewport().update() + return True + + if not additive: + self.bg_manipulator.clear() + self._notify_bg_selection() + self._bg_marquee_origin = scene_pos + self._bg_marquee_current = scene_pos + self.viewport().update() + return True + + def _bg_mouse_move(self, event): + """Handle a background-edit drag/marquee move. Return True if used.""" + if not (event.buttons() & QtCore.Qt.MouseButton.LeftButton): + return False + scene_pos = self.mapToScene(event.pos()) + + if self._bg_dragging: + keep = bool(event.modifiers() & QtCore.Qt.ShiftModifier) + self.bg_manipulator.update_drag(scene_pos.x(), scene_pos.y(), keep) + self._refresh_layer_geometry() + self._notify_bg_fields() + self.viewport().update() + return True + + if self._bg_marquee_origin is not None: + self._bg_marquee_current = scene_pos + self.viewport().update() + return True + + return False + + def _bg_mouse_release(self, event): + """Handle a background-edit release. Return True if consumed.""" + if event.button() != QtCore.Qt.MouseButton.LeftButton: + return False + + if self._bg_dragging: + self.bg_manipulator.end_drag() + self._bg_dragging = False + # Grow the pan bounds to the layer's final size (no view refit). + self._update_scene_rect() + self._notify_bg_fields() + return True + + if self._bg_marquee_origin is not None: + additive = bool(event.modifiers() & QtCore.Qt.ShiftModifier) + rect = QtCore.QRectF( + self._bg_marquee_origin, self._bg_marquee_current + ).normalized() + self._bg_marquee_origin = None + self._bg_marquee_current = None + if rect.width() > 1e-5 or rect.height() > 1e-5: + self.bg_manipulator.select_in_rect(rect, additive) + self._notify_bg_selection() + self.viewport().update() + return True + + return False + + def _draw_bg_marquee(self, painter): + """Draw the background-selection marquee rectangle, if active.""" + if self._bg_marquee_origin is None or self._bg_marquee_current is None: + return + rect = QtCore.QRectF( + self._bg_marquee_origin, self._bg_marquee_current + ).normalized() + pen = QtGui.QPen( + QtGui.QColor(255, 200, 40, 200), 0, QtCore.Qt.DashLine + ) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawRect(rect) + def background_options(self): tabWidget = self.parent().parent() # Delete old window @@ -1069,6 +1222,11 @@ def drawForeground(self, painter, rect): if __EDIT_MODE__.get(): self.draw_overlay_axis(painter, rect) + # Background layer manipulator overlay + selection marquee + if self.background_edit: + self.bg_manipulator.paint(painter) + self._draw_bg_marquee(painter) + return result def draw_overlay_axis(self, painter, rect): diff --git a/release/scripts/mgear/anim_picker/widgets/background_manipulator.py b/release/scripts/mgear/anim_picker/widgets/background_manipulator.py new file mode 100644 index 00000000..d910f13f --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/background_manipulator.py @@ -0,0 +1,181 @@ +"""On-canvas move/scale manipulator for background layers. + +``BackgroundManipulator`` owns the runtime selection of background layers, the +hit-testing, the overlay drawing (bounding box + 8 handles at a constant screen +size), and the drag lifecycle. The actual group-transform math lives in the +Qt/Maya-free ``background_transform`` module; this controller only bridges it to +the Qt view. It reads/writes ``BackgroundLayer`` models on the view's +``background_layers`` list and never touches persistence. +""" + +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtGui + +from mgear.anim_picker.widgets import background_transform + + +class BackgroundManipulator(object): + """Selection + move/scale controller for a view's background layers.""" + + HANDLE_PX = 6 # half-size of a handle square, in screen pixels + PICK_PX = 8 # handle pick radius, in screen pixels + MIN_SIZE = 1.0 + COLOR = (255, 200, 40, 220) + + def __init__(self, view): + self.view = view + self.selected = [] + # Drag state, captured at begin_drag so transforms are from the start. + self._drag_mode = None # "body" | "handle" | None + self._drag_handle = None + self._drag_origin = None # (x, y) in scene units + self._orig_layers = None # [(cx, cy, w, h), ...] + self._orig_bounds = None # (x, y, w, h) + + # -- helpers -------------------------------------------------------- + def _px_to_scene(self): + """Return the scene units per screen pixel for the current zoom.""" + m11 = self.view.transform().m11() + if not m11: + return 1.0 + return 1.0 / abs(m11) + + def _layers(self): + return self.view.background_layers + + def selected_models(self): + """Return the selected BackgroundLayer models in selection order.""" + layers = self._layers() + return [ + layers[i].layer for i in self.selected if 0 <= i < len(layers) + ] + + def bounds(self): + """Return the union bounds of the selection, or None.""" + return background_transform.union_bounds(self.selected_models()) + + # -- selection ------------------------------------------------------ + def clear(self): + self.selected = [] + + def set_selected(self, indices): + count = len(self._layers()) + self.selected = [i for i in indices if 0 <= i < count] + + def select(self, index, additive=False): + if additive: + if index in self.selected: + self.selected.remove(index) + else: + self.selected.append(index) + else: + self.selected = [index] + + def layer_at(self, x, y): + """Return the front-most layer index containing (x, y), or None.""" + layers = self._layers() + point = QtCore.QPointF(x, y) + for i in range(len(layers) - 1, -1, -1): + rect = layers[i].rect + if rect is not None and rect.contains(point): + return i + return None + + def select_in_rect(self, rect, additive=False): + """Select every layer whose cached rect intersects ``rect``.""" + hits = [ + i + for i, loaded in enumerate(self._layers()) + if loaded.rect is not None and loaded.rect.intersects(rect) + ] + if additive: + for i in hits: + if i not in self.selected: + self.selected.append(i) + else: + self.selected = hits + + # -- hit testing ---------------------------------------------------- + def hit_test(self, x, y): + """Return ("handle", id) / ("body",) / None for a scene point.""" + if not self.selected: + return None + bounds = self.bounds() + if bounds is None: + return None + radius = self.PICK_PX * self._px_to_scene() + for hid, (hx, hy) in background_transform.handle_points(bounds).items(): + if abs(x - hx) <= radius and abs(y - hy) <= radius: + return ("handle", hid) + bx, by, bw, bh = bounds + if bx <= x <= bx + bw and by <= y <= by + bh: + return ("body",) + return None + + # -- drag lifecycle ------------------------------------------------- + def begin_drag(self, mode, handle, x, y): + self._drag_mode = mode + self._drag_handle = handle + self._drag_origin = (x, y) + self._orig_layers = [ + (m.position[0], m.position[1], m.size[0], m.size[1]) + for m in self.selected_models() + ] + self._orig_bounds = self.bounds() + + def update_drag(self, x, y, keep_aspect=False): + if self._drag_mode is None: + return + models = self.selected_models() + # Restore originals so the transform is computed from the drag start. + for model, orig in zip(models, self._orig_layers): + model.position = [orig[0], orig[1]] + model.size = [orig[2], orig[3]] + + if self._drag_mode == "body": + dx = x - self._drag_origin[0] + dy = y - self._drag_origin[1] + background_transform.move_layers(models, dx, dy) + else: + anchor_x, anchor_y, sx, sy = background_transform.scale_factors( + self._orig_bounds, self._drag_handle, x, y, keep_aspect + ) + background_transform.scale_layers( + models, anchor_x, anchor_y, sx, sy, self.MIN_SIZE + ) + + def end_drag(self): + self._drag_mode = None + self._drag_handle = None + self._drag_origin = None + self._orig_layers = None + self._orig_bounds = None + + def is_dragging(self): + return self._drag_mode is not None + + # -- painting ------------------------------------------------------- + def paint(self, painter): + """Draw the selection bounding box and handles (scene coordinates).""" + if not self.selected: + return + bounds = self.bounds() + if bounds is None: + return + + x, y, w, h = bounds + color = QtGui.QColor(*self.COLOR) + + # Cosmetic 1px outline keeps a constant screen width at any zoom. + pen = QtGui.QPen(color, 0) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawRect(QtCore.QRectF(x, y, w, h)) + + half = self.HANDLE_PX * self._px_to_scene() + painter.setBrush(color) + for hx, hy in background_transform.handle_points(bounds).values(): + painter.drawRect( + QtCore.QRectF(hx - half, hy - half, half * 2.0, half * 2.0) + ) diff --git a/release/scripts/mgear/anim_picker/widgets/background_transform.py b/release/scripts/mgear/anim_picker/widgets/background_transform.py new file mode 100644 index 00000000..e9dea3e9 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/background_transform.py @@ -0,0 +1,138 @@ +"""Qt/Maya-free move/scale math for background layer manipulation. + +These functions operate on any object exposing ``position`` ([cx, cy], the layer +center) and ``size`` ([w, h]) -- i.e. ``BackgroundLayer`` -- so the on-canvas +manipulator's group-transform math stays testable without a running DCC. + +Bounds are expressed as ``(x, y, w, h)`` where ``(x, y)`` is the min corner in the +view's centered, Y-up scene space and ``w``/``h`` are positive. +""" + + +HANDLE_IDS = ("bl", "br", "tl", "tr", "l", "r", "t", "b") + +# Opposite handle used as the fixed anchor while scaling. +_OPPOSITE = { + "bl": "tr", + "tr": "bl", + "br": "tl", + "tl": "br", + "l": "r", + "r": "l", + "b": "t", + "t": "b", +} + +_SCALES_X = ("bl", "br", "tl", "tr", "l", "r") +_SCALES_Y = ("bl", "br", "tl", "tr", "t", "b") + + +def union_bounds(layers): + """Return the union bounds of the given layers, or None when empty. + + Args: + layers (list): objects with ``position`` and ``size``. + + Returns: + tuple: ``(x, y, w, h)`` or None. + """ + if not layers: + return None + min_x = min(layer.position[0] - layer.size[0] / 2.0 for layer in layers) + max_x = max(layer.position[0] + layer.size[0] / 2.0 for layer in layers) + min_y = min(layer.position[1] - layer.size[1] / 2.0 for layer in layers) + max_y = max(layer.position[1] + layer.size[1] / 2.0 for layer in layers) + return (min_x, min_y, max_x - min_x, max_y - min_y) + + +def handle_points(bounds): + """Return a dict of handle id -> (x, y) for the given bounds. + + Args: + bounds (tuple): ``(x, y, w, h)``. + + Returns: + dict: handle id -> (x, y). + """ + x, y, w, h = bounds + return { + "bl": (x, y), + "br": (x + w, y), + "tl": (x, y + h), + "tr": (x + w, y + h), + "l": (x, y + h / 2.0), + "r": (x + w, y + h / 2.0), + "b": (x + w / 2.0, y), + "t": (x + w / 2.0, y + h), + } + + +def move_layers(layers, dx, dy): + """Translate each layer's center by (dx, dy) in place. + + Args: + layers (list): objects with ``position``. + dx (float): x delta in scene units. + dy (float): y delta in scene units. + """ + for layer in layers: + layer.position = [layer.position[0] + dx, layer.position[1] + dy] + + +def scale_factors(bounds, handle_id, cursor_x, cursor_y, keep_aspect=False): + """Compute the anchor and scale factors for a handle drag. + + Corner handles scale both axes, edge handles scale one; ``keep_aspect`` + forces a uniform factor. The anchor is the opposite handle's position. + + Args: + bounds (tuple): original ``(x, y, w, h)`` at drag start. + handle_id (str): one of ``HANDLE_IDS``. + cursor_x (float): current cursor x in scene units. + cursor_y (float): current cursor y in scene units. + keep_aspect (bool, optional): keep the aspect ratio. + + Returns: + tuple: ``(anchor_x, anchor_y, sx, sy)``. + """ + x, y, w, h = bounds + anchor_x, anchor_y = handle_points(bounds)[_OPPOSITE[handle_id]] + + scales_x = handle_id in _SCALES_X + scales_y = handle_id in _SCALES_Y + + sx = abs(cursor_x - anchor_x) / w if (scales_x and w) else 1.0 + sy = abs(cursor_y - anchor_y) / h if (scales_y and h) else 1.0 + + if keep_aspect: + if scales_x and scales_y: + sx = sy = max(sx, sy) + elif scales_x: + sy = sx + elif scales_y: + sx = sy + + return anchor_x, anchor_y, sx, sy + + +def scale_layers(layers, anchor_x, anchor_y, sx, sy, min_size=1.0): + """Scale each layer about (anchor_x, anchor_y) by (sx, sy) in place. + + Sizes are clamped to ``min_size`` so a layer never collapses or flips. + + Args: + layers (list): objects with ``position`` and ``size``. + anchor_x (float): fixed anchor x. + anchor_y (float): fixed anchor y. + sx (float): x scale factor. + sy (float): y scale factor. + min_size (float, optional): minimum layer width/height. + """ + for layer in layers: + cx, cy = layer.position + w, h = layer.size + layer.position = [ + anchor_x + (cx - anchor_x) * sx, + anchor_y + (cy - anchor_y) * sy, + ] + layer.size = [max(w * sx, min_size), max(h * sy, min_size)] diff --git a/release/scripts/mgear/anim_picker/widgets/basic.py b/release/scripts/mgear/anim_picker/widgets/basic.py index 73726322..46d839ff 100644 --- a/release/scripts/mgear/anim_picker/widgets/basic.py +++ b/release/scripts/mgear/anim_picker/widgets/basic.py @@ -429,9 +429,14 @@ def __init__(self, tabWidget, parent=None): self.keep_aspect_ratio = True # Guard to avoid write-back while the fields are being populated. self._syncing = False + # View whose background-edit sub-mode this panel drives. + self._edit_view = None # Layer list + list actions self.layer_list = QtWidgets.QListWidget() + self.layer_list.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) self.add_button = QtWidgets.QPushButton("Add Layer") self.remove_button = QtWidgets.QPushButton("Remove Layer") self.up_button = QtWidgets.QPushButton("Move Up") @@ -454,6 +459,11 @@ def __init__(self, tabWidget, parent=None): self.connectSignals() self.refresh_layer_list() + # Activate on-canvas manipulation for the current view. + self._edit_view = self.gfx_view() + if self._edit_view: + self._edit_view.enter_background_edit() + def build_layout(self): self.main_layout = QtWidgets.QVBoxLayout(self) self.main_layout.addWidget(self.layer_list) @@ -479,6 +489,9 @@ def connectSignals(self): self.up_button.clicked.connect(self.move_up) self.down_button.clicked.connect(self.move_down) self.layer_list.currentRowChanged.connect(self.on_selection_changed) + self.layer_list.itemSelectionChanged.connect( + self.on_list_selection_changed + ) self.aspect_button.clicked.connect(self.toggle_aspect_value) self.pos_x_box.editingFinished.connect(self.apply_position) self.pos_y_box.editingFinished.connect(self.apply_position) @@ -493,6 +506,10 @@ def current_index(self): """Return the selected layer index (-1 if none).""" return self.layer_list.currentRow() + def _selected_rows(self): + """Return the sorted list of selected layer rows.""" + return sorted({idx.row() for idx in self.layer_list.selectedIndexes()}) + def refresh_layer_list(self): """Rebuild the layer list from the view, preserving the selection.""" view = self.gfx_view() @@ -513,6 +530,9 @@ def refresh_layer_list(self): self.populate_fields() + # Keep the canvas manipulator selection in step with the rebuilt list. + self.on_list_selection_changed() + def populate_fields(self): """Load the selected layer's position/size into the fields.""" view = self.gfx_view() @@ -545,6 +565,44 @@ def on_selection_changed(self, *args): return self.populate_fields() + def on_list_selection_changed(self): + """Push the list's multi-selection to the canvas manipulator.""" + if self._syncing: + return + view = self.gfx_view() + if view: + view.set_selected_bg_indices(self._selected_rows()) + + def on_canvas_selection_changed(self): + """Reflect the canvas selection back into the list (called by view).""" + if self._syncing: + return + view = self.gfx_view() + if not view: + return + indices = view.get_selected_bg_indices() + self._syncing = True + self.layer_list.clearSelection() + for i in indices: + item = self.layer_list.item(i) + if item is not None: + item.setSelected(True) + if indices: + self.layer_list.setCurrentRow(indices[-1]) + self._syncing = False + self.populate_fields() + + def refresh_active_fields(self): + """Refresh the fields from the active layer (called during a drag).""" + self.populate_fields() + + def closeEvent(self, event): + """Deactivate the canvas sub-mode when the panel closes.""" + if self._edit_view is not None: + self._edit_view.exit_background_edit() + self._edit_view = None + super().closeEvent(event) + def add_layer(self): view = self.gfx_view() if not view: diff --git a/releaseLog.rst b/releaseLog.rst index 5a9d2eba..1fea5eac 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: On-canvas background layer manipulators — with the Background layers panel open (edit mode), click / shift-click / marquee-select layers directly on the canvas and drag to move or drag the bounding-box handles to scale (corner = both axes, edge = one, Shift keeps aspect); multi-selected layers transform together as a group, and the panel selection and fields stay in sync * Anim Picker: Composite and flexible tab backgrounds — a tab background is now an ordered list of image layers, each with its own position and size, drawn back-to-front; the canvas spans both the background layers and the picker buttons (never smaller than the default), so pan and zoom reach all content and backgrounds can be larger and non-proportional #108 (the former 6000 size cap is removed). A new background-layer manager (right-click > Background layers...) adds / removes / reorders and repositions layers. Legacy single-image pickers still load (mapped to one layer); writes now emit a "backgrounds" list * Anim Picker: Modernize the module (Phase 1 cleanup) — Python-3-only idioms, mgear.log-based logging, Maya API 2.0 selection handling, and dead-code / debug-comment removal; internal anim_picker version bumped to 2.0.0 * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) From d813256ff1732c27725a7e1011db695038aaf7dd Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Wed, 8 Jul 2026 15:52:24 +0900 Subject: [PATCH 05/25] AnimPicker: Window: Re-enable dockable mode Add a reachable, working dockable picker. A new "Anim Picker (Dockable)" menu entry launches the window as a Maya workspaceControl through the shared mgear.core.pyqt.showDialog helper (the same path the other dockable tools use), with a partial supplying the edit/dockable args. MainDockableWindow now forwards edit/dockable to MainDockWindow (they were dropped, leaving is_dockable False). Only the dockable window carries the object name, so Maya names its workspaceControl deterministically and the floating window no longer shares that identity -- fixing the cases where opening both swapped the opacity UI into the docked window, depended on open order, or where closing the floating window closed the docked one. close() only tears down the workspaceControl when the window is dockable. The floating window remains the default. --- .../scripts/mgear/anim_picker/main_window.py | 72 ++++++++++--------- release/scripts/mgear/anim_picker/menu.py | 6 ++ releaseLog.rst | 1 + 3 files changed, 46 insertions(+), 33 deletions(-) diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 110f85cc..2c902cbe 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -4,6 +4,8 @@ passthrough event filter used by the main window. """ +from functools import partial + from maya import cmds import mgear.pymaya as pm @@ -92,6 +94,9 @@ def __init__(self, parent=None, edit=False, dockable=False): __EDIT_MODE__.set_init(edit) self.is_dockable = dockable + # mGear dockable convention: toolName is the attribute core.pyqt + # (showDialog / deleteInstances) reads to name the workspaceControl. + self.toolName = self.__OBJ_NAME__ # Setup ui self.cb_manager = callbackManager.CallbackManager() @@ -105,9 +110,12 @@ def __init__(self, parent=None, edit=False, dockable=False): def setup(self): """Setup interface""" - # Main window setting - # Setting object name makes docking not useable? da fuck - # self.setObjectName(self.__OBJ_NAME__) + # Only the dockable window gets an object name: Maya derives its + # workspaceControl name from it. Giving the floating window the same + # name makes Maya swap/confuse the two when both are open (opacity UI + # leaking, close-one-closes-both), so leave the floating window unnamed. + if self.is_dockable: + self.setObjectName(self.__OBJ_NAME__) self.setWindowTitle(self.__TITLE__) # Add main widget and vertical layout @@ -413,17 +421,15 @@ def close(self): except Exception: pass - # Default close - # mayaMixin bug that i need to correct for - corrected_for_dashes = self.objectName().replace("_", "-") - corrected_for_initial_dash = corrected_for_dashes.replace("-", "_", 1) - work_name = "{}WorkspaceControl".format(corrected_for_initial_dash) - try: - cmds.workspaceControl(work_name, e=True, close=True) - except ValueError: - pass - except RuntimeError: - pass + # Only the dockable window owns a workspaceControl; closing it from the + # floating window would tear down the (separate) docked picker. Derive + # the name the same way pyqt.showDialog does (toolName + suffix). + if self.is_dockable: + work_name = self.toolName + "WorkspaceControl" + try: + cmds.workspaceControl(work_name, e=True, close=True) + except (ValueError, RuntimeError): + pass self.deleteLater() def showEvent(self, *args, **kwargs): @@ -744,36 +750,36 @@ def selection_change_event(self, *args): # version of the anim picker ui that uses MayaQWidgetDockableMixin for docking class MainDockableWindow(MayaQWidgetDockableMixin, MainDockWindow): def __init__(self, parent=None, edit=False, dockable=True): - super().__init__(parent=parent) + # Pass edit/dockable through: MayaQWidgetDockableMixin forwards extra + # kwargs down the MRO to MainDockWindow, so is_dockable/edit are set + # (dropping them left is_dockable False and showed the opacity UI). + super().__init__(parent=parent, edit=edit, dockable=dockable) # ============================================================================= # Load user interface function # ============================================================================= def load(edit=False, dockable=False): - """To launch the ui and not get the same instance - - Returns: - Anim_picker: instance + """Launch the anim picker UI (a fresh instance each call). Args: - edit (bool, optional): Description - dockable (bool, optional): Description + edit (bool, optional): open in edit mode. + dockable (bool, optional): open as a dockable workspaceControl. + Returns: + MainDockWindow: the created window instance. """ - - # NOTE: if instead we set dockable to false the window doesn't get - # parented to Maya UI. - # Deferred (feature phase): dockable mode breaks the interface when - # docked, so it is intentionally not exposed from the menu yet. See the - # anim_picker refactor roadmap (Phase 3+: "Fix + re-enable dockable"). if dockable: - ANIM_PKR_UI = MainDockableWindow( - parent=None, edit=edit, dockable=dockable + # Launch through the shared mGear docking helper, the same path the + # other dockable tools (crank, channel master, spring manager) use. + # A partial supplies the edit/dockable args because showDialog builds + # the window with no arguments; showDialog also closes any stale + # WorkspaceControl before showing. + return pyqt.showDialog( + partial(MainDockableWindow, edit=edit, dockable=True), + dockable=True, ) - ANIM_PKR_UI.show(dockable=True) - else: - ANIM_PKR_UI = MainDockWindow(parent=pyqt.get_main_window(), edit=edit) - ANIM_PKR_UI.show() + ANIM_PKR_UI = MainDockWindow(parent=pyqt.get_main_window(), edit=edit) + ANIM_PKR_UI.show() return ANIM_PKR_UI diff --git a/release/scripts/mgear/anim_picker/menu.py b/release/scripts/mgear/anim_picker/menu.py index d7ac0805..9da539da 100644 --- a/release/scripts/mgear/anim_picker/menu.py +++ b/release/scripts/mgear/anim_picker/menu.py @@ -11,6 +11,11 @@ anim_picker.load(False, False) """ +str_open_dockable_mode = """ +from mgear import anim_picker +anim_picker.load(False, True) +""" + str_open_edit_mode = """ from mgear import anim_picker anim_picker.load(True, False) @@ -76,6 +81,7 @@ def install(): image="mgear_mouse-pointer.svg", ) cmds.menuItem(label="Anim Picker", command=str_open_picker_mode) + cmds.menuItem(label="Anim Picker (Dockable)", command=str_open_dockable_mode) pm.menuItem(divider=True) cmds.menuItem(label="Edit Anim Picker", command=str_open_edit_mode) pm.menuItem(divider=True) diff --git a/releaseLog.rst b/releaseLog.rst index 1fea5eac..42d00042 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Re-enable dockable mode — a new "Anim Picker (Dockable)" menu entry launches the picker as a Maya workspaceControl. The window now sets a stable object name so its workspaceControl is created, closed, and re-opened cleanly (no duplicate or orphaned controls); the floating window remains the default * Anim Picker: On-canvas background layer manipulators — with the Background layers panel open (edit mode), click / shift-click / marquee-select layers directly on the canvas and drag to move or drag the bounding-box handles to scale (corner = both axes, edge = one, Shift keeps aspect); multi-selected layers transform together as a group, and the panel selection and fields stay in sync * Anim Picker: Composite and flexible tab backgrounds — a tab background is now an ordered list of image layers, each with its own position and size, drawn back-to-front; the canvas spans both the background layers and the picker buttons (never smaller than the default), so pan and zoom reach all content and backgrounds can be larger and non-proportional #108 (the former 6000 size cap is removed). A new background-layer manager (right-click > Background layers...) adds / removes / reorders and repositions layers. Legacy single-image pickers still load (mapped to one layer); writes now emit a "backgrounds" list * Anim Picker: Modernize the module (Phase 1 cleanup) — Python-3-only idioms, mgear.log-based logging, Maya API 2.0 selection handling, and dead-code / debug-comment removal; internal anim_picker version bumped to 2.0.0 From c617c776f4abc6bcaf6b8da94469366fefba06ee Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Thu, 9 Jul 2026 09:37:26 +0900 Subject: [PATCH 06/25] AnimPicker: Editing: On-canvas item manipulators, inline edit panel, and left tool strip Add roadmap 6a of the anim_picker 2.0 creation/edit UX overhaul. - Shared Qt-free manipulator_transform (handle points, scale factors, rotate helpers); background_transform now re-exports from it. - item_manipulator: bounding-box overlay with scale handles + a rotate handle; group scale about the opposite anchor (Shift = uniform) and rigid rotate about the group center; single item rotates in place. - edit_panel: right-docked, collapsible, edit-mode-only inline editor for the whole selection (Transform/Appearance/Shape/Controls/Action) with mixed-value-aware fields; reuses PickerItem setters and dialogs. - tool_bar: Photoshop-style left strip with an exclusive Select (default) / Transform tool group; the manipulator is opt-in via the Transform tool. add_command hook reserved for future quick commands. - Route double-click / Options to the inline panel (modal kept as a fallback). Fix: drag-moving a selected group no longer collapses the selection to the item under the cursor. --- .../scripts/mgear/anim_picker/main_window.py | 68 +- release/scripts/mgear/anim_picker/view.py | 100 +++ .../widgets/background_transform.py | 91 +- .../mgear/anim_picker/widgets/edit_panel.py | 777 ++++++++++++++++++ .../anim_picker/widgets/item_manipulator.py | 195 +++++ .../widgets/manipulator_transform.py | 133 +++ .../mgear/anim_picker/widgets/picker_item.py | 26 +- .../mgear/anim_picker/widgets/tool_bar.py | 126 +++ releaseLog.rst | 1 + 9 files changed, 1437 insertions(+), 80 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/widgets/edit_panel.py create mode 100644 release/scripts/mgear/anim_picker/widgets/item_manipulator.py create mode 100644 release/scripts/mgear/anim_picker/widgets/manipulator_transform.py create mode 100644 release/scripts/mgear/anim_picker/widgets/tool_bar.py diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 2c902cbe..aa412501 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -27,6 +27,8 @@ from mgear.anim_picker.view import GraphicViewWidget from mgear.anim_picker.tab_widget import ContextMenuTabWidget from mgear.anim_picker.widgets import basic +from mgear.anim_picker.widgets import edit_panel +from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.widgets import overlay_widgets from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ @@ -91,6 +93,9 @@ def __init__(self, parent=None, edit=False, dockable=False): self.status = False self.childs = [] self.script_jobs = [] + # Active canvas tool (Photoshop-style left toolbar); the view reads + # this to decide whether the transform manipulator is shown/active. + self.active_tool = tool_bar.TOOL_SELECT __EDIT_MODE__.set_init(edit) self.is_dockable = dockable @@ -360,7 +365,29 @@ def add_character_selector(self): def add_tab_widget(self, name="default"): """Add control display field""" self.tab_widget = ContextMenuTabWidget(self, main_window=self) - self.main_vertical_layout.addWidget(self.tab_widget) + + # Right-docked inline item editor (edit mode only). The tab widget and + # the panel share a resizable, collapsible splitter, so the classic + # single-pane view is simply the panel-hidden state. + self.edit_panel = edit_panel.ItemEditPanel(main_window=self) + self.edit_panel.setMinimumWidth(pyqt.dpi_scale(220)) + + self.editor_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) + self.editor_splitter.addWidget(self.tab_widget) + self.editor_splitter.addWidget(self.edit_panel) + self.editor_splitter.setStretchFactor(0, 1) + self.editor_splitter.setStretchFactor(1, 0) + self.editor_splitter.setCollapsible(0, False) + self.editor_splitter.setCollapsible(1, True) + + # Photoshop-style tool strip on the left of the canvas + the splitter. + self.left_toolbar = tool_bar.PickerToolBar(main_window=self) + canvas_row = QtWidgets.QHBoxLayout() + canvas_row.setContentsMargins(0, 0, 0, 0) + canvas_row.setSpacing(0) + canvas_row.addWidget(self.left_toolbar) + canvas_row.addWidget(self.editor_splitter) + self.main_vertical_layout.addLayout(canvas_row) # Add default first tab view = GraphicViewWidget(main_window=self) @@ -371,6 +398,42 @@ def add_tab_widget(self, name="default"): sp_retain.setRetainSizeWhenHidden(True) self.tab_widget.setSizePolicy(sp_retain) + # Editor panel and tool strip are edit-mode only; refresh the panel + # when the tab changes. + self.edit_panel.setVisible(__EDIT_MODE__.get()) + self.left_toolbar.setVisible(__EDIT_MODE__.get()) + self.tab_widget.currentChanged.connect(self._on_tab_changed) + + def _on_tab_changed(self, *args): + """Rebind the inline editor to the newly active tab's selection.""" + panel = getattr(self, "edit_panel", None) + if panel is not None: + panel.sync() + + def set_active_tool(self, name): + """Set the active canvas tool and repaint so the overlay updates. + + Args: + name (str): a ``tool_bar`` tool id (TOOL_SELECT / TOOL_TRANSFORM). + """ + self.active_tool = name + view = self.tab_widget.currentWidget() + if view is not None: + view.viewport().update() + + def _sync_edit_panel(self): + """Show/hide the inline editor + tool strip with the mode.""" + edit = __EDIT_MODE__.get() + toolbar = getattr(self, "left_toolbar", None) + if toolbar is not None: + toolbar.setVisible(edit) + panel = getattr(self, "edit_panel", None) + if panel is None: + return + panel.setVisible(edit) + if edit: + panel.sync() + def add_overlays(self): """Add transparent overlay widgets""" self.about_widget = overlay_widgets.AboutOverlayWidget(self) @@ -543,6 +606,9 @@ def refresh(self): # Force view resize self.tab_widget.fit_contents() + # Sync the inline editor's visibility/content with the current mode. + self._sync_edit_panel() + # Set focus on view self.tab_widget.currentWidget().setFocus() diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 91467059..c12afefc 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -26,6 +26,8 @@ from mgear.anim_picker.widgets import picker_widgets from mgear.anim_picker.widgets import background_model from mgear.anim_picker.widgets import background_manipulator +from mgear.anim_picker.widgets import item_manipulator +from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.handlers import __EDIT_MODE__ @@ -98,6 +100,10 @@ def __init__(self, namespace=None, main_window=None): self._bg_marquee_origin = None self._bg_marquee_current = None + # On-canvas picker item scale/rotate manipulator (edit mode only). + self.item_manipulator = item_manipulator.ItemManipulator(self) + self._item_dragging = False + self.fit_margin = 8 # # undo list --------------------------------------------------------- @@ -113,6 +119,9 @@ def mousePressEvent(self, event): # Background layer manipulation intercepts left-click when active. if self.background_edit and self._bg_mouse_press(event): return + # Item scale/rotate handle press intercepts left-click in edit mode. + if self._item_mouse_press(event): + return self.modified_select = False self.item_selected = False self.__move_prompt = False @@ -178,6 +187,9 @@ def mouseMoveEvent(self, event): # Background layer drag / marquee intercepts movement when active. if self.background_edit and self._bg_mouse_move(event): return + # Item scale/rotate drag intercepts movement when active. + if self._item_dragging and self._item_mouse_move(event): + return result = QtWidgets.QGraphicsView.mouseMoveEvent(self, event) if ( @@ -224,11 +236,20 @@ def mouseReleaseEvent(self, event): # Background layer drag / marquee release when active. if self.background_edit and self._bg_mouse_release(event): return + # Item scale/rotate drag release when active. + if self._item_dragging and self._item_mouse_release(event): + return result = QtWidgets.QGraphicsView.mouseReleaseEvent(self, event) + # A left release that was NOT a drag re-selects the item under the + # cursor (click-to-select). Skip it when a move happened + # (``__move_prompt``): dragging a selected item moves the whole + # selection, so re-selecting the item under the cursor would collapse + # a multi-selection down to one. if ( not self.drag_active and event.button() == QtCore.Qt.MouseButton.LeftButton and not self.modified_select + and not self.__move_prompt ): self.modified_select = False scene_pos = self.mapToScene(event.pos()) @@ -327,6 +348,12 @@ def mouseReleaseEvent(self, event): self.zoom_active = False self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) + # Refresh the item manipulator overlay + inline edit panel for the new + # selection (edit mode only; a no-op when nothing consumes it). + if __EDIT_MODE__.get(): + self._notify_item_selection() + self.viewport().update() + self.drag_active = False return result @@ -1002,6 +1029,75 @@ def _bg_mouse_release(self, event): return False + def _transform_tool_active(self): + """Return True when the Transform tool is active (manipulator on).""" + active = getattr( + self.main_window, "active_tool", tool_bar.TOOL_SELECT + ) + return active == tool_bar.TOOL_TRANSFORM + + def _notify_item_selection(self): + """Tell the inline edit panel the item selection changed (full sync).""" + panel = getattr(self.main_window, "edit_panel", None) + if panel is not None: + panel.sync_from_view(self) + + def _notify_item_transform(self): + """Tell the panel the selection's transform changed (light refresh).""" + panel = getattr(self.main_window, "edit_panel", None) + if panel is not None: + panel.refresh_transform() + + def _item_mouse_press(self, event): + """Begin an item scale/rotate drag if a handle was pressed. + + Returns True (consuming the event) only when a manipulator handle under + the cursor starts a drag; otherwise the event falls through to normal + selection / move handling. + """ + if not __EDIT_MODE__.get(): + return False + if not self._transform_tool_active(): + return False + if event.button() != QtCore.Qt.MouseButton.LeftButton: + return False + if not self.item_manipulator.is_active(): + return False + scene_pos = self.mapToScene(event.pos()) + hit = self.item_manipulator.hit_test(scene_pos.x(), scene_pos.y()) + if hit is None: + return False + mode = hit[0] + handle = hit[1] if mode == "scale" else None + self.item_manipulator.begin_drag( + mode, handle, scene_pos.x(), scene_pos.y() + ) + self._item_dragging = True + return True + + def _item_mouse_move(self, event): + """Update an in-progress item scale/rotate drag. Return True if used.""" + if not (event.buttons() & QtCore.Qt.MouseButton.LeftButton): + return False + scene_pos = self.mapToScene(event.pos()) + keep = bool(event.modifiers() & QtCore.Qt.ShiftModifier) + self.item_manipulator.update_drag(scene_pos.x(), scene_pos.y(), keep) + self._notify_item_transform() + self.viewport().update() + return True + + def _item_mouse_release(self, event): + """Finish an item scale/rotate drag. Return True if consumed.""" + if event.button() != QtCore.Qt.MouseButton.LeftButton: + return False + self.item_manipulator.end_drag() + self._item_dragging = False + # Grow the pan bounds to the items' new extent (no view refit). + self._update_scene_rect() + self._notify_item_selection() + self.viewport().update() + return True + def _draw_bg_marquee(self, painter): """Draw the background-selection marquee rectangle, if active.""" if self._bg_marquee_origin is None or self._bg_marquee_current is None: @@ -1221,6 +1317,10 @@ def drawForeground(self, painter, rect): # Paint axis in edit mode if __EDIT_MODE__.get(): self.draw_overlay_axis(painter, rect) + # Item scale/rotate manipulator overlay: opt-in via the Transform + # tool and suppressed while manipulating background layers. + if not self.background_edit and self._transform_tool_active(): + self.item_manipulator.paint(painter) # Background layer manipulator overlay + selection marquee if self.background_edit: diff --git a/release/scripts/mgear/anim_picker/widgets/background_transform.py b/release/scripts/mgear/anim_picker/widgets/background_transform.py index e9dea3e9..09bada1f 100644 --- a/release/scripts/mgear/anim_picker/widgets/background_transform.py +++ b/release/scripts/mgear/anim_picker/widgets/background_transform.py @@ -4,27 +4,28 @@ center) and ``size`` ([w, h]) -- i.e. ``BackgroundLayer`` -- so the on-canvas manipulator's group-transform math stays testable without a running DCC. +The generic handle geometry (``handle_points`` / ``scale_factors``) lives in the +shared ``manipulator_transform`` module and is re-exported here for backward +compatibility; this module keeps only the layer-specific helpers (union bounds, +move, scale). + Bounds are expressed as ``(x, y, w, h)`` where ``(x, y)`` is the min corner in the view's centered, Y-up scene space and ``w``/``h`` are positive. """ +from mgear.anim_picker.widgets.manipulator_transform import HANDLE_IDS +from mgear.anim_picker.widgets.manipulator_transform import handle_points +from mgear.anim_picker.widgets.manipulator_transform import scale_factors -HANDLE_IDS = ("bl", "br", "tl", "tr", "l", "r", "t", "b") - -# Opposite handle used as the fixed anchor while scaling. -_OPPOSITE = { - "bl": "tr", - "tr": "bl", - "br": "tl", - "tl": "br", - "l": "r", - "r": "l", - "b": "t", - "t": "b", -} - -_SCALES_X = ("bl", "br", "tl", "tr", "l", "r") -_SCALES_Y = ("bl", "br", "tl", "tr", "t", "b") +# Re-exported so existing importers (background_manipulator) keep working. +__all__ = [ + "HANDLE_IDS", + "handle_points", + "scale_factors", + "union_bounds", + "move_layers", + "scale_layers", +] def union_bounds(layers): @@ -45,28 +46,6 @@ def union_bounds(layers): return (min_x, min_y, max_x - min_x, max_y - min_y) -def handle_points(bounds): - """Return a dict of handle id -> (x, y) for the given bounds. - - Args: - bounds (tuple): ``(x, y, w, h)``. - - Returns: - dict: handle id -> (x, y). - """ - x, y, w, h = bounds - return { - "bl": (x, y), - "br": (x + w, y), - "tl": (x, y + h), - "tr": (x + w, y + h), - "l": (x, y + h / 2.0), - "r": (x + w, y + h / 2.0), - "b": (x + w / 2.0, y), - "t": (x + w / 2.0, y + h), - } - - def move_layers(layers, dx, dy): """Translate each layer's center by (dx, dy) in place. @@ -79,42 +58,6 @@ def move_layers(layers, dx, dy): layer.position = [layer.position[0] + dx, layer.position[1] + dy] -def scale_factors(bounds, handle_id, cursor_x, cursor_y, keep_aspect=False): - """Compute the anchor and scale factors for a handle drag. - - Corner handles scale both axes, edge handles scale one; ``keep_aspect`` - forces a uniform factor. The anchor is the opposite handle's position. - - Args: - bounds (tuple): original ``(x, y, w, h)`` at drag start. - handle_id (str): one of ``HANDLE_IDS``. - cursor_x (float): current cursor x in scene units. - cursor_y (float): current cursor y in scene units. - keep_aspect (bool, optional): keep the aspect ratio. - - Returns: - tuple: ``(anchor_x, anchor_y, sx, sy)``. - """ - x, y, w, h = bounds - anchor_x, anchor_y = handle_points(bounds)[_OPPOSITE[handle_id]] - - scales_x = handle_id in _SCALES_X - scales_y = handle_id in _SCALES_Y - - sx = abs(cursor_x - anchor_x) / w if (scales_x and w) else 1.0 - sy = abs(cursor_y - anchor_y) / h if (scales_y and h) else 1.0 - - if keep_aspect: - if scales_x and scales_y: - sx = sy = max(sx, sy) - elif scales_x: - sy = sx - elif scales_y: - sx = sy - - return anchor_x, anchor_y, sx, sy - - def scale_layers(layers, anchor_x, anchor_y, sx, sy, min_size=1.0): """Scale each layer about (anchor_x, anchor_y) by (sx, sy) in place. diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py new file mode 100644 index 00000000..d84e0ab5 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -0,0 +1,777 @@ +"""Inline multi-selection edit panel for picker items. + +``ItemEditPanel`` is the right-docked, edit-mode-only replacement for the +per-item ``ItemOptionsWindow`` modal. It binds to the current picker selection +and edits every selected item at once through the existing ``PickerItem`` +setters -- no re-implementation of the item logic. Fields whose value differs +across the selection are shown in a distinct "mixed" state (numeric spin boxes +via a sentinel + special text, text/color swatches via a placeholder) so a +multi-edit never silently clobbers differing values. + +The view calls :meth:`sync_from_view` after any selection change or manipulator +drag; the main window calls :meth:`sync` on tab change / mode toggle. A +``_syncing`` guard breaks the canvas<->panel feedback loop, mirroring the +background options panel. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.core import widgets as mwidgets + +from mgear.anim_picker.widgets import basic +from mgear.anim_picker.widgets.dialogs.handles_window import ( + HandlesPositionWindow, +) +from mgear.anim_picker.widgets.dialogs.script_dialog import ( + CustomScriptEditDialog, +) +from mgear.anim_picker.widgets.dialogs.script_dialog import ( + CustomMenuEditDialog, +) +from mgear.anim_picker.widgets.dialogs.search_replace_dialog import ( + SearchAndReplaceDialog, +) + + +class ItemEditPanel(QtWidgets.QWidget): + """Right-docked inline editor for the current picker item selection.""" + + # Sentinel spin value shown as ``MIXED_TEXT`` when a field is mixed. It is + # the spin box minimum, so the display reads "-" until the user commits a + # real value; the apply handlers skip it so a mixed field is never written. + MIXED = -1.0e7 + MIXED_INT = -1000000 + MIXED_TEXT = "-" + + def __init__(self, parent=None, main_window=None): + super().__init__(parent=parent) + self.main_window = main_window + # Currently bound selection and the view it came from. + self.items = [] + self._view = None + # Guard against panel<->canvas write-back while populating fields. + self._syncing = False + # Child windows opened from the panel (handles table). + self.handles_window = None + # Interactive widgets toggled with the selection presence. + self._fields = [] + + # Widgets created in the section builders. + self.pos_x_sb = None + self.pos_y_sb = None + self.rotate_sb = None + self.scale_factor_sb = None + self.worldspace_cb = None + self.color_button = None + self.alpha_sb = None + self.text_field = None + self.text_size_sb = None + self.text_color_button = None + self.text_alpha_sb = None + self.count_sb = None + self.handles_cb = None + self.control_list = None + self.menus_list = None + self.custom_action_cb = None + + self._build_ui() + self.refresh_fields() + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + def _build_ui(self): + outer = QtWidgets.QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + + self.title = QtWidgets.QLabel("Item Editor") + self.title.setAlignment(QtCore.Qt.AlignCenter) + outer.addWidget(self.title) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + outer.addWidget(scroll) + + content = QtWidgets.QWidget() + self.content_layout = QtWidgets.QVBoxLayout(content) + self.content_layout.setContentsMargins(2, 2, 2, 2) + self.content_layout.setSpacing(2) + scroll.setWidget(content) + + self._build_transform_section() + self._build_appearance_section() + self._build_shape_section() + self._build_controls_section() + self._build_action_section() + self.content_layout.addStretch() + + def _add_section(self, title): + """Add a collapsible section and return its widget.""" + section = mwidgets.CollapsibleWidget(title, expanded=True) + self.content_layout.addWidget(section) + return section + + def _double_spin(self, callback, maximum=1.0e6, step=1.0, decimals=2): + """Return a mixed-aware double spin box (sentinel minimum). + + Built on ``basic.CallBackDoubleSpinBox`` (the framework's value->callback + wrapper); the mixed state is layered on via the sentinel minimum + + ``setSpecialValueText``. + """ + spin = basic.CallBackDoubleSpinBox( + callback=callback, value=0.0, min=self.MIXED, max=maximum + ) + spin.setDecimals(decimals) + spin.setSingleStep(step) + spin.setSpecialValueText(self.MIXED_TEXT) + self._fields.append(spin) + return spin + + def _int_spin(self, callback, minimum, maximum): + """Return a mixed-aware int spin box (sentinel minimum).""" + spin = basic.CallBackSpinBox( + callback=callback, value=0, min=self.MIXED_INT, max=maximum + ) + spin.setSpecialValueText(self.MIXED_TEXT) + # ``minimum`` is the real usable floor; the sentinel sits below it. + spin.setProperty("usable_minimum", minimum) + self._fields.append(spin) + return spin + + def _build_transform_section(self): + section = self._add_section("Transform") + + form = QtWidgets.QFormLayout() + self.pos_x_sb = self._double_spin(self._apply_position) + self.pos_y_sb = self._double_spin(self._apply_position) + self.rotate_sb = self._double_spin( + self._apply_rotation, maximum=3600.0, step=5.0 + ) + form.addRow("X", self.pos_x_sb) + form.addRow("Y", self.pos_y_sb) + form.addRow("Rotation", self.rotate_sb) + section.addLayout(form) + + reset_rot = basic.CallbackButton(callback=self._reset_rotation) + reset_rot.setText("Reset Rotation") + self._fields.append(reset_rot) + section.addWidget(reset_rot) + + # Scale (shape) factor + axis buttons, mirroring the modal. + scale_row = QtWidgets.QHBoxLayout() + scale_row.addWidget(QtWidgets.QLabel("Factor")) + self.scale_factor_sb = QtWidgets.QDoubleSpinBox() + self.scale_factor_sb.setRange(0.01, 100.0) + self.scale_factor_sb.setValue(1.1) + self.scale_factor_sb.setSingleStep(0.05) + scale_row.addWidget(self.scale_factor_sb) + section.addLayout(scale_row) + + self.worldspace_cb = QtWidgets.QCheckBox("World space") + section.addWidget(self.worldspace_cb) + + btn_row = QtWidgets.QHBoxLayout() + for label, kwargs in ( + ("X", {"x": True}), + ("Y", {"y": True}), + ("XY", {"x": True, "y": True}), + ): + btn = basic.CallbackButton(callback=self._apply_scale, **kwargs) + btn.setText(label) + self._fields.append(btn) + btn_row.addWidget(btn) + section.addLayout(btn_row) + + def _build_appearance_section(self): + section = self._add_section("Appearance") + + color_row = QtWidgets.QHBoxLayout() + self.color_button = basic.CallbackButton(callback=self._pick_color) + self.color_button.setToolTip("Shape color") + color_row.addWidget(self.color_button) + color_row.addWidget(QtWidgets.QLabel("Alpha")) + self.alpha_sb = self._int_spin(self._apply_alpha, 0, 255) + color_row.addWidget(self.alpha_sb) + section.addLayout(color_row) + + # ``CallbackLineEdit`` commits on Enter (returnPressed), so a focus-out + # never clobbers a field left blank for the mixed state. + self.text_field = basic.CallbackLineEdit(callback=self._apply_text) + self.text_field.setPlaceholderText("Text") + self._fields.append(self.text_field) + section.addWidget(self.text_field) + + text_form = QtWidgets.QFormLayout() + self.text_size_sb = self._double_spin( + self._apply_text_size, maximum=100.0, step=0.1 + ) + text_form.addRow("Text size", self.text_size_sb) + section.addLayout(text_form) + + tcolor_row = QtWidgets.QHBoxLayout() + self.text_color_button = basic.CallbackButton( + callback=self._pick_text_color + ) + self.text_color_button.setToolTip("Text color") + tcolor_row.addWidget(self.text_color_button) + tcolor_row.addWidget(QtWidgets.QLabel("Alpha")) + self.text_alpha_sb = self._int_spin(self._apply_text_alpha, 0, 255) + tcolor_row.addWidget(self.text_alpha_sb) + section.addLayout(tcolor_row) + + def _build_shape_section(self): + section = self._add_section("Shape") + + self.handles_cb = QtWidgets.QCheckBox("Show handles") + self.handles_cb.setTristate(True) + self.handles_cb.clicked.connect(self._apply_show_handles) + self._fields.append(self.handles_cb) + section.addWidget(self.handles_cb) + + count_row = QtWidgets.QHBoxLayout() + count_row.addWidget(QtWidgets.QLabel("Vtx count")) + self.count_sb = self._int_spin(self._apply_point_count, 2, 200) + count_row.addWidget(self.count_sb) + section.addLayout(count_row) + + handles_btn = basic.CallbackButton(callback=self._edit_handles) + handles_btn.setText("Handles Positions...") + self._fields.append(handles_btn) + section.addWidget(handles_btn) + + def _build_controls_section(self): + section = self._add_section("Controls") + + self.control_list = QtWidgets.QListWidget() + self.control_list.setSelectionMode( + QtWidgets.QAbstractItemView.ExtendedSelection + ) + self.control_list.setToolTip( + "Controls of the active (last selected) item" + ) + self.control_list.setMaximumHeight(120) + section.addWidget(self.control_list) + + row1 = QtWidgets.QHBoxLayout() + add_btn = basic.CallbackButton(callback=self._add_selected_controls) + add_btn.setText("Add Selection") + add_btn.setToolTip("Add the Maya selection to every selected item") + self._fields.append(add_btn) + row1.addWidget(add_btn) + remove_btn = basic.CallbackButton(callback=self._remove_controls) + remove_btn.setText("Remove") + remove_btn.setToolTip("Remove the highlighted controls (active item)") + self._fields.append(remove_btn) + row1.addWidget(remove_btn) + section.addLayout(row1) + + replace_btn = basic.CallbackButton(callback=self._search_replace) + replace_btn.setText("Search && Replace") + replace_btn.setToolTip("Search/replace control names on all selected") + self._fields.append(replace_btn) + section.addWidget(replace_btn) + + def _build_action_section(self): + section = self._add_section("Action") + + self.custom_action_cb = QtWidgets.QCheckBox("Custom action (script)") + self.custom_action_cb.setTristate(True) + self.custom_action_cb.setToolTip( + "Off = select controls, On = run the custom action script" + ) + self.custom_action_cb.clicked.connect(self._apply_action_mode) + self._fields.append(self.custom_action_cb) + section.addWidget(self.custom_action_cb) + + script_btn = basic.CallbackButton(callback=self._edit_action_script) + script_btn.setText("Edit Action Script...") + self._fields.append(script_btn) + section.addWidget(script_btn) + + section.addWidget(QtWidgets.QLabel("Custom Menus (active item)")) + self.menus_list = QtWidgets.QListWidget() + self.menus_list.setMaximumHeight(90) + self.menus_list.itemDoubleClicked.connect(self._edit_menu) + section.addWidget(self.menus_list) + + menu_row = QtWidgets.QHBoxLayout() + new_menu = basic.CallbackButton(callback=self._new_menu) + new_menu.setText("New") + self._fields.append(new_menu) + menu_row.addWidget(new_menu) + del_menu = basic.CallbackButton(callback=self._remove_menu) + del_menu.setText("Remove") + self._fields.append(del_menu) + menu_row.addWidget(del_menu) + section.addLayout(menu_row) + + # ------------------------------------------------------------------ + # Selection binding + # ------------------------------------------------------------------ + def _current_view(self): + """Return the main window's active graphics view, or None.""" + tab_widget = getattr(self.main_window, "tab_widget", None) + if tab_widget is None: + return None + return tab_widget.currentWidget() + + def sync(self): + """Rebind to the current active view's selection.""" + self.sync_from_view(self._current_view()) + + def sync_from_view(self, view): + """Rebind to ``view``'s current picker selection. + + Args: + view (GraphicViewWidget): the view whose selection to edit. + """ + self._view = view + items = [] + if view is not None: + items = view.scene().get_selected_items() + self.items = list(items) + self.refresh_fields() + + def refresh_transform(self): + """Refresh only the transform fields (live during a manipulator drag). + + The selection set is stable during a drag, so this avoids rebuilding the + control / menu lists (and their per-control ``objExists`` checks) every + mouse-move frame. Skipped entirely when the panel is hidden. + """ + if not self.items or not self.isVisible(): + return + self._guarded(self._populate_transform) + + def _active_item(self): + """Return the reference item for per-item fields (controls/menus).""" + return self.items[-1] if self.items else None + + def _shared(self, func): + """Return ``(value, mixed)`` for ``func`` across the selection. + + ``value`` is the first item's value; ``mixed`` is True when the items + do not all agree. + """ + if not self.items: + return (None, False) + values = [func(item) for item in self.items] + first = values[0] + mixed = any(value != first for value in values[1:]) + return (first, mixed) + + # ------------------------------------------------------------------ + # Field population + # ------------------------------------------------------------------ + def _guarded(self, populate): + """Run a populate function with the canvas<->panel sync guard set.""" + self._syncing = True + try: + populate() + finally: + self._syncing = False + + def _populate_all(self): + self._populate_transform() + self._populate_appearance() + self._populate_shape() + self._populate_controls() + self._populate_action() + + def refresh_fields(self): + """Populate every field from the current selection (mixed-aware).""" + has = bool(self.items) + for widget in self._fields: + widget.setEnabled(has) + self.title.setText( + "Item Editor - {} selected".format(len(self.items)) + if has + else "Item Editor - no selection" + ) + if not has: + return + self._guarded(self._populate_all) + + def _set_spin(self, spin, value, mixed): + """Set a spin box to ``value`` or the mixed sentinel.""" + if mixed or value is None: + spin.setValue(spin.minimum()) + else: + spin.setValue(value) + + def _populate_transform(self): + x, x_mixed = self._shared(lambda item: round(item.x(), 4)) + y, y_mixed = self._shared(lambda item: round(item.y(), 4)) + rot, rot_mixed = self._shared(lambda item: round(item.rotation(), 4)) + self._set_spin(self.pos_x_sb, x, x_mixed) + self._set_spin(self.pos_y_sb, y, y_mixed) + self._set_spin(self.rotate_sb, rot, rot_mixed) + + def _populate_appearance(self): + color, color_mixed = self._shared( + lambda item: item.get_color().getRgb() + ) + self._set_swatch( + self.color_button, None if color_mixed else QtGui.QColor(*color) + ) + alpha, alpha_mixed = self._shared( + lambda item: item.get_color().alpha() + ) + self._set_spin(self.alpha_sb, alpha, alpha_mixed) + + text, text_mixed = self._shared(lambda item: item.get_text()) + self.text_field.blockSignals(True) + if text_mixed: + self.text_field.clear() + self.text_field.setPlaceholderText("- multiple -") + else: + self.text_field.setText(text or "") + self.text_field.setPlaceholderText("Text") + self.text_field.blockSignals(False) + + size, size_mixed = self._shared( + lambda item: round(item.get_text_size(), 4) + ) + self._set_spin(self.text_size_sb, size, size_mixed) + + tcolor, tcolor_mixed = self._shared( + lambda item: item.get_text_color().getRgb() + ) + self._set_swatch( + self.text_color_button, + None if tcolor_mixed else QtGui.QColor(*tcolor), + ) + talpha, talpha_mixed = self._shared( + lambda item: item.get_text_color().alpha() + ) + self._set_spin(self.text_alpha_sb, talpha, talpha_mixed) + + def _populate_shape(self): + count, count_mixed = self._shared(lambda item: item.point_count) + self._set_spin(self.count_sb, count, count_mixed) + + status, status_mixed = self._shared( + lambda item: item.get_edit_status() + ) + self.handles_cb.blockSignals(True) + self.handles_cb.setTristate(True) + if status_mixed: + self.handles_cb.setCheckState(QtCore.Qt.PartiallyChecked) + else: + state = QtCore.Qt.Checked if status else QtCore.Qt.Unchecked + self.handles_cb.setCheckState(state) + self.handles_cb.blockSignals(False) + + def _populate_controls(self): + self.control_list.clear() + item = self._active_item() + if item is None: + return + for name in item.get_controls(with_namespace=False): + list_item = basic.CtrlListWidgetItem() + list_item.setText(name) + self.control_list.addItem(list_item) + + def _populate_action(self): + mode, mode_mixed = self._shared( + lambda item: item.get_custom_action_mode() + ) + self.custom_action_cb.blockSignals(True) + self.custom_action_cb.setTristate(True) + if mode_mixed: + self.custom_action_cb.setCheckState(QtCore.Qt.PartiallyChecked) + else: + state = QtCore.Qt.Checked if mode else QtCore.Qt.Unchecked + self.custom_action_cb.setCheckState(state) + self.custom_action_cb.blockSignals(False) + + self.menus_list.clear() + item = self._active_item() + if item is None: + return + for name, _cmd in item.get_custom_menus(): + self.menus_list.addItem(name) + + def _set_swatch(self, button, color): + """Show ``color`` on ``button`` or a 'multiple' placeholder if None.""" + if color is None: + button.setText("multiple") + button.setPalette(QtWidgets.QApplication.palette()) + button.setAutoFillBackground(False) + else: + button.setText("") + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.Button, color) + button.setPalette(palette) + button.setAutoFillBackground(True) + + # ------------------------------------------------------------------ + # Apply helpers (write to every selected item) + # ------------------------------------------------------------------ + def _repaint_view(self): + """Repaint the canvas so edits + the manipulator overlay refresh.""" + if self._view is not None: + self._view.viewport().update() + + def _committed(self, spin): + """Return a committed spin value, or None while the field is mixed. + + A mixed field sits at ``spin.minimum()`` (the sentinel shown as + ``MIXED_TEXT``); any other value is a real, user-committed edit. + """ + value = spin.value() + return None if value == spin.minimum() else value + + # -- transform ------------------------------------------------------ + def _apply_position(self, *args, **kwargs): + if self._syncing or not self.items: + return + x = self._committed(self.pos_x_sb) + y = self._committed(self.pos_y_sb) + for item in self.items: + new_x = item.x() if x is None else x + new_y = item.y() if y is None else y + item.setPos(new_x, new_y) + self._repaint_view() + + def _apply_rotation(self, *args, **kwargs): + if self._syncing or not self.items: + return + angle = self._committed(self.rotate_sb) + if angle is None: + return + for item in self.items: + item.setRotation(angle) + item.update() + self._repaint_view() + + def _reset_rotation(self): + if not self.items: + return + for item in self.items: + item.reset_rotation() + self._repaint_view() + self.refresh_fields() + + def _apply_scale(self, x=False, y=False): + if not self.items: + return + factor = self.scale_factor_sb.value() + world = self.worldspace_cb.isChecked() + sx = factor if x else 1.0 + sy = factor if y else 1.0 + for item in self.items: + item.scale_shape(x=sx, y=sy, world=world) + self._repaint_view() + # Position may change in world-space scale mode. + self._guarded(self._populate_transform) + + # -- appearance ----------------------------------------------------- + def _pick_color(self): + if not self.items: + return + initial = self._active_item().get_color() + color = QtWidgets.QColorDialog.getColor(initial=initial, parent=self) + if not color.isValid(): + return + for item in self.items: + new_color = QtGui.QColor(color) + new_color.setAlpha(item.get_color().alpha()) + item.set_color(new_color) + self._repaint_view() + self._guarded(self._populate_appearance) + + def _apply_alpha(self, *args, **kwargs): + if self._syncing or not self.items: + return + alpha = self._committed(self.alpha_sb) + if alpha is None: + return + for item in self.items: + color = item.get_color() + color.setAlpha(alpha) + item.set_color(color) + self._repaint_view() + + def _apply_text(self, *args, **kwargs): + if self._syncing or not self.items: + return + text = str(self.text_field.text()) + for item in self.items: + item.set_text(text) + self._repaint_view() + + def _apply_text_size(self, *args, **kwargs): + if self._syncing or not self.items: + return + size = self._committed(self.text_size_sb) + if size is None: + return + for item in self.items: + item.set_text_size(size) + self._repaint_view() + + def _pick_text_color(self): + if not self.items: + return + initial = self._active_item().get_text_color() + color = QtWidgets.QColorDialog.getColor(initial=initial, parent=self) + if not color.isValid(): + return + for item in self.items: + new_color = QtGui.QColor(color) + new_color.setAlpha(item.get_text_color().alpha()) + item.set_text_color(new_color) + self._repaint_view() + self._guarded(self._populate_appearance) + + def _apply_text_alpha(self, *args, **kwargs): + if self._syncing or not self.items: + return + alpha = self._committed(self.text_alpha_sb) + if alpha is None: + return + for item in self.items: + color = item.get_text_color() + color.setAlpha(alpha) + item.set_text_color(color) + self._repaint_view() + + # -- shape ---------------------------------------------------------- + def _apply_show_handles(self, *args, **kwargs): + if self._syncing or not self.items: + return + state = self.handles_cb.checkState() + # A user click resolves the tristate; treat partial as "show". + show = state != QtCore.Qt.Unchecked + self.handles_cb.setTristate(False) + for item in self.items: + item.set_edit_status(show) + self._repaint_view() + + def _apply_point_count(self, *args, **kwargs): + if self._syncing or not self.items: + return + count = self._committed(self.count_sb) + if count is None: + return + floor = self.count_sb.property("usable_minimum") or 2 + count = max(count, floor) + for item in self.items: + item.edit_point_count(count) + self._repaint_view() + + def _edit_handles(self): + item = self._active_item() + if item is None: + return + if self.handles_window: + try: + self.handles_window.close() + self.handles_window.deleteLater() + except Exception: + pass + self.handles_window = HandlesPositionWindow( + parent=self, picker_item=item + ) + self.handles_window.show() + self.handles_window.raise_() + + # -- controls ------------------------------------------------------- + def _add_selected_controls(self): + if not self.items: + return + for item in self.items: + item.add_selected_controls() + self._populate_controls() + + def _remove_controls(self): + item = self._active_item() + if item is None: + return + for row in self.control_list.selectedItems(): + item.remove_control(row.node()) + self._populate_controls() + + def _search_replace(self): + if not self.items: + return + search, replace, ok = SearchAndReplaceDialog.get() + if not ok: + return + for item in self.items: + item.search_and_replace_controls(search=search, replace=replace) + self._populate_controls() + + # -- action --------------------------------------------------------- + def _apply_action_mode(self, *args, **kwargs): + if self._syncing or not self.items: + return + custom = self.custom_action_cb.checkState() != QtCore.Qt.Unchecked + self.custom_action_cb.setTristate(False) + for item in self.items: + item.set_custom_action_mode(custom) + + def _edit_action_script(self): + item = self._active_item() + if item is None: + return + cmd, ok = CustomScriptEditDialog.get( + cmd=item.get_custom_action_script(), item=item + ) + if not (ok and cmd): + return + for target in self.items: + target.set_custom_action_script(cmd) + + def _edit_menu(self, list_item): + item = self._active_item() + if item is None: + return + index = self.menus_list.row(list_item) + menus = item.get_custom_menus() + if not (0 <= index < len(menus)): + return + name, cmd = menus[index] + name, cmd, ok = CustomMenuEditDialog.get(name=name, cmd=cmd, item=item) + if not (ok and name and cmd): + return + menus[index] = [name, cmd] + item.set_custom_menus(menus) + self._populate_action() + + def _new_menu(self): + item = self._active_item() + if item is None: + return + name, cmd, ok = CustomMenuEditDialog.get(item=item) + if not (ok and name and cmd): + return + menus = item.get_custom_menus() + menus.append([name, cmd]) + item.set_custom_menus(menus) + self._populate_action() + + def _remove_menu(self): + item = self._active_item() + if item is None: + return + index = self.menus_list.currentRow() + menus = item.get_custom_menus() + if not (0 <= index < len(menus)): + return + menus.pop(index) + item.set_custom_menus(menus) + self._populate_action() + + # ------------------------------------------------------------------ + def closeEvent(self, event): + if self.handles_window: + try: + self.handles_window.close() + except Exception: + pass + super().closeEvent(event) diff --git a/release/scripts/mgear/anim_picker/widgets/item_manipulator.py b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py new file mode 100644 index 00000000..cd66ce1c --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py @@ -0,0 +1,195 @@ +"""On-canvas scale/rotate manipulator for picker items. + +``ItemManipulator`` draws a bounding-box overlay with 8 scale handles and a +rotate handle around the current picker-item selection (edit mode only), +hit-tests those handles, and applies a group scale / rotate to every selected +item on drag. Moving is left to the existing item drag -- dragging a selected +item already moves the whole selection -- so the manipulator adds no body-move +(which would fight the rubber-band selection). + +The selection is read live from the scene (``get_selected_items``), so this +controller stores no selection state of its own; only the drag capture. The +transform math lives in the Qt/Maya-free ``manipulator_transform`` module shared +with the background manipulator. +""" + +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtGui + +from mgear.anim_picker.widgets import manipulator_transform + + +class ItemManipulator(object): + """Scale/rotate controller for the view's selected picker items.""" + + HANDLE_PX = 6 # half-size of a scale handle square, screen pixels + PICK_PX = 8 # handle pick radius, screen pixels + ROTATE_PX = 22 # rotate handle offset past the box top, screen pixels + ROTATE_RADIUS_PX = 6 # rotate handle circle radius, screen pixels + COLOR = (40, 200, 255, 220) + + def __init__(self, view): + self.view = view + # Drag state, captured at begin_drag so transforms are from the start. + self._drag_mode = None # "scale" | "rotate" | None + self._drag_handle = None + self._drag_origin = None # (x, y) in scene units + # [(item, x, y, rotation, [[hx, hy], ...]), ...] at drag start + self._orig_items = None + self._orig_bounds = None # (x, y, w, h); center is derived from this + + # -- helpers -------------------------------------------------------- + def _px_to_scene(self): + """Return the scene units per screen pixel for the current zoom.""" + m11 = self.view.transform().m11() + if not m11: + return 1.0 + return 1.0 / abs(m11) + + def selected_items(self): + """Return the currently selected picker items (live from the scene).""" + return self.view.scene().get_selected_items() + + def is_active(self): + """Return True when there is a selection to manipulate.""" + return bool(self.selected_items()) + + def bounds(self): + """Return the union sceneBoundingRect of the selection as bounds. + + Returns: + tuple: ``(x, y, w, h)`` or None when the selection is empty. + """ + rect = None + for item in self.selected_items(): + item_rect = item.sceneBoundingRect() + rect = item_rect if rect is None else rect.united(item_rect) + if rect is None: + return None + return (rect.x(), rect.y(), rect.width(), rect.height()) + + def _rotate_handle_point(self, bounds, px): + """Return the rotate handle (x, y), above the box top-center. + + The scene is Y-up (the view flips Y), so the box top edge is at + ``y + h`` and a positive offset places the handle above it on screen. + + Args: + bounds (tuple): ``(x, y, w, h)``. + px (float): scene units per screen pixel (precomputed by caller). + """ + x, y, w, h = bounds + offset = self.ROTATE_PX * px + return (x + w / 2.0, y + h + offset) + + # -- hit testing ---------------------------------------------------- + def hit_test(self, x, y): + """Return ("rotate",) / ("scale", id) / None for a scene point.""" + bounds = self.bounds() + if bounds is None: + return None + px = self._px_to_scene() + radius = self.PICK_PX * px + + # Rotate handle first: it sits outside the box, above top-center. + rx, ry = self._rotate_handle_point(bounds, px) + rot_radius = max(self.ROTATE_RADIUS_PX, self.PICK_PX) * px + if abs(x - rx) <= rot_radius and abs(y - ry) <= rot_radius: + return ("rotate",) + + for hid, (hx, hy) in manipulator_transform.handle_points( + bounds + ).items(): + if abs(x - hx) <= radius and abs(y - hy) <= radius: + return ("scale", hid) + return None + + # -- drag lifecycle ------------------------------------------------- + def begin_drag(self, mode, handle, x, y): + """Capture the selection's original transforms and the group frame.""" + self._drag_mode = mode + self._drag_handle = handle + self._drag_origin = (x, y) + self._orig_bounds = self.bounds() + self._orig_items = [] + for item in self.selected_items(): + handles = [[h.x(), h.y()] for h in item.handles] + self._orig_items.append( + (item, item.x(), item.y(), item.rotation(), handles) + ) + + def update_drag(self, x, y, keep_aspect=False): + """Recompute the transform from the drag start (no cumulative drift).""" + if self._drag_mode == "scale": + self._update_scale(x, y, keep_aspect) + elif self._drag_mode == "rotate": + self._update_rotate(x, y) + + def _update_scale(self, x, y, keep_aspect): + anchor_x, anchor_y, sx, sy = manipulator_transform.scale_factors( + self._orig_bounds, self._drag_handle, x, y, keep_aspect + ) + for item, ox, oy, _orot, ohandles in self._orig_items: + item.setPos( + anchor_x + (ox - anchor_x) * sx, + anchor_y + (oy - anchor_y) * sy, + ) + # Scale each item's handles in its own (axis-aligned) local frame, + # rebuilt from the captured originals. Non-uniform scale of a + # rotated item shears it (accepted v1 limitation); uniform is exact. + for handle, (hx, hy) in zip(item.handles, ohandles): + handle.setPos(hx * sx, hy * sy) + item.update() + + def _update_rotate(self, x, y): + bx, by, bw, bh = self._orig_bounds + cx, cy = bx + bw / 2.0, by + bh / 2.0 + angle = manipulator_transform.rotate_delta( + cx, cy, self._drag_origin[0], self._drag_origin[1], x, y + ) + # Rigid rotation about the group center: orbit each item's position and + # add the same angle to its rotation. A single item's center is its own + # bbox center, so it rotates in place. + for item, ox, oy, orot, _ohandles in self._orig_items: + nx, ny = manipulator_transform.rotate_point(ox, oy, cx, cy, angle) + item.setPos(nx, ny) + item.setRotation(orot + angle) + + def end_drag(self): + self._drag_mode = None + self._drag_handle = None + self._drag_origin = None + self._orig_items = None + self._orig_bounds = None + + # -- painting ------------------------------------------------------- + def paint(self, painter): + """Draw the selection bbox, scale handles, and rotate handle.""" + bounds = self.bounds() + if bounds is None: + return + + x, y, w, h = bounds + px = self._px_to_scene() + color = QtGui.QColor(*self.COLOR) + + # Cosmetic 1px outline keeps a constant screen width at any zoom. + pen = QtGui.QPen(color, 0) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawRect(QtCore.QRectF(x, y, w, h)) + + # Rotate handle: a stalk from the top-center plus a filled circle. + rx, ry = self._rotate_handle_point(bounds, px) + painter.drawLine(QtCore.QLineF(x + w / 2.0, y + h, rx, ry)) + r = self.ROTATE_RADIUS_PX * px + painter.setBrush(color) + painter.drawEllipse(QtCore.QRectF(rx - r, ry - r, r * 2.0, r * 2.0)) + + # Scale handles (constant screen size). + half = self.HANDLE_PX * px + for hx, hy in manipulator_transform.handle_points(bounds).values(): + painter.drawRect( + QtCore.QRectF(hx - half, hy - half, half * 2.0, half * 2.0) + ) diff --git a/release/scripts/mgear/anim_picker/widgets/manipulator_transform.py b/release/scripts/mgear/anim_picker/widgets/manipulator_transform.py new file mode 100644 index 00000000..f344475b --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/manipulator_transform.py @@ -0,0 +1,133 @@ +"""Qt/Maya-free group-transform math shared by the on-canvas manipulators. + +This is the tested geometry core behind both the background layer manipulator +(``background_manipulator``) and the picker item manipulator +(``item_manipulator``). It has no Qt or Maya dependency so the scale/rotate math +stays unit-testable without a running DCC. + +Bounds are expressed as ``(x, y, w, h)`` where ``(x, y)`` is the min corner in +the view's centered scene space and ``w``/``h`` are positive. Rotation is in +degrees, positive counter-clockwise in that scene space. +""" + +import math + + +HANDLE_IDS = ("bl", "br", "tl", "tr", "l", "r", "t", "b") + +# Opposite handle used as the fixed anchor while scaling. +_OPPOSITE = { + "bl": "tr", + "tr": "bl", + "br": "tl", + "tl": "br", + "l": "r", + "r": "l", + "b": "t", + "t": "b", +} + +_SCALES_X = ("bl", "br", "tl", "tr", "l", "r") +_SCALES_Y = ("bl", "br", "tl", "tr", "t", "b") + + +def handle_points(bounds): + """Return a dict of handle id -> (x, y) for the given bounds. + + Args: + bounds (tuple): ``(x, y, w, h)``. + + Returns: + dict: handle id -> (x, y). + """ + x, y, w, h = bounds + return { + "bl": (x, y), + "br": (x + w, y), + "tl": (x, y + h), + "tr": (x + w, y + h), + "l": (x, y + h / 2.0), + "r": (x + w, y + h / 2.0), + "b": (x + w / 2.0, y), + "t": (x + w / 2.0, y + h), + } + + +def scale_factors(bounds, handle_id, cursor_x, cursor_y, keep_aspect=False): + """Compute the anchor and scale factors for a handle drag. + + Corner handles scale both axes, edge handles scale one; ``keep_aspect`` + forces a uniform factor. The anchor is the opposite handle's position. + + Args: + bounds (tuple): original ``(x, y, w, h)`` at drag start. + handle_id (str): one of ``HANDLE_IDS``. + cursor_x (float): current cursor x in scene units. + cursor_y (float): current cursor y in scene units. + keep_aspect (bool, optional): keep the aspect ratio. + + Returns: + tuple: ``(anchor_x, anchor_y, sx, sy)``. + """ + x, y, w, h = bounds + anchor_x, anchor_y = handle_points(bounds)[_OPPOSITE[handle_id]] + + scales_x = handle_id in _SCALES_X + scales_y = handle_id in _SCALES_Y + + sx = abs(cursor_x - anchor_x) / w if (scales_x and w) else 1.0 + sy = abs(cursor_y - anchor_y) / h if (scales_y and h) else 1.0 + + if keep_aspect: + if scales_x and scales_y: + sx = sy = max(sx, sy) + elif scales_x: + sy = sx + elif scales_y: + sx = sy + + return anchor_x, anchor_y, sx, sy + + +def rotate_delta(cx, cy, from_x, from_y, to_x, to_y): + """Return the signed rotation (degrees) from one cursor point to another. + + Both points are taken relative to the pivot ``(cx, cy)``; the result is the + angle swept from ``(from_x, from_y)`` to ``(to_x, to_y)``, positive + counter-clockwise. + + Args: + cx (float): pivot x. + cy (float): pivot y. + from_x (float): drag-start cursor x. + from_y (float): drag-start cursor y. + to_x (float): current cursor x. + to_y (float): current cursor y. + + Returns: + float: signed angle in degrees. + """ + start = math.atan2(from_y - cy, from_x - cx) + current = math.atan2(to_y - cy, to_x - cx) + return math.degrees(current - start) + + +def rotate_point(x, y, cx, cy, deg): + """Rotate a point about a pivot by ``deg`` degrees (CCW positive). + + Args: + x (float): point x. + y (float): point y. + cx (float): pivot x. + cy (float): pivot y. + deg (float): rotation in degrees. + + Returns: + tuple: the rotated ``(x, y)``. + """ + rad = math.radians(deg) + cos_a = math.cos(rad) + sin_a = math.sin(rad) + dx = x - cx + dy = y - cy + return (cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a) diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 27d72d9f..97de7b2c 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -542,8 +542,27 @@ def custom_eval(*args, **kwargs): # ========================================================================= # Edit picker item options --- def edit_options(self): - """Open Edit options window""" - # Delete old window + """Surface the inline editor bound to this item. + + Replaces the per-item modal for the common path (6a): the docked panel + edits the whole selection inline. Falls back to the legacy + ``ItemOptionsWindow`` only when no inline panel is available. + """ + panel = getattr(self.main_window, "edit_panel", None) + if panel is None: + self._open_options_modal() + return + + # Bind the panel to this item, keeping any existing multi-selection. + if not self.polygon.selected: + self.scene().select_picker_items([self]) + panel.sync() + panel.setVisible(True) + panel.raise_() + panel.setFocus() + + def _open_options_modal(self): + """Open the legacy single-item options modal (fallback only).""" if self.edit_window: try: self.edit_window.close() @@ -551,12 +570,9 @@ def edit_options(self): except Exception: pass - # Init new window self.edit_window = ItemOptionsWindow( parent=self.main_window, picker_item=self ) - - # Show window self.edit_window.show() self.edit_window.raise_() diff --git a/release/scripts/mgear/anim_picker/widgets/tool_bar.py b/release/scripts/mgear/anim_picker/widgets/tool_bar.py new file mode 100644 index 00000000..a4a82fe0 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/tool_bar.py @@ -0,0 +1,126 @@ +"""Left-side tool strip for the anim picker canvas (Photoshop-style). + +``PickerToolBar`` is a thin vertical strip on the left of the picker canvas. +For now it hosts the exclusive edit *tools* that gate on-canvas behavior: + +- **Select** (default): click / marquee select and drag-move items. +- **Transform**: additionally shows the on-canvas scale/rotate manipulator so + the transform handles are opt-in rather than always drawn. + +It is built to grow: future quick-access *command* buttons (duplicate, mirror, +...) attach below the tools via ``add_command`` without disturbing the tool +group. +""" + +from functools import partial + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + + +# Tool identifiers, also read by the view to gate the manipulator. +TOOL_SELECT = "select" +TOOL_TRANSFORM = "transform" + + +class PickerToolBar(QtWidgets.QWidget): + """Vertical tool strip that drives the active picker tool.""" + + _BUTTON_SIZE = 34 + + def __init__(self, main_window=None, parent=None): + super(PickerToolBar, self).__init__(parent) + self.main_window = main_window + + # Keep the strip at its natural (narrow) width; the canvas takes the + # remaining space in the enclosing row. + self.setSizePolicy( + QtWidgets.QSizePolicy.Maximum, QtWidgets.QSizePolicy.Preferred + ) + + self.tool_group = QtWidgets.QButtonGroup(self) + self.tool_group.setExclusive(True) + self._tool_buttons = {} + + self.main_layout = QtWidgets.QVBoxLayout(self) + self.main_layout.setContentsMargins(2, 2, 2, 2) + self.main_layout.setSpacing(2) + self.main_layout.setAlignment(QtCore.Qt.AlignTop) + + self._add_tool( + TOOL_SELECT, + "Sel", + "Select tool: click / marquee select and drag to move items", + ":/aselect.png", + checked=True, + ) + self._add_tool( + TOOL_TRANSFORM, + "Xfrm", + "Transform tool: show on-canvas scale / rotate handles", + ":/move_M.png", + checked=False, + ) + + self.main_layout.addStretch() + + def _style_button(self, button, label, tooltip, icon_resource): + """Apply the shared strip button look, using an icon when available.""" + button.setToolTip(tooltip) + button.setAutoRaise(True) + button.setFixedWidth(self._BUTTON_SIZE) + button.setMinimumHeight(self._BUTTON_SIZE) + # Prefer a Maya resource icon; fall back to a short text label so the + # strip is usable even when the resource is absent. + icon = QtGui.QIcon(icon_resource) if icon_resource else QtGui.QIcon() + if not icon.isNull(): + button.setIcon(icon) + button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) + else: + button.setText(label) + button.setToolButtonStyle(QtCore.Qt.ToolButtonTextOnly) + + def _add_tool(self, name, label, tooltip, icon_resource, checked=False): + """Add an exclusive tool toggle button.""" + button = QtWidgets.QToolButton() + button.setCheckable(True) + button.setChecked(checked) + self._style_button(button, label, tooltip, icon_resource) + button.clicked.connect(partial(self._tool_clicked, name)) + self.tool_group.addButton(button) + self._tool_buttons[name] = button + self.main_layout.addWidget(button) + + def add_command(self, label, tooltip, callback, icon_resource=None): + """Add a non-exclusive quick-access command button below the tools. + + Reserved for future commands (duplicate, mirror, ...); returns the + created button so callers can enable/disable it with the selection. + + Args: + label (str): short button label (used when no icon is available). + tooltip (str): hover description. + callback (callable): invoked on click. + icon_resource (str, optional): icon resource path. + + Returns: + QtWidgets.QToolButton: the created button. + """ + button = QtWidgets.QToolButton() + self._style_button(button, label, tooltip, icon_resource) + button.clicked.connect(callback) + # Insert before the trailing stretch. + self.main_layout.insertWidget(self.main_layout.count() - 1, button) + return button + + def _tool_clicked(self, name): + if self.main_window is not None: + self.main_window.set_active_tool(name) + + def active_tool(self): + """Return the identifier of the currently checked tool.""" + for name, button in self._tool_buttons.items(): + if button.isChecked(): + return name + return TOOL_SELECT diff --git a/releaseLog.rst b/releaseLog.rst index 42d00042..a4d0437f 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: On-canvas item manipulators + inline multi-edit panel — a Photoshop-style tool strip on the left of the canvas (edit mode) toggles between the Select tool (default) and the Transform tool; with the Transform tool active, selected picker buttons show a bounding-box overlay with scale handles (corner = both axes, edge = one, Shift keeps aspect) and a rotate handle; a single item rotates in place while a multi-selection scales/rotates as a group about its center (moving stays the item drag, and drag-moving a group keeps the whole selection). A new right-docked, collapsible editor panel (edit mode only) edits the whole selection at once — Transform, Appearance, Shape, Controls, and Action — with fields that differ across the selection shown as "mixed" so a multi-edit never clobbers them; the panel and canvas stay in sync. Double-click / "Options" now surface this panel instead of the per-item modal (the modal remains as a fallback). The scale/rotate geometry is shared with the background manipulator * Anim Picker: Re-enable dockable mode — a new "Anim Picker (Dockable)" menu entry launches the picker as a Maya workspaceControl. The window now sets a stable object name so its workspaceControl is created, closed, and re-opened cleanly (no duplicate or orphaned controls); the floating window remains the default * Anim Picker: On-canvas background layer manipulators — with the Background layers panel open (edit mode), click / shift-click / marquee-select layers directly on the canvas and drag to move or drag the bounding-box handles to scale (corner = both axes, edge = one, Shift keeps aspect); multi-selected layers transform together as a group, and the panel selection and fields stay in sync * Anim Picker: Composite and flexible tab backgrounds — a tab background is now an ordered list of image layers, each with its own position and size, drawn back-to-front; the canvas spans both the background layers and the picker buttons (never smaller than the default), so pan and zoom reach all content and backgrounds can be larger and non-proportional #108 (the former 6000 size cap is removed). A new background-layer manager (right-click > Background layers...) adds / removes / reorders and repositions layers. Legacy single-image pickers still load (mapped to one layer); writes now emit a "backgrounds" list From ebbd3a0334b4a9afecdef6fb79ada68ea9dac7be Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Thu, 9 Jul 2026 09:37:58 +0900 Subject: [PATCH 07/25] Core: Widgets: Add reusable PythonCodeEditor and adopt it in the anim picker script editor - mgear.core.pycodeeditor.PythonCodeEditor: QPlainTextEdit with a line number gutter, current-line highlight, Python syntax highlighting, configurable monospace font (Consolas default), spaces-or-tabs indent with configurable width, show-whitespace, convert-tabs-to-spaces, and smart Tab / Shift-Tab + auto-indent on Enter. Prefs persist via QSettings. DCC-agnostic (no Maya import) so any tool can embed it. - anim_picker custom script / menu dialog now embeds it, with a File / Edit / View menu bar (open/save scripts, edit ops, convert to spaces, show indentation) and a font / indentation toolbar. --- .../widgets/dialogs/script_dialog.py | 123 ++++- release/scripts/mgear/core/pycodeeditor.py | 434 ++++++++++++++++++ releaseLog.rst | 2 + 3 files changed, 556 insertions(+), 3 deletions(-) create mode 100644 release/scripts/mgear/core/pycodeeditor.py diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py index 7eb3bfd8..3c344acb 100644 --- a/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py @@ -7,6 +7,7 @@ from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets +from mgear.core import pycodeeditor from mgear.anim_picker.widgets import basic from mgear.anim_picker.handlers import python_handlers @@ -46,13 +47,18 @@ def setup(self): # Add layout self.main_layout = QtWidgets.QVBoxLayout(self) - # Add cmd txt field - self.cmd_widget = QtWidgets.QTextEdit() + # Modern Python code editor (line numbers, syntax highlight, smart + # indent) from mgear.core. Create it first so the menu bar and the + # font/indent toolbar can bind to it. + self.cmd_widget = pycodeeditor.PythonCodeEditor() + self.main_layout.setMenuBar(self._build_menu_bar()) + self.main_layout.addLayout(self._build_editor_toolbar()) + if self.cmd: text = self.cmd else: text = SCRIPT_DOC_HEADER - self.cmd_widget.setText(text) + self.cmd_widget.setPlainText(text) newCursor = self.cmd_widget.textCursor() newCursor.movePosition(QtGui.QTextCursor.End) self.cmd_widget.setTextCursor(newCursor) @@ -76,6 +82,117 @@ def setup(self): self.resize(500, 600) + def _build_menu_bar(self): + """Build the File / Edit / View menu bar bound to the editor.""" + editor = self.cmd_widget + menu_bar = QtWidgets.QMenuBar(self) + + file_menu = menu_bar.addMenu("File") + file_menu.addAction("New", self._file_new) + file_menu.addAction("Open...", self._file_open) + file_menu.addAction("Save As...", self._file_save) + + edit_menu = menu_bar.addMenu("Edit") + edit_menu.addAction("Undo", editor.undo) + edit_menu.addAction("Redo", editor.redo) + edit_menu.addSeparator() + edit_menu.addAction("Cut", editor.cut) + edit_menu.addAction("Copy", editor.copy) + edit_menu.addAction("Paste", editor.paste) + edit_menu.addAction("Select All", editor.selectAll) + edit_menu.addSeparator() + edit_menu.addAction( + "Convert Indentation to Spaces", + editor.convert_indentation_to_spaces, + ) + + view_menu = menu_bar.addMenu("View") + self.show_ws_action = view_menu.addAction("Show Indentation") + self.show_ws_action.setCheckable(True) + self.show_ws_action.setChecked(editor.show_whitespace()) + self.show_ws_action.toggled.connect(editor.set_show_whitespace) + + return menu_bar + + def _file_new(self): + """Reset the editor to the documentation header.""" + self.cmd_widget.setPlainText(SCRIPT_DOC_HEADER) + + def _file_open(self): + """Load a python file into the editor.""" + path, _flt = QtWidgets.QFileDialog.getOpenFileName( + self, "Open script", "", "Python (*.py);;All Files (*)" + ) + if not path: + return + with open(path, "r") as script_file: + self.cmd_widget.setPlainText(script_file.read()) + + def _file_save(self): + """Write the editor contents to a python file.""" + path, _flt = QtWidgets.QFileDialog.getSaveFileName( + self, "Save script", "", "Python (*.py);;All Files (*)" + ) + if not path: + return + with open(path, "w") as script_file: + script_file.write(self.cmd_widget.toPlainText()) + + def _build_editor_toolbar(self): + """Build the font / indentation controls row bound to the editor.""" + editor = self.cmd_widget + layout = QtWidgets.QHBoxLayout() + + layout.addWidget(QtWidgets.QLabel("Font")) + self.font_combo = QtWidgets.QFontComboBox() + self.font_combo.setFontFilters( + QtWidgets.QFontComboBox.MonospacedFonts + ) + self.font_combo.setCurrentFont(editor.font()) + self.font_combo.currentFontChanged.connect(self._editor_font_changed) + layout.addWidget(self.font_combo) + + self.font_size_sb = QtWidgets.QSpinBox() + self.font_size_sb.setRange(6, 48) + self.font_size_sb.setValue(editor.font().pointSize()) + self.font_size_sb.valueChanged.connect(self._editor_font_changed) + layout.addWidget(self.font_size_sb) + + layout.addSpacing(10) + layout.addWidget(QtWidgets.QLabel("Indent")) + self.indent_mode_cb = QtWidgets.QComboBox() + self.indent_mode_cb.addItems(["Spaces", "Tabs"]) + self.indent_mode_cb.setCurrentIndex( + 0 if editor.use_spaces() else 1 + ) + self.indent_mode_cb.currentIndexChanged.connect( + self._editor_indent_changed + ) + layout.addWidget(self.indent_mode_cb) + + self.indent_width_sb = QtWidgets.QSpinBox() + self.indent_width_sb.setRange(1, 8) + self.indent_width_sb.setValue(editor.indent_width()) + self.indent_width_sb.valueChanged.connect( + self._editor_indent_changed + ) + layout.addWidget(self.indent_width_sb) + + layout.addStretch() + return layout + + def _editor_font_changed(self, *args): + """Apply the toolbar font family/size to the editor.""" + self.cmd_widget.set_editor_font( + self.font_combo.currentFont().family(), + self.font_size_sb.value(), + ) + + def _editor_indent_changed(self, *args): + """Apply the toolbar indentation mode/width to the editor.""" + self.cmd_widget.set_use_spaces(self.indent_mode_cb.currentIndex() == 0) + self.cmd_widget.set_indent_width(self.indent_width_sb.value()) + def accept_event(self): """Accept button event""" self.apply = True diff --git a/release/scripts/mgear/core/pycodeeditor.py b/release/scripts/mgear/core/pycodeeditor.py new file mode 100644 index 00000000..55d5a6f5 --- /dev/null +++ b/release/scripts/mgear/core/pycodeeditor.py @@ -0,0 +1,434 @@ +"""Reusable Python code editor widget for mGear UIs. + +A modern-looking multi-line Python editor built on ``QPlainTextEdit`` with: + +- a line-number gutter, +- current-line highlight, +- Python syntax highlighting (keywords, builtins, strings, comments, numbers, + decorators, def/class names), +- configurable monospace font family and size, +- configurable indentation (spaces or tabs) and indent width, +- smart Tab / Shift-Tab (indent / unindent, block-aware) and auto-indent on + Enter (copies the previous line's indent, adds one level after a ``:``). + +Preferences (font, indent mode/width) persist via ``QSettings`` so the editor +looks the same across sessions and across the tools that embed it. + +Framework-first: this lives in ``mgear.core`` so any tool needing a Python +editor (anim picker custom scripts, shifter custom steps, ...) can reuse it +instead of dropping a bare ``QTextEdit``. +""" + +import re + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + + +# -- preferences ------------------------------------------------------------- +_SETTINGS_ORG = "mgear" +_SETTINGS_APP = "pycodeeditor" +DEFAULT_FONT_FAMILY = "Consolas" +DEFAULT_FONT_SIZE = 10 +DEFAULT_INDENT_WIDTH = 4 +DEFAULT_USE_SPACES = True +DEFAULT_SHOW_WHITESPACE = False + +# -- dark editor palette ----------------------------------------------------- +_BG_COLOR = QtGui.QColor(30, 30, 30) +_TEXT_COLOR = QtGui.QColor(212, 212, 212) +_GUTTER_BG = QtGui.QColor(45, 45, 45) +_GUTTER_FG = QtGui.QColor(130, 130, 130) +_CURRENT_LINE = QtGui.QColor(58, 58, 70) + +# Python keywords / builtins for highlighting. +_KEYWORDS = ( + "and as assert async await break class continue def del elif else except " + "finally for from global if import in is lambda nonlocal not or pass raise " + "return try while with yield True False None" +).split() +_BUILTINS = ( + "abs all any bin bool bytearray bytes callable chr classmethod compile " + "complex delattr dict dir divmod enumerate eval exec filter float format " + "frozenset getattr globals hasattr hash help hex id input int isinstance " + "issubclass iter len list locals map max min next object oct open ord pow " + "print property range repr reversed round set setattr slice sorted " + "staticmethod str sum super tuple type vars zip" +).split() + + +def _char_format(color, bold=False, italic=False): + """Return a QTextCharFormat for the given color/style.""" + fmt = QtGui.QTextCharFormat() + fmt.setForeground(color) + if bold: + fmt.setFontWeight(QtGui.QFont.Bold) + if italic: + fmt.setFontItalic(True) + return fmt + + +class PythonHighlighter(QtGui.QSyntaxHighlighter): + """Lightweight Python syntax highlighter (regex + triple-string states).""" + + def __init__(self, document): + super(PythonHighlighter, self).__init__(document) + + keyword_fmt = _char_format(QtGui.QColor(86, 156, 214), bold=True) + builtin_fmt = _char_format(QtGui.QColor(78, 201, 176)) + self_fmt = _char_format(QtGui.QColor(156, 220, 254), italic=True) + number_fmt = _char_format(QtGui.QColor(181, 206, 168)) + decorator_fmt = _char_format(QtGui.QColor(220, 220, 170)) + defname_fmt = _char_format(QtGui.QColor(220, 220, 170)) + self._string_fmt = _char_format(QtGui.QColor(206, 145, 120)) + self._comment_fmt = _char_format(QtGui.QColor(106, 153, 85), italic=True) + + # (compiled regex, capture group, format). Applied in order; later + # matches override earlier ones for overlapping ranges. + self._rules = [] + keyword_re = r"\b(?:{})\b".format("|".join(_KEYWORDS)) + builtin_re = r"\b(?:{})\b".format("|".join(_BUILTINS)) + self._rules.append((re.compile(keyword_re), 0, keyword_fmt)) + self._rules.append((re.compile(builtin_re), 0, builtin_fmt)) + self._rules.append((re.compile(r"\bself\b"), 0, self_fmt)) + self._rules.append( + (re.compile(r"\b[0-9]+\.?[0-9]*\b"), 0, number_fmt) + ) + self._rules.append((re.compile(r"@\w+"), 0, decorator_fmt)) + self._rules.append( + (re.compile(r"\b(?:def|class)\s+(\w+)"), 1, defname_fmt) + ) + # Single-line strings. + self._rules.append( + (re.compile(r"'[^'\\\n]*(?:\\.[^'\\\n]*)*'"), 0, self._string_fmt) + ) + self._rules.append( + (re.compile(r'"[^"\\\n]*(?:\\.[^"\\\n]*)*"'), 0, self._string_fmt) + ) + # Comments last so they win over code (a "#" inside a string is a known + # limitation of a simple line highlighter and is acceptable here). + self._rules.append((re.compile(r"#[^\n]*"), 0, self._comment_fmt)) + + self._tri_single = re.compile(r"'''") + self._tri_double = re.compile(r'"""') + + def highlightBlock(self, text): + for pattern, group, fmt in self._rules: + for match in pattern.finditer(text): + start = match.start(group) + end = match.end(group) + self.setFormat(start, end - start, fmt) + + # Triple-quoted (possibly multi-line) strings via block state. + self.setCurrentBlockState(0) + if not self._match_multiline(text, self._tri_single, 1): + self._match_multiline(text, self._tri_double, 2) + + def _match_multiline(self, text, delimiter, state): + """Highlight a triple-quoted string that may span blocks.""" + start = 0 + if self.previousBlockState() == state: + start = 0 + add = 0 + else: + match = delimiter.search(text) + if not match: + return False + start = match.start() + add = match.end() - match.start() + + while start >= 0: + end_match = delimiter.search(text, start + add) + if end_match: + length = end_match.end() - start + self.setCurrentBlockState(0) + else: + self.setCurrentBlockState(state) + length = len(text) - start + self.setFormat(start, length, self._string_fmt) + if not end_match: + break + next_match = delimiter.search(text, start + length) + start = next_match.start() if next_match else -1 + add = 3 + return self.currentBlockState() == state + + +class _LineNumberArea(QtWidgets.QWidget): + """Gutter widget painted by the editor to show line numbers.""" + + def __init__(self, editor): + super(_LineNumberArea, self).__init__(editor) + self._editor = editor + + def sizeHint(self): + return QtCore.QSize(self._editor.line_number_area_width(), 0) + + def paintEvent(self, event): + self._editor.line_number_area_paint_event(event) + + +class PythonCodeEditor(QtWidgets.QPlainTextEdit): + """A ``QPlainTextEdit`` with line numbers, highlighting and smart indent.""" + + def __init__(self, parent=None): + super(PythonCodeEditor, self).__init__(parent) + + self._line_number_area = _LineNumberArea(self) + self._highlighter = PythonHighlighter(self.document()) + + # Indentation / display state (loaded from settings in _load_prefs). + self._use_spaces = DEFAULT_USE_SPACES + self._indent_width = DEFAULT_INDENT_WIDTH + self._show_whitespace = DEFAULT_SHOW_WHITESPACE + + self.setLineWrapMode(QtWidgets.QPlainTextEdit.NoWrap) + palette = self.palette() + palette.setColor(QtGui.QPalette.Base, _BG_COLOR) + palette.setColor(QtGui.QPalette.Text, _TEXT_COLOR) + self.setPalette(palette) + + self.blockCountChanged.connect(self._update_line_number_area_width) + self.updateRequest.connect(self._update_line_number_area) + self.cursorPositionChanged.connect(self._highlight_current_line) + + self._load_prefs() + self._update_line_number_area_width(0) + self._highlight_current_line() + + # -- preferences ---------------------------------------------------- + def _settings(self): + return QtCore.QSettings(_SETTINGS_ORG, _SETTINGS_APP) + + def _load_prefs(self): + settings = self._settings() + family = settings.value("font_family", DEFAULT_FONT_FAMILY) + size = int(settings.value("font_size", DEFAULT_FONT_SIZE)) + self._use_spaces = ( + str(settings.value("use_spaces", DEFAULT_USE_SPACES)).lower() + in ("true", "1") + ) + self._indent_width = int( + settings.value("indent_width", DEFAULT_INDENT_WIDTH) + ) + self._show_whitespace = ( + str( + settings.value("show_whitespace", DEFAULT_SHOW_WHITESPACE) + ).lower() + in ("true", "1") + ) + self.set_editor_font(family, size) + self.set_show_whitespace(self._show_whitespace) + + def _save_prefs(self): + settings = self._settings() + settings.setValue("font_family", self.font().family()) + settings.setValue("font_size", self.font().pointSize()) + settings.setValue("use_spaces", self._use_spaces) + settings.setValue("indent_width", self._indent_width) + settings.setValue("show_whitespace", self._show_whitespace) + + # -- public configuration ------------------------------------------- + def set_editor_font(self, family, size): + """Set a fixed-pitch editor font and refresh dependent metrics.""" + font = QtGui.QFont(family) + font.setStyleHint(QtGui.QFont.Monospace) + font.setFixedPitch(True) + font.setPointSize(int(size)) + self.setFont(font) + self._line_number_area.setFont(font) + self._apply_tab_stop() + self._update_line_number_area_width(0) + self._save_prefs() + + def set_indent_width(self, width): + """Set the number of columns one indent level occupies.""" + self._indent_width = max(1, int(width)) + self._apply_tab_stop() + self._save_prefs() + + def set_use_spaces(self, use_spaces): + """Choose spaces (True) or a tab character (False) for indentation.""" + self._use_spaces = bool(use_spaces) + self._save_prefs() + + def use_spaces(self): + return self._use_spaces + + def indent_width(self): + return self._indent_width + + def set_show_whitespace(self, show): + """Toggle rendering of spaces/tabs (visualize indentation).""" + self._show_whitespace = bool(show) + option = self.document().defaultTextOption() + flags = option.flags() + if self._show_whitespace: + flags |= QtGui.QTextOption.ShowTabsAndSpaces + else: + flags &= ~QtGui.QTextOption.ShowTabsAndSpaces + option.setFlags(flags) + self.document().setDefaultTextOption(option) + self.viewport().update() + self._save_prefs() + + def show_whitespace(self): + return self._show_whitespace + + def convert_indentation_to_spaces(self): + """Replace every tab with ``indent_width`` spaces and switch to spaces. + + Preserves the cursor position (clamped) and groups as one undo step. + """ + spaces = " " * self._indent_width + cursor = self.textCursor() + position = cursor.position() + new_text = self.toPlainText().replace("\t", spaces) + cursor.beginEditBlock() + cursor.select(QtGui.QTextCursor.Document) + cursor.insertText(new_text) + cursor.endEditBlock() + restore = self.textCursor() + restore.setPosition(min(position, len(new_text))) + self.setTextCursor(restore) + self.set_use_spaces(True) + + def _apply_tab_stop(self): + advance = self.fontMetrics().horizontalAdvance(" ") + self.setTabStopDistance(self._indent_width * advance) + + def _indent_text(self): + return " " * self._indent_width if self._use_spaces else "\t" + + # -- line number area ---------------------------------------------- + def line_number_area_width(self): + digits = len(str(max(1, self.blockCount()))) + return 10 + self.fontMetrics().horizontalAdvance("9") * digits + + def _update_line_number_area_width(self, _count): + self.setViewportMargins(self.line_number_area_width(), 0, 0, 0) + + def _update_line_number_area(self, rect, dy): + if dy: + self._line_number_area.scroll(0, dy) + else: + self._line_number_area.update( + 0, rect.y(), self._line_number_area.width(), rect.height() + ) + if rect.contains(self.viewport().rect()): + self._update_line_number_area_width(0) + + def resizeEvent(self, event): + super(PythonCodeEditor, self).resizeEvent(event) + cr = self.contentsRect() + self._line_number_area.setGeometry( + QtCore.QRect( + cr.left(), cr.top(), self.line_number_area_width(), cr.height() + ) + ) + + def line_number_area_paint_event(self, event): + painter = QtGui.QPainter(self._line_number_area) + painter.fillRect(event.rect(), _GUTTER_BG) + + block = self.firstVisibleBlock() + block_number = block.blockNumber() + offset = self.contentOffset() + top = int(self.blockBoundingGeometry(block).translated(offset).top()) + bottom = top + int(self.blockBoundingRect(block).height()) + width = self._line_number_area.width() - 5 + height = self.fontMetrics().height() + + while block.isValid() and top <= event.rect().bottom(): + if block.isVisible() and bottom >= event.rect().top(): + painter.setPen(_GUTTER_FG) + painter.drawText( + 0, + top, + width, + height, + QtCore.Qt.AlignRight, + str(block_number + 1), + ) + block = block.next() + top = bottom + bottom = top + int(self.blockBoundingRect(block).height()) + block_number += 1 + + def _highlight_current_line(self): + selections = [] + if not self.isReadOnly(): + selection = QtWidgets.QTextEdit.ExtraSelection() + selection.format.setBackground(_CURRENT_LINE) + selection.format.setProperty( + QtGui.QTextFormat.FullWidthSelection, True + ) + selection.cursor = self.textCursor() + selection.cursor.clearSelection() + selections.append(selection) + self.setExtraSelections(selections) + + # -- indentation / key handling ------------------------------------ + def keyPressEvent(self, event): + key = event.key() + if key == QtCore.Qt.Key_Tab: + self._indent(True) + return + if key == QtCore.Qt.Key_Backtab: + self._indent(False) + return + if key in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self._newline(event) + return + super(PythonCodeEditor, self).keyPressEvent(event) + + def _indent(self, forward): + cursor = self.textCursor() + # Tab with no selection just inserts one indent level at the cursor. + if forward and not cursor.hasSelection(): + cursor.insertText(self._indent_text()) + return + doc = self.document() + if cursor.hasSelection(): + first = doc.findBlock(cursor.selectionStart()).blockNumber() + last = doc.findBlock(cursor.selectionEnd()).blockNumber() + else: + first = last = cursor.blockNumber() + self._shift_lines(first, last, forward) + + def _shift_lines(self, first, last, forward): + """Indent or unindent the block-number range [first, last].""" + doc = self.document() + indent_text = self._indent_text() + group = self.textCursor() + group.beginEditBlock() + for number in range(first, last + 1): + block = doc.findBlockByNumber(number) + line_cursor = QtGui.QTextCursor(block) + line_cursor.movePosition(QtGui.QTextCursor.StartOfBlock) + if forward: + line_cursor.insertText(indent_text) + else: + line = block.text() + if line.startswith("\t"): + remove = 1 + else: + leading = len(line) - len(line.lstrip(" ")) + remove = min(leading, self._indent_width) + if remove: + line_cursor.movePosition( + QtGui.QTextCursor.NextCharacter, + QtGui.QTextCursor.KeepAnchor, + remove, + ) + line_cursor.removeSelectedText() + group.endEditBlock() + + def _newline(self, event): + cursor = self.textCursor() + line = cursor.block().text() + indent = re.match(r"[ \t]*", line).group(0) + extra = self._indent_text() if line.strip().endswith(":") else "" + super(PythonCodeEditor, self).keyPressEvent(event) + self.insertPlainText(indent + extra) diff --git a/releaseLog.rst b/releaseLog.rst index a4d0437f..d9b858df 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,8 @@ Release Log 5.3.4 ------ **Enhancements** + * Core: Add a reusable ``PythonCodeEditor`` widget (mgear.core.pycodeeditor) — a modern Python editor with a line-number gutter, current-line highlight, Python syntax highlighting, a configurable monospace font (Consolas by default, family + size), spaces-or-tabs indentation with a configurable width, "show indentation" (visualize whitespace), convert-tabs-to-spaces, and smart Tab / Shift-Tab (block indent/unindent) + auto-indent on Enter; preferences persist via QSettings. Any mGear tool can embed it instead of a bare QTextEdit + * Anim Picker: Custom script / menu editor now uses the modern ``PythonCodeEditor`` (line numbers, syntax highlighting, font + indentation controls) with a File / Edit / View menu bar (open & save scripts to file, undo/redo/cut/copy/paste, convert indentation to spaces, show indentation) in place of the plain text box * Anim Picker: On-canvas item manipulators + inline multi-edit panel — a Photoshop-style tool strip on the left of the canvas (edit mode) toggles between the Select tool (default) and the Transform tool; with the Transform tool active, selected picker buttons show a bounding-box overlay with scale handles (corner = both axes, edge = one, Shift keeps aspect) and a rotate handle; a single item rotates in place while a multi-selection scales/rotates as a group about its center (moving stays the item drag, and drag-moving a group keeps the whole selection). A new right-docked, collapsible editor panel (edit mode only) edits the whole selection at once — Transform, Appearance, Shape, Controls, and Action — with fields that differ across the selection shown as "mixed" so a multi-edit never clobbers them; the panel and canvas stay in sync. Double-click / "Options" now surface this panel instead of the per-item modal (the modal remains as a fallback). The scale/rotate geometry is shared with the background manipulator * Anim Picker: Re-enable dockable mode — a new "Anim Picker (Dockable)" menu entry launches the picker as a Maya workspaceControl. The window now sets a stable object name so its workspaceControl is created, closed, and re-opened cleanly (no duplicate or orphaned controls); the floating window remains the default * Anim Picker: On-canvas background layer manipulators — with the Background layers panel open (edit mode), click / shift-click / marquee-select layers directly on the canvas and drag to move or drag the bounding-box handles to scale (corner = both axes, edge = one, Shift keeps aspect); multi-selected layers transform together as a group, and the panel selection and fields stay in sync From 7dad95a4a8da43c8da8d30323caabbfcabf16dca Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Thu, 9 Jul 2026 11:49:28 +0900 Subject: [PATCH 08/25] AnimPicker: Editing: Mirror relationships, shape library, color palette, and tool-strip commands Roadmap 6b of the anim_picker 2.0 creation/edit UX overhaul. - Qt-free mirror math (widgets/mirror.py: reflect position / rotation / handles); PickerItem.mirror_* refactored onto it. - Persistent, realtime live mirror relationships about a symmetry axis: optional additive item-data keys id / mirror (old pickers load unchanged), lazy pair resolution + dangling cleanup, a _mirroring loop guard, a cached has-links flag, and mirroring on the manipulator drag, the plain item drag-move, and panel edits. Linked items get a pink dotted shape outline; a symmetry-axis guide is drawn. - Left tool-strip quick commands with bundled mGear icons (Add, Duplicate, Duplicate & Mirror, Mirror Shape, Shapes); Select / Transform tools use default Maya icons. - Shape library: Qt-free widgets/shape_library.py (named handle-lists, bundled default_shapes.json + per-user JSON in the Maya prefs dir with legacy migration) + a swatch-grid dialog; apply premade / save custom. - Explicit L/R color palette bar (widgets/color_palette.py): 6 role swatches, click applies (partner gets the opposite side, same level), double / right-click edits (persisted); color is no longer auto-swapped on a live geometry mirror. - Edit-mode UX: clicking an item selects it on press (single click-drag selects + moves) while preserving group drag, Shift/Ctrl, marquee, Alt control-select, and anim-mode behavior. --- .../scripts/mgear/anim_picker/main_window.py | 190 +++++++++++++- .../anim_picker/shapes/default_shapes.json | 243 ++++++++++++++++++ release/scripts/mgear/anim_picker/view.py | 203 ++++++++++++++- .../anim_picker/widgets/color_palette.py | 197 ++++++++++++++ .../widgets/dialogs/shape_library_dialog.py | 140 ++++++++++ .../mgear/anim_picker/widgets/edit_panel.py | 125 ++++++++- .../mgear/anim_picker/widgets/item_model.py | 15 ++ .../mgear/anim_picker/widgets/mirror.py | 63 +++++ .../mgear/anim_picker/widgets/picker_item.py | 68 +++-- .../anim_picker/widgets/shape_library.py | 154 +++++++++++ .../mgear/anim_picker/widgets/tool_bar.py | 52 ++-- releaseLog.rst | 1 + 12 files changed, 1403 insertions(+), 48 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/shapes/default_shapes.json create mode 100644 release/scripts/mgear/anim_picker/widgets/color_palette.py create mode 100644 release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py create mode 100644 release/scripts/mgear/anim_picker/widgets/mirror.py create mode 100644 release/scripts/mgear/anim_picker/widgets/shape_library.py diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index aa412501..b1d16605 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -29,7 +29,11 @@ from mgear.anim_picker.widgets import basic from mgear.anim_picker.widgets import edit_panel from mgear.anim_picker.widgets import tool_bar +from mgear.anim_picker.widgets import color_palette from mgear.anim_picker.widgets import overlay_widgets +from mgear.anim_picker.widgets.dialogs.shape_library_dialog import ( + ShapeLibraryDialog, +) from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ @@ -382,6 +386,40 @@ def add_tab_widget(self, name="default"): # Photoshop-style tool strip on the left of the canvas + the splitter. self.left_toolbar = tool_bar.PickerToolBar(main_window=self) + # Quick-access commands below the tools; selection-dependent ones are + # collected so they can be enabled/disabled with the selection. + self.left_toolbar.add_command( + "Add", + "Add a new item at the origin", + self._cmd_add_item, + tool_bar.mgear_icon("mgear_plus-square"), + ) + self._selection_commands = [ + self.left_toolbar.add_command( + "Dup", + "Duplicate selected items", + self._cmd_duplicate, + tool_bar.mgear_icon("mgear_copy"), + ), + self.left_toolbar.add_command( + "DupM", + "Duplicate and mirror the selection (linked as a pair)", + self._cmd_duplicate_mirror, + tool_bar.mgear_icon("mgear_duplicate_sym"), + ), + self.left_toolbar.add_command( + "MirS", + "Mirror the selected shapes", + self._cmd_mirror_shape, + tool_bar.mgear_icon("mgear_mirror_controls"), + ), + self.left_toolbar.add_command( + "Shp", + "Apply a premade / saved shape to the selection", + self._cmd_shapes, + tool_bar.mgear_icon("mgear_replace_shape"), + ), + ] canvas_row = QtWidgets.QHBoxLayout() canvas_row.setContentsMargins(0, 0, 0, 0) canvas_row.setSpacing(0) @@ -389,6 +427,10 @@ def add_tab_widget(self, name="default"): canvas_row.addWidget(self.editor_splitter) self.main_vertical_layout.addLayout(canvas_row) + # Preset L/R/center color palette along the bottom (edit mode only). + self.color_palette = color_palette.ColorPaletteBar(main_window=self) + self.main_vertical_layout.addWidget(self.color_palette) + # Add default first tab view = GraphicViewWidget(main_window=self) self.tab_widget.addTab(view, name) @@ -398,10 +440,11 @@ def add_tab_widget(self, name="default"): sp_retain.setRetainSizeWhenHidden(True) self.tab_widget.setSizePolicy(sp_retain) - # Editor panel and tool strip are edit-mode only; refresh the panel - # when the tab changes. + # Editor panel, tool strip and palette are edit-mode only; refresh the + # panel when the tab changes. self.edit_panel.setVisible(__EDIT_MODE__.get()) self.left_toolbar.setVisible(__EDIT_MODE__.get()) + self.color_palette.setVisible(__EDIT_MODE__.get()) self.tab_widget.currentChanged.connect(self._on_tab_changed) def _on_tab_changed(self, *args): @@ -409,6 +452,7 @@ def _on_tab_changed(self, *args): panel = getattr(self, "edit_panel", None) if panel is not None: panel.sync() + self.update_tool_commands() def set_active_tool(self, name): """Set the active canvas tool and repaint so the overlay updates. @@ -421,18 +465,160 @@ def set_active_tool(self, name): if view is not None: view.viewport().update() + # ===================================================================== + # Tool-strip quick commands --- + def _current_view(self): + """Return the active graphics view, or None.""" + return self.tab_widget.currentWidget() + + def _selected_items(self): + """Return the current view's selected picker items.""" + view = self._current_view() + return view.scene().get_selected_items() if view is not None else [] + + def update_tool_commands(self): + """Enable/disable the selection-dependent command buttons.""" + has = bool(self._selected_items()) + for button in getattr(self, "_selection_commands", []): + button.setEnabled(has) + + def _after_command(self): + """Refresh the canvas, inline panel and command states after an op.""" + view = self._current_view() + if view is not None: + view.viewport().update() + panel = getattr(self, "edit_panel", None) + if panel is not None: + panel.sync() + self.update_tool_commands() + + def _cmd_add_item(self): + view = self._current_view() + if view is not None: + view.add_picker_item_gui(QtCore.QPointF(0, 0)) + self._after_command() + + def _cmd_duplicate(self): + items = self._selected_items() + if items: + items[0].duplicate_selected() + self._after_command() + + def _cmd_shapes(self): + items = self._selected_items() + if not items: + return + current = [[h.x(), h.y()] for h in items[-1].handles] + dialog = ShapeLibraryDialog( + parent=self, + apply_callback=self._apply_shape_to_selection, + current_handles=current, + ) + dialog.show() + + def _apply_shape_to_selection(self, handles): + for item in self._selected_items(): + item.set_handles([list(point) for point in handles]) + self._after_command() + + def _cmd_mirror(self, method_name): + """Run a PickerItem mirror op on the selection and propagate it.""" + view = self._current_view() + items = self._selected_items() + for item in items: + getattr(item, method_name)() + if view is not None: + view.apply_mirror_for(items) + self._after_command() + + def _cmd_mirror_shape(self): + self._cmd_mirror("mirror_shape") + + def apply_palette_color(self, side, level): + """Apply a preset color to the selection, mirroring by side to partners. + + The selected item(s) get the clicked (side, level) color; each item's + mirror partner (when not itself selected) gets the same level on the + opposite side. Presets never repaint items that already used the color. + + Args: + side (str): "left" / "center" / "right". + level (str): "primary" / "secondary". + """ + view = self._current_view() + if view is None: + return + color = self.color_palette.color_for(side, level) + if color is None: + return + mirror_side = color_palette.MIRROR_SIDE.get(side, side) + partner_color = self.color_palette.color_for(mirror_side, level) + items = self._selected_items() + selected = set(items) + for item in items: + self._set_item_rgb(item, color) + partner = view.get_mirror_partner(item) + if ( + partner is not None + and partner not in selected + and partner_color is not None + ): + self._set_item_rgb(partner, partner_color) + view.viewport().update() + self._after_command() + + def _set_item_rgb(self, item, color): + """Set an item's RGB from ``color`` while preserving its alpha.""" + result = QtGui.QColor(color) + result.setAlpha(item.get_color().alpha()) + item.set_color(result) + + def _cmd_duplicate_mirror(self): + view = self._current_view() + items = self._selected_items() + if not (view and items): + return + # Reuse the existing duplicate+mirror (handles the search/replace + # prompt once); link each returned pair as a persistent mirror. + pairs = items[0].duplicate_and_mirror_selected() or [] + for source, new_item in pairs: + view.link_mirror_pair(source, new_item) + self._apply_mirror_color_from_palette(source, new_item) + self._after_command() + + def _apply_mirror_color_from_palette(self, source, new_item): + """Color a mirrored copy from the palette (opposite side, same level). + + If the source's color matches a palette swatch, the new item gets the + opposite-side preset at the same level; otherwise the source color is + copied (never the old red/blue swap that duplicate_and_mirror applied). + """ + slot = self.color_palette.match_color(source.get_color()) + if slot is None: + new_item.set_color(source.get_color()) + return + side, level = slot + mirror_side = color_palette.MIRROR_SIDE.get(side, side) + color = self.color_palette.color_for(mirror_side, level) + if color is not None: + self._set_item_rgb(new_item, color) + def _sync_edit_panel(self): """Show/hide the inline editor + tool strip with the mode.""" edit = __EDIT_MODE__.get() toolbar = getattr(self, "left_toolbar", None) if toolbar is not None: toolbar.setVisible(edit) + palette = getattr(self, "color_palette", None) + if palette is not None: + palette.setVisible(edit) panel = getattr(self, "edit_panel", None) if panel is None: return panel.setVisible(edit) if edit: panel.sync() + self.update_tool_commands() def add_overlays(self): """Add transparent overlay widgets""" diff --git a/release/scripts/mgear/anim_picker/shapes/default_shapes.json b/release/scripts/mgear/anim_picker/shapes/default_shapes.json new file mode 100644 index 00000000..7d8ee6f0 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/default_shapes.json @@ -0,0 +1,243 @@ +[ + { + "name": "Square", + "handles": [ + [ + -20, + -20 + ], + [ + 20, + -20 + ], + [ + 20, + 20 + ], + [ + -20, + 20 + ] + ] + }, + { + "name": "Circle", + "handles": [ + [ + 0, + 0 + ], + [ + 20, + 0 + ] + ] + }, + { + "name": "Triangle", + "handles": [ + [ + 0.0, + 20.0 + ], + [ + -17.321, + -10.0 + ], + [ + 17.321, + -10.0 + ] + ] + }, + { + "name": "Diamond", + "handles": [ + [ + 0, + 20 + ], + [ + 20, + 0 + ], + [ + 0, + -20 + ], + [ + -20, + 0 + ] + ] + }, + { + "name": "Pentagon", + "handles": [ + [ + 0.0, + 20.0 + ], + [ + -19.021, + 6.18 + ], + [ + -11.756, + -16.18 + ], + [ + 11.756, + -16.18 + ], + [ + 19.021, + 6.18 + ] + ] + }, + { + "name": "Hexagon", + "handles": [ + [ + 0.0, + 20.0 + ], + [ + -17.321, + 10.0 + ], + [ + -17.321, + -10.0 + ], + [ + -0.0, + -20.0 + ], + [ + 17.321, + -10.0 + ], + [ + 17.321, + 10.0 + ] + ] + }, + { + "name": "Octagon", + "handles": [ + [ + 0.0, + 20.0 + ], + [ + -14.142, + 14.142 + ], + [ + -20.0, + 0.0 + ], + [ + -14.142, + -14.142 + ], + [ + -0.0, + -20.0 + ], + [ + 14.142, + -14.142 + ], + [ + 20.0, + -0.0 + ], + [ + 14.142, + 14.142 + ] + ] + }, + { + "name": "Star", + "handles": [ + [ + 0.0, + 20.0 + ], + [ + -4.702, + 6.472 + ], + [ + -19.021, + 6.18 + ], + [ + -7.608, + -2.472 + ], + [ + -11.756, + -16.18 + ], + [ + -0.0, + -8.0 + ], + [ + 11.756, + -16.18 + ], + [ + 7.608, + -2.472 + ], + [ + 19.021, + 6.18 + ], + [ + 4.702, + 6.472 + ] + ] + }, + { + "name": "Arrow", + "handles": [ + [ + -8, + -20 + ], + [ + -8, + 4 + ], + [ + -16, + 4 + ], + [ + 0, + 20 + ], + [ + 16, + 4 + ], + [ + 8, + 4 + ], + [ + 8, + -20 + ] + ] + } +] \ No newline at end of file diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index c12afefc..d77898f4 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -6,6 +6,7 @@ import os import copy import json +import uuid from functools import partial from maya import cmds @@ -28,6 +29,7 @@ from mgear.anim_picker.widgets import background_manipulator from mgear.anim_picker.widgets import item_manipulator from mgear.anim_picker.widgets import tool_bar +from mgear.anim_picker.widgets import mirror from mgear.anim_picker.handlers import __EDIT_MODE__ @@ -104,6 +106,14 @@ def __init__(self, namespace=None, main_window=None): self.item_manipulator = item_manipulator.ItemManipulator(self) self._item_dragging = False + # Persistent mirror relationships: the symmetry axis (scene x), a + # guard that breaks the A -> B -> A live-mirror feedback loop, and a + # cached "any pair linked?" flag so the per-frame paint / per-edit + # propagation short-circuit when nothing is linked (the common case). + self.mirror_axis_x = 0.0 + self._mirroring = False + self._has_mirror_links = False + self.fit_margin = 8 # # undo list --------------------------------------------------------- @@ -125,6 +135,21 @@ def mousePressEvent(self, event): self.modified_select = False self.item_selected = False self.__move_prompt = False + # Select the clicked item on press (edit mode), before the default + # handler sets up the item drag -- so a single click selects and can + # move in the same motion, and the drag moves the right set. Returns + # True when the selection is finalized here (release should not re-run + # it); modified_select carries that, matching its existing meaning. + if ( + event.button() == QtCore.Qt.MouseButton.LeftButton + and __EDIT_MODE__.get() + ): + transform = self.viewportTransform() + picker_at = self.scene().picker_at( + self.mapToScene(event.pos()), transform + ) + if picker_at and self._select_on_press(picker_at, event): + self.modified_select = True QtWidgets.QGraphicsView.mousePressEvent(self, event) if event.buttons() == QtCore.Qt.MouseButton.LeftButton: self.scene_mouse_origin = self.mapToScene(event.pos()) @@ -147,9 +172,6 @@ def mousePressEvent(self, event): pt = [picker.x(), picker.y(), picker.rotation()] self.tmp_picker_pos_info[picker.uuid] = pt # undo --------------------------------------------------- - if event.modifiers(): - # this allows for shift selecting in edit - self.modified_select = False else: self.modified_select = True picker_widgets.select_picker_controls([picker_at], event) @@ -206,6 +228,8 @@ def mouseMoveEvent(self, event): ): # confirm undo move chunck, a picker has been moved self.__move_prompt = True + # Live-mirror the moving selection to linked partners in realtime. + self.apply_mirror_for(self.scene().get_selected_items()) # undo ---------------------------------------------------------------- if self.pan_active: @@ -289,6 +313,9 @@ def mouseReleaseEvent(self, event): copy.deepcopy(self.tmp_picker_pos_info) ) self.undo_move_order_index = -1 + # Live-mirror the moved selection to any linked partners (covers a + # plain item drag-move, not just the manipulator). + self.apply_mirror_for(self.scene().get_selected_items()) self.__move_prompt = None self.tmp_picker_pos_info = {} # undo ---------------------------------------------------------------- @@ -651,9 +678,15 @@ def add_picker_item_gui(self, mouse_pos=None): Args: mouse_pos (QPosition, optional): mouse position + + Returns: + PickerItem: the created (and selected) item. """ ctrl = self.add_picker_item() ctrl.setPos(mouse_pos) + # Keep the new item selected so it can be edited immediately. + self.scene().select_picker_items([ctrl]) + return ctrl def add_picker_item_selected(self, mouse_pos=None): """Add new PickerItem to current view""" @@ -1036,11 +1069,47 @@ def _transform_tool_active(self): ) return active == tool_bar.TOOL_TRANSFORM + def _select_on_press(self, picker_at, event): + """Select ``picker_at`` on mouse press (edit mode). + + Returns True when the selection is fully determined here (release must + not re-handle it): Shift adds, Ctrl removes, and a plain click on an + unselected item selects just it. Returns False for a plain click on an + already-selected item (kept for a group drag; a release without a drag + collapses it to one) and for Alt (control-selection handled at release). + + Args: + picker_at (PickerItem): the item under the cursor. + event (QMouseEvent): the press event. + + Returns: + bool: True if the selection was finalized here. + """ + modifiers = event.modifiers() + if modifiers == QtCore.Qt.ShiftModifier: + picker_at.set_selected_state(True) + elif modifiers == QtCore.Qt.ControlModifier: + picker_at.set_selected_state(False) + elif ( + not modifiers + and picker_at not in self.scene().get_selected_items() + ): + self.scene().clear_picker_selection() + picker_at.set_selected_state(True) + else: + return False + self._notify_item_selection() + self.viewport().update() + return True + def _notify_item_selection(self): """Tell the inline edit panel the item selection changed (full sync).""" panel = getattr(self.main_window, "edit_panel", None) if panel is not None: panel.sync_from_view(self) + update = getattr(self.main_window, "update_tool_commands", None) + if update is not None: + update() def _notify_item_transform(self): """Tell the panel the selection's transform changed (light refresh).""" @@ -1082,6 +1151,8 @@ def _item_mouse_move(self, event): scene_pos = self.mapToScene(event.pos()) keep = bool(event.modifiers() & QtCore.Qt.ShiftModifier) self.item_manipulator.update_drag(scene_pos.x(), scene_pos.y(), keep) + # Live-mirror to linked partners in realtime (not just on release). + self.apply_mirror_for(self.scene().get_selected_items()) self._notify_item_transform() self.viewport().update() return True @@ -1094,10 +1165,98 @@ def _item_mouse_release(self, event): self._item_dragging = False # Grow the pan bounds to the items' new extent (no view refit). self._update_scene_rect() + # Live-mirror the transformed selection to any linked partners. + self.apply_mirror_for(self.scene().get_selected_items()) self._notify_item_selection() self.viewport().update() return True + # -- mirror relationships ------------------------------------------- + def get_mirror_partner(self, item): + """Return the item linked as ``item``'s mirror partner, or None.""" + if not item.mirror_id: + return None + for other in self.get_picker_items(): + if other is not item and other.item_id == item.mirror_id: + return other + return None + + def link_mirror_pair(self, item_a, item_b): + """Link two items as a mirror pair, minting ids as needed.""" + if not item_a.item_id: + item_a.item_id = str(uuid.uuid4()) + if not item_b.item_id: + item_b.item_id = str(uuid.uuid4()) + item_a.mirror_id = item_b.item_id + item_b.mirror_id = item_a.item_id + self._has_mirror_links = True + + def unlink_mirror(self, item): + """Break the mirror link on ``item`` and its partner.""" + partner = self.get_mirror_partner(item) + item.mirror_id = None + if partner is not None: + partner.mirror_id = None + self._recompute_mirror_links() + + def has_mirror_links(self): + """Return True when any item in the view is mirror-linked (cached).""" + return self._has_mirror_links + + def _recompute_mirror_links(self): + """Refresh the cached mirror-link flag (call on link changes / load).""" + self._has_mirror_links = any( + item.mirror_id for item in self.get_picker_items() + ) + + def _mirror_item_to(self, src, dst): + """Write ``src``'s mirrored transform / shape onto ``dst``. + + Color is intentionally NOT mirrored here: L/R coloring is set + explicitly through the color palette, so a live geometry mirror never + overwrites a deliberately chosen side color. + """ + axis = self.mirror_axis_x + pos = mirror.mirror_position([src.x(), src.y()], axis) + dst.setPos(pos[0], pos[1]) + dst.setRotation(mirror.mirror_rotation(src.rotation())) + src_handles = [[h.x(), h.y()] for h in src.handles] + dst.set_handles(mirror.mirror_handles(src_handles)) + dst.set_text(src.get_text()) + dst.update() + + def apply_mirror_for(self, items): + """Mirror each linked item onto its partner (loop-guarded). + + Partners that are themselves in ``items`` are skipped so a both-sides + edit does not fight itself. No-op when nothing is linked. + """ + if self._mirroring or not self._has_mirror_links: + return + self._mirroring = True + try: + edited = set(items) + for item in items: + partner = self.get_mirror_partner(item) + if partner is None or partner in edited: + continue + self._mirror_item_to(item, partner) + finally: + self._mirroring = False + + def _clean_dangling_mirrors(self): + """Drop mirror ids that no longer point at an existing item.""" + items = self.get_picker_items() + ids = set(item.item_id for item in items if item.item_id) + for item in items: + if item.mirror_id and item.mirror_id not in ids: + mgear.log( + "anim_picker: dropped a dangling mirror link on load", + mgear.sev_warning, + ) + item.mirror_id = None + self._recompute_mirror_links() + def _draw_bg_marquee(self, painter): """Draw the background-selection marquee rectangle, if active.""" if self._bg_marquee_origin is None or self._bg_marquee_current is None: @@ -1240,6 +1399,7 @@ def clear(self): old_scene = self.scene() self.setScene(OrderedGraphicsScene(parent=self)) old_scene.deleteLater() + self._has_mirror_links = False def get_picker_items(self): """Return scene picker items in proper order (back to front)""" @@ -1290,6 +1450,10 @@ def set_data(self, data): item = self.add_picker_item() item.set_data(item_data) + # Now that every item exists, drop any mirror link whose partner is + # missing (resolve pairs lazily via item ids at edit time). + self._clean_dangling_mirrors() + # Size the scene to include the items too, now that they exist. self._update_scene_size() @@ -1321,6 +1485,11 @@ def drawForeground(self, painter, rect): # tool and suppressed while manipulating background layers. if not self.background_edit and self._transform_tool_active(): self.item_manipulator.paint(painter) + # Symmetry-axis guide + pink dotted outline on linked items, + # shown once any mirror pair exists. + if self.has_mirror_links(): + self._draw_mirror_axis(painter, rect) + self._draw_mirror_links(painter) # Background layer manipulator overlay + selection marquee if self.background_edit: @@ -1329,6 +1498,34 @@ def drawForeground(self, painter, rect): return result + def _draw_mirror_axis(self, painter, rect): + """Draw the vertical symmetry-axis guide at ``mirror_axis_x``.""" + x = self.mirror_axis_x + if not (rect.x() <= x <= rect.x() + rect.width()): + return + pen = QtGui.QPen( + QtGui.QColor(255, 120, 180, 170), 0, QtCore.Qt.DashLine + ) + pen.setCosmetic(True) + painter.setPen(pen) + painter.drawLine( + QtCore.QLineF(x, rect.y(), x, rect.y() + rect.height()) + ) + + def _draw_mirror_links(self, painter): + """Draw a pink dotted outline matching each linked item's shape.""" + pen = QtGui.QPen( + QtGui.QColor(255, 105, 180, 220), 0, QtCore.Qt.DotLine + ) + pen.setCosmetic(True) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + for item in self.get_picker_items(): + if item.mirror_id and item.polygon is not None: + # Map the item's polygon outline into scene space so the guide + # follows the actual shape (and its rotation), not a bbox. + painter.drawPath(item.mapToScene(item.polygon.shape())) + def draw_overlay_axis(self, painter, rect): """Draw x and y origin axis""" # Set Pen diff --git a/release/scripts/mgear/anim_picker/widgets/color_palette.py b/release/scripts/mgear/anim_picker/widgets/color_palette.py new file mode 100644 index 00000000..4bd0b34d --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/color_palette.py @@ -0,0 +1,197 @@ +"""Preset color palette bar for the anim picker (bottom of the window). + +Six preset swatches with explicit left / center / right side roles at two +levels (primary / secondary): + + [ sec-Left pri-Left sec-Center pri-Center pri-Right sec-Right ] + +Left-clicking a swatch applies its color to the selected item(s); a selected +item's mirror partner receives the *same level* on the *opposite side* +(primary-left mirrors to primary-right, secondary to secondary, center to +center) -- explicit L/R coloring rather than an automatic red/blue swap. +Double-click or right-click a swatch to change its color (a color-picker); that +only edits the preset and never repaints items already using the old color. +Colors persist via ``QSettings``. +""" + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + + +_SETTINGS_ORG = "mgear" +_SETTINGS_APP = "anim_picker_palette" + +# Left-to-right swatch order and each slot's (side, level) role. +SLOTS = ( + ("left", "secondary"), + ("left", "primary"), + ("center", "secondary"), + ("center", "primary"), + ("right", "primary"), + ("right", "secondary"), +) + +# Opposite side for mirror-partner coloring; level is preserved. +MIRROR_SIDE = {"left": "right", "right": "left", "center": "center"} + +# Short label shown under each swatch. +SLOT_LABELS = { + ("left", "secondary"): "Sec L", + ("left", "primary"): "Pri L", + ("center", "secondary"): "Sec C", + ("center", "primary"): "Pri C", + ("right", "primary"): "Pri R", + ("right", "secondary"): "Sec R", +} + +_DEFAULTS = { + ("left", "primary"): (0, 60, 255), + ("left", "secondary"): (90, 130, 255), + ("center", "primary"): (255, 220, 0), + ("center", "secondary"): (255, 235, 130), + ("right", "primary"): (255, 40, 40), + ("right", "secondary"): (255, 130, 130), +} + + +class _Swatch(QtWidgets.QFrame): + """A single clickable preset color cell.""" + + _WIDTH = 52 + _HEIGHT = 28 + + def __init__(self, bar, side, level, color): + super(_Swatch, self).__init__() + self.bar = bar + self.side = side + self.level = level + self.color = color + self.setFixedSize(self._WIDTH, self._HEIGHT) + self.setFrameShape(QtWidgets.QFrame.Box) + self.setCursor(QtCore.Qt.PointingHandCursor) + # Distinguish a single click (apply) from a double click (edit). + self._click_timer = QtCore.QTimer(self) + self._click_timer.setSingleShot(True) + self._click_timer.timeout.connect(self._commit_single) + self._refresh() + + def set_color(self, color): + self.color = color + self._refresh() + + def _refresh(self): + self.setStyleSheet( + "background-color: {}; border: 1px solid #222;".format( + self.color.name() + ) + ) + self.setToolTip( + "{} {} - click to apply, double / right-click to edit".format( + self.level.capitalize(), self.side.capitalize() + ) + ) + + def mouseReleaseEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._click_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + super(_Swatch, self).mouseReleaseEvent(event) + + def mouseDoubleClickEvent(self, event): + self._click_timer.stop() + self.bar.edit_swatch(self) + + def contextMenuEvent(self, event): + self.bar.edit_swatch(self) + + def _commit_single(self): + self.bar.apply_swatch(self) + + +class ColorPaletteBar(QtWidgets.QWidget): + """Row of preset swatches that color the selection (with L/R mirroring).""" + + def __init__(self, main_window=None, parent=None): + super(ColorPaletteBar, self).__init__(parent) + self.main_window = main_window + self._swatches = {} + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(6, 2, 6, 2) + layout.setSpacing(6) + layout.addWidget(QtWidgets.QLabel("Palette:")) + + settings = self._settings() + for slot in SLOTS: + swatch = _Swatch( + self, slot[0], slot[1], self._load_color(settings, slot) + ) + self._swatches[slot] = swatch + # Each swatch gets a caption below it. + column = QtWidgets.QVBoxLayout() + column.setSpacing(1) + column.addWidget(swatch, 0, QtCore.Qt.AlignHCenter) + caption = QtWidgets.QLabel(SLOT_LABELS[slot]) + caption.setAlignment(QtCore.Qt.AlignHCenter) + column.addWidget(caption) + layout.addLayout(column) + layout.addStretch() + + def _settings(self): + return QtCore.QSettings(_SETTINGS_ORG, _SETTINGS_APP) + + def _key(self, slot): + return "{}_{}".format(slot[0], slot[1]) + + def _load_color(self, settings, slot): + value = settings.value(self._key(slot)) + if value: + try: + parts = [int(c) for c in str(value).split(",")] + except ValueError: + parts = [] + if len(parts) == 3: + return QtGui.QColor(*parts) + return QtGui.QColor(*_DEFAULTS[slot]) + + def _save_color(self, slot, color): + self._settings().setValue( + self._key(slot), + "{},{},{}".format(color.red(), color.green(), color.blue()), + ) + + def color_for(self, side, level): + """Return the preset QColor for a (side, level), or None.""" + swatch = self._swatches.get((side, level)) + return QtGui.QColor(swatch.color) if swatch else None + + def match_color(self, color): + """Return the (side, level) slot whose swatch RGB equals ``color``. + + Used so Duplicate & Mirror can map a palette-colored source to its + opposite-side preset instead of a red/blue swap. Returns None when the + color does not match any swatch (alpha is ignored). + """ + for slot, swatch in self._swatches.items(): + if ( + swatch.color.red() == color.red() + and swatch.color.green() == color.green() + and swatch.color.blue() == color.blue() + ): + return slot + return None + + def apply_swatch(self, swatch): + if self.main_window is not None: + self.main_window.apply_palette_color(swatch.side, swatch.level) + + def edit_swatch(self, swatch): + color = QtWidgets.QColorDialog.getColor( + initial=swatch.color, parent=self + ) + if not color.isValid(): + return + swatch.set_color(color) + self._save_color((swatch.side, swatch.level), color) diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py new file mode 100644 index 00000000..c05bb13f --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py @@ -0,0 +1,140 @@ +"""Shape library picker dialog for the anim picker. + +A small popup grid of shape swatches (bundled + user shapes). Clicking a swatch +applies that shape to the current selection via the supplied callback; the +current item's shape can be saved as a named custom shape, and user shapes can +be deleted from their right-click menu. The shape data / persistence lives in +the Qt-free ``widgets.shape_library`` module. +""" + +from functools import partial + +from mgear.vendor.Qt import QtGui +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.widgets import shape_library + + +class ShapeLibraryDialog(QtWidgets.QDialog): + """Popup grid to apply a premade / custom shape to the selection.""" + + _ICON_SIZE = 44 + _COLUMNS = 4 + + def __init__(self, parent=None, apply_callback=None, current_handles=None): + super(ShapeLibraryDialog, self).__init__(parent) + self.setWindowTitle("Shape Library") + self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + + self._apply_callback = apply_callback + self._current_handles = current_handles + + self.main_layout = QtWidgets.QVBoxLayout(self) + self.grid_host = QtWidgets.QWidget() + self.grid_layout = QtWidgets.QGridLayout(self.grid_host) + self.main_layout.addWidget(self.grid_host) + + button_row = QtWidgets.QHBoxLayout() + self.save_button = QtWidgets.QPushButton("Save current shape...") + self.save_button.setEnabled(bool(current_handles)) + self.save_button.clicked.connect(self._save_current) + button_row.addWidget(self.save_button) + button_row.addStretch() + close_button = QtWidgets.QPushButton("Close") + close_button.clicked.connect(self.reject) + button_row.addWidget(close_button) + self.main_layout.addLayout(button_row) + + self._rebuild_grid() + + def _rebuild_grid(self): + """Repopulate the swatch grid from the shape library.""" + while self.grid_layout.count(): + item = self.grid_layout.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + for index, shape in enumerate(shape_library.load_shapes()): + button = QtWidgets.QToolButton() + button.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon) + button.setIcon(self._shape_icon(shape["handles"])) + button.setIconSize(QtCore.QSize(self._ICON_SIZE, self._ICON_SIZE)) + button.setText(shape["name"]) + button.setAutoRaise(True) + button.clicked.connect( + partial(self._apply_shape, shape["handles"]) + ) + if not shape["builtin"]: + button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + button.customContextMenuRequested.connect( + partial(self._user_shape_menu, shape["name"]) + ) + button.setToolTip("User shape (right-click to delete)") + self.grid_layout.addWidget( + button, index // self._COLUMNS, index % self._COLUMNS + ) + + def _shape_icon(self, handles, size=None): + """Render a shape's outline into a QIcon preview.""" + size = size or self._ICON_SIZE + pixmap = QtGui.QPixmap(size, size) + pixmap.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(pixmap) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(QtGui.QPen(QtGui.QColor(220, 220, 220), 1.5)) + + margin = 6.0 + span = size - 2.0 * margin + if len(handles) == 2: + # Native circle: center + radius point -> a centered ellipse. + painter.drawEllipse( + QtCore.QRectF(margin, margin, span, span) + ) + else: + xs = [point[0] for point in handles] + ys = [point[1] for point in handles] + min_x, max_x = min(xs), max(xs) + min_y, max_y = min(ys), max(ys) + width = (max_x - min_x) or 1.0 + height = (max_y - min_y) or 1.0 + scale = min(span / width, span / height) + # Center the scaled shape; flip Y (scene is Y-up, icon Y-down). + off_x = margin + (span - width * scale) / 2.0 + off_y = margin + (span - height * scale) / 2.0 + polygon = QtGui.QPolygonF() + for hx, hy in handles: + polygon.append( + QtCore.QPointF( + off_x + (hx - min_x) * scale, + off_y + (max_y - hy) * scale, + ) + ) + painter.drawPolygon(polygon) + painter.end() + return QtGui.QIcon(pixmap) + + def _apply_shape(self, handles): + if self._apply_callback is not None: + self._apply_callback(handles) + self.accept() + + def _save_current(self): + if not self._current_handles: + return + name, ok = QtWidgets.QInputDialog.getText( + self, "Save shape", "Shape name" + ) + if not (ok and name): + return + shape_library.save_user_shape(str(name), self._current_handles) + self._rebuild_grid() + + def _user_shape_menu(self, name, pos): + menu = QtWidgets.QMenu(self) + delete_action = menu.addAction("Delete '{}'".format(name)) + chosen = menu.exec_(QtGui.QCursor.pos()) + if chosen == delete_action: + shape_library.remove_user_shape(name) + self._rebuild_grid() diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index d84e0ab5..fdf2e36c 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -33,6 +33,9 @@ from mgear.anim_picker.widgets.dialogs.search_replace_dialog import ( SearchAndReplaceDialog, ) +from mgear.anim_picker.widgets.dialogs.shape_library_dialog import ( + ShapeLibraryDialog, +) class ItemEditPanel(QtWidgets.QWidget): @@ -75,6 +78,8 @@ def __init__(self, parent=None, main_window=None): self.control_list = None self.menus_list = None self.custom_action_cb = None + self.mirror_axis_sb = None + self.mirror_status = None self._build_ui() self.refresh_fields() @@ -104,6 +109,7 @@ def _build_ui(self): self._build_transform_section() self._build_appearance_section() self._build_shape_section() + self._build_mirror_section() self._build_controls_section() self._build_action_section() self.content_layout.addStretch() @@ -242,6 +248,44 @@ def _build_shape_section(self): self._fields.append(handles_btn) section.addWidget(handles_btn) + shapes_btn = basic.CallbackButton(callback=self._open_shape_library) + shapes_btn.setText("Shapes...") + shapes_btn.setToolTip("Apply a premade / saved shape to the selection") + self._fields.append(shapes_btn) + section.addWidget(shapes_btn) + + def _build_mirror_section(self): + section = self._add_section("Mirror") + + form = QtWidgets.QFormLayout() + # Global axis setting (not a per-item field), so it uses the framework + # callback spin directly rather than the mixed-value _double_spin. + self.mirror_axis_sb = basic.CallBackDoubleSpinBox( + callback=self._apply_mirror_axis, value=0.0, min=-1.0e6, max=1.0e6 + ) + form.addRow("Axis X", self.mirror_axis_sb) + section.addLayout(form) + + link_btn = basic.CallbackButton(callback=self._link_mirror) + link_btn.setText("Link selected pair") + link_btn.setToolTip("Link exactly two selected items as a mirror pair") + self._fields.append(link_btn) + section.addWidget(link_btn) + + row = QtWidgets.QHBoxLayout() + unlink_btn = basic.CallbackButton(callback=self._unlink_mirror) + unlink_btn.setText("Unlink") + self._fields.append(unlink_btn) + row.addWidget(unlink_btn) + symm_btn = basic.CallbackButton(callback=self._make_symmetric) + symm_btn.setText("Make Symmetric") + self._fields.append(symm_btn) + row.addWidget(symm_btn) + section.addLayout(row) + + self.mirror_status = QtWidgets.QLabel("") + section.addWidget(self.mirror_status) + def _build_controls_section(self): section = self._add_section("Controls") @@ -378,9 +422,21 @@ def _populate_all(self): self._populate_transform() self._populate_appearance() self._populate_shape() + self._populate_mirror() self._populate_controls() self._populate_action() + def _populate_mirror(self): + if self._view is not None: + self.mirror_axis_sb.setValue(self._view.mirror_axis_x) + linked = sum(1 for item in self.items if item.mirror_id) + if linked: + self.mirror_status.setText( + "{} of {} selected linked".format(linked, len(self.items)) + ) + else: + self.mirror_status.setText("no mirror link") + def refresh_fields(self): """Populate every field from the current selection (mixed-aware).""" has = bool(self.items) @@ -512,9 +568,13 @@ def _set_swatch(self, button, color): # Apply helpers (write to every selected item) # ------------------------------------------------------------------ def _repaint_view(self): - """Repaint the canvas so edits + the manipulator overlay refresh.""" - if self._view is not None: - self._view.viewport().update() + """Propagate edits to mirror partners, then repaint the canvas.""" + if self._view is None: + return + # Live-mirror the edit to any linked partners (guarded against loops), + # then repaint so both sides refresh. + self._view.apply_mirror_for(self.items) + self._view.viewport().update() def _committed(self, spin): """Return a committed spin value, or None while the field is mixed. @@ -680,6 +740,65 @@ def _edit_handles(self): self.handles_window.show() self.handles_window.raise_() + def _open_shape_library(self): + if not self.items: + return + active = self._active_item() + current = None + if active is not None: + current = [[h.x(), h.y()] for h in active.handles] + dialog = ShapeLibraryDialog( + parent=self, + apply_callback=self._apply_shape, + current_handles=current, + ) + dialog.show() + + def _apply_shape(self, handles): + if not self.items: + return + for item in self.items: + item.set_handles([list(point) for point in handles]) + self._repaint_view() + + # -- mirror --------------------------------------------------------- + def _apply_mirror_axis(self, *args, **kwargs): + if self._syncing or self._view is None: + return + self._view.mirror_axis_x = self.mirror_axis_sb.value() + self._view.viewport().update() + + def _link_mirror(self): + if self._view is None: + return + if len(self.items) != 2: + QtWidgets.QMessageBox.information( + self, "Mirror", "Select exactly two items to link." + ) + return + # Establish the relationship without moving anything; the user snaps + # the sides explicitly with Make Symmetric. + self._view.link_mirror_pair(self.items[0], self.items[1]) + self._view.viewport().update() + self._guarded(self._populate_mirror) + + def _unlink_mirror(self): + if self._view is None: + return + for item in self.items: + self._view.unlink_mirror(item) + self._view.viewport().update() + self._guarded(self._populate_mirror) + + def _make_symmetric(self): + if self._view is None: + return + # Reflect each selected item onto its partner (per-item, so a selected + # item forces its own side onto the other). + for item in self.items: + self._view.apply_mirror_for([item]) + self._view.viewport().update() + # -- controls ------------------------------------------------------- def _add_selected_controls(self): if not self.items: diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py index 078959e7..baedc383 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_model.py +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -18,6 +18,8 @@ text str text_size float text_color RGBA tuple + item_id str (optional stable id, minted when first mirror-linked) + mirror_id str (optional mirror partner's item_id) """ @@ -36,6 +38,8 @@ def __init__(self): self.text = None self.text_size = None self.text_color = None + self.item_id = None + self.mirror_id = None @classmethod def from_dict(cls, data): @@ -71,6 +75,10 @@ def from_dict(cls, data): model.text_size = data.get("text_size") if "text_color" in data: model.text_color = tuple(data["text_color"]) + if data.get("id"): + model.item_id = data["id"] + if data.get("mirror"): + model.mirror_id = data["mirror"] return model @@ -104,4 +112,11 @@ def to_dict(self): data["text_size"] = self.text_size data["text_color"] = self.text_color + # Mirror link (additive optional keys; absent when unlinked so old + # readers and unlinked pickers are unaffected). + if self.item_id: + data["id"] = self.item_id + if self.mirror_id: + data["mirror"] = self.mirror_id + return data diff --git a/release/scripts/mgear/anim_picker/widgets/mirror.py b/release/scripts/mgear/anim_picker/widgets/mirror.py new file mode 100644 index 00000000..104c1aa9 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/mirror.py @@ -0,0 +1,63 @@ +"""Qt/Maya-free mirror math for picker items. + +Pure functions that reflect a picker item's data about a vertical symmetry axis +(scene ``x = axis_x``, default 0). They back both the one-shot Duplicate/Mirror +operations and the persistent live mirror relationships, so the reflection logic +lives in exactly one tested place -- the same pattern as ``manipulator_transform``. + +Positions and handles are ``[x, y]``; colors are RGBA tuples/lists; rotation is in +degrees. +""" + + +def mirror_position(pos, axis_x=0.0): + """Reflect a position about the vertical axis at ``axis_x``. + + Args: + pos (list): ``[x, y]``. + axis_x (float, optional): the mirror axis x (default 0). + + Returns: + list: the mirrored ``[x, y]``. + """ + return [2.0 * axis_x - pos[0], pos[1]] + + +def mirror_rotation(deg): + """Reflect a rotation (degrees) about a vertical axis. + + A vertical mirror negates the rotation; the result is normalized to + ``[0, 360)``. + + Args: + deg (float): rotation in degrees. + + Returns: + float: the mirrored rotation in degrees. + """ + deg = deg % 360.0 + return (360.0 - deg) % 360.0 + + +def mirror_handles(handles): + """Reflect each handle's local x (shape mirror in the item frame). + + Args: + handles (list): list of ``[x, y]``. + + Returns: + list: mirrored list of ``[x, y]``. + """ + return [[-handle[0], handle[1]] for handle in handles] + + +def mirror_color(rgba): + """Swap the red and blue channels (left/right color convention). + + Args: + rgba (sequence): ``[r, g, b, a]``. + + Returns: + list: ``[b, g, r, a]``. + """ + return [rgba[2], rgba[1], rgba[0], rgba[3]] diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 97de7b2c..ed0cecfe 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -28,6 +28,7 @@ ) from mgear.anim_picker.widgets.dialogs.copy_paste_dialog import DataCopyDialog from mgear.anim_picker.widgets.item_model import PickerItemData +from mgear.anim_picker.widgets import mirror from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ from mgear.anim_picker.handlers import python_handlers @@ -105,6 +106,11 @@ def __init__( # uuid & undo self.uuid = uuid.uuid4() + # Persistent mirror link (optional): item_id is a stable id minted when + # the item is first linked; mirror_id is the partner's item_id. + self.item_id = None + self.mirror_id = None + def shape(self): path = QtGui.QPainterPath() @@ -667,6 +673,11 @@ def remove_selected(self): [picker.remove() for picker in selected_pickers] def remove(self): + # Break any mirror link so the partner is not left pointing at a + # deleted item. + view = self.parent() + if self.mirror_id and hasattr(view, "unlink_mirror"): + view.unlink_mirror(self) self.scene().removeItem(self) self.setParent(None) self.deleteLater() @@ -677,38 +688,27 @@ def get_delta_from_point(self, point): # ========================================================================= # Ducplicate and mirror methods --- - def mirror_position(self): - """Mirror picker position (on X axis)""" - self.setX(-1 * self.pos().x()) + def mirror_position(self, axis_x=0.0): + """Mirror picker position about the vertical axis at ``axis_x``.""" + pos = mirror.mirror_position([self.pos().x(), self.pos().y()], axis_x) + self.setX(pos[0]) def mirror_rotation(self, angle=None): """Mirror picker rotation angle""" if not angle: angle = self.rotation() - - if angle > 360: - angle = angle - 360 - - mirror_angle = abs(angle - 360) - - self.setRotation(mirror_angle) + self.setRotation(mirror.mirror_rotation(angle)) self.update() def mirror_shape(self): """Will mirror polygon handles position on X axis""" - for handle in self.handles: - handle.mirror_x_position() + handles = [[handle.x(), handle.y()] for handle in self.handles] + self.set_handles(mirror.mirror_handles(handles)) def mirror_color(self): """Will reverse red/bleu rgb values for the polygon color""" - old_color = self.get_color() - new_color = QtGui.QColor( - old_color.blue(), - old_color.green(), - old_color.red(), - ) - new_color.setAlpha(old_color.alpha()) - self.set_color(new_color) + new_color = mirror.mirror_color(self.get_color().getRgb()) + self.set_color(QtGui.QColor(*new_color)) def duplicate_selected(self, *args, **kwargs): selected_pickers = self.scene().get_selected_items() @@ -736,24 +736,37 @@ def duplicate(self, *args, **kwargs): data = copy.deepcopy(self.get_data()) new_item.set_data(data) + # A duplicate is an independent item: never inherit the source's + # stable id or mirror link (those are re-established by explicit + # linking, e.g. Duplicate & Mirror). + new_item.item_id = None + new_item.mirror_id = None + return new_item def duplicate_and_mirror_selected(self): + """Duplicate + mirror the selection. + + Returns: + list: ``(source, new)`` pairs, so callers (e.g. the toolbar + command) can link each pair as a persistent mirror relationship. + """ selected_pickers = self.scene().get_selected_items() if self not in selected_pickers: selected_pickers.append(self) search = None replace = None - new_pickers = [] + pairs = [] for picker in selected_pickers: if picker.get_controls() and not search and not replace: search, replace, ok = SearchAndReplaceDialog.get() if not ok: break new_picker = picker.duplicate_and_mirror(search, replace) - new_pickers.append(new_picker) - self.scene().select_picker_items(new_pickers) + pairs.append((picker, new_picker)) + self.scene().select_picker_items([new for _, new in pairs]) + return pairs def duplicate_and_mirror(self, search=None, replace=None): """Duplicate and mirror picker item""" @@ -1037,6 +1050,12 @@ def set_data(self, data): if "menus" in data: self.set_custom_menus(model.menus) + # Mirror link (optional, additive keys) + if model.item_id: + self.item_id = model.item_id + if model.mirror_id: + self.mirror_id = model.mirror_id + def get_data(self): """Get picker item data in dictionary form. @@ -1064,4 +1083,7 @@ def get_data(self): model.text_size = self.get_text_size() model.text_color = self.get_text_color().getRgb() + model.item_id = self.item_id + model.mirror_id = self.mirror_id + return model.to_dict() diff --git a/release/scripts/mgear/anim_picker/widgets/shape_library.py b/release/scripts/mgear/anim_picker/widgets/shape_library.py new file mode 100644 index 00000000..21717b20 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/shape_library.py @@ -0,0 +1,154 @@ +"""Qt/Maya-free shape library for picker items. + +A shape is a named list of handle coordinates -- the same ``[[x, y], ...]`` a +``PickerItem`` stores -- so applying a library shape is just ``set_handles`` and +saving one is just reading ``get_data()["handles"]``. Two sources are merged: + +- **bundled** premade shapes shipped as JSON next to the package + (``anim_picker/shapes/default_shapes.json``), read-only; +- **user** shapes saved to ``mGear_anim_picker_shapes.json`` in the Maya user + preferences directory (alongside ``mGear_user_settings.ini``), editable. + +No hard Qt or Maya dependency (Maya is imported lazily only to resolve the +prefs dir, with a home-dir fallback), so the load/save round-trip is +unit-testable; the Qt picker UI lives in +``widgets/dialogs/shape_library_dialog.py``. +""" + +import os +import json + + +_USER_SHAPES_FILE = "mGear_anim_picker_shapes.json" + + +def bundled_shapes_path(): + """Return the path to the bundled default shapes JSON.""" + here = os.path.dirname(__file__) + return os.path.join( + os.path.dirname(here), "shapes", "default_shapes.json" + ) + + +def _user_prefs_dir(): + """Return the Maya user prefs dir (mGear convention), or a home fallback.""" + try: + from maya import cmds + + return cmds.internalVar(userPrefDir=True) + except Exception: + return os.path.join(os.path.expanduser("~"), "mgear") + + +def user_shapes_path(): + """Return the path to the user's custom shapes JSON (Maya prefs dir).""" + return os.path.join(_user_prefs_dir(), _USER_SHAPES_FILE) + + +def _legacy_user_shapes_path(): + """Return the pre-5.3.4 shapes location (migrated on first load).""" + return os.path.join( + os.path.expanduser("~"), "mgear", "anim_picker", "user_shapes.json" + ) + + +def _read_shapes(path): + """Read a shapes JSON file, returning [] on missing / invalid content.""" + if not (path and os.path.exists(path)): + return [] + try: + with open(path, "r") as shape_file: + data = json.load(shape_file) + except (ValueError, IOError): + return [] + shapes = [] + for entry in data or []: + name = entry.get("name") + handles = entry.get("handles") + if name and handles: + shapes.append({"name": name, "handles": handles}) + return shapes + + +def load_bundled_shapes(): + """Return the bundled premade shapes.""" + return _read_shapes(bundled_shapes_path()) + + +def load_user_shapes(): + """Return the user's saved custom shapes. + + Reads from the Maya prefs dir; on first run, a pre-existing legacy file + (``~/mgear/anim_picker/user_shapes.json``) is migrated to the new location. + """ + path = user_shapes_path() + if os.path.exists(path): + return _read_shapes(path) + legacy = _read_shapes(_legacy_user_shapes_path()) + if legacy: + try: + _write_user_shapes(legacy) + except (IOError, OSError): + pass + return legacy + + +def load_shapes(): + """Return all shapes, each tagged ``builtin`` (bundled) True/False.""" + shapes = [] + for shape in load_bundled_shapes(): + shapes.append( + { + "name": shape["name"], + "handles": shape["handles"], + "builtin": True, + } + ) + for shape in load_user_shapes(): + shapes.append( + { + "name": shape["name"], + "handles": shape["handles"], + "builtin": False, + } + ) + return shapes + + +def _write_user_shapes(shapes): + """Write the user shapes list to disk, creating the directory.""" + path = user_shapes_path() + directory = os.path.dirname(path) + if not os.path.isdir(directory): + os.makedirs(directory) + with open(path, "w") as shape_file: + json.dump(shapes, shape_file, indent=2) + + +def save_user_shape(name, handles): + """Add or replace a user shape by name. + + Args: + name (str): shape name. + handles (list): list of ``[x, y]``. + + Returns: + bool: True on success. + """ + if not (name and handles): + return False + shapes = load_user_shapes() + shapes = [shape for shape in shapes if shape["name"] != name] + shapes.append({"name": name, "handles": handles}) + _write_user_shapes(shapes) + return True + + +def remove_user_shape(name): + """Remove a user shape by name. Returns True if one was removed.""" + shapes = load_user_shapes() + kept = [shape for shape in shapes if shape["name"] != name] + if len(kept) == len(shapes): + return False + _write_user_shapes(kept) + return True diff --git a/release/scripts/mgear/anim_picker/widgets/tool_bar.py b/release/scripts/mgear/anim_picker/widgets/tool_bar.py index a4a82fe0..954f55af 100644 --- a/release/scripts/mgear/anim_picker/widgets/tool_bar.py +++ b/release/scripts/mgear/anim_picker/widgets/tool_bar.py @@ -14,6 +14,7 @@ from functools import partial +from mgear.core import pyqt from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets @@ -24,10 +25,24 @@ TOOL_TRANSFORM = "transform" +def maya_icon(resource): + """Return a QIcon for a Maya resource path (e.g. ``:/aselect.png``).""" + return QtGui.QIcon(resource) + + +def mgear_icon(name): + """Return a QIcon for an mGear SVG icon name (from release/icons).""" + try: + return QtGui.QIcon(pyqt.get_icon(name)) + except Exception: + return QtGui.QIcon() + + class PickerToolBar(QtWidgets.QWidget): """Vertical tool strip that drives the active picker tool.""" _BUTTON_SIZE = 34 + _ICON_SIZE = 22 def __init__(self, main_window=None, parent=None): super(PickerToolBar, self).__init__(parent) @@ -48,67 +63,70 @@ def __init__(self, main_window=None, parent=None): self.main_layout.setSpacing(2) self.main_layout.setAlignment(QtCore.Qt.AlignTop) + # Tools use the default Maya tool icons. self._add_tool( TOOL_SELECT, "Sel", "Select tool: click / marquee select and drag to move items", - ":/aselect.png", + maya_icon(":/aselect.png"), checked=True, ) self._add_tool( TOOL_TRANSFORM, "Xfrm", "Transform tool: show on-canvas scale / rotate handles", - ":/move_M.png", + maya_icon(":/move_M.png"), checked=False, ) self.main_layout.addStretch() - def _style_button(self, button, label, tooltip, icon_resource): - """Apply the shared strip button look, using an icon when available.""" + def _style_button(self, button, label, tooltip, icon): + """Apply the shared strip button look, using an icon when available. + + Args: + button (QToolButton): the button to style. + label (str): short text fallback when ``icon`` is null. + tooltip (str): hover description. + icon (QtGui.QIcon): icon to show, or a null icon for text. + """ button.setToolTip(tooltip) button.setAutoRaise(True) button.setFixedWidth(self._BUTTON_SIZE) button.setMinimumHeight(self._BUTTON_SIZE) - # Prefer a Maya resource icon; fall back to a short text label so the - # strip is usable even when the resource is absent. - icon = QtGui.QIcon(icon_resource) if icon_resource else QtGui.QIcon() - if not icon.isNull(): + if icon is not None and not icon.isNull(): button.setIcon(icon) + button.setIconSize(QtCore.QSize(self._ICON_SIZE, self._ICON_SIZE)) button.setToolButtonStyle(QtCore.Qt.ToolButtonIconOnly) else: button.setText(label) button.setToolButtonStyle(QtCore.Qt.ToolButtonTextOnly) - def _add_tool(self, name, label, tooltip, icon_resource, checked=False): + def _add_tool(self, name, label, tooltip, icon, checked=False): """Add an exclusive tool toggle button.""" button = QtWidgets.QToolButton() button.setCheckable(True) button.setChecked(checked) - self._style_button(button, label, tooltip, icon_resource) + self._style_button(button, label, tooltip, icon) button.clicked.connect(partial(self._tool_clicked, name)) self.tool_group.addButton(button) self._tool_buttons[name] = button self.main_layout.addWidget(button) - def add_command(self, label, tooltip, callback, icon_resource=None): + def add_command(self, label, tooltip, callback, icon=None): """Add a non-exclusive quick-access command button below the tools. - Reserved for future commands (duplicate, mirror, ...); returns the - created button so callers can enable/disable it with the selection. - Args: - label (str): short button label (used when no icon is available). + label (str): short button label (used when ``icon`` is null). tooltip (str): hover description. callback (callable): invoked on click. - icon_resource (str, optional): icon resource path. + icon (QtGui.QIcon, optional): icon to show. Returns: QtWidgets.QToolButton: the created button. """ button = QtWidgets.QToolButton() - self._style_button(button, label, tooltip, icon_resource) + self._style_button(button, label, tooltip, icon) button.clicked.connect(callback) # Insert before the trailing stretch. self.main_layout.insertWidget(self.main_layout.count() - 1, button) diff --git a/releaseLog.rst b/releaseLog.rst index d9b858df..8aa55832 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Mirror relationships + shape library — the left tool strip gains icon quick commands (Add, Duplicate, Duplicate & Mirror, Mirror Shape, Shapes) using bundled mGear icons, with the Select / Transform tools showing the default Maya tool icons. In edit mode, clicking a picker item now selects it immediately on press (so a single click-drag selects and moves it), while dragging a multi-selection keeps the whole group. Two items can be linked as a **persistent mirror pair** about a symmetry axis: editing one (move/scale/rotate on canvas or via the inline panel) live-mirrors its position, rotation, and shape to its partner **in realtime** (during the drag, not just on release), and the link is saved with the picker (additive optional ``id`` / ``mirror`` keys, so older pickers load unchanged). Linked items show a pink dotted outline so pairs are spottable at a glance. "Duplicate & Mirror" creates and links the pair; the panel's Mirror section links/unlinks, sets the axis, and snaps with Make Symmetric. Left/right coloring is now explicit via a **preset color palette** along the bottom (secondary/primary per left/center/right): clicking a swatch colors the selection and its mirror partner gets the opposite side at the same level; double / right-click edits a swatch (persisted, and it never repaints existing items). A new **shape library** (tool strip Shapes button or panel Shape > Shapes...) applies premade shapes (square, circle, triangle, diamond, pentagon, hexagon, octagon, star, arrow) to the selection and can save the current shape as a named custom shape (stored in the Maya user prefs dir, next to mGear_user_settings.ini), keeping the curve-import path * Core: Add a reusable ``PythonCodeEditor`` widget (mgear.core.pycodeeditor) — a modern Python editor with a line-number gutter, current-line highlight, Python syntax highlighting, a configurable monospace font (Consolas by default, family + size), spaces-or-tabs indentation with a configurable width, "show indentation" (visualize whitespace), convert-tabs-to-spaces, and smart Tab / Shift-Tab (block indent/unindent) + auto-indent on Enter; preferences persist via QSettings. Any mGear tool can embed it instead of a bare QTextEdit * Anim Picker: Custom script / menu editor now uses the modern ``PythonCodeEditor`` (line numbers, syntax highlighting, font + indentation controls) with a File / Edit / View menu bar (open & save scripts to file, undo/redo/cut/copy/paste, convert indentation to spaces, show indentation) in place of the plain text box * Anim Picker: On-canvas item manipulators + inline multi-edit panel — a Photoshop-style tool strip on the left of the canvas (edit mode) toggles between the Select tool (default) and the Transform tool; with the Transform tool active, selected picker buttons show a bounding-box overlay with scale handles (corner = both axes, edge = one, Shift keeps aspect) and a rotate handle; a single item rotates in place while a multi-selection scales/rotates as a group about its center (moving stays the item drag, and drag-moving a group keeps the whole selection). A new right-docked, collapsible editor panel (edit mode only) edits the whole selection at once — Transform, Appearance, Shape, Controls, and Action — with fields that differ across the selection shown as "mixed" so a multi-edit never clobbers them; the panel and canvas stay in sync. Double-click / "Options" now surface this panel instead of the per-item modal (the modal remains as a fallback). The scale/rotate geometry is shared with the background manipulator From 4281b25df2035f88a2d4930283c73d4677f8e6c7 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Thu, 9 Jul 2026 13:52:31 +0900 Subject: [PATCH 09/25] AnimPicker: Editing: Viewport-pinned overlay (HUD) buttons Add pinned picker items that lock to the viewport and ignore canvas pan and zoom, staying at a fixed screen position and constant size (HUD-like) - e.g. a global reset, a space switch, or a settings button locked to a corner. A pinned item is placed by a 3x3 anchor (corners / edges / center) plus an inward pixel offset and keeps all its normal button behavior. - New Qt/Maya-free widgets/overlay.py: anchor_point / offset_from_anchor / nearest_anchor (3x3 anchor + inward offset math), standalone-tested. - PickerItem gains pinned / anchor / offset state; set_pinned toggles ItemIgnoresTransformations (constant screen size) with a scale(1,-1) compensation so shape / text stay upright in the Y-flipped view. - Additive optional item keys (pinned / anchor / offset), emitted only when pinned, so older pickers load unchanged and non-pinned items are unaffected. - view._update_pinned_items re-anchors pins on every pan / zoom / resize / fit / load; pins are excluded from the scene extent and fit bounds via scene.content_bounding_rect so a HUD never enlarges the canvas or gets framed by reset view. In edit mode a pinned item can be dragged to set its offset (the anchor snaps to the nearest region). - edit_panel Pin section (Pinned checkbox, 3x3 anchor picker, X / Y offset, mixed-value aware) and a tool-strip Pin quick command. - Refactor: shared _set_tristate / _resolve_tristate panel helpers. --- .../scripts/mgear/anim_picker/main_window.py | 22 +++ release/scripts/mgear/anim_picker/scene.py | 34 +++- release/scripts/mgear/anim_picker/view.py | 106 ++++++++++- .../mgear/anim_picker/widgets/edit_panel.py | 173 +++++++++++++++--- .../mgear/anim_picker/widgets/item_model.py | 21 +++ .../mgear/anim_picker/widgets/overlay.py | 121 ++++++++++++ .../mgear/anim_picker/widgets/picker_item.py | 94 ++++++++++ releaseLog.rst | 1 + 8 files changed, 547 insertions(+), 25 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/widgets/overlay.py diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index b1d16605..648ddd39 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -419,6 +419,12 @@ def add_tab_widget(self, name="default"): self._cmd_shapes, tool_bar.mgear_icon("mgear_replace_shape"), ), + self.left_toolbar.add_command( + "Pin", + "Pin / unpin the selection to the viewport (HUD overlay)", + self._cmd_toggle_pin, + tool_bar.mgear_icon("mgear_map-pin"), + ), ] canvas_row = QtWidgets.QHBoxLayout() canvas_row.setContentsMargins(0, 0, 0, 0) @@ -534,6 +540,22 @@ def _cmd_mirror(self, method_name): def _cmd_mirror_shape(self): self._cmd_mirror("mirror_shape") + def _cmd_toggle_pin(self): + """Pin / unpin the selection to the viewport (anchored to its region). + + Toggles based on the first item's state so a mixed selection resolves + to a single, predictable result. + """ + view = self._current_view() + items = self._selected_items() + if not (view and items): + return + target = not items[0].get_pinned() + for item in items: + view.set_item_pinned(item, target) + view.viewport().update() + self._after_command() + def apply_palette_color(self, side, level): """Apply a preset color to the selection, mirroring by side to partners. diff --git a/release/scripts/mgear/anim_picker/scene.py b/release/scripts/mgear/anim_picker/scene.py index 75a0ecbb..96a1ecaf 100644 --- a/release/scripts/mgear/anim_picker/scene.py +++ b/release/scripts/mgear/anim_picker/scene.py @@ -88,7 +88,7 @@ def get_bounding_rect(self, margin=0, selection=False): scene_rect.setCoords(x1, y1, x2, y2) else: - scene_rect = self.itemsBoundingRect() + scene_rect = self.content_bounding_rect() # Stop here if no margin if not margin: @@ -102,6 +102,38 @@ def get_bounding_rect(self, margin=0, selection=False): return scene_rect + def content_bounding_rect(self): + """Scene bounding rect of all items, excluding pinned picker items. + + Pinned items are viewport-locked HUD overlays: their scene position is + driven by the current pan/zoom, so including them would enlarge the + scrollable canvas or let a corner button be framed by fit / reset view. + Identical to ``itemsBoundingRect`` when nothing is pinned (the common + case), so non-pinned pickers behave exactly as before. + + Returns: + QtCore.QRectF: the content bounds (possibly null when empty). + """ + pinned = [ + item for item in self.get_picker_items() if item.pinned + ] + if not pinned: + return self.itemsBoundingRect() + + # Skip the pinned items and their child graphics (polygon / text / + # handles) so only free canvas content contributes to the extent. + excluded = set() + for item in pinned: + excluded.add(item) + excluded.update(item.childItems()) + rect = None + for item in self.items(): + if item in excluded: + continue + item_rect = item.sceneBoundingRect() + rect = item_rect if rect is None else rect.united(item_rect) + return rect if rect is not None else QtCore.QRectF() + def clear(self): """Reset default z index on clear""" QtWidgets.QGraphicsScene.clear(self) diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index d77898f4..61cc36e5 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -30,6 +30,7 @@ from mgear.anim_picker.widgets import item_manipulator from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.widgets import mirror +from mgear.anim_picker.widgets import overlay from mgear.anim_picker.handlers import __EDIT_MODE__ @@ -114,6 +115,11 @@ def __init__(self, namespace=None, main_window=None): self._mirroring = False self._has_mirror_links = False + # Viewport-pinned (HUD overlay) items: a cached "any pinned?" flag so + # the reposition on every pan / zoom / resize short-circuits when + # nothing is pinned (the common case). + self._has_pinned_items = False + self.fit_margin = 8 # # undo list --------------------------------------------------------- @@ -240,6 +246,7 @@ def mouseMoveEvent(self, event): scene_paning - self.scene_mouse_origin ) self.centerOn(new_center) + self._update_pinned_items() if self.zoom_active: cursor_pos = QtGui.QVector2D(self.mapToGlobal(event.pos())) @@ -252,6 +259,7 @@ def mouseMoveEvent(self, event): # Apply zoom self.scale(factor, factor) self.zoom_delta = current_delta + self._update_pinned_items() return result @@ -364,6 +372,7 @@ def mouseReleaseEvent(self, event): scene_drag_end - self.scene_mouse_origin ) self.centerOn(new_center) + self._update_pinned_items() self.pan_active = False self.setDragMode(QtWidgets.QGraphicsView.RubberBandDrag) @@ -400,6 +409,8 @@ def wheelEvent(self, event): # Apply zoom self.scale(factor, factor) + # Keep viewport-pinned items locked to their screen anchors. + self._update_pinned_items() # undo -------------------------------------------------------------------- def undo_move(self): @@ -610,12 +621,18 @@ def resizeEvent(self, *args, **kwargs): self.fit_scene_content() # Run default resizeEvent - return QtWidgets.QGraphicsView.resizeEvent(self, *args, **kwargs) + result = QtWidgets.QGraphicsView.resizeEvent(self, *args, **kwargs) + # Re-anchor pinned items to the new viewport size (covers the case + # where auto-frame is off and no fit happened above). + self._update_pinned_items() + return result def fit_scene_content(self): """Will fit scene content to view, by scaling it""" scene_rect = self.scene().get_bounding_rect(margin=self.fit_margin) self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) + # The fit changed the view transform; re-anchor pinned items. + self._update_pinned_items() def set_auto_frame_view(self): """Enable auto fit when a resize event happens""" @@ -631,6 +648,7 @@ def fit_selection_content(self): ) if scene_rect: self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) + self._update_pinned_items() def get_color_picker_override(self, picker, ctrl): """Get the maya override color and return picker equivelant @@ -883,8 +901,9 @@ def _update_scene_rect(self): # Union the background layer bounds with the picker items' extent so # the canvas is not clamped to the image size (buttons stay reachable). + # Pinned HUD items are excluded so they never inflate the canvas. content = self._bounding_rect - items_rect = self.scene().itemsBoundingRect() + items_rect = self.scene().content_bounding_rect() if not items_rect.isNull(): content = ( items_rect @@ -1257,6 +1276,83 @@ def _clean_dangling_mirrors(self): item.mirror_id = None self._recompute_mirror_links() + # -- viewport pins (HUD overlay) ------------------------------------ + def _recompute_pinned_flag(self): + """Refresh the cached "any pinned item?" flag (on toggle / load).""" + self._has_pinned_items = any( + item.pinned for item in self.get_picker_items() + ) + + def _viewport_size(self): + """Return the viewport ``(width, height)`` for the overlay math.""" + return (self.viewport().width(), self.viewport().height()) + + def _update_pinned_items(self): + """Lock each pinned item to its viewport anchor (constant screen spot). + + A pinned item ignores the view transform for *drawing* (constant size) + but still lives at a scene point that pans with the canvas; this maps + its 3x3 anchor + pixel offset to the current viewport and writes the + item's scene position, so it stays fixed to the viewport through pan / + zoom / resize. A no-op when nothing is pinned. + """ + if not self._has_pinned_items: + return + size = self._viewport_size() + # Scene accessor (no draw-order reverse) since order is irrelevant to + # repositioning; this runs per pan / zoom frame while pins exist. + for item in self.scene().get_picker_items(): + if not item.pinned: + continue + px, py = overlay.anchor_point(size, item.anchor, item.offset) + item.setPos(self.mapToScene(int(round(px)), int(round(py)))) + + def set_item_pinned(self, item, state, anchor=None): + """Pin / unpin an item and reposition it (default anchor = its region). + + Args: + item (PickerItem): the item to pin or unpin. + state (bool): pin when True, unpin when False. + anchor (str, optional): explicit anchor code; when pinning without + one, the item's current on-screen region is used (least + surprising) and the residual is stored as the offset. + """ + if state: + view_pt = self.mapFromScene(item.pos()) + size = self._viewport_size() + point = (view_pt.x(), view_pt.y()) + if anchor is None: + anchor = overlay.nearest_anchor(size, point) + item.set_anchor(anchor) + item.set_offset(overlay.offset_from_anchor(size, anchor, point)) + item.set_pinned(state) + self._recompute_pinned_flag() + self._update_pinned_items() + # Pins are excluded from the canvas extent, so toggling one may shrink + # or grow the scrollable bounds. + self._update_scene_rect() + + def begin_pin_drag(self, item, scene_grab): + """Record a pinned item's grab delta at the start of an offset drag.""" + grab = self.mapFromScene(scene_grab) + anchor_pt = self.mapFromScene(item.pos()) + item._pin_drag_delta = ( + anchor_pt.x() - grab.x(), + anchor_pt.y() - grab.y(), + ) + + def update_pin_drag(self, item, scene_pos): + """Update a pinned item's anchor / offset from an in-progress drag.""" + grab = self.mapFromScene(scene_pos) + px = grab.x() + item._pin_drag_delta[0] + py = grab.y() + item._pin_drag_delta[1] + size = self._viewport_size() + anchor = overlay.nearest_anchor(size, (px, py)) + item.set_anchor(anchor) + item.set_offset(overlay.offset_from_anchor(size, anchor, (px, py))) + self._update_pinned_items() + self._notify_item_transform() + def _draw_bg_marquee(self, painter): """Draw the background-selection marquee rectangle, if active.""" if self._bg_marquee_origin is None or self._bg_marquee_current is None: @@ -1400,6 +1496,7 @@ def clear(self): self.setScene(OrderedGraphicsScene(parent=self)) old_scene.deleteLater() self._has_mirror_links = False + self._has_pinned_items = False def get_picker_items(self): """Return scene picker items in proper order (back to front)""" @@ -1454,8 +1551,11 @@ def set_data(self, data): # missing (resolve pairs lazily via item ids at edit time). self._clean_dangling_mirrors() - # Size the scene to include the items too, now that they exist. + # Record whether any item is pinned, then size the scene (pins are + # excluded from the extent) and lock the pins to their anchors. + self._recompute_pinned_flag() self._update_scene_size() + self._update_pinned_items() def drawBackground(self, painter, rect): """Draw the tab's background image layers (back to front)""" diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index fdf2e36c..b6e5dbd1 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -14,6 +14,8 @@ background options panel. """ +from functools import partial + from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets @@ -21,6 +23,7 @@ from mgear.core import widgets as mwidgets from mgear.anim_picker.widgets import basic +from mgear.anim_picker.widgets import overlay from mgear.anim_picker.widgets.dialogs.handles_window import ( HandlesPositionWindow, ) @@ -80,6 +83,11 @@ def __init__(self, parent=None, main_window=None): self.custom_action_cb = None self.mirror_axis_sb = None self.mirror_status = None + self.pinned_cb = None + self.anchor_group = None + self.anchor_buttons = {} + self.pin_off_x_sb = None + self.pin_off_y_sb = None self._build_ui() self.refresh_fields() @@ -109,6 +117,7 @@ def _build_ui(self): self._build_transform_section() self._build_appearance_section() self._build_shape_section() + self._build_pin_section() self._build_mirror_section() self._build_controls_section() self._build_action_section() @@ -254,6 +263,76 @@ def _build_shape_section(self): self._fields.append(shapes_btn) section.addWidget(shapes_btn) + def _build_pin_section(self): + section = self._add_section("Pin") + + self.pinned_cb = QtWidgets.QCheckBox("Pinned to viewport") + self.pinned_cb.setTristate(True) + self.pinned_cb.setToolTip( + "Lock the item to a viewport corner / edge (HUD overlay); it " + "ignores canvas pan and zoom" + ) + self.pinned_cb.clicked.connect(self._apply_pinned) + self._fields.append(self.pinned_cb) + section.addWidget(self.pinned_cb) + + # 3x3 anchor picker: nine exclusive toggle cells laid out like the + # viewport regions (top-left ... bottom-right). Explicit cell borders + # are set so the empty (unchecked) cells stay visible against Maya's + # dark stylesheet -- a bare QToolButton renders frameless there. + section.addWidget(QtWidgets.QLabel("Anchor")) + anchor_grid = QtWidgets.QGridLayout() + anchor_grid.setSpacing(2) + anchor_style = ( + "QToolButton{border:1px solid #5a5a5a;border-radius:2px;" + "background:#3c3c3c;}" + "QToolButton:hover{border:1px solid #8a8a8a;}" + "QToolButton:checked{background:#5285a6;" + "border:1px solid #79b0d0;}" + ) + self.anchor_group = QtWidgets.QButtonGroup(self) + self.anchor_group.setExclusive(True) + labels = { + "tl": "Top-left", + "tc": "Top-center", + "tr": "Top-right", + "ml": "Middle-left", + "mc": "Center", + "mr": "Middle-right", + "bl": "Bottom-left", + "bc": "Bottom-center", + "br": "Bottom-right", + } + for index, code in enumerate(overlay.ANCHOR_CODES): + button = QtWidgets.QToolButton() + button.setCheckable(True) + button.setFixedSize(QtCore.QSize(22, 22)) + button.setStyleSheet(anchor_style) + button.setToolTip(labels[code]) + button.clicked.connect(partial(self._apply_anchor, code)) + self.anchor_group.addButton(button) + self.anchor_buttons[code] = button + self._fields.append(button) + anchor_grid.addWidget(button, index // 3, index % 3) + anchor_row = QtWidgets.QHBoxLayout() + anchor_row.addLayout(anchor_grid) + anchor_row.addStretch() + section.addLayout(anchor_row) + + offset_form = QtWidgets.QFormLayout() + self.pin_off_x_sb = self._int_spin(self._apply_offset, -10000, 10000) + self.pin_off_y_sb = self._int_spin(self._apply_offset, -10000, 10000) + offset_form.addRow("Offset X", self.pin_off_x_sb) + offset_form.addRow("Offset Y", self.pin_off_y_sb) + section.addLayout(offset_form) + + hint = QtWidgets.QLabel( + "Drag the item on the canvas to set its offset; the anchor snaps " + "to the nearest region." + ) + hint.setWordWrap(True) + section.addWidget(hint) + def _build_mirror_section(self): section = self._add_section("Mirror") @@ -422,10 +501,28 @@ def _populate_all(self): self._populate_transform() self._populate_appearance() self._populate_shape() + self._populate_pin() self._populate_mirror() self._populate_controls() self._populate_action() + def _populate_pin(self): + pinned, pinned_mixed = self._shared(lambda item: item.get_pinned()) + self._set_tristate(self.pinned_cb, pinned, pinned_mixed) + + anchor, anchor_mixed = self._shared(lambda item: item.get_anchor()) + self.anchor_group.setExclusive(False) + for code, button in self.anchor_buttons.items(): + button.blockSignals(True) + button.setChecked(not anchor_mixed and code == anchor) + button.blockSignals(False) + self.anchor_group.setExclusive(True) + + ox, ox_mixed = self._shared(lambda item: int(round(item.get_offset()[0]))) + oy, oy_mixed = self._shared(lambda item: int(round(item.get_offset()[1]))) + self._set_spin(self.pin_off_x_sb, ox, ox_mixed) + self._set_spin(self.pin_off_y_sb, oy, oy_mixed) + def _populate_mirror(self): if self._view is not None: self.mirror_axis_sb.setValue(self._view.mirror_axis_x) @@ -458,6 +555,17 @@ def _set_spin(self, spin, value, mixed): else: spin.setValue(value) + def _set_tristate(self, checkbox, value, mixed): + """Set a tristate checkbox from a shared value (partial when mixed).""" + checkbox.blockSignals(True) + checkbox.setTristate(True) + if mixed: + checkbox.setCheckState(QtCore.Qt.PartiallyChecked) + else: + state = QtCore.Qt.Checked if value else QtCore.Qt.Unchecked + checkbox.setCheckState(state) + checkbox.blockSignals(False) + def _populate_transform(self): x, x_mixed = self._shared(lambda item: round(item.x(), 4)) y, y_mixed = self._shared(lambda item: round(item.y(), 4)) @@ -512,14 +620,7 @@ def _populate_shape(self): status, status_mixed = self._shared( lambda item: item.get_edit_status() ) - self.handles_cb.blockSignals(True) - self.handles_cb.setTristate(True) - if status_mixed: - self.handles_cb.setCheckState(QtCore.Qt.PartiallyChecked) - else: - state = QtCore.Qt.Checked if status else QtCore.Qt.Unchecked - self.handles_cb.setCheckState(state) - self.handles_cb.blockSignals(False) + self._set_tristate(self.handles_cb, status, status_mixed) def _populate_controls(self): self.control_list.clear() @@ -535,14 +636,7 @@ def _populate_action(self): mode, mode_mixed = self._shared( lambda item: item.get_custom_action_mode() ) - self.custom_action_cb.blockSignals(True) - self.custom_action_cb.setTristate(True) - if mode_mixed: - self.custom_action_cb.setCheckState(QtCore.Qt.PartiallyChecked) - else: - state = QtCore.Qt.Checked if mode else QtCore.Qt.Unchecked - self.custom_action_cb.setCheckState(state) - self.custom_action_cb.blockSignals(False) + self._set_tristate(self.custom_action_cb, mode, mode_mixed) self.menus_list.clear() item = self._active_item() @@ -585,6 +679,15 @@ def _committed(self, spin): value = spin.value() return None if value == spin.minimum() else value + def _resolve_tristate(self, checkbox): + """Resolve a user-clicked tristate to a bool (partial counts as on). + + Clears the tristate so a subsequent click toggles cleanly on/off. + """ + resolved = checkbox.checkState() != QtCore.Qt.Unchecked + checkbox.setTristate(False) + return resolved + # -- transform ------------------------------------------------------ def _apply_position(self, *args, **kwargs): if self._syncing or not self.items: @@ -704,10 +807,8 @@ def _apply_text_alpha(self, *args, **kwargs): def _apply_show_handles(self, *args, **kwargs): if self._syncing or not self.items: return - state = self.handles_cb.checkState() # A user click resolves the tristate; treat partial as "show". - show = state != QtCore.Qt.Unchecked - self.handles_cb.setTristate(False) + show = self._resolve_tristate(self.handles_cb) for item in self.items: item.set_edit_status(show) self._repaint_view() @@ -799,6 +900,37 @@ def _make_symmetric(self): self._view.apply_mirror_for([item]) self._view.viewport().update() + # -- pin ------------------------------------------------------------ + def _apply_pinned(self, *args, **kwargs): + if self._syncing or not self.items or self._view is None: + return + state = self._resolve_tristate(self.pinned_cb) + for item in self.items: + self._view.set_item_pinned(item, state) + self._view.viewport().update() + self._guarded(self._populate_pin) + + def _apply_anchor(self, code, *args, **kwargs): + if self._syncing or not self.items or self._view is None: + return + for item in self.items: + item.set_anchor(code) + self._view._update_pinned_items() + self._view.viewport().update() + + def _apply_offset(self, *args, **kwargs): + if self._syncing or not self.items or self._view is None: + return + x = self._committed(self.pin_off_x_sb) + y = self._committed(self.pin_off_y_sb) + for item in self.items: + off = item.get_offset() + item.set_offset( + [off[0] if x is None else x, off[1] if y is None else y] + ) + self._view._update_pinned_items() + self._view.viewport().update() + # -- controls ------------------------------------------------------- def _add_selected_controls(self): if not self.items: @@ -829,8 +961,7 @@ def _search_replace(self): def _apply_action_mode(self, *args, **kwargs): if self._syncing or not self.items: return - custom = self.custom_action_cb.checkState() != QtCore.Qt.Unchecked - self.custom_action_cb.setTristate(False) + custom = self._resolve_tristate(self.custom_action_cb) for item in self.items: item.set_custom_action_mode(custom) diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py index baedc383..07277811 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_model.py +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -20,6 +20,9 @@ text_color RGBA tuple item_id str (optional stable id, minted when first mirror-linked) mirror_id str (optional mirror partner's item_id) + pinned bool (optional; item locked to the viewport as a HUD overlay) + anchor str (optional 3x3 viewport anchor code, e.g. "tl") + offset [dx, dy] (optional inward pixel offset from the anchor) """ @@ -40,6 +43,9 @@ def __init__(self): self.text_color = None self.item_id = None self.mirror_id = None + self.pinned = False + self.anchor = None + self.offset = None @classmethod def from_dict(cls, data): @@ -79,6 +85,12 @@ def from_dict(cls, data): model.item_id = data["id"] if data.get("mirror"): model.mirror_id = data["mirror"] + if data.get("pinned"): + model.pinned = True + model.anchor = data.get("anchor") + model.offset = ( + list(data["offset"]) if "offset" in data else None + ) return model @@ -119,4 +131,13 @@ def to_dict(self): if self.mirror_id: data["mirror"] = self.mirror_id + # Viewport pin (additive optional keys; only emitted when pinned so old + # readers and non-pinned items are unaffected). + if self.pinned: + data["pinned"] = True + if self.anchor: + data["anchor"] = self.anchor + if self.offset is not None: + data["offset"] = list(self.offset) + return data diff --git a/release/scripts/mgear/anim_picker/widgets/overlay.py b/release/scripts/mgear/anim_picker/widgets/overlay.py new file mode 100644 index 00000000..8d37bbc4 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/overlay.py @@ -0,0 +1,121 @@ +"""Qt/Maya-free anchor math for viewport-pinned picker items. + +A pinned ("overlay" / HUD) picker item is placed by a viewport *anchor* -- one +of a 3x3 grid of screen regions (corners / edges / center) -- plus an inward +pixel *offset*. These pure functions convert an anchor + offset to a screen +point (and back), and snap a dropped point to its nearest grid cell, so the +placement logic lives in exactly one tested place with no Qt or Maya +dependency (the same pattern as ``mirror`` and ``manipulator_transform``). + +An anchor is a two-character code ```` where ``v`` is the vertical band -- +``t`` (top), ``m`` (middle), ``b`` (bottom) -- and ``h`` is the horizontal band +-- ``l`` (left), ``c`` (center), ``r`` (right). So ``"tl"`` is top-left, +``"mc"`` is the center, ``"br"`` is bottom-right. + +The offset is measured *inward* from the anchor: from the top/left edge it grows +down/right, from the bottom/right edge it grows up/left, and from a middle / +center band it is a plain screen delta (down / right). This keeps a corner +button a fixed number of pixels from its corner as the viewport is resized. +""" + + +ANCHOR_CODES = ( + "tl", + "tc", + "tr", + "ml", + "mc", + "mr", + "bl", + "bc", + "br", +) + +# Default placement for a freshly pinned item (top-left, a small inset). +DEFAULT_ANCHOR = "tl" +DEFAULT_OFFSET = (12, 12) + +# Fractional position of each band's base line along the axis. +_V_BASE = {"t": 0.0, "m": 0.5, "b": 1.0} +_H_BASE = {"l": 0.0, "c": 0.5, "r": 1.0} + +# Inward direction of the offset for each band (see module docstring). +_V_SIGN = {"t": 1.0, "m": 1.0, "b": -1.0} +_H_SIGN = {"l": 1.0, "c": 1.0, "r": -1.0} + + +def _split(anchor): + """Return ``(v, h)`` band codes for ``anchor``, defaulting if invalid.""" + if ( + not anchor + or len(anchor) != 2 + or anchor[0] not in _V_BASE + or anchor[1] not in _H_BASE + ): + anchor = DEFAULT_ANCHOR + return anchor[0], anchor[1] + + +def anchor_point(size, anchor, offset): + """Return the screen point for an anchor + inward pixel offset. + + Args: + size (tuple): viewport ``(width, height)`` in pixels. + anchor (str): a two-character anchor code (see module docstring). + offset (sequence): inward ``[dx, dy]`` pixel offset. + + Returns: + tuple: the ``(x, y)`` screen point. + """ + width, height = size + v, h = _split(anchor) + dx, dy = offset + x = _H_BASE[h] * width + _H_SIGN[h] * dx + y = _V_BASE[v] * height + _V_SIGN[v] * dy + return (x, y) + + +def offset_from_anchor(size, anchor, point): + """Return the inward offset of ``point`` from ``anchor`` (inverse of above). + + Args: + size (tuple): viewport ``(width, height)`` in pixels. + anchor (str): a two-character anchor code. + point (sequence): the ``(x, y)`` screen point. + + Returns: + tuple: the inward ``(dx, dy)`` pixel offset. + """ + width, height = size + v, h = _split(anchor) + px, py = point + dx = _H_SIGN[h] * (px - _H_BASE[h] * width) + dy = _V_SIGN[v] * (py - _V_BASE[v] * height) + return (dx, dy) + + +def nearest_anchor(size, point): + """Return the anchor code of the 3x3 cell containing ``point``. + + Args: + size (tuple): viewport ``(width, height)`` in pixels. + point (sequence): the ``(x, y)`` screen point. + + Returns: + str: the two-character anchor code of the nearest grid cell. + """ + width, height = size + px, py = point + if px < width / 3.0: + h = "l" + elif px < 2.0 * width / 3.0: + h = "c" + else: + h = "r" + if py < height / 3.0: + v = "t" + elif py < 2.0 * height / 3.0: + v = "m" + else: + v = "b" + return v + h diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index ed0cecfe..0fae1978 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -29,6 +29,7 @@ from mgear.anim_picker.widgets.dialogs.copy_paste_dialog import DataCopyDialog from mgear.anim_picker.widgets.item_model import PickerItemData from mgear.anim_picker.widgets import mirror +from mgear.anim_picker.widgets import overlay from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ from mgear.anim_picker.handlers import python_handlers @@ -111,6 +112,15 @@ def __init__( self.item_id = None self.mirror_id = None + # Viewport pin (optional HUD overlay): when pinned the item ignores the + # canvas pan/zoom and is repositioned by the view to ``anchor`` + + # ``offset`` (a 3x3 viewport anchor code + inward pixel offset). The + # view records a per-item drag delta here while an offset drag is live. + self.pinned = False + self.anchor = overlay.DEFAULT_ANCHOR + self.offset = list(overlay.DEFAULT_OFFSET) + self._pin_drag_delta = (0.0, 0.0) + def shape(self): path = QtGui.QPainterPath() @@ -238,6 +248,13 @@ def hoverEnterEvent(self, event=None): super().hoverEnterEvent(event) def mouseMoveEvent_offset(self, event): + # A pinned item is not moved in scene space: its drag updates its + # viewport anchor / offset instead (the view snaps the anchor). + if self.pinned: + view = self.parent() + if hasattr(view, "update_pin_drag"): + view.update_pin_drag(self, event.scenePos()) + return self.setPos(event.scenePos() + self.cursor_delta) def mouseMoveEvent(self, event): @@ -248,6 +265,11 @@ def mouseMoveEvent(self, event): item.mouseMoveEvent_offset(event) for item in self.currently_selected ] + # The pressed item is moved by Qt (ItemIsMovable) when free, but a + # pinned item is non-movable, so route its drag to an offset here. + if self.pinned: + self.mouseMoveEvent_offset(event) + return super().mouseMoveEvent(gfx_event) def mousePressEvent(self, event): @@ -268,6 +290,14 @@ def mousePressEvent(self, event): item.get_delta_from_point(event.scenePos()) for item in self.currently_selected ] + # Prime the offset drag for any pinned item in the drag set (the + # view records a per-item grab delta so the anchor tracks the + # cursor without jumping to the item's origin). + view = self.parent() + if hasattr(view, "begin_pin_drag"): + for item in [self] + self.currently_selected: + if item.pinned: + view.begin_pin_drag(item, event.scenePos()) return DefaultPolygon.mousePressEvent(self, event) # Run selection on left mouse button event @@ -686,6 +716,56 @@ def get_delta_from_point(self, point): self.cursor_delta = self.pos() - point return self.cursor_delta + # ========================================================================= + # Viewport pin (HUD overlay) --- + def get_pinned(self): + """Return True when the item is pinned to the viewport.""" + return self.pinned + + def set_pinned(self, state): + """Pin / unpin the item to the viewport. + + A pinned item ignores the view transform so it draws at a constant + screen size (like the point handles), and a compensating vertical flip + is applied so its shape / text stay upright despite the Y-flipped view. + The view owns the item's *position* (see ``_update_pinned_items``); a + pinned item is not free-dragged in scene space, so it is made + non-movable while pinned. + + Args: + state (bool): pin when True, unpin when False. + """ + self.pinned = bool(state) + self.setFlag( + QtWidgets.QGraphicsItem.ItemIgnoresTransformations, self.pinned + ) + # Counter the view's scale(1, -1): with the view transform ignored the + # item would otherwise render vertically mirrored. + if self.pinned: + self.setTransform(QtGui.QTransform().scale(1, -1)) + else: + self.setTransform(QtGui.QTransform()) + if __EDIT_MODE__.get(): + self.setFlag( + QtWidgets.QGraphicsItem.ItemIsMovable, not self.pinned + ) + + def get_anchor(self): + """Return the item's 3x3 viewport anchor code.""" + return self.anchor + + def set_anchor(self, anchor): + """Set the item's viewport anchor code (e.g. ``"tl"``).""" + self.anchor = anchor + + def get_offset(self): + """Return the item's inward ``[dx, dy]`` pixel offset.""" + return self.offset + + def set_offset(self, offset): + """Set the item's inward ``[dx, dy]`` pixel offset.""" + self.offset = list(offset) + # ========================================================================= # Ducplicate and mirror methods --- def mirror_position(self, axis_x=0.0): @@ -1056,6 +1136,15 @@ def set_data(self, data): if model.mirror_id: self.mirror_id = model.mirror_id + # Viewport pin (optional, additive keys). Apply anchor / offset first so + # the view can place the item on the next _update_pinned_items pass. + if model.pinned: + if model.anchor: + self.set_anchor(model.anchor) + if model.offset is not None: + self.set_offset(model.offset) + self.set_pinned(True) + def get_data(self): """Get picker item data in dictionary form. @@ -1086,4 +1175,9 @@ def get_data(self): model.item_id = self.item_id model.mirror_id = self.mirror_id + if self.pinned: + model.pinned = True + model.anchor = self.anchor + model.offset = list(self.offset) + return model.to_dict() diff --git a/releaseLog.rst b/releaseLog.rst index 8aa55832..0064fe7d 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Pinned / overlay HUD buttons — a picker item can be **pinned to the viewport** so it stays at a fixed screen position and constant size, ignoring canvas pan and zoom (e.g. a global reset, a space switch, a settings button locked to a corner). A pinned item is placed by a **3x3 anchor** (corners / edges / center) plus an inward pixel **offset**, and keeps all its normal button behavior (control selection, custom action, menus, color, text, shape). Set it from the inline panel's new **Pin** section (Pinned checkbox, 3x3 anchor picker, X / Y offset) or the tool strip's **Pin** quick command (anchors to the item's current screen region); in edit mode you can also drag a pinned item on the canvas to set its offset (the anchor snaps to the nearest region). Pinned items are excluded from the canvas' pan / zoom extent, so a HUD overlay never enlarges the scrollable scene or gets framed by "reset view". Persisted as additive optional item keys (``pinned`` / ``anchor`` / ``offset``), so older pickers load unchanged and non-pinned items are unaffected * Anim Picker: Mirror relationships + shape library — the left tool strip gains icon quick commands (Add, Duplicate, Duplicate & Mirror, Mirror Shape, Shapes) using bundled mGear icons, with the Select / Transform tools showing the default Maya tool icons. In edit mode, clicking a picker item now selects it immediately on press (so a single click-drag selects and moves it), while dragging a multi-selection keeps the whole group. Two items can be linked as a **persistent mirror pair** about a symmetry axis: editing one (move/scale/rotate on canvas or via the inline panel) live-mirrors its position, rotation, and shape to its partner **in realtime** (during the drag, not just on release), and the link is saved with the picker (additive optional ``id`` / ``mirror`` keys, so older pickers load unchanged). Linked items show a pink dotted outline so pairs are spottable at a glance. "Duplicate & Mirror" creates and links the pair; the panel's Mirror section links/unlinks, sets the axis, and snaps with Make Symmetric. Left/right coloring is now explicit via a **preset color palette** along the bottom (secondary/primary per left/center/right): clicking a swatch colors the selection and its mirror partner gets the opposite side at the same level; double / right-click edits a swatch (persisted, and it never repaints existing items). A new **shape library** (tool strip Shapes button or panel Shape > Shapes...) applies premade shapes (square, circle, triangle, diamond, pentagon, hexagon, octagon, star, arrow) to the selection and can save the current shape as a named custom shape (stored in the Maya user prefs dir, next to mGear_user_settings.ini), keeping the curve-import path * Core: Add a reusable ``PythonCodeEditor`` widget (mgear.core.pycodeeditor) — a modern Python editor with a line-number gutter, current-line highlight, Python syntax highlighting, a configurable monospace font (Consolas by default, family + size), spaces-or-tabs indentation with a configurable width, "show indentation" (visualize whitespace), convert-tabs-to-spaces, and smart Tab / Shift-Tab (block indent/unindent) + auto-indent on Enter; preferences persist via QSettings. Any mGear tool can embed it instead of a bare QTextEdit * Anim Picker: Custom script / menu editor now uses the modern ``PythonCodeEditor`` (line numbers, syntax highlighting, font + indentation controls) with a File / Edit / View menu bar (open & save scripts to file, undo/redo/cut/copy/paste, convert indentation to spaces, show indentation) in place of the plain text box From 930cde1b1571ad8c1720ffd856d7eab0e13bacbe Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Thu, 9 Jul 2026 15:10:18 +0900 Subject: [PATCH 10/25] AnimPicker: Tabs: Drag-reorder tabs and multi-tab tiled view Add two tab-presentation improvements to the picker. Tabs can be dragged to reorder on the tab bar in edit mode (ContextMenuTabWidget.setMovable), the right-click Move actions kept as a fallback; the order saves exactly as before (get_data is positional). A View selector (Tabbed / Grid / Rows / Columns) in the character bar switches the picker area between the one-at-a-time tabbed view and a multi-tab tiled view that shows every tab's picker at once. Each tiled section is a fully live, interactive picker, laid out in nested draggable QSplitters (minimal dividers) so the user configures each picker's relative space; no per-cell title/frame is drawn. - New Qt/Maya-free widgets/tiled_layout.py: grid_shape(count, columns) -> (rows, cols), standalone-tested (near-square / vertical / horizontal). - New widgets/tiled_view.py: TiledPickerView (borrows the live views into splitters, tracks the active view via a viewport click) and PickerTabArea (facade presenting the tabs as tabbed or tiled; routes active view / all views / data / fit so callers work in either mode). - ContextMenuTabWidget.borrow_views / restore_views + shared tab_data_entry (one serialization authority for both presentations). - main_window routes _current_view / selection sync / data / fit through the area; forces tabbed before load; hands the whole splitter to the picker area when the inline panel is hidden; persists the mode + grid columns via QSettings (never in the .pkr, so older pickers load unchanged). --- .../scripts/mgear/anim_picker/main_window.py | 144 +++++++- .../scripts/mgear/anim_picker/tab_widget.py | 57 ++- .../mgear/anim_picker/widgets/edit_panel.py | 6 +- .../mgear/anim_picker/widgets/tiled_layout.py | 38 ++ .../mgear/anim_picker/widgets/tiled_view.py | 328 ++++++++++++++++++ releaseLog.rst | 1 + 6 files changed, 547 insertions(+), 27 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/widgets/tiled_layout.py create mode 100644 release/scripts/mgear/anim_picker/widgets/tiled_view.py diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 648ddd39..43fedea5 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -30,6 +30,7 @@ from mgear.anim_picker.widgets import edit_panel from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.widgets import color_palette +from mgear.anim_picker.widgets import tiled_view from mgear.anim_picker.widgets import overlay_widgets from mgear.anim_picker.widgets.dialogs.shape_library_dialog import ( ShapeLibraryDialog, @@ -245,10 +246,10 @@ def eventFilter(self, QObject, event): # hide main tab widget for os compatibility if QObject in getattr(self, "overlays", []): if event.type() == QtCore.QEvent.Type.Show: - self.tab_widget.hide() + self.tab_area.hide() return True elif event.type() == QtCore.QEvent.Type.Hide: - self.tab_widget.show() + self.tab_area.show() return True return False @@ -311,6 +312,8 @@ def add_character_selector(self): # Add option buttons btns_layout = QtWidgets.QHBoxLayout() box_layout.addLayout(btns_layout) + # Kept so add_tab_widget can append the view-mode selector here. + self.char_btns_layout = btns_layout # Add horizont spacer spacer = QtWidgets.QSpacerItem( @@ -376,8 +379,16 @@ def add_tab_widget(self, name="default"): self.edit_panel = edit_panel.ItemEditPanel(main_window=self) self.edit_panel.setMinimumWidth(pyqt.dpi_scale(220)) + # Wrap the tab widget so it can also be presented as a tiled multi-tab + # view (grid / vertical / horizontal); the area is the state-sensitive + # facade (active view, all views, data, fit) used across the window. + self.tab_area = tiled_view.PickerTabArea( + self.tab_widget, main_window=self + ) + self._build_view_mode_selector() + self.editor_splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) - self.editor_splitter.addWidget(self.tab_widget) + self.editor_splitter.addWidget(self.tab_area) self.editor_splitter.addWidget(self.edit_panel) self.editor_splitter.setStretchFactor(0, 1) self.editor_splitter.setStretchFactor(1, 0) @@ -441,10 +452,10 @@ def add_tab_widget(self, name="default"): view = GraphicViewWidget(main_window=self) self.tab_widget.addTab(view, name) - # ensure the tab retains its size when hidden - sp_retain = self.tab_widget.sizePolicy() + # ensure the picker area retains its size when hidden (overlay filter) + sp_retain = self.tab_area.sizePolicy() sp_retain.setRetainSizeWhenHidden(True) - self.tab_widget.setSizePolicy(sp_retain) + self.tab_area.setSizePolicy(sp_retain) # Editor panel, tool strip and palette are edit-mode only; refresh the # panel when the tab changes. @@ -453,6 +464,87 @@ def add_tab_widget(self, name="default"): self.color_palette.setVisible(__EDIT_MODE__.get()) self.tab_widget.currentChanged.connect(self._on_tab_changed) + # In anim mode the panel is hidden; hand the whole splitter to the + # picker area so the canvas / tiled view fills the full width. + if not __EDIT_MODE__.get(): + self.editor_splitter.setSizes([1, 0]) + + # Restore the last-used view mode / grid column count. + self._apply_saved_view_mode() + + # ===================================================================== + # View mode (tabbed / tiled multi-tab) --- + _VIEW_MODES = ( + tiled_view.MODE_TABBED, + tiled_view.MODE_GRID, + tiled_view.MODE_VERTICAL, + tiled_view.MODE_HORIZONTAL, + ) + + def _build_view_mode_selector(self): + """Add the Tabbed / Grid / Rows / Columns selector to the char bar.""" + self.char_btns_layout.addWidget(QtWidgets.QLabel("View")) + self.view_mode_cb = QtWidgets.QComboBox() + self.view_mode_cb.addItems(["Tabbed", "Grid", "Rows", "Columns"]) + self.view_mode_cb.setToolTip( + "Show one tab at a time, or all tabs tiled at once" + ) + self.view_mode_cb.currentIndexChanged.connect( + self._on_view_mode_changed + ) + self.char_btns_layout.addWidget(self.view_mode_cb) + + self.grid_cols_sb = QtWidgets.QSpinBox() + self.grid_cols_sb.setRange(0, 12) + self.grid_cols_sb.setToolTip("Grid columns (0 = auto)") + self.grid_cols_sb.setEnabled(False) + self.grid_cols_sb.valueChanged.connect(self._on_grid_cols_changed) + self.char_btns_layout.addWidget(self.grid_cols_sb) + + def _view_settings(self): + """Return the QSettings store for anim picker UI preferences.""" + return QtCore.QSettings("mgear", "anim_picker") + + def _apply_saved_view_mode(self): + """Restore the persisted view mode + grid columns, without re-saving.""" + settings = self._view_settings() + mode = settings.value("tab_view_mode", tiled_view.MODE_TABBED) + try: + columns = int(settings.value("tab_grid_columns", 0) or 0) + except (TypeError, ValueError): + columns = 0 + if mode not in self._VIEW_MODES: + mode = tiled_view.MODE_TABBED + + self.grid_cols_sb.blockSignals(True) + self.grid_cols_sb.setValue(columns) + self.grid_cols_sb.blockSignals(False) + self.tab_area.set_grid_columns(columns or None) + + index = self._VIEW_MODES.index(mode) + self.view_mode_cb.blockSignals(True) + self.view_mode_cb.setCurrentIndex(index) + self.view_mode_cb.blockSignals(False) + self._set_view_mode(mode) + + def _on_view_mode_changed(self, index): + """Apply and persist the selected view mode.""" + mode = self._VIEW_MODES[index] + self._view_settings().setValue("tab_view_mode", mode) + self._set_view_mode(mode) + + def _on_grid_cols_changed(self, value): + """Apply and persist the grid column count (0 = auto).""" + self._view_settings().setValue("tab_grid_columns", value) + self.tab_area.set_grid_columns(value or None) + + def _set_view_mode(self, mode): + """Switch the picker area to ``mode`` and refresh dependent UI.""" + self.tab_area.set_view_mode(mode) + self.grid_cols_sb.setEnabled(mode == tiled_view.MODE_GRID) + self.tab_area.fit_contents() + self._on_tab_changed() + def _on_tab_changed(self, *args): """Rebind the inline editor to the newly active tab's selection.""" panel = getattr(self, "edit_panel", None) @@ -467,7 +559,7 @@ def set_active_tool(self, name): name (str): a ``tool_bar`` tool id (TOOL_SELECT / TOOL_TRANSFORM). """ self.active_tool = name - view = self.tab_widget.currentWidget() + view = self.tab_area.active_view() if view is not None: view.viewport().update() @@ -475,7 +567,7 @@ def set_active_tool(self, name): # Tool-strip quick commands --- def _current_view(self): """Return the active graphics view, or None.""" - return self.tab_widget.currentWidget() + return self.tab_area.active_view() def _selected_items(self): """Return the current view's selected picker items.""" @@ -638,6 +730,14 @@ def _sync_edit_panel(self): if panel is None: return panel.setVisible(edit) + # Give the whole splitter to the picker area when the panel is hidden + # (anim mode), so the canvas / tiled view fills the full width. + splitter = getattr(self, "editor_splitter", None) + if splitter is not None: + if edit: + splitter.setSizes([pyqt.dpi_scale(600), pyqt.dpi_scale(240)]) + else: + splitter.setSizes([1, 0]) if edit: panel.sync() self.update_tool_commands() @@ -655,11 +755,11 @@ def add_overlays(self): def get_picker_items(self): """Return picker items for current active tab""" - return self.tab_widget.get_current_picker_items() + return self.tab_area.get_current_picker_items() def get_all_picker_items(self): """Return all picker items for current picker""" - return self.tab_widget.get_all_picker_items() + return self.tab_area.get_all_picker_items() def dockCloseEventTriggered(self): self.close() @@ -775,7 +875,7 @@ def set_field_status(self): # Set status self.char_selector_cb.setEnabled(self.status) - self.tab_widget.setEnabled(self.status) + self.tab_area.setEnabled(self.status) if self.save_char_btn: self.save_char_btn.setEnabled(self.status) @@ -785,6 +885,9 @@ def set_field_status(self): def load_default_tabs(self): """Will reset and load default empty tabs""" + # Return to tabbed so the tab widget owns the (about to be replaced) + # views before clearing. + self.tab_area.ensure_tabbed() self.tab_widget.clear() self.tab_widget.addTab(GraphicViewWidget(main_window=self), "None") @@ -812,13 +915,15 @@ def refresh(self): self.selection_change_event() # Force view resize - self.tab_widget.fit_contents() + self.tab_area.fit_contents() # Sync the inline editor's visibility/content with the current mode. self._sync_edit_panel() # Set focus on view - self.tab_widget.currentWidget().setFocus() + active_view = self.tab_area.active_view() + if active_view is not None: + active_view.setFocus() def load_from_sel_node(self): """Will try to load character for selected node""" @@ -920,9 +1025,9 @@ def load_character(self): path = picker_data.get("snapshot", None) self.pic_widget.set_background(path) - # load tabs + # load tabs (set_data returns the area to the tabbed presentation) tabs_data = picker_data.get("tabs", {}) - self.tab_widget.set_data(tabs_data) + self.tab_area.set_data(tabs_data) # Default tab if not self.tab_widget.count(): @@ -933,8 +1038,11 @@ def load_character(self): # Return to first tab self.tab_widget.setCurrentIndex(0) + # Re-apply the persisted view mode over the freshly loaded tabs. + self._apply_saved_view_mode() + # Fit content - self.tab_widget.fit_contents() + self.tab_area.fit_contents() # Update selection states self.selection_change_event() @@ -969,8 +1077,8 @@ def get_character_data(self): if snapshot_data: picker_data["snapshot"] = snapshot_data - # Add tabs data - tabs_data = self.tab_widget.get_data() + # Add tabs data (from the area, so it works in tabbed or tiled view) + tabs_data = self.tab_area.get_data() if tabs_data: picker_data["tabs"] = tabs_data diff --git a/release/scripts/mgear/anim_picker/tab_widget.py b/release/scripts/mgear/anim_picker/tab_widget.py index dd151f8e..2d5c0e62 100644 --- a/release/scripts/mgear/anim_picker/tab_widget.py +++ b/release/scripts/mgear/anim_picker/tab_widget.py @@ -10,12 +10,32 @@ from mgear.anim_picker.handlers import __EDIT_MODE__ +def tab_data_entry(name, view): + """Return the serialized ``{"name", "data"}`` entry for one tab view. + + The single authority for a tab's on-disk shape, so the tabbed and tiled + presentations serialize identically. + + Args: + name (str): the tab name. + view (GraphicViewWidget): the tab's picker view. + + Returns: + dict: ``{"name": str, "data": dict}``. + """ + return {"name": str(name), "data": view.get_data()} + + class ContextMenuTabWidget(QtWidgets.QTabWidget): """Custom tab widget with specific context menu support""" def __init__(self, parent, main_window=None, *args, **kwargs): QtWidgets.QTabWidget.__init__(self, parent, *args, **kwargs) self.main_window = main_window + # Drag-to-reorder tabs (edit mode only, matching the Move menu). The + # order is the widget's own tab order, already serialized positionally + # by get_data, so a reorder persists with no extra work. + self.setMovable(__EDIT_MODE__.get()) def contextMenuEvent(self, event): """Right click menu options""" @@ -145,14 +165,39 @@ def get_all_picker_items(self): items.extend(self.widget(i).get_picker_items()) return items + def borrow_views(self): + """Detach every view page for temporary display elsewhere (tiled view). + + Returns an ordered ``[(name, view), ...]`` snapshot and empties the tab + widget without deleting the views (``removeTab`` reparents them). Pair + with :meth:`restore_views` to put them back in order. + + Returns: + list: ``(name, GraphicViewWidget)`` pairs in tab order. + """ + snapshot = [] + for i in range(self.count()): + snapshot.append((str(self.tabText(i)), self.widget(i))) + # Remove from the end so indices stay valid; views survive removeTab. + for i in reversed(range(self.count())): + self.removeTab(i) + return snapshot + + def restore_views(self, snapshot): + """Re-attach borrowed views as tabs, in order. + + Args: + snapshot (list): ``(name, view)`` pairs from :meth:`borrow_views`. + """ + for name, view in snapshot: + self.addTab(view, name) + def get_data(self): """Will return all tabs data""" - data = [] - for i in range(self.count()): - name = str(self.tabText(i)) - tab_data = self.widget(i).get_data() - data.append({"name": name, "data": tab_data}) - return data + return [ + tab_data_entry(self.tabText(i), self.widget(i)) + for i in range(self.count()) + ] def set_data(self, data): """Will, set/load tabs data""" diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index b6e5dbd1..1c0b8c2d 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -436,10 +436,10 @@ def _build_action_section(self): # ------------------------------------------------------------------ def _current_view(self): """Return the main window's active graphics view, or None.""" - tab_widget = getattr(self.main_window, "tab_widget", None) - if tab_widget is None: + getter = getattr(self.main_window, "_current_view", None) + if getter is None: return None - return tab_widget.currentWidget() + return getter() def sync(self): """Rebind to the current active view's selection.""" diff --git a/release/scripts/mgear/anim_picker/widgets/tiled_layout.py b/release/scripts/mgear/anim_picker/widgets/tiled_layout.py new file mode 100644 index 00000000..7b637822 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/tiled_layout.py @@ -0,0 +1,38 @@ +"""Qt/Maya-free layout math for the multi-tab tiled picker view. + +`grid_shape` maps a tab count (and an optional fixed column count) to a +``(rows, cols)`` grid, driving the ``TiledPickerView`` Qt layout. Keeping it a +pure function -- no Qt or Maya -- lets it be unit-tested without a DCC, the same +pattern as ``overlay`` / ``mirror`` / ``manipulator_transform``. + +The three tiled modes map onto it as: + grid columns=None -> a near-square grid (ceil(sqrt(count)) columns) + vertical columns=1 -> one column, ``count`` rows + horizontal columns=count -> one row, ``count`` columns +""" + +from math import ceil +from math import sqrt + + +def grid_shape(count, columns=None): + """Return the ``(rows, cols)`` grid for ``count`` cells. + + Args: + count (int): number of cells to lay out. + columns (int, optional): fixed column count; when None a near-square + grid is used (``ceil(sqrt(count))`` columns). Clamped to at least 1 + and at most ``count`` so there are no empty trailing columns. + + Returns: + tuple: ``(rows, cols)`` -- ``(0, 0)`` when ``count`` is zero or less. + """ + if count <= 0: + return (0, 0) + if columns is None: + cols = int(ceil(sqrt(count))) + else: + cols = columns + cols = max(1, min(int(cols), count)) + rows = int(ceil(count / float(cols))) + return (rows, cols) diff --git a/release/scripts/mgear/anim_picker/widgets/tiled_view.py b/release/scripts/mgear/anim_picker/widgets/tiled_view.py new file mode 100644 index 00000000..ec226fc8 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/tiled_view.py @@ -0,0 +1,328 @@ +"""Multi-tab tiled picker view. + +``TiledPickerView`` shows every picker tab at once, tiling the *borrowed* +``GraphicViewWidget`` instances (see ``ContextMenuTabWidget.borrow_views``) in a +grid, a vertical stack, or a horizontal row. It owns no picker data -- the tab +widget stays the single source of truth -- it only re-parents the live views for +display and hands them back on demand. + +The views are laid out in nested ``QSplitter``s, so the dividers are minimal and +draggable and the user configures each picker's relative space; clicking into a +view marks it the active picker so per-view actions (fit, add-item) have an +unambiguous target. The grid row/column count comes from the Qt-free +``tiled_layout.grid_shape``. +""" + +from mgear.core import pyqt +from mgear.vendor.Qt import QtCore +from mgear.vendor.Qt import QtWidgets + +from mgear.anim_picker.tab_widget import tab_data_entry +from mgear.anim_picker.widgets.tiled_layout import grid_shape + + +# Tiled layout modes; the area adds "tabbed" (the default single-tab view). +MODE_TABBED = "tabbed" +MODE_GRID = "grid" +MODE_VERTICAL = "vertical" +MODE_HORIZONTAL = "horizontal" +TILED_MODES = (MODE_GRID, MODE_VERTICAL, MODE_HORIZONTAL) + + +class TiledPickerView(QtWidgets.QWidget): + """Tiles borrowed picker views (grid / vertical / horizontal). + + The views are laid out in nested ``QSplitter``s so the dividers between + sections are minimal and draggable -- the user resizes each picker's + relative space directly. No per-cell title/frame is drawn (the views fill + the splitter cells edge to edge). + """ + + # Minimal divider thickness between sections (px, dpi-scaled at use). + _HANDLE = 3 + + def __init__(self, area=None, parent=None): + super(TiledPickerView, self).__init__(parent) + self.area = area + # Borrowed (name, view) pairs on display, the active view, the layout + # mode, the grid column override (None = square), and the root splitter. + self.pairs = [] + self.active = None + self.mode = MODE_GRID + self.columns = None + self._root = None + # Maps each view's viewport to its view so a click can set the active + # cell before the view handles the event. + self._viewport_to_view = {} + + self.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + self._layout = QtWidgets.QVBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + + # ------------------------------------------------------------------ + def set_views(self, pairs=None, mode=MODE_GRID, columns=None): + """Tile the views in ``mode``; ``pairs`` None re-tiles the current set. + + Args: + pairs (list, optional): borrowed ``(name, view)`` pairs to display; + None keeps the already-borrowed pairs (a re-layout). + mode (str): grid / vertical / horizontal. + columns (int, optional): grid column override (None = near-square). + """ + if pairs is not None: + self.pairs = list(pairs) + self.mode = mode + self.columns = columns + # Keep the active view if it is still present, else default to first. + views = [view for _name, view in self.pairs] + if self.active not in views: + self.active = views[0] if views else None + self._rebuild() + + def take_views(self): + """Detach and return the borrowed ``(name, view)`` pairs (for restore). + + The views are re-parented out of the splitters so they survive the + teardown; the caller (the area) hands them back to the tab widget. + """ + pairs = list(self.pairs) + self._detach_views() + self.pairs = [] + return pairs + + def views(self): + """Return the borrowed views in display order.""" + return [view for _name, view in self.pairs] + + def active_view(self): + """Return the active picker view (fallback: first, or None).""" + if self.active is not None: + return self.active + return self.pairs[0][1] if self.pairs else None + + def fit_contents(self): + """Fit every tiled view to its cell.""" + for _name, view in self.pairs: + view.fit_scene_content() + + # ------------------------------------------------------------------ + def _columns_for_mode(self): + """Return the fixed column count for the current mode (None = square).""" + count = len(self.pairs) + if self.mode == MODE_VERTICAL: + return 1 + if self.mode == MODE_HORIZONTAL: + return max(1, count) + return self.columns + + def _detach_views(self): + """Re-parent views out and drop the splitters, keeping views alive.""" + # Stop watching the viewports before the views leave (so a returned + # view no longer routes clicks back into this tiled view). + for viewport in self._viewport_to_view: + viewport.removeEventFilter(self) + self._viewport_to_view = {} + for _name, view in self.pairs: + view.setParent(None) + if self._root is not None: + self._root.setParent(None) + self._root.deleteLater() + self._root = None + + def _new_splitter(self, orientation): + """Return a minimal, non-collapsible splitter.""" + splitter = QtWidgets.QSplitter(orientation) + splitter.setHandleWidth(pyqt.dpi_scale(self._HANDLE)) + splitter.setChildrenCollapsible(False) + return splitter + + def _add_view(self, splitter, view): + """Add one borrowed view to ``splitter`` (shown + click-watched).""" + view.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding + ) + view.setMinimumSize(pyqt.dpi_scale(60), pyqt.dpi_scale(60)) + splitter.addWidget(view) + # ``removeTab`` (used to borrow it) leaves the page hidden; re-show it. + view.show() + viewport = view.viewport() + self._viewport_to_view[viewport] = view + viewport.installEventFilter(self) + + def _rebuild(self): + """Rebuild the nested splitters from the current pairs / mode.""" + self._detach_views() + rows, cols = grid_shape(len(self.pairs), self._columns_for_mode()) + if not cols: + return + + if self.mode == MODE_HORIZONTAL: + root = self._new_splitter(QtCore.Qt.Horizontal) + for _name, view in self.pairs: + self._add_view(root, view) + root.setSizes([1000] * root.count()) + elif self.mode == MODE_VERTICAL: + root = self._new_splitter(QtCore.Qt.Vertical) + for _name, view in self.pairs: + self._add_view(root, view) + root.setSizes([1000] * root.count()) + else: + # Grid: a column of rows, each row a splitter of views. Both axes + # are draggable. + root = self._new_splitter(QtCore.Qt.Vertical) + for r in range(rows): + row = self._new_splitter(QtCore.Qt.Horizontal) + for c in range(cols): + index = r * cols + c + if index >= len(self.pairs): + break + self._add_view(row, self.pairs[index][1]) + row.setSizes([1000] * row.count()) + root.addWidget(row) + root.setSizes([1000] * root.count()) + + self._root = root + self._layout.addWidget(root) + + def _set_active(self, view): + """Track the active picker (used by ``active_view`` for per-view acts). + + The inline panel / tool commands are rebound by the view's own + ``_notify_item_selection`` on the same click, so nothing else fires + here; there is no per-cell header to highlight. + """ + self.active = view + + def eventFilter(self, obj, event): + """Set the active view when a tiled view's viewport is pressed.""" + if event.type() == QtCore.QEvent.MouseButtonPress: + view = self._viewport_to_view.get(obj) + if view is not None: + self._set_active(view) + return super(TiledPickerView, self).eventFilter(obj, event) + + +class PickerTabArea(QtWidgets.QWidget): + """Presents the picker tabs as either the tab widget or a tiled layout. + + The ``ContextMenuTabWidget`` remains the single owner of the views and the + serialization authority; this area only swaps between the tabbed + presentation and a ``TiledPickerView`` that borrows the live views. All + state-sensitive accessors (active view, all views, data, fit) are routed + here so callers work in either mode. + """ + + def __init__(self, tab_widget, main_window=None, parent=None): + super(PickerTabArea, self).__init__(parent) + self.tab_widget = tab_widget + self.main_window = main_window + self.mode = MODE_TABBED + self.grid_columns = None + + self.tiled = TiledPickerView(area=self) + + self.stack = QtWidgets.QStackedWidget() + self.stack.addWidget(self.tab_widget) + self.stack.addWidget(self.tiled) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.stack) + + # ------------------------------------------------------------------ + def set_view_mode(self, mode): + """Switch between "tabbed" and the tiled modes, moving views as needed. + + Args: + mode (str): ``tabbed`` / ``grid`` / ``vertical`` / ``horizontal``. + """ + if mode == self.mode: + return + was_tiled = self.mode in TILED_MODES + now_tiled = mode in TILED_MODES + + if not was_tiled and now_tiled: + # Tabbed -> tiled: borrow the views into the tiled view. + snapshot = self.tab_widget.borrow_views() + self.tiled.set_views(snapshot, mode, self.grid_columns) + self.stack.setCurrentWidget(self.tiled) + elif was_tiled and now_tiled: + # Tiled -> tiled: just re-lay the already-borrowed views. + self.tiled.set_views(mode=mode, columns=self.grid_columns) + elif was_tiled and not now_tiled: + # Tiled -> tabbed: hand the views back to the tab widget. + pairs = self.tiled.take_views() + self.tab_widget.restore_views(pairs) + self.stack.setCurrentWidget(self.tab_widget) + + self.mode = mode + + def ensure_tabbed(self): + """Return to the tabbed presentation (before any data operation).""" + if self.mode != MODE_TABBED: + self.set_view_mode(MODE_TABBED) + + def set_grid_columns(self, columns): + """Set the grid column override (None = near-square); re-tile if grid.""" + self.grid_columns = columns + if self.mode == MODE_GRID: + self.tiled.set_views(mode=MODE_GRID, columns=columns) + + # -- state-sensitive accessors (work in either mode) ---------------- + def active_view(self): + """Return the active picker view for per-view actions.""" + if self.mode == MODE_TABBED: + return self.tab_widget.currentWidget() + return self.tiled.active_view() + + def all_views(self): + """Return every picker view, wherever it currently lives.""" + if self.mode == MODE_TABBED: + return [ + self.tab_widget.widget(i) + for i in range(self.tab_widget.count()) + ] + return self.tiled.views() + + def get_data(self): + """Return the ordered tab data, built from the current presentation.""" + if self.mode == MODE_TABBED: + return self.tab_widget.get_data() + return [ + tab_data_entry(name, view) for name, view in self.tiled.pairs + ] + + def set_data(self, data): + """Load tab data (always into the tab widget).""" + self.ensure_tabbed() + self.tab_widget.set_data(data) + + def clear(self): + """Clear all tabs (in the tabbed presentation).""" + self.ensure_tabbed() + self.tab_widget.clear() + + def count(self): + """Return the number of picker views.""" + return len(self.all_views()) + + def fit_contents(self): + """Fit every view to its area.""" + if self.mode == MODE_TABBED: + self.tab_widget.fit_contents() + else: + self.tiled.fit_contents() + + def get_all_picker_items(self): + """Return picker items across every view.""" + items = [] + for view in self.all_views(): + items.extend(view.get_picker_items()) + return items + + def get_current_picker_items(self): + """Return picker items for the active view.""" + view = self.active_view() + return view.get_picker_items() if view is not None else [] diff --git a/releaseLog.rst b/releaseLog.rst index 0064fe7d..52909b4f 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Tab reorder + multi-tab view — tabs can now be **dragged to reorder** on the tab bar in edit mode (the right-click Move actions remain as a fallback), and the order saves exactly as before. A new **View** selector (Tabbed / Grid / Rows / Columns) in the character bar switches the picker area between the one-at-a-time tabbed view (default) and a **multi-tab tiled view** that shows every tab's picker at once — in a grid, a vertical stack, or a horizontal row — each cell a fully live, interactive picker (selecting controls / running actions works as usual). Clicking a cell marks it the active picker (highlighted header) so per-view actions (add item, fit) target it. The chosen mode + grid column count persist via user settings and are never written into the picker data, so the ``.pkr`` is unchanged and older pickers load identically * Anim Picker: Pinned / overlay HUD buttons — a picker item can be **pinned to the viewport** so it stays at a fixed screen position and constant size, ignoring canvas pan and zoom (e.g. a global reset, a space switch, a settings button locked to a corner). A pinned item is placed by a **3x3 anchor** (corners / edges / center) plus an inward pixel **offset**, and keeps all its normal button behavior (control selection, custom action, menus, color, text, shape). Set it from the inline panel's new **Pin** section (Pinned checkbox, 3x3 anchor picker, X / Y offset) or the tool strip's **Pin** quick command (anchors to the item's current screen region); in edit mode you can also drag a pinned item on the canvas to set its offset (the anchor snaps to the nearest region). Pinned items are excluded from the canvas' pan / zoom extent, so a HUD overlay never enlarges the scrollable scene or gets framed by "reset view". Persisted as additive optional item keys (``pinned`` / ``anchor`` / ``offset``), so older pickers load unchanged and non-pinned items are unaffected * Anim Picker: Mirror relationships + shape library — the left tool strip gains icon quick commands (Add, Duplicate, Duplicate & Mirror, Mirror Shape, Shapes) using bundled mGear icons, with the Select / Transform tools showing the default Maya tool icons. In edit mode, clicking a picker item now selects it immediately on press (so a single click-drag selects and moves it), while dragging a multi-selection keeps the whole group. Two items can be linked as a **persistent mirror pair** about a symmetry axis: editing one (move/scale/rotate on canvas or via the inline panel) live-mirrors its position, rotation, and shape to its partner **in realtime** (during the drag, not just on release), and the link is saved with the picker (additive optional ``id`` / ``mirror`` keys, so older pickers load unchanged). Linked items show a pink dotted outline so pairs are spottable at a glance. "Duplicate & Mirror" creates and links the pair; the panel's Mirror section links/unlinks, sets the axis, and snaps with Make Symmetric. Left/right coloring is now explicit via a **preset color palette** along the bottom (secondary/primary per left/center/right): clicking a swatch colors the selection and its mirror partner gets the opposite side at the same level; double / right-click edits a swatch (persisted, and it never repaints existing items). A new **shape library** (tool strip Shapes button or panel Shape > Shapes...) applies premade shapes (square, circle, triangle, diamond, pentagon, hexagon, octagon, star, arrow) to the selection and can save the current shape as a named custom shape (stored in the Maya user prefs dir, next to mGear_user_settings.ini), keeping the curve-import path * Core: Add a reusable ``PythonCodeEditor`` widget (mgear.core.pycodeeditor) — a modern Python editor with a line-number gutter, current-line highlight, Python syntax highlighting, a configurable monospace font (Consolas by default, family + size), spaces-or-tabs indentation with a configurable width, "show indentation" (visualize whitespace), convert-tabs-to-spaces, and smart Tab / Shift-Tab (block indent/unindent) + auto-indent on Enter; preferences persist via QSettings. Any mGear tool can embed it instead of a bare QTextEdit From 845d3815306a6c2d765bd95069b1d8b6f713c8bb Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Thu, 9 Jul 2026 20:25:10 +0900 Subject: [PATCH 11/25] AnimPicker: Widgets: Add interactive widgets, control trace, backdrops and align tools Interactive widgets: a picker item can be a checkbox, 1D slider or 2D slider bound to a Maya attribute and/or a per-state script (state/value exposed as __STATE__/__VALUE__/__X__/__Y__), reflecting the bound value on the existing selection-change refresh. New Qt-free widget_binding + safe attr binding handler; modern compact rendering (grooved track, fixed round handle/knob, upright checkbox); drag-to-canvas palette (Button/Checkbox/Slider/2D/Backdrop) with double-click-to-create; seeded "print value" default scripts; script editor documents the widget vars and focuses the code area. Control trace: a tool-strip Trace command builds one silhouette button per selected control (front/side/top convex-hull projection via Qt-free silhouette.py + OM2 shape-point extraction), colored from the control, centered on the viewport, auto-linking _L/_R pairs as mirrors (convertRLName). Backdrop containers: additive backdrop/title/corner_radius keys, a rounded or straight translucent panel drawn behind the buttons with a clipped title bar, that moves its geometrically-contained items together (nesting supported); inner nested backdrops stay selectable via smallest-under-cursor resolution. Align + distribute: a left-strip Align section (Qt-free alignment.py) aligns edges/centers and distributes by bounding box, fanning a stack of overlapping items into an evenly spaced row/column. Also: per-item text alignment (center/top/bottom/left/right + offset) and viewport-pinned items keep their apparent size. All persistence is additive optional keys, so older pickers load unchanged. --- release/icons/mgear_align_bottom.svg | 1 + release/icons/mgear_align_hcenter.svg | 1 + release/icons/mgear_align_left.svg | 1 + release/icons/mgear_align_right.svg | 1 + release/icons/mgear_align_top.svg | 1 + release/icons/mgear_align_vcenter.svg | 1 + release/icons/mgear_distribute_h.svg | 1 + release/icons/mgear_distribute_v.svg | 1 + release/icons/mgear_widget_backdrop.svg | 1 + release/icons/mgear_widget_button.svg | 1 + release/icons/mgear_widget_checkbox.svg | 1 + release/icons/mgear_widget_slider.svg | 1 + release/icons/mgear_widget_slider2d.svg | 1 + .../anim_picker/handlers/maya_handlers.py | 78 +++ .../anim_picker/handlers/widget_handlers.py | 151 ++++++ .../scripts/mgear/anim_picker/main_window.py | 146 ++++++ release/scripts/mgear/anim_picker/view.py | 300 +++++++++++- .../mgear/anim_picker/widgets/alignment.py | 120 +++++ .../widgets/dialogs/script_dialog.py | 17 + .../mgear/anim_picker/widgets/edit_panel.py | 389 +++++++++++++++ .../mgear/anim_picker/widgets/graphics.py | 371 +++++++++++++- .../mgear/anim_picker/widgets/item_model.py | 54 +++ .../mgear/anim_picker/widgets/picker_item.py | 451 +++++++++++++++++- .../mgear/anim_picker/widgets/silhouette.py | 137 ++++++ .../mgear/anim_picker/widgets/tool_bar.py | 133 ++++++ .../anim_picker/widgets/widget_binding.py | 223 +++++++++ releaseLog.rst | 1 + 27 files changed, 2567 insertions(+), 17 deletions(-) create mode 100644 release/icons/mgear_align_bottom.svg create mode 100644 release/icons/mgear_align_hcenter.svg create mode 100644 release/icons/mgear_align_left.svg create mode 100644 release/icons/mgear_align_right.svg create mode 100644 release/icons/mgear_align_top.svg create mode 100644 release/icons/mgear_align_vcenter.svg create mode 100644 release/icons/mgear_distribute_h.svg create mode 100644 release/icons/mgear_distribute_v.svg create mode 100644 release/icons/mgear_widget_backdrop.svg create mode 100644 release/icons/mgear_widget_button.svg create mode 100644 release/icons/mgear_widget_checkbox.svg create mode 100644 release/icons/mgear_widget_slider.svg create mode 100644 release/icons/mgear_widget_slider2d.svg create mode 100644 release/scripts/mgear/anim_picker/handlers/widget_handlers.py create mode 100644 release/scripts/mgear/anim_picker/widgets/alignment.py create mode 100644 release/scripts/mgear/anim_picker/widgets/silhouette.py create mode 100644 release/scripts/mgear/anim_picker/widgets/widget_binding.py diff --git a/release/icons/mgear_align_bottom.svg b/release/icons/mgear_align_bottom.svg new file mode 100644 index 00000000..cca92849 --- /dev/null +++ b/release/icons/mgear_align_bottom.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_align_hcenter.svg b/release/icons/mgear_align_hcenter.svg new file mode 100644 index 00000000..f8ea080d --- /dev/null +++ b/release/icons/mgear_align_hcenter.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_align_left.svg b/release/icons/mgear_align_left.svg new file mode 100644 index 00000000..514144db --- /dev/null +++ b/release/icons/mgear_align_left.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_align_right.svg b/release/icons/mgear_align_right.svg new file mode 100644 index 00000000..3bff3ebd --- /dev/null +++ b/release/icons/mgear_align_right.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_align_top.svg b/release/icons/mgear_align_top.svg new file mode 100644 index 00000000..919b678f --- /dev/null +++ b/release/icons/mgear_align_top.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_align_vcenter.svg b/release/icons/mgear_align_vcenter.svg new file mode 100644 index 00000000..52d9e3ac --- /dev/null +++ b/release/icons/mgear_align_vcenter.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_distribute_h.svg b/release/icons/mgear_distribute_h.svg new file mode 100644 index 00000000..4325c222 --- /dev/null +++ b/release/icons/mgear_distribute_h.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_distribute_v.svg b/release/icons/mgear_distribute_v.svg new file mode 100644 index 00000000..5c6b02ec --- /dev/null +++ b/release/icons/mgear_distribute_v.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_widget_backdrop.svg b/release/icons/mgear_widget_backdrop.svg new file mode 100644 index 00000000..f2cbbbf7 --- /dev/null +++ b/release/icons/mgear_widget_backdrop.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_widget_button.svg b/release/icons/mgear_widget_button.svg new file mode 100644 index 00000000..0e083e23 --- /dev/null +++ b/release/icons/mgear_widget_button.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_widget_checkbox.svg b/release/icons/mgear_widget_checkbox.svg new file mode 100644 index 00000000..95dee97d --- /dev/null +++ b/release/icons/mgear_widget_checkbox.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_widget_slider.svg b/release/icons/mgear_widget_slider.svg new file mode 100644 index 00000000..5631002c --- /dev/null +++ b/release/icons/mgear_widget_slider.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_widget_slider2d.svg b/release/icons/mgear_widget_slider2d.svg new file mode 100644 index 00000000..bd2c669b --- /dev/null +++ b/release/icons/mgear_widget_slider2d.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/handlers/maya_handlers.py b/release/scripts/mgear/anim_picker/handlers/maya_handlers.py index 5a8f4a26..43260404 100644 --- a/release/scripts/mgear/anim_picker/handlers/maya_handlers.py +++ b/release/scripts/mgear/anim_picker/handlers/maya_handlers.py @@ -145,6 +145,84 @@ def reset_node_attributes(node, attr="rigBindPose"): return True +def _shape_cv_points(shape): + """Return a shape's world-space CV / vertex points (empty on failure). + + Args: + shape (str): a nurbsCurve / nurbsSurface / mesh shape node. + + Returns: + list: ``(x, y, z)`` world-space points. + """ + sel = OpenMaya.MSelectionList() + try: + sel.add(shape) + dag = sel.getDagPath(0) + except Exception: + return [] + world = OpenMaya.MSpace.kWorld + node_type = cmds.nodeType(shape) + try: + if node_type == "nurbsCurve": + points = OpenMaya.MFnNurbsCurve(dag).cvPositions(world) + elif node_type == "nurbsSurface": + points = OpenMaya.MFnNurbsSurface(dag).cvPositions(world) + elif node_type == "mesh": + points = OpenMaya.MFnMesh(dag).getPoints(world) + else: + return [] + except Exception: + return [] + return [(point.x, point.y, point.z) for point in points] + + +def _bounding_box_points(node): + """Return the eight world-bounding-box corners of ``node`` (or empty).""" + if not cmds.objExists(node): + return [] + x0, y0, z0, x1, y1, z1 = cmds.exactWorldBoundingBox(node) + return [ + (x0, y0, z0), + (x1, y0, z0), + (x1, y1, z0), + (x0, y1, z0), + (x0, y0, z1), + (x1, y0, z1), + (x1, y1, z1), + (x0, y1, z1), + ] + + +def get_shape_points(node): + """Return a node's world-space shape points for the trace tool. + + Gathers NURBS curve / surface CVs or mesh vertices in world space via the + Maya API; when the node has no such shape (or the query fails) it falls + back to the eight corners of the world bounding box, so the trace never + fails on an unusual control. + + Args: + node (str): a transform or shape node name. + + Returns: + list: ``(x, y, z)`` world-space points (empty only for a bad node). + """ + if not cmds.objExists(node): + return [] + if cmds.nodeType(node) == "transform": + shapes = cmds.listRelatives(node, shapes=True, fullPath=True) or [] + else: + shapes = [node] + + points = [] + for shape in shapes: + points.extend(_shape_cv_points(shape)) + + if points: + return points + return _bounding_box_points(node) + + class SelectionCheck(object): def __init__(self): self.sel = OpenMaya.MSelectionList() diff --git a/release/scripts/mgear/anim_picker/handlers/widget_handlers.py b/release/scripts/mgear/anim_picker/handlers/widget_handlers.py new file mode 100644 index 00000000..73ef3684 --- /dev/null +++ b/release/scripts/mgear/anim_picker/handlers/widget_handlers.py @@ -0,0 +1,151 @@ +"""Maya attribute binding for interactive picker widgets. + +A widget item (checkbox / slider) can drive a Maya attribute. This module is +the single place that reads and writes those attributes safely: a missing, +locked, or incoming-connected attribute is skipped with a warning rather than +raising, so an interaction on a rig that has changed never throws. Slider drags +are wrapped in one undo chunk via :func:`undo_chunk` so a whole drag is a +single undo step. + +The pure value <-> range mapping lives in the Qt-free +``widgets.widget_binding`` module; this module only touches ``maya.cmds``. +""" + +from contextlib import contextmanager + +from maya import cmds + +import mgear + + +def resolve_attr(attr, namespace=None): + """Return ``attr`` with the item's namespace applied to its node token. + + Bindings store the attribute as ``node.attr`` without a namespace (like + control names), so the namespace is prefixed at runtime when the item has + one and the node is not already namespaced. + + Args: + attr (str): a ``node.attr`` string, or None / empty. + namespace (str, optional): namespace to prefix onto the node. + + Returns: + str: the resolved ``node.attr`` (unchanged when there is nothing to + do), or an empty string for an empty input. + """ + if not attr: + return "" + if not namespace or ":" in attr: + return attr + if "." not in attr: + return "{}:{}".format(namespace, attr) + node, _, plug = attr.partition(".") + return "{}:{}.{}".format(namespace, node, plug) + + +def is_settable(attr): + """Return True when ``attr`` exists and can be written. + + Guards the three cases that would make a widget write fail: the attribute + does not exist, it is locked, or it has an incoming connection (a driven / + constrained attribute). Any of these logs a warning and returns False. + + Args: + attr (str): a resolved ``node.attr`` string. + + Returns: + bool: True when a ``setAttr`` would succeed. + """ + if not attr or not cmds.objExists(attr): + mgear.log( + "anim_picker: widget attribute '{}' not found".format(attr), + mgear.sev_warning, + ) + return False + if cmds.getAttr(attr, lock=True): + mgear.log( + "anim_picker: widget attribute '{}' is locked".format(attr), + mgear.sev_warning, + ) + return False + if cmds.connectionInfo(attr, isDestination=True): + mgear.log( + "anim_picker: widget attribute '{}' is connected " + "(read-only)".format(attr), + mgear.sev_warning, + ) + return False + return True + + +def read_attr(attr): + """Return the value of ``attr``, or None when it cannot be read. + + A missing attribute returns None silently (the caller falls back to a + default); it is not an error to read a widget whose target is absent. + + Args: + attr (str): a resolved ``node.attr`` string. + + Returns: + The attribute value, or None. + """ + if not attr or not cmds.objExists(attr): + return None + try: + return cmds.getAttr(attr) + except Exception: + return None + + +def write_attr(attr, value): + """Set ``attr`` to ``value`` when it is settable; never raise. + + Args: + attr (str): a resolved ``node.attr`` string. + value: the value to set. + + Returns: + bool: True when the attribute was written. + """ + if not is_settable(attr): + return False + try: + cmds.setAttr(attr, value) + return True + except Exception as exc: + mgear.log( + "anim_picker: failed to set '{}' -> {} ({})".format( + attr, value, exc + ), + mgear.sev_warning, + ) + return False + + +def toggle_attr(attr): + """Flip a boolean ``attr`` and return the new state, or None on failure. + + Args: + attr (str): a resolved ``node.attr`` string. + + Returns: + bool: the new state, or None when the attribute could not be toggled. + """ + current = read_attr(attr) + if current is None: + return None + new_state = not bool(current) + if write_attr(attr, new_state): + return new_state + return None + + +@contextmanager +def undo_chunk(): + """Group the wrapped Maya edits into a single undo step.""" + cmds.undoInfo(openChunk=True) + try: + yield + finally: + cmds.undoInfo(closeChunk=True) diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 43fedea5..1c81e87d 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -29,6 +29,8 @@ from mgear.anim_picker.widgets import basic from mgear.anim_picker.widgets import edit_panel from mgear.anim_picker.widgets import tool_bar +from mgear.anim_picker.widgets import widget_binding +from mgear.anim_picker.widgets import alignment from mgear.anim_picker.widgets import color_palette from mgear.anim_picker.widgets import tiled_view from mgear.anim_picker.widgets import overlay_widgets @@ -437,6 +439,82 @@ def add_tab_widget(self, name="default"): tool_bar.mgear_icon("mgear_map-pin"), ), ] + # Trace: one silhouette button per selected Maya control. Not gated on + # a picker selection (it reads the Maya selection); the drop-down picks + # the projection plane, a plain click uses Front. + trace_btn = self.left_toolbar.add_command( + "Trc", + "Trace a silhouette button per selected control (Front)", + partial(self._cmd_trace, "front"), + tool_bar.mgear_icon("mgear_camera"), + ) + trace_menu = QtWidgets.QMenu(trace_btn) + for label, plane in (("Front", "front"), ("Side", "side"), + ("Top", "top")): + trace_menu.addAction(label).triggered.connect( + partial(self._cmd_trace, plane) + ) + trace_btn.setMenu(trace_menu) + trace_btn.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup) + + # Align / distribute the current multi-selection. Two columns: the left + # column is horizontal ops (left / H-center / right / distribute-H), + # the right column vertical (top / V-center / bottom / distribute-V). + self.left_toolbar.add_separator() + self.left_toolbar.add_section_label("Align") + align_specs = ( + ("mgear_align_left", "Align left edges", + partial(self._align_selected, "left")), + ("mgear_align_top", "Align top edges", + partial(self._align_selected, "top")), + ("mgear_align_hcenter", "Align horizontal centers", + partial(self._align_selected, "hcenter")), + ("mgear_align_vcenter", "Align vertical centers", + partial(self._align_selected, "vcenter")), + ("mgear_align_right", "Align right edges", + partial(self._align_selected, "right")), + ("mgear_align_bottom", "Align bottom edges", + partial(self._align_selected, "bottom")), + ("mgear_distribute_h", "Distribute horizontally (3+ to redistribute)", + partial(self._distribute_selected, "h")), + ("mgear_distribute_v", "Distribute vertically (3+ to redistribute)", + partial(self._distribute_selected, "v")), + ) + self.left_toolbar.add_button_grid( + [ + (tooltip, callback, tool_bar.mgear_icon(icon)) + for icon, tooltip, callback in align_specs + ], + columns=2, + ) + + # Drag-to-canvas creation palette: drag a tile onto the canvas to create + # that item / widget at the drop position (the primary way to add). + self.left_toolbar.add_separator() + self.left_toolbar.add_section_label("Drag to add") + # Each tile drops to create at the drop point, or double-click to + # create at the canvas center (a backdrop double-click wraps the + # current selection). + palette_specs = ( + ("Btn", "Drag to add a button (double-click = center)", + widget_binding.WIDGET_BUTTON, "mgear_widget_button"), + ("Chk", "Drag to add a checkbox (double-click = center)", + widget_binding.WIDGET_CHECKBOX, "mgear_widget_checkbox"), + ("Sld", "Drag to add a slider (double-click = center)", + widget_binding.WIDGET_SLIDER, "mgear_widget_slider"), + ("2D", "Drag to add a 2D slider (double-click = center)", + widget_binding.WIDGET_SLIDER2D, "mgear_widget_slider2d"), + ("Bkd", "Drag to add a backdrop (double-click = wrap selection)", + tool_bar.BACKDROP_PAYLOAD, "mgear_widget_backdrop"), + ) + for label, tooltip, payload, icon in palette_specs: + self.left_toolbar.add_palette_item( + label, + tooltip, + payload, + tool_bar.mgear_icon(icon), + double_callback=partial(self._palette_create, payload), + ) canvas_row = QtWidgets.QHBoxLayout() canvas_row.setContentsMargins(0, 0, 0, 0) canvas_row.setSpacing(0) @@ -648,6 +726,74 @@ def _cmd_toggle_pin(self): view.viewport().update() self._after_command() + def _palette_create(self, payload): + """Create a palette item at the canvas center (double-click path). + + A backdrop wraps the current selection when one exists (auto-sized), + otherwise it (and every other tile) is created at the viewport center. + + Args: + payload (str): the palette payload (widget type / backdrop). + """ + view = self._current_view() + if view is None: + return + if payload == tool_bar.BACKDROP_PAYLOAD: + view.add_backdrop_item( + mouse_pos=view.get_center_pos(), + fit_items=self._selected_items() or None, + ) + else: + view.add_widget_item(payload, view.get_center_pos()) + self._after_command() + + def _item_scene_rect(self, item): + """Return an item's scene bounding box as ``(x0, y0, x1, y1)``.""" + rect = item.sceneBoundingRect() + return (rect.left(), rect.top(), rect.right(), rect.bottom()) + + def _apply_item_offsets(self, min_count, offsets_fn): + """Move the current selection by per-item ``(dx, dy)`` offsets. + + Args: + min_count (int): minimum selection size to act on. + offsets_fn (callable): ``rects -> [(dx, dy), ...]``. + """ + items = self._selected_items() + if len(items) < min_count: + return + rects = [self._item_scene_rect(item) for item in items] + for item, (dx, dy) in zip(items, offsets_fn(rects)): + item.setPos(item.x() + dx, item.y() + dy) + self._after_command() + + def _align_selected(self, mode): + """Align the selected items by ``mode`` (needs 2+).""" + self._apply_item_offsets( + 2, partial(alignment.align_offsets, mode=mode) + ) + + def _distribute_selected(self, axis): + """Distribute the selected items along ``axis`` by bounding box (2+). + + Spreads items with even edge gaps when they have room, and fans a + stack of overlapping items into a row / column. + """ + self._apply_item_offsets( + 2, partial(alignment.distribute_offsets, axis=axis) + ) + + def _cmd_trace(self, plane): + """Trace a silhouette button per selected control onto ``plane``. + + Args: + plane (str): projection plane ("front" / "side" / "top"). + """ + view = self._current_view() + if view is not None: + view.add_picker_item_trace(plane=plane) + self._after_command() + def apply_palette_color(self, side, level): """Apply a preset color to the selection, mirroring by side to partners. diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 61cc36e5..6e2d6a02 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -14,6 +14,7 @@ import mgear from mgear.core import attribute +from mgear.core import string from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets @@ -31,7 +32,19 @@ from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.widgets import mirror from mgear.anim_picker.widgets import overlay +from mgear.anim_picker.widgets import silhouette +from mgear.anim_picker.widgets import widget_binding from mgear.anim_picker.handlers import __EDIT_MODE__ +from mgear.anim_picker.handlers import maya_handlers + + +def _united_scene_rect(items): + """Return the union of ``items`` scene bounding rects (None when empty).""" + union = None + for item in items: + rect = item.sceneBoundingRect() + union = rect if union is None else union.united(rect) + return union class _LoadedBackground(object): @@ -86,6 +99,10 @@ def __init__(self, namespace=None, main_window=None): self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + # Accept drops from the left tool-strip palette (drag an item / widget + # tile onto the canvas to create it at the drop position, edit mode). + self.setAcceptDrops(True) + # Set background color brush = QtGui.QBrush(QtGui.QColor(70, 70, 70, 255)) self.setBackgroundBrush(brush) @@ -151,9 +168,9 @@ def mousePressEvent(self, event): and __EDIT_MODE__.get() ): transform = self.viewportTransform() - picker_at = self.scene().picker_at( - self.mapToScene(event.pos()), transform - ) + scene_pos = self.mapToScene(event.pos()) + picker_at = self.scene().picker_at(scene_pos, transform) + picker_at = self._resolve_backdrop_pick(picker_at, scene_pos) if picker_at and self._select_on_press(picker_at, event): self.modified_select = True QtWidgets.QGraphicsView.mousePressEvent(self, event) @@ -163,7 +180,8 @@ def mousePressEvent(self, event): transform = self.viewportTransform() scene_pos = self.mapToScene(event.pos()) # Clear selection if no picker item below mouse - picker_at = self.scene().picker_at(scene_pos, transform) or [] + picker_at = self.scene().picker_at(scene_pos, transform) + picker_at = self._resolve_backdrop_pick(picker_at, scene_pos) or [] if picker_at: if __EDIT_MODE__.get(): self.item_selected = True @@ -706,6 +724,156 @@ def add_picker_item_gui(self, mouse_pos=None): self.scene().select_picker_items([ctrl]) return ctrl + def add_widget_item(self, widget_type, mouse_pos=None): + """Create a picker item of ``widget_type`` at ``mouse_pos``. + + Used by the left-strip drag-and-drop palette. An interactive widget + (checkbox / slider / slider2d) is created with a type-appropriate + default shape, a default binding range, and seeded "just print" + scripts; a plain button is created with the generic item shape. + + Args: + widget_type (str): a value from ``widget_binding.WIDGET_TYPES``. + mouse_pos (QPointF, optional): scene position for the new item. + + Returns: + PickerItem: the created (and selected) item. + """ + if widget_type == tool_bar.BACKDROP_PAYLOAD: + return self.add_backdrop_item(mouse_pos) + ctrl = self.add_picker_item() + if mouse_pos is not None: + ctrl.setPos(mouse_pos) + if widget_binding.is_interactive(widget_type): + # set_widget_type seeds the default binding + scripts, so only the + # type and the type-appropriate shape are supplied here. + data = {"widget": widget_type} + handles = widget_binding.default_handles(widget_type) + if handles: + data["handles"] = handles + ctrl.set_data(data) + self.scene().select_picker_items([ctrl]) + return ctrl + + def add_backdrop_item(self, mouse_pos=None, fit_items=None): + """Create a backdrop container, sent behind the picker items. + + Args: + mouse_pos (QPointF, optional): scene position for the backdrop + (ignored when ``fit_items`` is given). + fit_items (list, optional): items to enclose; the backdrop is sized + and placed to wrap them (with padding). When omitted a small + default backdrop is created so it does not swallow items by + accident. + + Returns: + PickerItem: the created (and selected) backdrop. + """ + ctrl = self.add_picker_item() + data = { + "backdrop": True, + "title": "Backdrop", + "corner_radius": 8.0, + "color": (70, 80, 110, 80), + } + if fit_items: + union = _united_scene_rect(fit_items) + pad = 24.0 + half_w = union.width() / 2.0 + pad + half_h = union.height() / 2.0 + pad + data["handles"] = [ + [-half_w, -half_h], + [half_w, -half_h], + [half_w, half_h], + [-half_w, half_h], + ] + data["position"] = [union.center().x(), union.center().y()] + else: + if mouse_pos is not None: + ctrl.setPos(mouse_pos) + # A modest default so a dropped backdrop does not enclose nearby + # items unintentionally. + data["handles"] = [[-70, -45], [70, -45], [70, 45], [-70, 45]] + ctrl.set_data(data) + # Backdrops sit behind the picker items so the buttons stay clickable. + # Nested-backdrop *selection* is resolved geometrically in + # ``_resolve_backdrop_pick`` (smallest under the cursor wins), so no + # area-based z-restack is needed here. + ctrl.move_to_back() + self.scene().select_picker_items([ctrl]) + return ctrl + + def _resolve_backdrop_pick(self, picker_at, scene_pos): + """Prefer the smallest backdrop under the cursor (innermost nested one). + + A normal button drawn over the backdrops wins as usual (``picker_at`` + is returned unchanged). But when the pick is a backdrop, the + smallest-area backdrop whose rectangle contains the point is chosen, so + an inner nested backdrop is always selectable regardless of z-order. + + Args: + picker_at (PickerItem): the item the scene hit-test returned. + scene_pos (QPointF): the click position in scene coordinates. + + Returns: + PickerItem: the resolved item (or the input when not a backdrop). + """ + if picker_at is None or not getattr(picker_at, "backdrop", False): + return picker_at + best = picker_at + best_area = self._backdrop_area(best) + for item in self.get_picker_items(): + if item is best or not item.backdrop: + continue + rect = item.sceneBoundingRect() + if rect.contains(scene_pos): + area = rect.width() * rect.height() + if area < best_area: + best = item + best_area = area + return best + + @staticmethod + def _backdrop_area(item): + """Return an item's scene bounding-box area.""" + rect = item.sceneBoundingRect() + return rect.width() * rect.height() + + def _is_palette_drag(self, event): + """Return True when ``event`` is a palette-tile drag we accept.""" + return __EDIT_MODE__.get() and event.mimeData().hasFormat( + tool_bar.WIDGET_MIME + ) + + def dragEnterEvent(self, event): + """Accept a palette drag (widget/item tile) in edit mode.""" + if self._is_palette_drag(event): + event.acceptProposedAction() + else: + QtWidgets.QGraphicsView.dragEnterEvent(self, event) + + def dragMoveEvent(self, event): + """Keep accepting the palette drag while it hovers the canvas.""" + if self._is_palette_drag(event): + event.acceptProposedAction() + else: + QtWidgets.QGraphicsView.dragMoveEvent(self, event) + + def dropEvent(self, event): + """Create the dropped widget/item at the drop position (edit mode).""" + if not self._is_palette_drag(event): + QtWidgets.QGraphicsView.dropEvent(self, event) + return + mime = event.mimeData() + widget_type = bytes(mime.data(tool_bar.WIDGET_MIME)).decode("utf-8") + # Qt6 (Maya 2025+) drops pos() in favor of position(); support both. + try: + view_pos = event.position().toPoint() + except AttributeError: + view_pos = event.pos() + self.add_widget_item(widget_type, self.mapToScene(view_pos)) + event.acceptProposedAction() + def add_picker_item_selected(self, mouse_pos=None): """Add new PickerItem to current view""" ctrl = self.add_picker_item() @@ -749,6 +917,130 @@ def add_picker_item_per_selected(self, mouse_pos=None): return created_ctrls + def add_picker_item_trace(self, plane="front", mouse_pos=None): + """Create one silhouette button per selected control (trace tool). + + For each selected control the world-space shape points are projected + onto ``plane`` (front / side / top), reduced to a 2D convex hull, and + scaled -- with a single shared factor so the traced set keeps the rig's + proportions (auto-fit) -- into a picker button placed at the control's + projected position and colored from the control. + + Args: + plane (str): projection plane (``silhouette.PLANE_*``). + mouse_pos (QPointF, optional): canvas anchor for the traced set. + + Returns: + list: the created PickerItem instances. + """ + # Target canvas span (pixels) the whole traced set is auto-fit into. + target_span = 400.0 + + selection = cmds.ls(sl=True) or [] + if not selection: + return [] + + # First pass: extract + project + hull each control, and collect the + # global bounds so one scale keeps the set's proportions. + traced = [] + all_points = [] + for ctrl in selection: + points_3d = maya_handlers.get_shape_points(ctrl) + if not points_3d: + continue + hull = silhouette.convex_hull_2d( + silhouette.project_to_plane(points_3d, plane) + ) + if not hull: + continue + traced.append((ctrl, hull)) + all_points.extend(hull) + + if not traced: + return [] + + scale = silhouette.fit_scale( + silhouette.bounding_box(all_points), target_span + ) + centers = [silhouette.centroid(hull) for _ctrl, hull in traced] + + # Second pass: lay each button out at its scaled projected center + # (relative arrangement); the group is centered on the target below. + created = [] + item_by_ctrl = {} + for (ctrl, hull), (center_x, center_y) in zip(traced, centers): + handles = [ + [(hx - center_x) * scale, (hy - center_y) * scale] + for hx, hy in hull + ] + # A point / edge-on projection collapses to < 3 hull points; use a + # small box so the polygon shape stays valid and visible. + if len(handles) < 3: + handles = [[-8, -8], [8, -8], [8, 8], [-8, 8]] + item = self.add_picker_item() + item.set_data( + { + "controls": [ctrl], + "handles": handles, + "position": [center_x * scale, center_y * scale], + } + ) + item.set_color(color=self.get_color_picker_override(item, ctrl)) + item.set_selected_state(True) + created.append(item) + item_by_ctrl[ctrl] = item + + # Center the whole traced group on the target by its real bounding box + # (accounts for the button shapes, so it lands visually centered + # regardless of asymmetric silhouettes). + target = mouse_pos if mouse_pos is not None else self.get_center_pos() + self._center_items_on(created, target) + self._link_traced_mirror_pairs(item_by_ctrl) + return created + + def _center_items_on(self, items, target): + """Shift ``items`` as a group so their union bbox center is ``target``. + + Args: + items (list): PickerItems to move together. + target (QPointF): scene point the group's center should land on. + """ + union = _united_scene_rect(items) + if union is None: + return + dx = target.x() - union.center().x() + dy = target.y() - union.center().y() + for item in items: + item.setPos(item.x() + dx, item.y() + dy) + + def _link_traced_mirror_pairs(self, item_by_ctrl): + """Link traced L/R control pairs as mirror pairs. + + Detects mirror partners by the ``_L`` / ``_R`` naming convention (the + same ``convertRLName`` logic ``pickWalk`` uses); when both sides of a + pair were traced in this run, their buttons are linked so editing one + mirrors to the other. + + Args: + item_by_ctrl (dict): ``{control_name: PickerItem}`` traced this run. + """ + linked = set() + for ctrl, item in item_by_ctrl.items(): + if item in linked: + continue + try: + mirror_name = string.convertRLName(ctrl) + except Exception: + mirror_name = None + if not mirror_name or mirror_name == ctrl: + continue + partner = item_by_ctrl.get(mirror_name) + if partner is None or partner is item or partner in linked: + continue + self.link_mirror_pair(item, partner) + linked.add(item) + linked.add(partner) + def copy_event(self): """reset the clipboard and populate the list with picker data for paste""" global _CLIPBOARD diff --git a/release/scripts/mgear/anim_picker/widgets/alignment.py b/release/scripts/mgear/anim_picker/widgets/alignment.py new file mode 100644 index 00000000..53737ba7 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/alignment.py @@ -0,0 +1,120 @@ +"""Qt/Maya-free align + distribute math for multi-selected picker items. + +Given each item's scene bounding box as an ``(x0, y0, x1, y1)`` tuple (with +``x0 <= x1`` and ``y0 <= y1``), these pure functions return the per-item +``(dx, dy)`` offset to align an edge / center, or to evenly distribute the +items along an axis. The caller applies the offsets to the items. No Qt or +Maya dependency, so the geometry is unit-testable standalone (matching the +``overlay`` / ``silhouette`` pattern). + +Note: the picker view is Y-flipped (scene ``+y`` draws *up* on screen), so +``top`` aligns the maximum-y edges (visually highest) and ``bottom`` the +minimum-y edges. +""" + + +ALIGN_MODES = ( + "left", + "hcenter", + "right", + "top", + "vcenter", + "bottom", +) + + +def align_offsets(rects, mode): + """Return per-rect ``(dx, dy)`` offsets to align ``rects`` by ``mode``. + + Args: + rects (list): ``(x0, y0, x1, y1)`` scene bounding boxes. + mode (str): one of ``ALIGN_MODES``. + + Returns: + list: ``(dx, dy)`` per rect (only one axis is non-zero per mode). + """ + if not rects: + return [] + x0 = [r[0] for r in rects] + y0 = [r[1] for r in rects] + x1 = [r[2] for r in rects] + y1 = [r[3] for r in rects] + + if mode == "left": + target = min(x0) + return [(target - r[0], 0.0) for r in rects] + if mode == "right": + target = max(x1) + return [(target - r[2], 0.0) for r in rects] + if mode == "hcenter": + target = (min(x0) + max(x1)) / 2.0 + return [(target - (r[0] + r[2]) / 2.0, 0.0) for r in rects] + if mode == "top": # visually highest -> maximum y under the Y-flip + target = max(y1) + return [(0.0, target - r[3]) for r in rects] + if mode == "bottom": + target = min(y0) + return [(0.0, target - r[1]) for r in rects] + if mode == "vcenter": + target = (min(y0) + max(y1)) / 2.0 + return [(0.0, target - (r[1] + r[3]) / 2.0) for r in rects] + return [(0.0, 0.0) for _ in rects] + + +def distribute_offsets(rects, axis, gap=8.0): + """Return per-rect ``(dx, dy)`` to lay ``rects`` out with even edge gaps. + + Works from the bounding boxes (sizes), not just the centers, so it also + spreads items that are stacked on top of one another: + + - When the items span more room than their combined size, the outer edges + of the extremes stay put and the items are re-spaced so the **gaps + between their edges are equal** (a true "distribute spacing"). + - When the items overlap / are stacked (no room to spread within their + current span), they are packed edge-to-edge with a fixed ``gap`` and the + run is centered on the cluster, so a pile of items fans out into a neat + row / column. + + Args: + rects (list): ``(x0, y0, x1, y1)`` scene bounding boxes. + axis (str): ``"h"`` (horizontal) or ``"v"`` (vertical). + gap (float): fallback edge gap (pixels) used when packing stacked items. + + Returns: + list: ``(dx, dy)`` per rect (identity offsets when fewer than 2 rects). + """ + count = len(rects) + if count < 2: + return [(0.0, 0.0)] * count + + # Axis-aligned min / max edge indices in the (x0, y0, x1, y1) tuple. + lo_index, hi_index = (0, 2) if axis == "h" else (1, 3) + los = [r[lo_index] for r in rects] + his = [r[hi_index] for r in rects] + sizes = [his[i] - los[i] for i in range(count)] + centers = [(los[i] + his[i]) / 2.0 for i in range(count)] + + order = sorted(range(count), key=lambda i: centers[i]) + total_size = sum(sizes) + extent_lo = min(los) + extent_hi = max(his) + free = (extent_hi - extent_lo) - total_size + + if free > 0: + # Room to spread: equal edge gaps within the current extent, keeping + # the leftmost / rightmost outer edges fixed. + use_gap = free / (count - 1) + cursor = extent_lo + else: + # Stacked / overlapping: pack edge-to-edge with a fixed gap, centered + # on the cluster so the pile fans out symmetrically. + use_gap = gap + run = total_size + use_gap * (count - 1) + cursor = (extent_lo + extent_hi) / 2.0 - run / 2.0 + + offsets = [(0.0, 0.0)] * count + for index in order: + delta = cursor - los[index] + offsets[index] = (delta, 0.0) if axis == "h" else (0.0, delta) + cursor += sizes[index] + use_gap + return offsets diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py index 3c344acb..935a1d1e 100644 --- a/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py @@ -20,6 +20,18 @@ # __NAMESPACE__ for current picker namespace # __INIT__ use 'if not' statement to avoid code execution on creation. # __SELF__ to get access to the PickerItem() instace. (Change color, size, etc) +# +# Interactive widget variables (checkbox / slider items): +# __STATE__ bool -> checkbox on/off state (in the on/off scripts). +# __VALUE__ float -> 1D slider value, mapped to the binding's min..max range. +# __X__ / __Y__ -> 2D slider X / Y values, each mapped to its own range. +# +# Example (checkbox on script): +# print("checkbox is now", __STATE__) +# Example (1D slider value script): +# import maya.cmds as cmds +# for ctl in __FLATCONTROLS__: +# cmds.setAttr(ctl + ".myAttr", __VALUE__) """ @@ -82,6 +94,11 @@ def setup(self): self.resize(500, 600) + def showEvent(self, event): + """Focus the code editor on show (not the font/toolbar widgets).""" + QtWidgets.QDialog.showEvent(self, event) + self.cmd_widget.setFocus(QtCore.Qt.OtherFocusReason) + def _build_menu_bar(self): """Build the File / Edit / View menu bar bound to the editor.""" editor = self.cmd_widget diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index 1c0b8c2d..729aefb7 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -24,6 +24,8 @@ from mgear.anim_picker.widgets import basic from mgear.anim_picker.widgets import overlay +from mgear.anim_picker.widgets import graphics +from mgear.anim_picker.widgets import widget_binding from mgear.anim_picker.widgets.dialogs.handles_window import ( HandlesPositionWindow, ) @@ -51,6 +53,14 @@ class ItemEditPanel(QtWidgets.QWidget): MIXED_INT = -1000000 MIXED_TEXT = "-" + # Widget type combo order (index -> widget_binding type). + _WIDGET_TYPE_ORDER = ( + widget_binding.WIDGET_BUTTON, + widget_binding.WIDGET_CHECKBOX, + widget_binding.WIDGET_SLIDER, + widget_binding.WIDGET_SLIDER2D, + ) + def __init__(self, parent=None, main_window=None): super().__init__(parent=parent) self.main_window = main_window @@ -76,6 +86,8 @@ def __init__(self, parent=None, main_window=None): self.text_size_sb = None self.text_color_button = None self.text_alpha_sb = None + self.text_align_combo = None + self.text_offset_sb = None self.count_sb = None self.handles_cb = None self.control_list = None @@ -88,6 +100,24 @@ def __init__(self, parent=None, main_window=None): self.anchor_buttons = {} self.pin_off_x_sb = None self.pin_off_y_sb = None + self.widget_type_combo = None + self.widget_attr_field = None + self.widget_min_sb = None + self.widget_max_sb = None + self.widget_orient_combo = None + self.widget_attr_x_field = None + self.widget_min_x_sb = None + self.widget_max_x_sb = None + self.widget_attr_y_field = None + self.widget_min_y_sb = None + self.widget_max_y_sb = None + self.widget_recenter_cb = None + self._wx_attr_row = None + self._wx_checkbox_box = None + self._wx_slider_box = None + self._wx_2d_box = None + self.backdrop_title_field = None + self.backdrop_radius_sb = None self._build_ui() self.refresh_fields() @@ -121,6 +151,8 @@ def _build_ui(self): self._build_mirror_section() self._build_controls_section() self._build_action_section() + self._build_widget_section() + self._build_backdrop_section() self.content_layout.addStretch() def _add_section(self, title): @@ -224,6 +256,26 @@ def _build_appearance_section(self): self._apply_text_size, maximum=100.0, step=0.1 ) text_form.addRow("Text size", self.text_size_sb) + + self.text_align_combo = QtWidgets.QComboBox() + self.text_align_combo.addItems( + ["Center", "Top", "Bottom", "Left", "Right"] + ) + self.text_align_combo.setToolTip( + "Text placement relative to the item (edge alignments sit just " + "outside so the text does not overlap the button)" + ) + self.text_align_combo.currentIndexChanged.connect( + self._apply_text_align + ) + self._fields.append(self.text_align_combo) + text_form.addRow("Text align", self.text_align_combo) + + self.text_offset_sb = self._double_spin( + self._apply_text_offset, maximum=500.0, step=1.0 + ) + self.text_offset_sb.setToolTip("Gap in pixels from the aligned edge") + text_form.addRow("Text offset", self.text_offset_sb) section.addLayout(text_form) tcolor_row = QtWidgets.QHBoxLayout() @@ -431,6 +483,162 @@ def _build_action_section(self): menu_row.addWidget(del_menu) section.addLayout(menu_row) + def _binding_spin(self): + """Return a framework callback double spin that applies on change. + + Uses ``basic.CallBackDoubleSpinBox`` (like ``_double_spin``) rather than + a raw ``QDoubleSpinBox``; binding fields follow the active item, so the + mixed-value sentinel of ``_double_spin`` is intentionally not used here. + """ + spin = basic.CallBackDoubleSpinBox( + callback=self._apply_binding, value=0.0, min=-1.0e6, max=1.0e6 + ) + spin.setDecimals(3) + self._fields.append(spin) + return spin + + def _binding_attr_field(self, placeholder): + """Return a line edit that applies the binding when editing finishes.""" + field = QtWidgets.QLineEdit() + field.setPlaceholderText(placeholder) + field.setToolTip( + "Enter node.attribute without a namespace; the picker's active " + "namespace is applied automatically (like control names). A " + "namespace you type explicitly is kept as-is." + ) + field.editingFinished.connect(self._apply_binding) + self._fields.append(field) + return field + + def _widget_script_button(self, label, key): + """Return a button that opens the script editor for a widget state.""" + button = basic.CallbackButton( + callback=partial(self._edit_widget_script, key) + ) + button.setText(label) + self._fields.append(button) + return button + + def _build_widget_section(self): + section = self._add_section("Widget") + + type_form = QtWidgets.QFormLayout() + self.widget_type_combo = QtWidgets.QComboBox() + self.widget_type_combo.addItems( + ["Button", "Checkbox", "Slider (1D)", "2D Slider"] + ) + self.widget_type_combo.setToolTip( + "Button selects controls / runs the action; the others drive a " + "bound attribute and/or a script" + ) + self.widget_type_combo.currentIndexChanged.connect( + self._apply_widget_type + ) + self._fields.append(self.widget_type_combo) + type_form.addRow("Type", self.widget_type_combo) + section.addLayout(type_form) + + # Shared attribute row (checkbox + 1D slider). + self._wx_attr_row = QtWidgets.QWidget() + attr_form = QtWidgets.QFormLayout(self._wx_attr_row) + attr_form.setContentsMargins(0, 0, 0, 0) + self.widget_attr_field = self._binding_attr_field("node.attribute") + attr_form.addRow("Attribute", self.widget_attr_field) + section.addWidget(self._wx_attr_row) + + # Checkbox on / off scripts. + self._wx_checkbox_box = QtWidgets.QWidget() + cb_row = QtWidgets.QHBoxLayout(self._wx_checkbox_box) + cb_row.setContentsMargins(0, 0, 0, 0) + cb_row.addWidget(self._widget_script_button("On Script...", "on")) + cb_row.addWidget(self._widget_script_button("Off Script...", "off")) + section.addWidget(self._wx_checkbox_box) + + # 1D slider range + orientation + value script. + self._wx_slider_box = QtWidgets.QWidget() + slider_layout = QtWidgets.QVBoxLayout(self._wx_slider_box) + slider_layout.setContentsMargins(0, 0, 0, 0) + range_row = QtWidgets.QHBoxLayout() + range_row.addWidget(QtWidgets.QLabel("Min")) + self.widget_min_sb = self._binding_spin() + range_row.addWidget(self.widget_min_sb) + range_row.addWidget(QtWidgets.QLabel("Max")) + self.widget_max_sb = self._binding_spin() + range_row.addWidget(self.widget_max_sb) + slider_layout.addLayout(range_row) + orient_form = QtWidgets.QFormLayout() + self.widget_orient_combo = QtWidgets.QComboBox() + self.widget_orient_combo.addItems(["Horizontal", "Vertical"]) + self.widget_orient_combo.currentIndexChanged.connect( + self._apply_binding + ) + self._fields.append(self.widget_orient_combo) + orient_form.addRow("Orientation", self.widget_orient_combo) + slider_layout.addLayout(orient_form) + slider_layout.addWidget( + self._widget_script_button("Value Script...", "value") + ) + section.addWidget(self._wx_slider_box) + + # 2D slider: two attributes + ranges + recenter + script. + self._wx_2d_box = QtWidgets.QWidget() + twod_layout = QtWidgets.QVBoxLayout(self._wx_2d_box) + twod_layout.setContentsMargins(0, 0, 0, 0) + x_row = QtWidgets.QHBoxLayout() + x_row.addWidget(QtWidgets.QLabel("X")) + self.widget_attr_x_field = self._binding_attr_field("node.attrX") + x_row.addWidget(self.widget_attr_x_field) + self.widget_min_x_sb = self._binding_spin() + self.widget_max_x_sb = self._binding_spin() + x_row.addWidget(self.widget_min_x_sb) + x_row.addWidget(self.widget_max_x_sb) + twod_layout.addLayout(x_row) + y_row = QtWidgets.QHBoxLayout() + y_row.addWidget(QtWidgets.QLabel("Y")) + self.widget_attr_y_field = self._binding_attr_field("node.attrY") + y_row.addWidget(self.widget_attr_y_field) + self.widget_min_y_sb = self._binding_spin() + self.widget_max_y_sb = self._binding_spin() + y_row.addWidget(self.widget_min_y_sb) + y_row.addWidget(self.widget_max_y_sb) + twod_layout.addLayout(y_row) + self.widget_recenter_cb = QtWidgets.QCheckBox("Recenter on release") + self.widget_recenter_cb.clicked.connect(self._apply_binding) + self._fields.append(self.widget_recenter_cb) + twod_layout.addWidget(self.widget_recenter_cb) + twod_layout.addWidget( + self._widget_script_button("XY Script...", "xy") + ) + section.addWidget(self._wx_2d_box) + + def _build_backdrop_section(self): + section = self._add_section("Backdrop") + + form = QtWidgets.QFormLayout() + self.backdrop_title_field = QtWidgets.QLineEdit() + self.backdrop_title_field.setPlaceholderText("Backdrop title") + self.backdrop_title_field.editingFinished.connect( + self._apply_backdrop_title + ) + self._fields.append(self.backdrop_title_field) + form.addRow("Title", self.backdrop_title_field) + + self.backdrop_radius_sb = self._double_spin( + self._apply_backdrop_radius, maximum=80.0, step=1.0 + ) + self.backdrop_radius_sb.setToolTip( + "Corner radius; 0 = straight corners" + ) + form.addRow("Corners", self.backdrop_radius_sb) + section.addLayout(form) + + hint = QtWidgets.QLabel( + "Color + transparency are set in Appearance. Drag the backdrop to " + "move everything inside it together." + ) + hint.setWordWrap(True) + section.addWidget(hint) + # ------------------------------------------------------------------ # Selection binding # ------------------------------------------------------------------ @@ -505,6 +713,68 @@ def _populate_all(self): self._populate_mirror() self._populate_controls() self._populate_action() + self._populate_widget() + self._populate_backdrop() + + def _populate_backdrop(self): + item = self._active_item() + is_backdrop = item is not None and item.get_backdrop() + self.backdrop_title_field.blockSignals(True) + self.backdrop_title_field.setText( + item.get_backdrop_title() if is_backdrop else "" + ) + self.backdrop_title_field.blockSignals(False) + radius = item.get_corner_radius() if is_backdrop else 0.0 + self._set_spin(self.backdrop_radius_sb, round(radius, 4), False) + + def _update_widget_visibility(self, widget_type): + """Show only the sub-rows relevant to ``widget_type`` (None hides all).""" + self._wx_attr_row.setVisible( + widget_type + in (widget_binding.WIDGET_CHECKBOX, widget_binding.WIDGET_SLIDER) + ) + self._wx_checkbox_box.setVisible( + widget_type == widget_binding.WIDGET_CHECKBOX + ) + self._wx_slider_box.setVisible( + widget_type == widget_binding.WIDGET_SLIDER + ) + self._wx_2d_box.setVisible( + widget_type == widget_binding.WIDGET_SLIDER2D + ) + + def _populate_widget(self): + wtype, wtype_mixed = self._shared(lambda item: item.get_widget_type()) + self.widget_type_combo.blockSignals(True) + if wtype_mixed or wtype not in self._WIDGET_TYPE_ORDER: + self.widget_type_combo.setCurrentIndex(-1 if wtype_mixed else 0) + else: + self.widget_type_combo.setCurrentIndex( + self._WIDGET_TYPE_ORDER.index(wtype) + ) + self.widget_type_combo.blockSignals(False) + + # Binding fields follow the active item (like controls / menus); an + # edit applies to the whole selection via _apply_binding. + item = self._active_item() + binding = (item.get_binding() if item else None) or {} + self.widget_attr_field.setText(binding.get("attr", "")) + self.widget_min_sb.setValue(binding.get("min", 0.0)) + self.widget_max_sb.setValue(binding.get("max", 1.0)) + horizontal = ( + binding.get("orientation", widget_binding.ORIENT_HORIZONTAL) + == widget_binding.ORIENT_HORIZONTAL + ) + self.widget_orient_combo.setCurrentIndex(0 if horizontal else 1) + self.widget_attr_x_field.setText(binding.get("attr_x", "")) + self.widget_min_x_sb.setValue(binding.get("min_x", -1.0)) + self.widget_max_x_sb.setValue(binding.get("max_x", 1.0)) + self.widget_attr_y_field.setText(binding.get("attr_y", "")) + self.widget_min_y_sb.setValue(binding.get("min_y", -1.0)) + self.widget_max_y_sb.setValue(binding.get("max_y", 1.0)) + self.widget_recenter_cb.setChecked(bool(binding.get("recenter"))) + + self._update_widget_visibility(None if wtype_mixed else wtype) def _populate_pin(self): pinned, pinned_mixed = self._shared(lambda item: item.get_pinned()) @@ -613,6 +883,21 @@ def _populate_appearance(self): ) self._set_spin(self.text_alpha_sb, talpha, talpha_mixed) + align, align_mixed = self._shared(lambda item: item.get_text_align()) + self.text_align_combo.blockSignals(True) + if align_mixed or align not in graphics.TEXT_ALIGNS: + self.text_align_combo.setCurrentIndex(-1 if align_mixed else 0) + else: + self.text_align_combo.setCurrentIndex( + graphics.TEXT_ALIGNS.index(align) + ) + self.text_align_combo.blockSignals(False) + + offset, offset_mixed = self._shared( + lambda item: round(item.get_text_offset(), 4) + ) + self._set_spin(self.text_offset_sb, offset, offset_mixed) + def _populate_shape(self): count, count_mixed = self._shared(lambda item: item.point_count) self._set_spin(self.count_sb, count, count_mixed) @@ -803,6 +1088,27 @@ def _apply_text_alpha(self, *args, **kwargs): item.set_text_color(color) self._repaint_view() + def _apply_text_align(self, *args, **kwargs): + if self._syncing or not self.items: + return + index = self.text_align_combo.currentIndex() + if index < 0: + return + align = graphics.TEXT_ALIGNS[index] + for item in self.items: + item.set_text_align(align) + self._repaint_view() + + def _apply_text_offset(self, *args, **kwargs): + if self._syncing or not self.items: + return + offset = self._committed(self.text_offset_sb) + if offset is None: + return + for item in self.items: + item.set_text_offset(offset) + self._repaint_view() + # -- shape ---------------------------------------------------------- def _apply_show_handles(self, *args, **kwargs): if self._syncing or not self.items: @@ -931,6 +1237,89 @@ def _apply_offset(self, *args, **kwargs): self._view._update_pinned_items() self._view.viewport().update() + # -- widget --------------------------------------------------------- + def _apply_widget_type(self, *args, **kwargs): + if self._syncing or not self.items: + return + index = self.widget_type_combo.currentIndex() + if index < 0: + return + widget_type = self._WIDGET_TYPE_ORDER[index] + for item in self.items: + item.set_widget_type(widget_type) + self._update_widget_visibility(widget_type) + self._repaint_view() + # A fresh non-button widget gets a default binding; reflect it back. + self._guarded(self._populate_widget) + + def _collect_binding(self): + """Build a binding dict from the current field values.""" + orientation = ( + widget_binding.ORIENT_HORIZONTAL + if self.widget_orient_combo.currentIndex() == 0 + else widget_binding.ORIENT_VERTICAL + ) + return { + "attr": str(self.widget_attr_field.text()).strip(), + "min": self.widget_min_sb.value(), + "max": self.widget_max_sb.value(), + "orientation": orientation, + "attr_x": str(self.widget_attr_x_field.text()).strip(), + "min_x": self.widget_min_x_sb.value(), + "max_x": self.widget_max_x_sb.value(), + "attr_y": str(self.widget_attr_y_field.text()).strip(), + "min_y": self.widget_min_y_sb.value(), + "max_y": self.widget_max_y_sb.value(), + "recenter": self.widget_recenter_cb.isChecked(), + } + + def _apply_binding(self, *args, **kwargs): + if self._syncing or not self.items: + return + binding = self._collect_binding() + for item in self.items: + item.set_binding(binding) + self._repaint_view() + + def _edit_widget_script(self, key): + """Edit the widget script for ``key`` and apply it to the selection.""" + item = self._active_item() + if item is None: + return + # Seed an empty script with the per-state sample snippet so the editor + # opens with a documented, runnable example. + current = (item.get_widget_scripts() or {}).get( + key + ) or widget_binding.script_template(key) + cmd, ok = CustomScriptEditDialog.get(cmd=current, item=item) + if not (ok and cmd): + return + for target in self.items: + scripts = dict(target.get_widget_scripts() or {}) + scripts[key] = cmd + target.set_widget_scripts(scripts) + + # -- backdrop ------------------------------------------------------- + def _apply_backdrop_title(self, *args, **kwargs): + if self._syncing or not self.items: + return + title = str(self.backdrop_title_field.text()) + for item in self.items: + if item.get_backdrop(): + item.set_backdrop_title(title) + self._repaint_view() + + def _apply_backdrop_radius(self, *args, **kwargs): + if self._syncing or not self.items: + return + radius = self._committed(self.backdrop_radius_sb) + if radius is None: + return + for item in self.items: + if item.get_backdrop(): + item.set_corner_radius(radius) + self._repaint_view() + # -- controls ------------------------------------------------------- def _add_selected_controls(self): if not self.items: diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index 3dd8af59..bdacd8eb 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -8,6 +8,8 @@ from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets +from mgear.anim_picker.widgets import widget_binding + class DefaultPolygon(QtWidgets.QGraphicsObject): """Default polygon class, with move and hover support""" @@ -380,6 +382,318 @@ def setText(self, text): return QtWidgets.QGraphicsSimpleTextItem.setText(self, str(text)) +class WidgetGraphic(DefaultPolygon): + """Interactive-widget affordance drawn over a picker item. + + Draws the checkbox / slider / 2D-slider affordance from the parent + ``PickerItem``'s widget state, sized to the item's polygon. It is passive: + all interaction (click / drag) is handled in ``PickerItem``'s mouse events, + and this child accepts no mouse buttons so presses fall through to the + item. The display values (``checked`` / ``value`` / ``value_xy``) are set + by the item's widget refresh from the bound attribute(s). + """ + + # Modern flat palette (Fusion-like): a neutral body, a recessed groove, + # a blue accent for progress / knob, and a light handle. + _BODY = QtGui.QColor(58, 58, 58, 235) + _BODY_BORDER = QtGui.QColor(96, 96, 96, 255) + _SEL_BORDER = QtGui.QColor(240, 240, 240, 235) + _GROOVE = QtGui.QColor(34, 34, 34, 255) + _GROOVE_BORDER = QtGui.QColor(96, 96, 96, 255) + _ACCENT = QtGui.QColor(90, 150, 205, 255) + _HANDLE = QtGui.QColor(228, 228, 228, 255) + _CHECK = QtGui.QColor(120, 200, 120, 255) + + # Fixed pixel sizes so handles never stretch with the item. + _HANDLE_R = 6.0 + _GROOVE_T = 4.0 + + def __init__(self, parent=None): + DefaultPolygon.__init__(self, parent=parent) + # Display state, refreshed from the bound attribute(s) by the item. + self.checked = False + self.value = 0.0 # 1D slider, normalized 0..1 + self.value_xy = (0.5, 0.5) # 2D slider, normalized (x, y) + # Passive overlay: let presses fall through to the parent PickerItem. + self.setAcceptHoverEvents(False) + self.setAcceptedMouseButtons(QtCore.Qt.NoButton) + self.setVisible(False) + + def _item_rect(self): + """Return the parent's polygon bounding rect in local coordinates.""" + parent = self.parentItem() + if parent is None or getattr(parent, "polygon", None) is None: + return QtCore.QRectF(-10, -10, 20, 20) + return parent.polygon.shape().boundingRect() + + def boundingRect(self): + return self._item_rect() + + def shape(self): + path = QtGui.QPainterPath() + path.addRect(self._item_rect()) + return path + + def _widget_type(self): + parent = self.parentItem() + return getattr(parent, "widget_type", widget_binding.WIDGET_BUTTON) + + def _is_horizontal(self): + parent = self.parentItem() + binding = getattr(parent, "binding", None) or {} + orientation = binding.get( + "orientation", widget_binding.ORIENT_HORIZONTAL + ) + return orientation == widget_binding.ORIENT_HORIZONTAL + + def _selected(self): + """Return True when the parent item is selected (for the border).""" + parent = self.parentItem() + polygon = getattr(parent, "polygon", None) + return bool(polygon and getattr(polygon, "selected", False)) + + def paint(self, painter, options, widget=None): + """Paint the affordance for the parent item's widget type. + + A neutral rounded body is drawn first (so the widget reads as a proper + control regardless of the underlying polygon fill), then the + type-specific affordance on top. The rect is computed once and shared. + """ + wtype = self._widget_type() + if not widget_binding.is_interactive(wtype): + return + painter.setRenderHint(QtGui.QPainter.Antialiasing) + rect = self._item_rect() + self._paint_body(painter, rect) + if wtype == widget_binding.WIDGET_CHECKBOX: + self._paint_checkbox(painter, rect) + elif wtype == widget_binding.WIDGET_SLIDER: + self._paint_slider(painter, rect) + elif wtype == widget_binding.WIDGET_SLIDER2D: + self._paint_slider2d(painter, rect) + + def _paint_body(self, painter, rect): + """Draw the neutral rounded widget body with selection-aware border.""" + selected = self._selected() + pen = QtGui.QPen(self._SEL_BORDER if selected else self._BODY_BORDER) + pen.setWidthF(1.5 if selected else 1.0) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(self._BODY)) + painter.drawRoundedRect(rect.adjusted(1, 1, -1, -1), 3.0, 3.0) + + def _paint_checkbox(self, painter, rect): + """Draw a recessed box on the left with an upright check when on.""" + size = min(rect.height() - 6.0, 14.0) + if size < 6.0: + size = max(6.0, min(rect.height(), rect.width()) - 2.0) + box = QtCore.QRectF( + rect.left() + 4.0, rect.center().y() - size / 2.0, size, size + ) + pen = QtGui.QPen(self._GROOVE_BORDER) + pen.setWidthF(1.2) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(self._GROOVE)) + painter.drawRoundedRect(box, 2.0, 2.0) + if not self.checked: + return + # The view is Y-flipped, so counter-flip about the box center to draw + # the checkmark upright (a filled glyph would otherwise be inverted). + painter.save() + painter.translate(box.center()) + painter.scale(1.0, -1.0) + painter.translate(-box.center()) + pen = QtGui.QPen(self._CHECK) + pen.setWidthF(2.0) + pen.setCapStyle(QtCore.Qt.RoundCap) + pen.setJoinStyle(QtCore.Qt.RoundJoin) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + check = QtGui.QPainterPath() + check.moveTo(box.left() + size * 0.22, box.top() + size * 0.52) + check.lineTo(box.left() + size * 0.42, box.top() + size * 0.72) + check.lineTo(box.left() + size * 0.80, box.top() + size * 0.28) + painter.drawPath(check) + painter.restore() + + def _paint_slider(self, painter, rect): + """Draw a thin groove, an accent fill, and a fixed round handle.""" + horizontal = self._is_horizontal() + x0, x1, y0, y1 = widget_binding.track_bounds( + rect.left(), rect.right(), rect.top(), rect.bottom() + ) + half_t = self._GROOVE_T / 2.0 + if horizontal: + cy = rect.center().y() + groove = QtCore.QRectF(x0, cy - half_t, x1 - x0, self._GROOVE_T) + hx = x0 + self.value * (x1 - x0) + hy = cy + fill = QtCore.QRectF(x0, cy - half_t, hx - x0, self._GROOVE_T) + else: + cx = rect.center().x() + groove = QtCore.QRectF(cx - half_t, y0, self._GROOVE_T, y1 - y0) + # Higher numeric y is higher on screen (Y-flipped view) -> value up. + hy = y0 + self.value * (y1 - y0) + hx = cx + fill = QtCore.QRectF(cx - half_t, y0, self._GROOVE_T, hy - y0) + pen = QtGui.QPen(self._GROOVE_BORDER) + pen.setWidthF(1.0) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(self._GROOVE)) + painter.drawRoundedRect(groove, half_t, half_t) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QBrush(self._ACCENT)) + painter.drawRoundedRect(fill, half_t, half_t) + self._draw_knob(painter, hx, hy) + + def _paint_slider2d(self, painter, rect): + """Draw a faint crosshair pad and a fixed knob at (x, y).""" + x0, x1, y0, y1 = widget_binding.track_bounds( + rect.left(), rect.right(), rect.top(), rect.bottom() + ) + pen = QtGui.QPen(self._GROOVE_BORDER) + pen.setWidthF(0.8) + pen.setStyle(QtCore.Qt.DotLine) + painter.setPen(pen) + cx = (x0 + x1) / 2.0 + cy = (y0 + y1) / 2.0 + painter.drawLine(QtCore.QPointF(x0, cy), QtCore.QPointF(x1, cy)) + painter.drawLine(QtCore.QPointF(cx, y0), QtCore.QPointF(cx, y1)) + vx, vy = self.value_xy + self._draw_knob( + painter, x0 + vx * (x1 - x0), y0 + vy * (y1 - y0) + ) + + def _draw_knob(self, painter, x, y): + """Draw a fixed-size light knob with an accent ring at ``(x, y)``.""" + pen = QtGui.QPen(self._ACCENT) + pen.setWidthF(1.5) + painter.setPen(pen) + painter.setBrush(QtGui.QBrush(self._HANDLE)) + painter.drawEllipse(QtCore.QPointF(x, y), self._HANDLE_R, self._HANDLE_R) + + +class BackdropGraphic(DefaultPolygon): + """Backdrop container fill + title, drawn as the item's body. + + A backdrop item hides its plain polygon and shows this instead: a filled + rounded (or straight) rectangle in the item's color / alpha, a border, and + a title strip. It is passive (no mouse); the item owns selection and the + move-together behavior. Title / corner radius are set by the item. + """ + + _TITLE_H = 18.0 + + def __init__(self, parent=None): + DefaultPolygon.__init__(self, parent=parent) + self.title = "" + self.corner_radius = 8.0 + self.setAcceptHoverEvents(False) + self.setAcceptedMouseButtons(QtCore.Qt.NoButton) + self.setVisible(False) + + def _item_rect(self): + """Return the parent's polygon bounding rect in local coordinates.""" + parent = self.parentItem() + if parent is None or getattr(parent, "polygon", None) is None: + return QtCore.QRectF(-50, -35, 100, 70) + return parent.polygon.shape().boundingRect() + + def boundingRect(self): + return self._item_rect() + + def shape(self): + path = QtGui.QPainterPath() + path.addRect(self._item_rect()) + return path + + def _selected(self): + parent = self.parentItem() + polygon = getattr(parent, "polygon", None) + return bool(polygon and getattr(polygon, "selected", False)) + + def _fill_color(self): + parent = self.parentItem() + if parent is not None: + return parent.get_color() + return QtGui.QColor(70, 80, 110, 70) + + def paint(self, painter, options, widget=None): + """Draw the backdrop rectangle + optional title strip.""" + rect = self._item_rect() + painter.setRenderHint(QtGui.QPainter.Antialiasing) + color = self._fill_color() + painter.setBrush(QtGui.QBrush(color)) + if self._selected(): + pen = QtGui.QPen(QtGui.QColor(255, 255, 255, 230)) + pen.setWidthF(2.0) + else: + border = QtGui.QColor( + color.red(), + color.green(), + color.blue(), + min(255, color.alpha() + 90), + ) + pen = QtGui.QPen(border) + pen.setWidthF(1.5) + painter.setPen(pen) + radius = max(0.0, self.corner_radius) + if radius > 0: + painter.drawRoundedRect(rect, radius, radius) + else: + painter.drawRect(rect) + if self.title: + self._paint_title(painter, rect, color, radius) + + def _paint_title(self, painter, rect, color, radius): + """Draw a title strip along the item's screen-top edge.""" + # Screen-top edge is the maximum numeric y under the Y-flipped view. + strip = QtCore.QRectF( + rect.left(), + rect.bottom() - self._TITLE_H, + rect.width(), + self._TITLE_H, + ) + strip_color = QtGui.QColor( + color.red(), + color.green(), + color.blue(), + min(255, color.alpha() + 60), + ) + # Clip to the backdrop shape so the title bar follows the rounded top + # corners instead of overhanging them. + painter.save() + clip = QtGui.QPainterPath() + if radius > 0: + clip.addRoundedRect(rect, radius, radius) + else: + clip.addRect(rect) + painter.setClipPath(clip) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QBrush(strip_color)) + painter.drawRect(strip) + # Counter the Y-flip so the title reads upright. + painter.translate(strip.center()) + painter.scale(1.0, -1.0) + painter.translate(-strip.center()) + painter.setPen(QtGui.QPen(QtGui.QColor(235, 235, 235, 255))) + font = painter.font() + font.setPointSizeF(9.0) + painter.setFont(font) + painter.drawText( + strip.adjusted(6.0, 0.0, -6.0, 0.0), + QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, + self.title, + ) + painter.restore() + + +# Text placement relative to the item, plus an inward/outward gap. "center" +# keeps the legacy origin-centered behavior; the edge alignments place the text +# just outside that edge of the item so it does not overlap the button. +TEXT_ALIGN_CENTER = "center" +TEXT_ALIGNS = ("center", "top", "bottom", "left", "right") + + class GraphicText(QtWidgets.QGraphicsSimpleTextItem): """Picker item text element""" @@ -392,6 +706,10 @@ def __init__(self, parent=None, scene=None): self.scale_transform = QtGui.QTransform().scale(1, -1) self.setTransform(self.scale_transform) + # Placement relative to the item + a gap in pixels. + self.align = TEXT_ALIGN_CENTER + self.offset = 0.0 + # Init default size self.set_size() self.set_color(GraphicText.__DEFAULT_COLOR__) @@ -399,10 +717,10 @@ def __init__(self, parent=None, scene=None): def set_text(self, text): """ Set current text - (Will center text on parent too) + (Will reposition text on parent too) """ self.setText(text) - self.center_on_parent() + self._reposition() def get_text(self): """Return element text""" @@ -413,7 +731,20 @@ def set_size(self, value=10.0): font = self.font() font.setPointSizeF(value) self.setFont(font) - self.center_on_parent() + self._reposition() + + def set_alignment(self, align=None, offset=0.0): + """Set the text placement (``TEXT_ALIGNS``) + gap and reposition.""" + self.align = align if align in TEXT_ALIGNS else TEXT_ALIGN_CENTER + self.offset = offset or 0.0 + self._reposition() + + def _reposition(self): + """Reposition the text from the current alignment + offset.""" + if self.align == TEXT_ALIGN_CENTER: + self.center_on_parent() + else: + self.align_on_parent(self.align, self.offset) def get_size(self): """Return text pointSizeF""" @@ -444,4 +775,38 @@ def center_on_parent(self): scale_xy = QtCore.QPointF(center_pos.x(), center_pos.y() * -1) self.setPos(-scale_xy) + def align_on_parent(self, align, offset=0.0): + """Place the text just outside the item's ``align`` edge. + + Positions the text's visual center against the parent polygon's + bounding box: left / right beside it, top / bottom above / below it, + each pushed out by ``offset`` pixels so the text clears the button. The + Y-flipped view is accounted for (higher numeric y is higher on screen). + + Args: + align (str): one of ``TEXT_ALIGNS`` (non-center). + offset (float): gap in pixels between the text and the item edge. + """ + parent = self.parentItem() + if parent is None or getattr(parent, "polygon", None) is None: + self.center_on_parent() + return + rect = parent.polygon.shape().boundingRect() + text_center = self.boundingRect().center() + half_tw = self.boundingRect().width() / 2.0 + half_th = self.boundingRect().height() / 2.0 + cx = rect.center().x() + cy = rect.center().y() + if align == "left": + cx = rect.center().x() - rect.width() / 2.0 - half_tw - offset + elif align == "right": + cx = rect.center().x() + rect.width() / 2.0 + half_tw + offset + elif align == "top": + cy = rect.center().y() + rect.height() / 2.0 + half_th + offset + elif align == "bottom": + cy = rect.center().y() - rect.height() / 2.0 - half_th - offset + # Place the text's visual center at (cx, cy). The item's own scale(1,-1) + # maps its local center (tx, ty) to (tx, -ty), so pos = center - (tx,-ty). + self.setPos(cx - text_center.x(), cy + text_center.y()) + diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py index 07277811..768b878d 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_model.py +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -18,11 +18,20 @@ text str text_size float text_color RGBA tuple + text_align str (optional placement: center/top/bottom/left/right) + text_offset float (optional gap in pixels from the aligned edge) item_id str (optional stable id, minted when first mirror-linked) mirror_id str (optional mirror partner's item_id) pinned bool (optional; item locked to the viewport as a HUD overlay) anchor str (optional 3x3 viewport anchor code, e.g. "tl") offset [dx, dy] (optional inward pixel offset from the anchor) + pin_scale float (optional screen scale baked into the pin transform) + widget str (optional interactive type: checkbox/slider/slider2d) + binding dict (optional attribute(s) + range the widget drives) + scripts dict (optional per-state scripts for the widget) + backdrop bool (optional; item is a backdrop container behind others) + title str (optional backdrop title) + corner_radius float (optional backdrop corner radius; 0 = straight) """ @@ -41,11 +50,20 @@ def __init__(self): self.text = None self.text_size = None self.text_color = None + self.text_align = None + self.text_offset = None self.item_id = None self.mirror_id = None self.pinned = False self.anchor = None self.offset = None + self.pin_scale = None + self.widget = None + self.binding = None + self.scripts = None + self.backdrop = False + self.title = None + self.corner_radius = None @classmethod def from_dict(cls, data): @@ -81,6 +99,8 @@ def from_dict(cls, data): model.text_size = data.get("text_size") if "text_color" in data: model.text_color = tuple(data["text_color"]) + model.text_align = data.get("text_align") + model.text_offset = data.get("text_offset") if data.get("id"): model.item_id = data["id"] if data.get("mirror"): @@ -91,6 +111,15 @@ def from_dict(cls, data): model.offset = ( list(data["offset"]) if "offset" in data else None ) + model.pin_scale = data.get("pin_scale") + if data.get("widget"): + model.widget = data["widget"] + model.binding = dict(data["binding"]) if "binding" in data else None + model.scripts = dict(data["scripts"]) if "scripts" in data else None + if data.get("backdrop"): + model.backdrop = True + model.title = data.get("title") + model.corner_radius = data.get("corner_radius") return model @@ -123,6 +152,11 @@ def to_dict(self): data["text"] = self.text data["text_size"] = self.text_size data["text_color"] = self.text_color + # Additive optional text placement (absent for legacy centered text). + if self.text_align and self.text_align != "center": + data["text_align"] = self.text_align + if self.text_offset: + data["text_offset"] = self.text_offset # Mirror link (additive optional keys; absent when unlinked so old # readers and unlinked pickers are unaffected). @@ -139,5 +173,25 @@ def to_dict(self): data["anchor"] = self.anchor if self.offset is not None: data["offset"] = list(self.offset) + if self.pin_scale is not None: + data["pin_scale"] = self.pin_scale + + # Interactive widget (additive optional keys; emitted only for a + # non-button widget so old readers and plain buttons are unaffected). + if self.widget: + data["widget"] = self.widget + if self.binding: + data["binding"] = dict(self.binding) + if self.scripts: + data["scripts"] = dict(self.scripts) + + # Backdrop container (additive optional keys; only emitted for a + # backdrop so old readers and normal items are unaffected). + if self.backdrop: + data["backdrop"] = True + if self.title: + data["title"] = self.title + if self.corner_radius is not None: + data["corner_radius"] = self.corner_radius return data diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 0fae1978..5b0a30e0 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -22,6 +22,8 @@ from mgear.anim_picker.widgets.graphics import PointHandle from mgear.anim_picker.widgets.graphics import Polygon from mgear.anim_picker.widgets.graphics import GraphicText +from mgear.anim_picker.widgets.graphics import WidgetGraphic +from mgear.anim_picker.widgets.graphics import BackdropGraphic from mgear.anim_picker.widgets.dialogs.item_options import ItemOptionsWindow from mgear.anim_picker.widgets.dialogs.search_replace_dialog import ( SearchAndReplaceDialog, @@ -30,10 +32,12 @@ from mgear.anim_picker.widgets.item_model import PickerItemData from mgear.anim_picker.widgets import mirror from mgear.anim_picker.widgets import overlay +from mgear.anim_picker.widgets import widget_binding from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ from mgear.anim_picker.handlers import python_handlers from mgear.anim_picker.handlers import maya_handlers +from mgear.anim_picker.handlers import widget_handlers def select_picker_controls(picker_items, event, modifiers=None): @@ -89,6 +93,14 @@ def __init__( # Add polygon self.polygon = Polygon(parent=self) + # Backdrop container body (rounded / straight fill + title); replaces + # the plain polygon when the item is a backdrop, hidden otherwise. + self.backdrop_graphic = BackdropGraphic(parent=self) + + # Interactive-widget affordance (checkbox / slider); drawn above the + # polygon and below the text, hidden unless the item is a widget. + self.widget_graphic = WidgetGraphic(parent=self) + # Add text self.text = GraphicText(parent=self) @@ -120,6 +132,24 @@ def __init__( self.anchor = overlay.DEFAULT_ANCHOR self.offset = list(overlay.DEFAULT_OFFSET) self._pin_drag_delta = (0.0, 0.0) + # Screen scale baked into the pin transform so a pinned item keeps the + # apparent size it had when pinned (rather than snapping to 1:1, which + # looks tiny when the canvas was zoomed in). Captured at pin time and + # persisted so a reload restores the size. + self.pin_scale = 1.0 + + # Interactive widget (optional): a non-button ``widget_type`` makes the + # item a checkbox / slider bound to a Maya attribute (``binding``) and/ + # or per-state ``widget_scripts``. ``_widget_dragging`` is set while a + # slider drag is live so the drag is grouped into one undo chunk. + self.widget_type = widget_binding.WIDGET_BUTTON + self.binding = {} + self.widget_scripts = {} + self._widget_dragging = False + + # Backdrop container (optional): a large rectangle behind the picker + # items that moves everything geometrically inside it when dragged. + self.backdrop = False def shape(self): path = QtGui.QPainterPath() @@ -259,6 +289,10 @@ def mouseMoveEvent_offset(self, event): def mouseMoveEvent(self, event): gfx_event = event + # A live slider drag (animation mode) updates the bound value. + if self._widget_dragging: + self._widget_mouse_drag(event) + return if event.buttons() == QtCore.Qt.LeftButton and __EDIT_MODE__.get(): if self.currently_selected: [ @@ -290,6 +324,17 @@ def mousePressEvent(self, event): item.get_delta_from_point(event.scenePos()) for item in self.currently_selected ] + # Backdrop move-together: dragging a backdrop (the pressed item or + # any selected one) also drags every item whose center lies within + # its rectangle, so items visually inside it -- including a nested + # backdrop and its contents -- move as a group. + movers = [self] + self.currently_selected + for backdrop in [item for item in movers if item.backdrop]: + for item in backdrop.contained_items(): + if item is self or item in self.currently_selected: + continue + self.currently_selected.append(item) + item.get_delta_from_point(event.scenePos()) # Prime the offset drag for any pinned item in the drag set (the # view records a per-item grab delta so the anchor tracks the # cursor without jumping to the item's origin). @@ -302,6 +347,14 @@ def mousePressEvent(self, event): # Run selection on left mouse button event if event.buttons() == QtCore.Qt.LeftButton: + # Interactive widget (checkbox / slider) handles its own press and + # keeps the mouse grab for a drag; it is not a control selection. + # Accepting the press makes the item the mouse grabber so the + # slider drag receives the subsequent move / release events. + if self.is_widget(): + event.accept() + self._widget_mouse_press(event) + return # Run custom script action if self.get_custom_action_mode(): self.mouse_press_custom_action(event) @@ -314,6 +367,13 @@ def mousePressEvent(self, event): if maya_window: maya_window.setFocus() + def mouseReleaseEvent(self, event): + """Event called on mouse release (ends a live slider drag).""" + if self._widget_dragging: + self._widget_mouse_release(event) + return + super().mouseReleaseEvent(event) + def mouse_press_select_event(self, event, modifiers=None): """ Default select event on mouse press. @@ -549,6 +609,13 @@ def get_exec_env(self): env["__SELF__"] = self env["__INIT__"] = False + # Widget state vars (populated by widget interactions before exec; + # present as defaults so a script can always reference them). + env["__STATE__"] = None + env["__VALUE__"] = None + env["__X__"] = None + env["__Y__"] = None + return env def _get_custom_action_menus(self): @@ -658,6 +725,22 @@ def get_text_size(self): def set_text_size(self, size): self.text.set_size(size) + def get_text_align(self): + """Return the text placement (``center`` / top / bottom / left / right).""" + return self.text.align + + def set_text_align(self, align): + """Set the text placement, keeping the current offset.""" + self.text.set_alignment(align, self.text.offset) + + def get_text_offset(self): + """Return the text gap (pixels) from the aligned edge.""" + return self.text.offset + + def set_text_offset(self, offset): + """Set the text gap (pixels), keeping the current alignment.""" + self.text.set_alignment(self.text.align, offset) + # ========================================================================= # Scene Placement --- def move_to_front(self): @@ -722,27 +805,36 @@ def get_pinned(self): """Return True when the item is pinned to the viewport.""" return self.pinned - def set_pinned(self, state): + def set_pinned(self, state, capture_scale=True): """Pin / unpin the item to the viewport. A pinned item ignores the view transform so it draws at a constant - screen size (like the point handles), and a compensating vertical flip - is applied so its shape / text stay upright despite the Y-flipped view. - The view owns the item's *position* (see ``_update_pinned_items``); a - pinned item is not free-dragged in scene space, so it is made - non-movable while pinned. + screen size (like the point handles). To avoid the item snapping to its + tiny 1:1 size when pinned (jarring when the canvas is zoomed in), the + current view scale is baked into the transform so it keeps its apparent + size; a compensating vertical flip keeps it upright despite the + Y-flipped view. The view owns the item's *position* (see + ``_update_pinned_items``); a pinned item is repositioned by an + offset-drag rather than a free scene move, so it is made non-movable. Args: state (bool): pin when True, unpin when False. + capture_scale (bool, optional): when pinning, sample the current + view scale into ``pin_scale``; False keeps the existing value + (used on load so a saved size is restored, not re-sampled). """ self.pinned = bool(state) self.setFlag( QtWidgets.QGraphicsItem.ItemIgnoresTransformations, self.pinned ) - # Counter the view's scale(1, -1): with the view transform ignored the - # item would otherwise render vertically mirrored. if self.pinned: - self.setTransform(QtGui.QTransform().scale(1, -1)) + if capture_scale: + self.pin_scale = self._current_view_scale() + # scale(s, -s): s preserves the apparent size, -s counters the + # view's scale(1, -1) so the item is not vertically mirrored. + self.setTransform( + QtGui.QTransform().scale(self.pin_scale, -self.pin_scale) + ) else: self.setTransform(QtGui.QTransform()) if __EDIT_MODE__.get(): @@ -750,6 +842,26 @@ def set_pinned(self, state): QtWidgets.QGraphicsItem.ItemIsMovable, not self.pinned ) + def _current_view_scale(self): + """Return the view's current uniform scale (1.0 when unavailable).""" + view = self.parent() + if view is None or not hasattr(view, "viewportTransform"): + return 1.0 + scale = abs(view.viewportTransform().m11()) + return scale or 1.0 + + def get_pin_scale(self): + """Return the screen scale baked into the pin transform.""" + return self.pin_scale + + def set_pin_scale(self, scale): + """Set the pin transform scale (re-applies when currently pinned).""" + self.pin_scale = scale or 1.0 + if self.pinned: + self.setTransform( + QtGui.QTransform().scale(self.pin_scale, -self.pin_scale) + ) + def get_anchor(self): """Return the item's 3x3 viewport anchor code.""" return self.anchor @@ -766,6 +878,284 @@ def set_offset(self, offset): """Set the item's inward ``[dx, dy]`` pixel offset.""" self.offset = list(offset) + # ========================================================================= + # Interactive widget (checkbox / slider) --- + def is_widget(self): + """Return True when the item is an interactive (non-button) widget.""" + return widget_binding.is_interactive(self.widget_type) + + def get_widget_type(self): + """Return the item's widget type (button / checkbox / slider / ...).""" + return self.widget_type + + def set_widget_type(self, widget_type): + """Set the widget type and toggle the affordance visibility. + + Args: + widget_type (str): a value from ``widget_binding.WIDGET_TYPES``. + """ + self.widget_type = widget_type or widget_binding.WIDGET_BUTTON + interactive = self.is_widget() + self.widget_graphic.setVisible(interactive) + # Give a freshly-typed widget a sane default range to drive, and seed + # "just print" scripts so it works out of the box and logs its value. + if interactive and not self.binding: + self.binding = widget_binding.default_binding() + if interactive and not self.widget_scripts: + self.widget_scripts = widget_binding.default_scripts( + self.widget_type + ) + self.refresh_widget_state() + self.update() + + def get_binding(self): + """Return the widget's binding dict (attribute(s) + range).""" + return self.binding + + def set_binding(self, binding): + """Set the widget's binding dict and refresh the displayed value.""" + self.binding = dict(binding) if binding else {} + self.refresh_widget_state() + + def get_widget_scripts(self): + """Return the widget's per-state script dict.""" + return self.widget_scripts + + def set_widget_scripts(self, scripts): + """Set the widget's per-state script dict.""" + self.widget_scripts = dict(scripts) if scripts else {} + + def _widget_is_horizontal(self): + """Return True when a 1D slider is horizontal (its default).""" + orientation = (self.binding or {}).get( + "orientation", widget_binding.ORIENT_HORIZONTAL + ) + return orientation == widget_binding.ORIENT_HORIZONTAL + + def _widget_norm_from_pos(self, pos): + """Return the normalized value(s) for a cursor position. + + Maps ``pos`` (item-local) within the polygon's bounding rect to 0..1. + Higher screen positions map to larger values (the view is Y-flipped). + + Args: + pos (QPointF): cursor position in item-local coordinates. + + Returns: + float or tuple: 0..1 for a 1D slider, ``(x, y)`` for a 2D slider. + """ + rect = self.polygon.shape().boundingRect() + x0, x1, y0, y1 = widget_binding.track_bounds( + rect.left(), rect.right(), rect.top(), rect.bottom() + ) + nx = 0.0 if x1 == x0 else (pos.x() - x0) / (x1 - x0) + ny = 0.0 if y1 == y0 else (pos.y() - y0) / (y1 - y0) + nx = widget_binding.clamp(nx, 0.0, 1.0) + ny = widget_binding.clamp(ny, 0.0, 1.0) + if self.widget_type == widget_binding.WIDGET_SLIDER2D: + return (nx, ny) + return nx if self._widget_is_horizontal() else ny + + def _widget_mouse_press(self, event): + """Handle a widget press in animation mode.""" + if self.widget_type == widget_binding.WIDGET_CHECKBOX: + self._widget_toggle() + return + # Slider: begin a single-undo drag and set the value from the press. + self._widget_dragging = True + cmds.undoInfo(openChunk=True) + self._widget_mouse_drag(event) + + def _widget_mouse_drag(self, event): + """Apply the value at the current cursor position during a drag.""" + self._apply_widget_value(self._widget_norm_from_pos(event.pos())) + + def _widget_mouse_release(self, event): + """End a slider drag (optionally recentering a 2D slider).""" + if self.widget_type == widget_binding.WIDGET_SLIDER2D and ( + self.binding or {} + ).get("recenter"): + self._apply_widget_value((0.5, 0.5)) + self._widget_dragging = False + cmds.undoInfo(closeChunk=True) + + def _run_widget_script(self, key, extra_env): + """Run the widget's script for ``key`` with extra exec-env vars. + + Args: + key (str): script key (``on`` / ``off`` / ``value`` / ``xy``). + extra_env (dict): widget-state vars merged into the exec env. + """ + script = (self.widget_scripts or {}).get(key) + if not script: + return + env = self.get_exec_env() + env.update(extra_env) + python_handlers.safe_code_exec(script, env=env) + + def _widget_toggle(self): + """Toggle the checkbox: flip the bound bool attr and/or run a script.""" + binding = self.binding or {} + attr = widget_handlers.resolve_attr( + binding.get("attr"), self.get_namespace() + ) + # One undo for the attribute flip + its script. + with widget_handlers.undo_chunk(): + if attr: + new_state = widget_handlers.toggle_attr(attr) + if new_state is not None: + self.widget_graphic.checked = new_state + else: + # Script-only checkbox: flip the displayed state itself. + self.widget_graphic.checked = not self.widget_graphic.checked + state = self.widget_graphic.checked + self._run_widget_script( + "on" if state else "off", {"__STATE__": state} + ) + self.widget_graphic.update() + + def _apply_widget_value(self, norm): + """Write a normalized slider value to the bound attribute(s)/script. + + Args: + norm (float or tuple): 0..1 for a 1D slider, ``(x, y)`` for 2D. + """ + binding = self.binding or {} + namespace = self.get_namespace() + if self.widget_type == widget_binding.WIDGET_SLIDER2D: + nx, ny = norm + vx = widget_binding.map_value( + nx, binding.get("min_x", -1.0), binding.get("max_x", 1.0) + ) + vy = widget_binding.map_value( + ny, binding.get("min_y", -1.0), binding.get("max_y", 1.0) + ) + self.widget_graphic.value_xy = (nx, ny) + attr_x = widget_handlers.resolve_attr( + binding.get("attr_x"), namespace + ) + attr_y = widget_handlers.resolve_attr( + binding.get("attr_y"), namespace + ) + if attr_x: + widget_handlers.write_attr(attr_x, vx) + if attr_y: + widget_handlers.write_attr(attr_y, vy) + self._run_widget_script("xy", {"__X__": vx, "__Y__": vy}) + else: + value = widget_binding.map_value( + norm, binding.get("min", 0.0), binding.get("max", 1.0) + ) + self.widget_graphic.value = norm + attr = widget_handlers.resolve_attr(binding.get("attr"), namespace) + if attr: + widget_handlers.write_attr(attr, value) + self._run_widget_script("value", {"__VALUE__": value}) + self.widget_graphic.update() + + def refresh_widget_state(self): + """Refresh the widget's displayed value from its bound attribute(s). + + Called on load and on the selection-change refresh so the widget + reflects the rig. Reads only, never writes, and is safe when the target + attribute is missing (the current / default display value is kept). + """ + if not self.is_widget(): + return + binding = self.binding or {} + namespace = self.get_namespace() + if self.widget_type == widget_binding.WIDGET_CHECKBOX: + attr = widget_handlers.resolve_attr(binding.get("attr"), namespace) + value = widget_handlers.read_attr(attr) if attr else None + if value is not None: + self.widget_graphic.checked = bool(value) + elif self.widget_type == widget_binding.WIDGET_SLIDER: + attr = widget_handlers.resolve_attr(binding.get("attr"), namespace) + value = widget_handlers.read_attr(attr) if attr else None + if value is not None: + self.widget_graphic.value = widget_binding.normalize( + value, binding.get("min", 0.0), binding.get("max", 1.0) + ) + elif self.widget_type == widget_binding.WIDGET_SLIDER2D: + attr_x = widget_handlers.resolve_attr( + binding.get("attr_x"), namespace + ) + attr_y = widget_handlers.resolve_attr( + binding.get("attr_y"), namespace + ) + value_x = widget_handlers.read_attr(attr_x) if attr_x else None + value_y = widget_handlers.read_attr(attr_y) if attr_y else None + cur_x, cur_y = self.widget_graphic.value_xy + if value_x is not None: + cur_x = widget_binding.normalize( + value_x, + binding.get("min_x", -1.0), + binding.get("max_x", 1.0), + ) + if value_y is not None: + cur_y = widget_binding.normalize( + value_y, + binding.get("min_y", -1.0), + binding.get("max_y", 1.0), + ) + self.widget_graphic.value_xy = (cur_x, cur_y) + self.widget_graphic.update() + + # ========================================================================= + # Backdrop container --- + def get_backdrop(self): + """Return True when the item is a backdrop container.""" + return self.backdrop + + def set_backdrop(self, state): + """Toggle backdrop mode (swaps the polygon body for the backdrop).""" + self.backdrop = bool(state) + # The backdrop graphic replaces the plain polygon fill; the polygon is + # kept only as the (still hit-testable) shape, so hide its drawing. + self.polygon.setVisible(not self.backdrop) + self.backdrop_graphic.setVisible(self.backdrop) + self.update() + + def get_backdrop_title(self): + """Return the backdrop title text.""" + return self.backdrop_graphic.title + + def set_backdrop_title(self, title): + """Set the backdrop title text.""" + self.backdrop_graphic.title = title or "" + self.backdrop_graphic.update() + + def get_corner_radius(self): + """Return the backdrop corner radius (0 = straight corners).""" + return self.backdrop_graphic.corner_radius + + def set_corner_radius(self, radius): + """Set the backdrop corner radius (0 = straight corners).""" + self.backdrop_graphic.corner_radius = max(0.0, radius or 0.0) + self.backdrop_graphic.update() + + def contained_items(self): + """Return items whose center lies within this backdrop's rectangle. + + Used for backdrop move-together; the center-in-rect test naturally + includes nested backdrops and their contents (anything visually inside + the rectangle moves as a group). + + Returns: + list: the contained PickerItems (empty when not a backdrop). + """ + view = self.parent() + if view is None or not hasattr(view, "get_picker_items"): + return [] + my_rect = self.sceneBoundingRect() + contained = [] + for item in view.get_picker_items(): + if item is self: + continue + if my_rect.contains(item.sceneBoundingRect().center()): + contained.append(item) + return contained + # ========================================================================= # Ducplicate and mirror methods --- def mirror_position(self, axis_x=0.0): @@ -1069,6 +1459,9 @@ def set_selected_state(self, state): def run_selection_check(self): """Will set selection state based on selection status""" self.set_selected_state(self.is_selected()) + # Reflect the bound attribute value on the selection-change refresh. + if self.is_widget(): + self.refresh_widget_state() # ========================================================================= # Custom menus handling --- @@ -1113,6 +1506,10 @@ def set_data(self, data): self.set_text(data["text"]) self.set_text_size(data["text_size"]) self.set_text_color(QtGui.QColor(*data["text_color"])) + if model.text_align is not None: + self.set_text_align(model.text_align) + if model.text_offset is not None: + self.set_text_offset(model.text_offset) # Set action mode if model.action_mode: @@ -1143,7 +1540,26 @@ def set_data(self, data): self.set_anchor(model.anchor) if model.offset is not None: self.set_offset(model.offset) - self.set_pinned(True) + if model.pin_scale is not None: + self.pin_scale = model.pin_scale + # Restore the saved size instead of re-sampling the current zoom. + self.set_pinned(True, capture_scale=False) + + # Interactive widget (optional, additive keys). Set the binding / + # scripts first so ``set_widget_type`` can refresh the display value. + if model.widget: + self.set_binding(model.binding or {}) + self.set_widget_scripts(model.scripts or {}) + self.set_widget_type(model.widget) + + # Backdrop container (optional, additive keys). Set the title / corner + # radius first, then enable so the graphic shows with them applied. + if model.backdrop: + if model.title is not None: + self.set_backdrop_title(model.title) + if model.corner_radius is not None: + self.set_corner_radius(model.corner_radius) + self.set_backdrop(True) def get_data(self): """Get picker item data in dictionary form. @@ -1171,6 +1587,8 @@ def get_data(self): model.text = self.get_text() model.text_size = self.get_text_size() model.text_color = self.get_text_color().getRgb() + model.text_align = self.get_text_align() + model.text_offset = self.get_text_offset() model.item_id = self.item_id model.mirror_id = self.mirror_id @@ -1179,5 +1597,18 @@ def get_data(self): model.pinned = True model.anchor = self.anchor model.offset = list(self.offset) + model.pin_scale = self.pin_scale + + if self.is_widget(): + model.widget = self.widget_type + model.binding = dict(self.binding) if self.binding else None + model.scripts = ( + dict(self.widget_scripts) if self.widget_scripts else None + ) + + if self.backdrop: + model.backdrop = True + model.title = self.get_backdrop_title() + model.corner_radius = self.get_corner_radius() return model.to_dict() diff --git a/release/scripts/mgear/anim_picker/widgets/silhouette.py b/release/scripts/mgear/anim_picker/widgets/silhouette.py new file mode 100644 index 00000000..e902b4eb --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/silhouette.py @@ -0,0 +1,137 @@ +"""Qt/Maya-free silhouette math for the *trace from selection* tool. + +Tracing a control turns its 3D shape points into a flat picker button shaped +like the control's silhouette: project the world-space points onto a view +plane (front / side / top), reduce them to a 2D convex hull (the approximate +outline), and scale the whole traced set to fit the canvas. These three steps +are pure geometry with no Qt or Maya dependency, so they are unit-testable +standalone (the same pattern as ``overlay`` and ``mirror``); the Maya point +extraction lives in ``handlers.maya_handlers.get_shape_points``. + +Plane mapping (Maya is Y-up): + front -> world (x, y) + side -> world (z, y) + top -> world (x, z) +""" + + +PLANE_FRONT = "front" +PLANE_SIDE = "side" +PLANE_TOP = "top" + +PLANES = (PLANE_FRONT, PLANE_SIDE, PLANE_TOP) + + +def project_to_plane(points, plane): + """Project world-space 3D points onto a 2D view plane. + + Args: + points (list): sequence of ``(x, y, z)`` world-space points. + plane (str): one of ``PLANE_FRONT`` / ``PLANE_SIDE`` / ``PLANE_TOP``. + + Returns: + list: ``(u, v)`` 2D points on the chosen plane. + """ + result = [] + for point in points: + x, y, z = point[0], point[1], point[2] + if plane == PLANE_SIDE: + result.append((z, y)) + elif plane == PLANE_TOP: + result.append((x, z)) + else: # front (default) + result.append((x, y)) + return result + + +def _cross(origin, a, b): + """2D cross product of vectors ``origin->a`` and ``origin->b``.""" + return (a[0] - origin[0]) * (b[1] - origin[1]) - (a[1] - origin[1]) * ( + b[0] - origin[0] + ) + + +def convex_hull_2d(points): + """Return the convex hull of 2D points (counter-clockwise). + + Uses Andrew's monotone chain. Degenerate inputs are returned as-is: a + single point stays one point, a collinear set collapses to its two + extremes, and duplicate points are ignored. + + Args: + points (list): sequence of ``(x, y)`` points. + + Returns: + list: the hull vertices as ``(x, y)`` tuples, without repeating the + first point at the end. + """ + # Deduplicate and sort lexicographically (x, then y). + pts = sorted(set((float(p[0]), float(p[1])) for p in points)) + if len(pts) <= 2: + return pts + + # Build lower and upper hulls. + lower = [] + for p in pts: + while len(lower) >= 2 and _cross(lower[-2], lower[-1], p) <= 0: + lower.pop() + lower.append(p) + + upper = [] + for p in reversed(pts): + while len(upper) >= 2 and _cross(upper[-2], upper[-1], p) <= 0: + upper.pop() + upper.append(p) + + # Concatenate, dropping each hull's last point (shared with the other). + hull = lower[:-1] + upper[:-1] + + # A fully collinear set yields a degenerate hull; fall back to extremes. + if len(hull) < 3: + return [pts[0], pts[-1]] + + return hull + + +def bounding_box(points): + """Return ``(min_x, min_y, max_x, max_y)`` of 2D points (None if empty).""" + if not points: + return None + xs = [p[0] for p in points] + ys = [p[1] for p in points] + return (min(xs), min(ys), max(xs), max(ys)) + + +def fit_scale(bounds, target): + """Return a uniform scale that fits ``bounds`` within ``target`` pixels. + + The scale maps the larger of the box's width / height to ``target`` so the + traced set keeps its proportions. A zero-size box (a single point or a + perfectly flat control) returns 1.0 rather than dividing by zero. + + Args: + bounds (tuple): ``(min_x, min_y, max_x, max_y)`` in world units, or + None. + target (float): desired maximum span in canvas pixels. + + Returns: + float: the uniform scale factor. + """ + if not bounds: + return 1.0 + width = bounds[2] - bounds[0] + height = bounds[3] - bounds[1] + span = max(width, height) + if span <= 0: + return 1.0 + return float(target) / span + + +def centroid(points): + """Return the average ``(x, y)`` of 2D points (``(0, 0)`` if empty).""" + if not points: + return (0.0, 0.0) + sx = sum(p[0] for p in points) + sy = sum(p[1] for p in points) + count = float(len(points)) + return (sx / count, sy / count) diff --git a/release/scripts/mgear/anim_picker/widgets/tool_bar.py b/release/scripts/mgear/anim_picker/widgets/tool_bar.py index 954f55af..1a00a9f8 100644 --- a/release/scripts/mgear/anim_picker/widgets/tool_bar.py +++ b/release/scripts/mgear/anim_picker/widgets/tool_bar.py @@ -24,6 +24,13 @@ TOOL_SELECT = "select" TOOL_TRANSFORM = "transform" +# Drag-and-drop mime type carrying a widget-type payload from a palette tile to +# the canvas (the view creates the item at the drop position). +WIDGET_MIME = "application/x-mgear-anim-picker-item" + +# Palette payload for a backdrop container (not a widget_binding type). +BACKDROP_PAYLOAD = "backdrop" + def maya_icon(resource): """Return a QIcon for a Maya resource path (e.g. ``:/aselect.png``).""" @@ -38,6 +45,59 @@ def mgear_icon(name): return QtGui.QIcon() +class PaletteButton(QtWidgets.QToolButton): + """A draggable palette tile that creates a picker item/widget on drop. + + Dragging the tile onto the canvas starts a drag carrying the widget-type + payload (``WIDGET_MIME``); the view's drop handler creates the item at the + drop position. A plain click does nothing (creation is drag-driven). + """ + + _ICON_SIZE = 22 + + def __init__(self, payload, parent=None): + super(PaletteButton, self).__init__(parent) + self._payload = payload + self._press_pos = None + self._double_callback = None + + def set_double_callback(self, callback): + """Set the callback invoked on a double-click (create at center).""" + self._double_callback = callback + + def mouseDoubleClickEvent(self, event): + if self._double_callback is not None: + self._double_callback() + super(PaletteButton, self).mouseDoubleClickEvent(event) + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._press_pos = event.pos() + super(PaletteButton, self).mousePressEvent(event) + + def mouseMoveEvent(self, event): + # Start the drag once the cursor has moved past the drag threshold. + if ( + not (event.buttons() & QtCore.Qt.LeftButton) + or self._press_pos is None + ): + super(PaletteButton, self).mouseMoveEvent(event) + return + moved = (event.pos() - self._press_pos).manhattanLength() + if moved < QtWidgets.QApplication.startDragDistance(): + super(PaletteButton, self).mouseMoveEvent(event) + return + drag = QtGui.QDrag(self) + mime = QtCore.QMimeData() + mime.setData(WIDGET_MIME, self._payload.encode("utf-8")) + drag.setMimeData(mime) + icon = self.icon() + if not icon.isNull(): + drag.setPixmap(icon.pixmap(self._ICON_SIZE, self._ICON_SIZE)) + drag.exec_(QtCore.Qt.CopyAction) + self._press_pos = None + + class PickerToolBar(QtWidgets.QWidget): """Vertical tool strip that drives the active picker tool.""" @@ -132,6 +192,79 @@ def add_command(self, label, tooltip, callback, icon=None): self.main_layout.insertWidget(self.main_layout.count() - 1, button) return button + def add_separator(self): + """Add a thin horizontal divider above the trailing stretch.""" + line = QtWidgets.QFrame() + line.setFrameShape(QtWidgets.QFrame.HLine) + line.setFrameShadow(QtWidgets.QFrame.Sunken) + line.setStyleSheet("color: #4a4a4a;") + self.main_layout.insertWidget(self.main_layout.count() - 1, line) + return line + + def add_section_label(self, text): + """Add a small centered section label above the trailing stretch.""" + label = QtWidgets.QLabel(text) + label.setAlignment(QtCore.Qt.AlignCenter) + font = label.font() + font.setPointSizeF(max(6.0, font.pointSizeF() - 2.0)) + label.setFont(font) + label.setStyleSheet("color: #9a9a9a;") + self.main_layout.insertWidget(self.main_layout.count() - 1, label) + return label + + def add_button_grid(self, specs, columns=2): + """Add a compact grid of icon command buttons above the stretch. + + Args: + specs (list): ``(tooltip, callback, icon)`` per button. + columns (int): number of columns in the grid. + + Returns: + list: the created QToolButtons. + """ + container = QtWidgets.QWidget() + grid = QtWidgets.QGridLayout(container) + grid.setContentsMargins(0, 0, 0, 0) + grid.setSpacing(2) + buttons = [] + for index, (tooltip, callback, icon) in enumerate(specs): + button = QtWidgets.QToolButton() + button.setToolTip(tooltip) + button.setAutoRaise(True) + button.setFixedSize(QtCore.QSize(26, 26)) + if icon is not None and not icon.isNull(): + button.setIcon(icon) + button.setIconSize(QtCore.QSize(18, 18)) + button.clicked.connect(callback) + grid.addWidget(button, index // columns, index % columns) + buttons.append(button) + self.main_layout.insertWidget(self.main_layout.count() - 1, container) + return buttons + + def add_palette_item( + self, label, tooltip, payload, icon=None, double_callback=None + ): + """Add a draggable palette tile that creates ``payload`` on drop. + + Args: + label (str): short tile label (used when ``icon`` is null). + tooltip (str): hover description. + payload (str): widget-type string carried by the drag. + icon (QtGui.QIcon, optional): icon to show. + double_callback (callable, optional): invoked on a double-click + (creates the item at the canvas center as an alternative to + dragging). + + Returns: + PaletteButton: the created draggable tile. + """ + button = PaletteButton(payload) + self._style_button(button, label, tooltip, icon) + if double_callback is not None: + button.set_double_callback(double_callback) + self.main_layout.insertWidget(self.main_layout.count() - 1, button) + return button + def _tool_clicked(self, name): if self.main_window is not None: self.main_window.set_active_tool(name) diff --git a/release/scripts/mgear/anim_picker/widgets/widget_binding.py b/release/scripts/mgear/anim_picker/widgets/widget_binding.py new file mode 100644 index 00000000..5a85ae87 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/widget_binding.py @@ -0,0 +1,223 @@ +"""Qt/Maya-free helpers for interactive picker widgets. + +A picker item can be a plain *button* (the default) or an interactive +*widget* -- a checkbox, a 1D slider, or a 2D slider -- bound to a Maya +attribute and/or a script. This module holds the widget-type vocabulary, the +binding / script schema defaults, and the pure value-mapping math (normalize a +value into a ``[min, max]`` range and back). It has no Qt or Maya dependency, +so the mapping is unit-testable standalone, matching the pattern used by +``overlay``, ``mirror``, and ``manipulator_transform``. + +The item stores two additive dictionaries alongside its ``widget`` type: + +``binding`` + Which Maya attribute(s) the widget drives and over what range:: + + {"attr": "node.attr", "min": 0.0, "max": 1.0, # checkbox / 1D + "attr_x": "node.tx", "min_x": -1.0, "max_x": 1.0, # 2D X + "attr_y": "node.ty", "min_y": -1.0, "max_y": 1.0, # 2D Y + "orientation": "horizontal", "recenter": False} + +``scripts`` + Optional per-state scripts run in addition to (or instead of) the + attribute: ``on`` / ``off`` for a checkbox, ``value`` for a 1D slider, + ``xy`` for a 2D slider. + +All keys are optional; an absent attribute means "script only" and an absent +script means "attribute only". +""" + + +# Widget type vocabulary. +WIDGET_BUTTON = "button" +WIDGET_CHECKBOX = "checkbox" +WIDGET_SLIDER = "slider" +WIDGET_SLIDER2D = "slider2d" + +WIDGET_TYPES = ( + WIDGET_BUTTON, + WIDGET_CHECKBOX, + WIDGET_SLIDER, + WIDGET_SLIDER2D, +) + +# 1D slider orientation. +ORIENT_HORIZONTAL = "horizontal" +ORIENT_VERTICAL = "vertical" + + +def is_interactive(widget_type): + """Return True when ``widget_type`` is an interactive (non-button) widget. + + Args: + widget_type (str): a value from ``WIDGET_TYPES``. + + Returns: + bool: True for checkbox / slider / slider2d, False otherwise. + """ + return bool(widget_type) and widget_type != WIDGET_BUTTON + + +def clamp(value, low, high): + """Clamp ``value`` to the ``[low, high]`` range (order-insensitive).""" + if low > high: + low, high = high, low + return max(low, min(high, value)) + + +def normalize(value, low, high): + """Return ``value``'s clamped 0..1 position within ``[low, high]``. + + Args: + value (float): the value to normalize. + low (float): range minimum. + high (float): range maximum. + + Returns: + float: 0.0 at ``low``, 1.0 at ``high`` (0.0 when the range is empty). + """ + if high == low: + return 0.0 + return clamp((value - low) / float(high - low), 0.0, 1.0) + + +def map_value(norm, low, high): + """Map a 0..1 normalized value to the ``[low, high]`` range. + + Args: + norm (float): normalized position (clamped to 0..1). + low (float): range minimum. + high (float): range maximum. + + Returns: + float: the value at ``norm`` within the range. + """ + return low + clamp(norm, 0.0, 1.0) * (high - low) + + +def track_bounds(left, right, top, bottom, inset=6.0): + """Return the inset ``(x0, x1, y0, y1)`` slider-track bounds of a rect. + + Shared by the slider painter and the drag hit-test so the drawn handle and + the cursor-to-value mapping use one inset convention (they must stay + pixel-identical or the handle won't sit under the cursor). + + Args: + left (float): rect left edge. + right (float): rect right edge. + top (float): rect top edge. + bottom (float): rect bottom edge. + inset (float, optional): pixels to inset the track from each edge. + + Returns: + tuple: ``(x0, x1, y0, y1)`` inset track bounds. + """ + return (left + inset, right - inset, top + inset, bottom - inset) + + +def default_binding(): + """Return a fresh binding dict with the schema's default ranges. + + Returns: + dict: a binding populated with empty attributes and sane ranges. + """ + return { + "attr": "", + "min": 0.0, + "max": 1.0, + "attr_x": "", + "min_x": -1.0, + "max_x": 1.0, + "attr_y": "", + "min_y": -1.0, + "max_y": 1.0, + "orientation": ORIENT_HORIZONTAL, + "recenter": False, + } + + +# Default body shape (half sizes) per widget type, so a freshly created widget +# has a clean, type-appropriate proportion instead of the generic item shape. +_DEFAULT_HALF_SIZE = { + WIDGET_CHECKBOX: (16.0, 9.0), + WIDGET_SLIDER: (40.0, 8.0), + WIDGET_SLIDER2D: (24.0, 24.0), +} + + +def default_handles(widget_type): + """Return default rectangle corner handles for a widget type. + + Args: + widget_type (str): a value from ``WIDGET_TYPES``. + + Returns: + list: ``[[x, y], ...]`` corner handles (CCW), or None for a plain + button (which keeps the generic item shape). + """ + size = _DEFAULT_HALF_SIZE.get(widget_type) + if not size: + return None + half_w, half_h = size + return [ + [-half_w, -half_h], + [half_w, -half_h], + [half_w, half_h], + [-half_w, half_h], + ] + + +# Per-state script templates. A freshly created widget is seeded with these so +# it works out of the box (printing its value to the Script Editor) and doubles +# as an editable, self-documenting example. Keys: on/off (checkbox), value (1D +# slider), xy (2D slider). +SCRIPT_TEMPLATES = { + "on": ( + "# Runs when the checkbox turns ON.\n" + "# Available: __STATE__ (bool), __CONTROLS__, __NAMESPACE__, __SELF__.\n" + "print('[anim_picker] checkbox ON ->', __STATE__)\n" + ), + "off": ( + "# Runs when the checkbox turns OFF.\n" + "# Available: __STATE__ (bool), __CONTROLS__, __NAMESPACE__, __SELF__.\n" + "print('[anim_picker] checkbox OFF ->', __STATE__)\n" + ), + "value": ( + "# Runs while dragging a 1D slider.\n" + "# Available: __VALUE__ (float, mapped to min..max), __CONTROLS__, " + "__SELF__.\n" + "print('[anim_picker] slider value ->', __VALUE__)\n" + ), + "xy": ( + "# Runs while dragging a 2D slider.\n" + "# Available: __X__ / __Y__ (floats), __CONTROLS__, __SELF__.\n" + "print('[anim_picker] 2D slider ->', __X__, __Y__)\n" + ), +} + +# Which script keys apply to each widget type. +_TYPE_SCRIPT_KEYS = { + WIDGET_CHECKBOX: ("on", "off"), + WIDGET_SLIDER: ("value",), + WIDGET_SLIDER2D: ("xy",), +} + + +def script_template(key): + """Return the sample-snippet script for a widget script key (or "").""" + return SCRIPT_TEMPLATES.get(key, "") + + +def default_scripts(widget_type): + """Return the default per-state print scripts for a widget type. + + Args: + widget_type (str): a value from ``WIDGET_TYPES``. + + Returns: + dict: ``{state_key: script}`` seeded from ``SCRIPT_TEMPLATES``. + """ + return { + key: SCRIPT_TEMPLATES[key] + for key in _TYPE_SCRIPT_KEYS.get(widget_type, ()) + } diff --git a/releaseLog.rst b/releaseLog.rst index 52909b4f..499da6c1 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Interactive widgets + trace from selection — a picker item can now be an interactive **widget** instead of a plain button: a **checkbox** (click toggles a bound boolean attribute and/or runs an on/off script, drawn on/off), a **1D slider** (drag maps to a bound attribute over a ``[min, max]`` range, horizontal or vertical, the whole drag one undo), or a **2D slider** (the knob drives two attributes X/Y over their ranges, with an optional recenter-on-release). A widget can bind to a Maya attribute **and/or** a script — the script receives the state as ``__STATE__`` / ``__VALUE__`` / ``__X__`` / ``__Y__`` — and reflects the bound attribute's value on the picker's existing selection-change refresh (no per-frame polling). Locked / connected / missing attributes warn and are skipped rather than throwing. Set it from the inline panel's new **Widget** section (type, attribute(s) + min/max, slider orientation, 2D recenter, per-state Edit script). Separately, a tool-strip **Trace** command (with a Front / Side / Top drop-down) builds one button per selected control shaped like the control's projected **silhouette** (convex hull), positioned to match the rig and **colored from the control** — a fast first pass of a picker from the rig. Persisted as additive optional item keys (``widget`` / ``binding`` / ``scripts``, emitted only for non-button items), so older pickers load unchanged and plain buttons are unaffected; traced buttons are ordinary items and add no new keys. Widgets render as compact, modern controls (a self-styled rounded body, a thin accent-filled groove with a fixed-size round handle / 2D knob that never stretches, and a clear checkmark), are created by **dragging a tile** (Button / Checkbox / Slider / 2D Slider) from the left tool strip onto the canvas at the drop position, and come seeded with a "just print the value" script so they work immediately and double as an editable example. The script editor's variable reference now documents the widget variables (`__STATE__` / `__VALUE__` / `__X__` / `__Y__`) with samples, opens focused on the code area, and seeds an empty widget script with a per-state snippet. Tracing also auto-detects `_L` / `_R` control pairs (the same naming logic as pickWalk) and links the traced buttons as mirror pairs. Item **text** can be aligned (center / top / bottom / left / right) with a pixel offset so labels sit outside the button, and a pinned overlay item now **keeps its apparent size** when pinned (instead of snapping tiny). **Align & distribute** tools (an Align section in the left tool strip) line up or evenly space the selected items. New **backdrop containers** — drag a Backdrop tile onto the canvas to add a titled, colored, adjustable-transparency panel with round or straight corners that sits behind the buttons; dragging a backdrop moves everything inside it together, backdrops can be nested (the inner one stays selectable), and the title bar follows the rounded corners * Anim Picker: Tab reorder + multi-tab view — tabs can now be **dragged to reorder** on the tab bar in edit mode (the right-click Move actions remain as a fallback), and the order saves exactly as before. A new **View** selector (Tabbed / Grid / Rows / Columns) in the character bar switches the picker area between the one-at-a-time tabbed view (default) and a **multi-tab tiled view** that shows every tab's picker at once — in a grid, a vertical stack, or a horizontal row — each cell a fully live, interactive picker (selecting controls / running actions works as usual). Clicking a cell marks it the active picker (highlighted header) so per-view actions (add item, fit) target it. The chosen mode + grid column count persist via user settings and are never written into the picker data, so the ``.pkr`` is unchanged and older pickers load identically * Anim Picker: Pinned / overlay HUD buttons — a picker item can be **pinned to the viewport** so it stays at a fixed screen position and constant size, ignoring canvas pan and zoom (e.g. a global reset, a space switch, a settings button locked to a corner). A pinned item is placed by a **3x3 anchor** (corners / edges / center) plus an inward pixel **offset**, and keeps all its normal button behavior (control selection, custom action, menus, color, text, shape). Set it from the inline panel's new **Pin** section (Pinned checkbox, 3x3 anchor picker, X / Y offset) or the tool strip's **Pin** quick command (anchors to the item's current screen region); in edit mode you can also drag a pinned item on the canvas to set its offset (the anchor snaps to the nearest region). Pinned items are excluded from the canvas' pan / zoom extent, so a HUD overlay never enlarges the scrollable scene or gets framed by "reset view". Persisted as additive optional item keys (``pinned`` / ``anchor`` / ``offset``), so older pickers load unchanged and non-pinned items are unaffected * Anim Picker: Mirror relationships + shape library — the left tool strip gains icon quick commands (Add, Duplicate, Duplicate & Mirror, Mirror Shape, Shapes) using bundled mGear icons, with the Select / Transform tools showing the default Maya tool icons. In edit mode, clicking a picker item now selects it immediately on press (so a single click-drag selects and moves it), while dragging a multi-selection keeps the whole group. Two items can be linked as a **persistent mirror pair** about a symmetry axis: editing one (move/scale/rotate on canvas or via the inline panel) live-mirrors its position, rotation, and shape to its partner **in realtime** (during the drag, not just on release), and the link is saved with the picker (additive optional ``id`` / ``mirror`` keys, so older pickers load unchanged). Linked items show a pink dotted outline so pairs are spottable at a glance. "Duplicate & Mirror" creates and links the pair; the panel's Mirror section links/unlinks, sets the axis, and snaps with Make Symmetric. Left/right coloring is now explicit via a **preset color palette** along the bottom (secondary/primary per left/center/right): clicking a swatch colors the selection and its mirror partner gets the opposite side at the same level; double / right-click edits a swatch (persisted, and it never repaints existing items). A new **shape library** (tool strip Shapes button or panel Shape > Shapes...) applies premade shapes (square, circle, triangle, diamond, pentagon, hexagon, octagon, star, arrow) to the selection and can save the current shape as a named custom shape (stored in the Maya user prefs dir, next to mGear_user_settings.ini), keeping the curve-import path From 0d7ee740faa040f4436f95043ab6bbfcbcbc3406 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Fri, 10 Jul 2026 10:24:56 +0900 Subject: [PATCH 12/25] AnimPicker: Widgets: Fix slider widget knob ghosting after drag The fixed-size slider / 2D-slider knob overhangs a thin track, but WidgetGraphic.boundingRect() stopped at the item rect, so the knob's overhang at the previous position was never repainted -- leaving a ghost of the old knob after a drag. boundingRect now pads by the knob radius so the whole knob (at any position) is cleared on repaint; shape() (hit testing) stays the plain item rect. --- release/scripts/mgear/anim_picker/widgets/graphics.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index bdacd8eb..a5d63e96 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -427,9 +427,16 @@ def _item_rect(self): return parent.polygon.shape().boundingRect() def boundingRect(self): - return self._item_rect() + # Expand past the item rect by the knob radius (+ pen / antialias + # margin): the fixed-size slider knob overhangs a thin track, and a + # bounding rect that stopped at the track would leave the old knob's + # overhang unrepainted -- a ghost of the previous drag position. + margin = self._HANDLE_R + 2.0 + return self._item_rect().adjusted(-margin, -margin, margin, margin) def shape(self): + # Hit-testing stays the item rect (the knob overhang is not clickable); + # only boundingRect is padded, for the repaint region. path = QtGui.QPainterPath() path.addRect(self._item_rect()) return path From 8ff79153b3d4aa3d342b3aae6a2b2f3d731ab5c3 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Fri, 10 Jul 2026 10:25:41 +0900 Subject: [PATCH 13/25] AnimPicker: Visibility: Add conditional item visibility (channel state + zoom level) A picker item can now be shown only when a condition passes in animation mode, so a busy picker declutters itself. Two condition modes: channel-state (a Maya attribute compared to a threshold, e.g. IK controls only when arm_L_ctl.ikBlend >= 0.5) and zoom-level (view zoom within a range, e.g. fine detail controls only when zoomed in). Conditions re-evaluate on demand without polling -- zoom conditions on every zoom/fit, channel conditions on selection change and on mouse-over the picker (like the Channel Master tool: hovering re-reads the bound attributes so a manual or animated channel change is picked up without focus, and also re-syncs interactive checkbox/slider widgets). There is no per-playback-frame refresh, so a heavy rig is not slowed. Edit mode always shows every item so a condition never blocks authoring, and a missing/malformed condition fails open (stays visible). New Qt-free widgets/visibility.py owns the pure evaluation (unit-testable standalone); PickerItem bridges the safe namespaced attribute read and setVisible; the view owns the timing and a cached "any conditioned item?" gate, mirroring the pinned-item pattern. Authored from a new Visibility section in the inline edit panel (mode + attribute/operator/threshold or min/max zoom with a "capture current zoom" helper). Persisted as one additive optional item key (visibility), emitted only when set, so older pickers load unchanged and unconditioned items are unaffected. --- .../scripts/mgear/anim_picker/main_window.py | 48 ++++ release/scripts/mgear/anim_picker/view.py | 59 ++++- .../mgear/anim_picker/widgets/edit_panel.py | 239 ++++++++++++++++-- .../mgear/anim_picker/widgets/item_model.py | 10 + .../mgear/anim_picker/widgets/picker_item.py | 65 +++++ .../mgear/anim_picker/widgets/visibility.py | 144 +++++++++++ releaseLog.rst | 2 + 7 files changed, 546 insertions(+), 21 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/widgets/visibility.py diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 1c81e87d..bb95c46a 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -1245,6 +1245,10 @@ def add_callback(self): self.cb_manager.newSceneCB( "anim_picker_newScene", self.selection_change_event ) + # No time-change callback: channel-state visibility is not refreshed + # per playback frame (that would read the rig every frame). A manual / + # animated channel change is picked up on mouse-over (enterEvent -> + # refresh_from_rig) instead -- on-demand, never polling. def selection_change_event(self, *args): """ @@ -1274,6 +1278,50 @@ def selection_change_event(self, *args): for item in self.get_picker_items(): item.run_selection_check() + # Re-evaluate conditional visibility for the new selection / rig state + # (channel-state conditions may now pass or fail). + self.refresh_item_visibility() + + def refresh_item_visibility(self, *args): + """Re-evaluate conditional-visibility items across every live view. + + Each view computes the zoom once and applies its items' show/hide. + Wired to selection- and time-change callbacks so channel-state + conditions update as the animator selects or scrubs; iterating every + view (not just the active one) keeps the multi-tab tiled presentation + live, and is cheap since each view early-outs when it has none. + """ + for view in self.tab_area.all_views(): + if view is not None: + view.refresh_item_visibility() + + def enterEvent(self, event): + """Refresh rig-driven display when the mouse enters the picker. + + Like the Channel Master tool, a mouse-enter (no focus needed) re-reads + the bound Maya attributes so interactive widgets and conditional- + visibility items reflect the current rig even after a change that fired + no selection / time callback (e.g. a manual channel-box edit). It is + on-demand only -- it never polls the rig. + """ + super().enterEvent(event) + self.refresh_from_rig() + + def refresh_from_rig(self, *args): + """Re-sync widgets + conditional visibility from the rig (all views). + + One pass per live view reads each interactive widget's attribute and + re-evaluates the conditional-visibility items. A no-op in edit mode + (nothing reads the rig while authoring). + """ + if __EDIT_MODE__.get(): + return + for view in self.tab_area.all_views(): + if view is None: + continue + view.refresh_widget_states() + view.refresh_item_visibility() + # version of the anim picker ui that uses MayaQWidgetDockableMixin for docking class MainDockableWindow(MayaQWidgetDockableMixin, MainDockWindow): diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 6e2d6a02..59df3916 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -137,6 +137,11 @@ def __init__(self, namespace=None, main_window=None): # nothing is pinned (the common case). self._has_pinned_items = False + # Conditional-visibility items: a cached "any condition?" flag so the + # per-item show/hide evaluation on zoom / selection / time is skipped + # when no item carries a visibility condition (the common case). + self._has_conditional_items = False + self.fit_margin = 8 # # undo list --------------------------------------------------------- @@ -278,6 +283,8 @@ def mouseMoveEvent(self, event): self.scale(factor, factor) self.zoom_delta = current_delta self._update_pinned_items() + # Zoom changed; re-evaluate zoom-level visibility conditions. + self.refresh_item_visibility() return result @@ -429,6 +436,8 @@ def wheelEvent(self, event): self.scale(factor, factor) # Keep viewport-pinned items locked to their screen anchors. self._update_pinned_items() + # Zoom changed; re-evaluate zoom-level visibility conditions. + self.refresh_item_visibility() # undo -------------------------------------------------------------------- def undo_move(self): @@ -641,7 +650,9 @@ def resizeEvent(self, *args, **kwargs): # Run default resizeEvent result = QtWidgets.QGraphicsView.resizeEvent(self, *args, **kwargs) # Re-anchor pinned items to the new viewport size (covers the case - # where auto-frame is off and no fit happened above). + # where auto-frame is off and no fit happened above). Visibility is not + # refreshed here: an auto-frame fit already did (via fit_scene_content), + # and without a fit a resize does not change the zoom scale. self._update_pinned_items() return result @@ -649,8 +660,9 @@ def fit_scene_content(self): """Will fit scene content to view, by scaling it""" scene_rect = self.scene().get_bounding_rect(margin=self.fit_margin) self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) - # The fit changed the view transform; re-anchor pinned items. + # The fit changed the view transform; re-anchor pins + re-eval zoom. self._update_pinned_items() + self.refresh_item_visibility() def set_auto_frame_view(self): """Enable auto fit when a resize event happens""" @@ -667,6 +679,7 @@ def fit_selection_content(self): if scene_rect: self.fitInView(scene_rect, QtCore.Qt.KeepAspectRatio) self._update_pinned_items() + self.refresh_item_visibility() def get_color_picker_override(self, picker, ctrl): """Get the maya override color and return picker equivelant @@ -1062,6 +1075,10 @@ def paste_event(self): ctrl = self.add_picker_item(event=None) ctrl.set_data(data) ctrl.set_selected_state(True) + # A pasted item may carry a visibility condition copied from another + # picker, so refresh the "any conditioned item?" gate and re-apply. + self._recompute_conditional_flag() + self.refresh_item_visibility() def toggle_all_handles_event(self, event=None): new_status = None @@ -1599,6 +1616,38 @@ def _update_pinned_items(self): px, py = overlay.anchor_point(size, item.anchor, item.offset) item.setPos(self.mapToScene(int(round(px)), int(round(py)))) + # -- conditional visibility ----------------------------------------- + def _recompute_conditional_flag(self): + """Refresh the cached "any conditioned item?" flag (on edit / load).""" + self._has_conditional_items = any( + item.has_visibility_condition() for item in self.get_picker_items() + ) + + def refresh_item_visibility(self): + """Show / hide each conditioned item for the current zoom + rig state. + + Mirrors ``_update_pinned_items``: the view owns *when* (called from the + zoom / pan / fit hooks and the selection / time callbacks) and computes + the zoom once, while each item owns its own decision (channel read + + pure evaluation). A no-op when no item carries a condition. + """ + if not self._has_conditional_items: + return + zoom = abs(self.viewportTransform().m11()) + for item in self.scene().get_picker_items(): + item.evaluate_visibility(zoom) + + def refresh_widget_states(self): + """Re-read every interactive widget's bound attribute into its display. + + The checkbox / slider / 2D-slider items re-sync their drawn state from + the rig; ``refresh_widget_state`` is a cheap no-op on non-widget items. + Used by the mouse-enter (hover) refresh so a manual channel change is + reflected without a selection / time callback. Read-only, never writes. + """ + for item in self.scene().get_picker_items(): + item.refresh_widget_state() + def set_item_pinned(self, item, state, anchor=None): """Pin / unpin an item and reposition it (default anchor = its region). @@ -1789,6 +1838,7 @@ def clear(self): old_scene.deleteLater() self._has_mirror_links = False self._has_pinned_items = False + self._has_conditional_items = False def get_picker_items(self): """Return scene picker items in proper order (back to front)""" @@ -1849,6 +1899,11 @@ def set_data(self, data): self._update_scene_size() self._update_pinned_items() + # Record whether any item is conditioned, then apply the initial + # show/hide for the loaded zoom + rig state. + self._recompute_conditional_flag() + self.refresh_item_visibility() + def drawBackground(self, painter, rect): """Draw the tab's background image layers (back to front)""" # Run default method diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index 729aefb7..b83da46a 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -26,6 +26,7 @@ from mgear.anim_picker.widgets import overlay from mgear.anim_picker.widgets import graphics from mgear.anim_picker.widgets import widget_binding +from mgear.anim_picker.widgets import visibility from mgear.anim_picker.widgets.dialogs.handles_window import ( HandlesPositionWindow, ) @@ -61,6 +62,13 @@ class ItemEditPanel(QtWidgets.QWidget): widget_binding.WIDGET_SLIDER2D, ) + # Visibility mode combo order (index -> visibility mode). + _VIS_MODE_ORDER = ( + visibility.VIS_NONE, + visibility.VIS_CHANNEL, + visibility.VIS_ZOOM, + ) + def __init__(self, parent=None, main_window=None): super().__init__(parent=parent) self.main_window = main_window @@ -118,6 +126,14 @@ def __init__(self, parent=None, main_window=None): self._wx_2d_box = None self.backdrop_title_field = None self.backdrop_radius_sb = None + self.vis_mode_combo = None + self.vis_attr_field = None + self.vis_operator_combo = None + self.vis_threshold_sb = None + self.vis_min_zoom_sb = None + self.vis_max_zoom_sb = None + self._vx_channel_box = None + self._vx_zoom_box = None self._build_ui() self.refresh_fields() @@ -153,6 +169,7 @@ def _build_ui(self): self._build_action_section() self._build_widget_section() self._build_backdrop_section() + self._build_visibility_section() self.content_layout.addStretch() def _add_section(self, title): @@ -497,8 +514,14 @@ def _binding_spin(self): self._fields.append(spin) return spin - def _binding_attr_field(self, placeholder): - """Return a line edit that applies the binding when editing finishes.""" + def _binding_attr_field(self, placeholder, callback=None): + """Return a namespaced-attribute line edit that applies on edit-finish. + + Args: + placeholder (str): the field's placeholder text. + callback (callable, optional): the ``editingFinished`` handler; + defaults to ``_apply_binding`` (the widget binding fields). + """ field = QtWidgets.QLineEdit() field.setPlaceholderText(placeholder) field.setToolTip( @@ -506,7 +529,7 @@ def _binding_attr_field(self, placeholder): "namespace is applied automatically (like control names). A " "namespace you type explicitly is kept as-is." ) - field.editingFinished.connect(self._apply_binding) + field.editingFinished.connect(callback or self._apply_binding) self._fields.append(field) return field @@ -639,6 +662,88 @@ def _build_backdrop_section(self): hint.setWordWrap(True) section.addWidget(hint) + def _capture_zoom_button(self, which): + """Return a button that captures the current view zoom into a bound.""" + button = basic.CallbackButton( + callback=partial(self._capture_zoom, which) + ) + button.setText("Capture current") + button.setToolTip("Set this bound from the view's current zoom level") + self._fields.append(button) + return button + + def _build_visibility_section(self): + section = self._add_section("Visibility") + + mode_form = QtWidgets.QFormLayout() + self.vis_mode_combo = QtWidgets.QComboBox() + self.vis_mode_combo.addItems(["None", "Channel state", "Zoom level"]) + self.vis_mode_combo.setToolTip( + "Show the item only when a Maya attribute passes a test " + "(channel state) or the view zoom is within a range (zoom level). " + "Edit mode always shows every item." + ) + self.vis_mode_combo.currentIndexChanged.connect(self._apply_visibility) + self._fields.append(self.vis_mode_combo) + mode_form.addRow("Condition", self.vis_mode_combo) + section.addLayout(mode_form) + + # Channel-state fields: attribute + operator + threshold. + self._vx_channel_box = QtWidgets.QWidget() + channel_layout = QtWidgets.QVBoxLayout(self._vx_channel_box) + channel_layout.setContentsMargins(0, 0, 0, 0) + attr_form = QtWidgets.QFormLayout() + self.vis_attr_field = self._binding_attr_field( + "node.attribute", callback=self._apply_visibility + ) + attr_form.addRow("Attribute", self.vis_attr_field) + channel_layout.addLayout(attr_form) + test_row = QtWidgets.QHBoxLayout() + test_row.addWidget(QtWidgets.QLabel("Show when")) + self.vis_operator_combo = QtWidgets.QComboBox() + self.vis_operator_combo.addItems(list(visibility.OPERATORS)) + self.vis_operator_combo.setCurrentIndex( + visibility.OPERATORS.index(">=") + ) + self.vis_operator_combo.currentIndexChanged.connect( + self._apply_visibility + ) + self._fields.append(self.vis_operator_combo) + test_row.addWidget(self.vis_operator_combo) + self.vis_threshold_sb = basic.CallBackDoubleSpinBox( + callback=self._apply_visibility, value=0.5, min=-1.0e6, max=1.0e6 + ) + self.vis_threshold_sb.setDecimals(3) + self._fields.append(self.vis_threshold_sb) + test_row.addWidget(self.vis_threshold_sb) + channel_layout.addLayout(test_row) + section.addWidget(self._vx_channel_box) + + # Zoom-level fields: min / max scale, each with a capture button. A + # bound of 0 means open-ended (a real zoom scale is always > 0). + self._vx_zoom_box = QtWidgets.QWidget() + zoom_form = QtWidgets.QFormLayout(self._vx_zoom_box) + zoom_form.setContentsMargins(0, 0, 0, 0) + min_row = QtWidgets.QHBoxLayout() + self.vis_min_zoom_sb = basic.CallBackDoubleSpinBox( + callback=self._apply_visibility, value=0.0, min=0.0, max=1.0e6 + ) + self.vis_min_zoom_sb.setDecimals(3) + self.vis_min_zoom_sb.setToolTip("Lower zoom bound; 0 = no lower bound") + min_row.addWidget(self.vis_min_zoom_sb) + min_row.addWidget(self._capture_zoom_button("min")) + zoom_form.addRow("Min zoom", min_row) + max_row = QtWidgets.QHBoxLayout() + self.vis_max_zoom_sb = basic.CallBackDoubleSpinBox( + callback=self._apply_visibility, value=0.0, min=0.0, max=1.0e6 + ) + self.vis_max_zoom_sb.setDecimals(3) + self.vis_max_zoom_sb.setToolTip("Upper zoom bound; 0 = no upper bound") + max_row.addWidget(self.vis_max_zoom_sb) + max_row.addWidget(self._capture_zoom_button("max")) + zoom_form.addRow("Max zoom", max_row) + section.addWidget(self._vx_zoom_box) + # ------------------------------------------------------------------ # Selection binding # ------------------------------------------------------------------ @@ -715,6 +820,7 @@ def _populate_all(self): self._populate_action() self._populate_widget() self._populate_backdrop() + self._populate_visibility() def _populate_backdrop(self): item = self._active_item() @@ -727,6 +833,37 @@ def _populate_backdrop(self): radius = item.get_corner_radius() if is_backdrop else 0.0 self._set_spin(self.backdrop_radius_sb, round(radius, 4), False) + def _update_visibility_mode(self, mode): + """Show only the sub-box relevant to ``mode`` (VIS_NONE hides both).""" + self._vx_channel_box.setVisible(mode == visibility.VIS_CHANNEL) + self._vx_zoom_box.setVisible(mode == visibility.VIS_ZOOM) + + def _populate_visibility(self): + mode, mode_mixed = self._shared( + lambda item: item.get_visibility().get("mode", visibility.VIS_NONE) + ) + self._set_enum_combo( + self.vis_mode_combo, self._VIS_MODE_ORDER, mode, mode_mixed + ) + + # Condition fields follow the active item (like the widget binding); an + # edit applies to the whole selection via _apply_visibility. + item = self._active_item() + condition = (item.get_visibility() if item else None) or {} + self.vis_attr_field.setText(condition.get("attr", "")) + self._set_enum_combo( + self.vis_operator_combo, + visibility.OPERATORS, + condition.get("operator", ">="), + ) + self.vis_threshold_sb.setValue(condition.get("threshold", 0.5)) + self.vis_min_zoom_sb.setValue(condition.get("min_zoom") or 0.0) + self.vis_max_zoom_sb.setValue(condition.get("max_zoom") or 0.0) + + self._update_visibility_mode( + visibility.VIS_NONE if mode_mixed else mode + ) + def _update_widget_visibility(self, widget_type): """Show only the sub-rows relevant to ``widget_type`` (None hides all).""" self._wx_attr_row.setVisible( @@ -745,14 +882,12 @@ def _update_widget_visibility(self, widget_type): def _populate_widget(self): wtype, wtype_mixed = self._shared(lambda item: item.get_widget_type()) - self.widget_type_combo.blockSignals(True) - if wtype_mixed or wtype not in self._WIDGET_TYPE_ORDER: - self.widget_type_combo.setCurrentIndex(-1 if wtype_mixed else 0) - else: - self.widget_type_combo.setCurrentIndex( - self._WIDGET_TYPE_ORDER.index(wtype) - ) - self.widget_type_combo.blockSignals(False) + self._set_enum_combo( + self.widget_type_combo, + self._WIDGET_TYPE_ORDER, + wtype, + wtype_mixed, + ) # Binding fields follow the active item (like controls / menus); an # edit applies to the whole selection via _apply_binding. @@ -836,6 +971,19 @@ def _set_tristate(self, checkbox, value, mixed): checkbox.setCheckState(state) checkbox.blockSignals(False) + def _set_enum_combo(self, combo, order, value, mixed=False): + """Select ``value``'s index in ``combo`` (signals blocked). + + Mixed selections clear the combo (index -1); an unknown value falls + back to the first entry. ``order`` is the index -> value sequence. + """ + combo.blockSignals(True) + if mixed or value not in order: + combo.setCurrentIndex(-1 if mixed else 0) + else: + combo.setCurrentIndex(order.index(value)) + combo.blockSignals(False) + def _populate_transform(self): x, x_mixed = self._shared(lambda item: round(item.x(), 4)) y, y_mixed = self._shared(lambda item: round(item.y(), 4)) @@ -884,14 +1032,9 @@ def _populate_appearance(self): self._set_spin(self.text_alpha_sb, talpha, talpha_mixed) align, align_mixed = self._shared(lambda item: item.get_text_align()) - self.text_align_combo.blockSignals(True) - if align_mixed or align not in graphics.TEXT_ALIGNS: - self.text_align_combo.setCurrentIndex(-1 if align_mixed else 0) - else: - self.text_align_combo.setCurrentIndex( - graphics.TEXT_ALIGNS.index(align) - ) - self.text_align_combo.blockSignals(False) + self._set_enum_combo( + self.text_align_combo, graphics.TEXT_ALIGNS, align, align_mixed + ) offset, offset_mixed = self._shared( lambda item: round(item.get_text_offset(), 4) @@ -1320,6 +1463,64 @@ def _apply_backdrop_radius(self, *args, **kwargs): item.set_corner_radius(radius) self._repaint_view() + # -- visibility ----------------------------------------------------- + def _collect_visibility(self): + """Build a visibility condition dict from the current field values.""" + index = self.vis_mode_combo.currentIndex() + mode = ( + self._VIS_MODE_ORDER[index] + if index >= 0 + else visibility.VIS_NONE + ) + if mode == visibility.VIS_CHANNEL: + return { + "mode": visibility.VIS_CHANNEL, + "attr": str(self.vis_attr_field.text()).strip(), + "operator": visibility.OPERATORS[ + self.vis_operator_combo.currentIndex() + ], + "threshold": self.vis_threshold_sb.value(), + } + if mode == visibility.VIS_ZOOM: + low = self.vis_min_zoom_sb.value() + high = self.vis_max_zoom_sb.value() + # A bound of 0 means open-ended (a real zoom scale is always > 0). + return { + "mode": visibility.VIS_ZOOM, + "min_zoom": low if low > 0 else None, + "max_zoom": high if high > 0 else None, + } + return {} + + def _apply_visibility(self, *args, **kwargs): + if self._syncing or not self.items: + return + condition = self._collect_visibility() + for item in self.items: + item.set_visibility(condition) + self._update_visibility_mode( + condition.get("mode", visibility.VIS_NONE) + ) + # Refresh the view's "any conditioned item?" gate and re-apply the + # show/hide. The display is unchanged while editing (edit mode forces + # everything visible), but this keeps the runtime state correct. + if self._view is not None: + self._view._recompute_conditional_flag() + self._view.refresh_item_visibility() + self._repaint_view() + + def _capture_zoom(self, which): + """Set a zoom bound from the active view's current zoom scale.""" + view = self._current_view() + if view is None: + return + zoom = round(abs(view.viewportTransform().m11()), 3) + spin = ( + self.vis_min_zoom_sb if which == "min" else self.vis_max_zoom_sb + ) + spin.setValue(zoom) + self._apply_visibility() + # -- controls ------------------------------------------------------- def _add_selected_controls(self): if not self.items: diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py index 768b878d..39e689a1 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_model.py +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -32,6 +32,8 @@ backdrop bool (optional; item is a backdrop container behind others) title str (optional backdrop title) corner_radius float (optional backdrop corner radius; 0 = straight) + visibility dict (optional condition; show only when a channel / zoom test + passes -- see ``widgets.visibility``) """ @@ -64,6 +66,7 @@ def __init__(self): self.backdrop = False self.title = None self.corner_radius = None + self.visibility = None @classmethod def from_dict(cls, data): @@ -120,6 +123,8 @@ def from_dict(cls, data): model.backdrop = True model.title = data.get("title") model.corner_radius = data.get("corner_radius") + if data.get("visibility"): + model.visibility = dict(data["visibility"]) return model @@ -194,4 +199,9 @@ def to_dict(self): if self.corner_radius is not None: data["corner_radius"] = self.corner_radius + # Visibility condition (additive optional key; only emitted when set so + # old readers and unconditioned items are unaffected). + if self.visibility: + data["visibility"] = dict(self.visibility) + return data diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 5b0a30e0..107a0aac 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -33,6 +33,7 @@ from mgear.anim_picker.widgets import mirror from mgear.anim_picker.widgets import overlay from mgear.anim_picker.widgets import widget_binding +from mgear.anim_picker.widgets import visibility from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ from mgear.anim_picker.handlers import python_handlers @@ -151,6 +152,11 @@ def __init__( # items that moves everything geometrically inside it when dragged. self.backdrop = False + # Visibility condition (optional): a channel-state / zoom-level test + # that hides the item in animation mode until it passes. Empty means + # always visible. Evaluated by the view on zoom / selection / time. + self.visibility = {} + def shape(self): path = QtGui.QPainterPath() @@ -794,6 +800,10 @@ def remove(self): self.scene().removeItem(self) self.setParent(None) self.deleteLater() + # Removing the last conditioned item must clear the view's visibility + # gate, else it keeps iterating every item on each zoom / selection. + if hasattr(view, "_recompute_conditional_flag"): + view._recompute_conditional_flag() def get_delta_from_point(self, point): self.cursor_delta = self.pos() - point @@ -1101,6 +1111,54 @@ def refresh_widget_state(self): self.widget_graphic.value_xy = (cur_x, cur_y) self.widget_graphic.update() + # ========================================================================= + # Conditional visibility --- + def get_visibility(self): + """Return the item's visibility condition dict (empty when none).""" + return self.visibility + + def set_visibility(self, condition): + """Set (or clear) the item's visibility condition. + + Args: + condition (dict): a ``widgets.visibility`` condition, or a falsy + value to clear it (the item then stays always visible). + """ + self.visibility = dict(condition) if condition else {} + + def has_visibility_condition(self): + """Return True when the item carries a visibility condition.""" + return bool(self.visibility) + + def evaluate_visibility(self, zoom): + """Show / hide the item for its condition at the current ``zoom``. + + Edit mode always shows the item so a condition can never block editing. + A channel condition reads its attribute here (namespace applied, safe + read); the pure show/hide decision is delegated to + ``widgets.visibility``. A no-op decision (fail-open) keeps the item + visible. + + Args: + zoom (float): the view's current zoom scale. + """ + if __EDIT_MODE__.get(): + self.setVisible(True) + return + condition = self.visibility + if not condition: + self.setVisible(True) + return + value = None + if condition.get("mode") == visibility.VIS_CHANNEL: + attr = widget_handlers.resolve_attr( + condition.get("attr"), self.get_namespace() + ) + value = widget_handlers.read_attr(attr) if attr else None + self.setVisible( + visibility.evaluate(condition, {"zoom": zoom, "value": value}) + ) + # ========================================================================= # Backdrop container --- def get_backdrop(self): @@ -1561,6 +1619,10 @@ def set_data(self, data): self.set_corner_radius(model.corner_radius) self.set_backdrop(True) + # Visibility condition (optional, additive key). + if model.visibility: + self.set_visibility(model.visibility) + def get_data(self): """Get picker item data in dictionary form. @@ -1611,4 +1673,7 @@ def get_data(self): model.title = self.get_backdrop_title() model.corner_radius = self.get_corner_radius() + if self.visibility: + model.visibility = dict(self.visibility) + return model.to_dict() diff --git a/release/scripts/mgear/anim_picker/widgets/visibility.py b/release/scripts/mgear/anim_picker/widgets/visibility.py new file mode 100644 index 00000000..73179328 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/visibility.py @@ -0,0 +1,144 @@ +"""Qt/Maya-free evaluation of a picker item's visibility condition. + +A picker item can carry an optional *visibility condition* that decides whether +it is shown in animation mode. Two modes are supported: + +``channel`` + Show only when a Maya attribute satisfies a comparison against a + threshold:: + + {"mode": "channel", "attr": "node.attr", + "operator": ">=", "threshold": 0.5} + +``zoom`` + Show only when the view zoom scale falls within an optional range (either + bound may be omitted for an open-ended range):: + + {"mode": "zoom", "min_zoom": 1.5, "max_zoom": None} + +This module holds only the pure decision: given a condition and a *context* +(the current zoom and, for a channel condition, the already-read attribute +value), it returns whether the item is visible. The Maya attribute read and the +Qt ``setVisible`` live in ``PickerItem`` / ``view``; this stays unit-testable +standalone, matching ``overlay``, ``alignment``, and ``widget_binding``. + +Fail-open rule: an empty or malformed condition, or a channel condition whose +attribute could not be read (value is None), evaluates to *visible*. A +mis-authored condition must never hide a control with no obvious way to get it +back. +""" + + +# Visibility condition modes. +VIS_NONE = "" +VIS_ZOOM = "zoom" +VIS_CHANNEL = "channel" + +# Comparison operators for a channel condition, in menu order. +OPERATORS = ( + "==", + "!=", + ">", + ">=", + "<", + "<=", +) + +# Float tolerance for equality comparisons. +_EPSILON = 1e-6 + + +def compare(value, operator, threshold): + """Return the truth of ``value threshold`` numerically. + + Equality (``==`` / ``!=``) uses a small tolerance so float channel values + compare sensibly. An unknown operator returns True (fail-open). + + Args: + value (float): the left-hand value (e.g. an attribute value). + operator (str): one of ``OPERATORS``. + threshold (float): the right-hand value to compare against. + + Returns: + bool: the comparison result. + """ + try: + value = float(value) + threshold = float(threshold) + except (TypeError, ValueError): + return True + if operator == "==": + return abs(value - threshold) <= _EPSILON + if operator == "!=": + return abs(value - threshold) > _EPSILON + if operator == ">": + return value > threshold + if operator == ">=": + return value >= threshold + if operator == "<": + return value < threshold + if operator == "<=": + return value <= threshold + return True + + +def in_range(zoom, min_zoom, max_zoom): + """Return True when ``zoom`` is within ``[min_zoom, max_zoom]``. + + Either bound may be None for an open-ended range; when both are None the + range is unbounded and the result is always True. + + Args: + zoom (float): the current view zoom scale. + min_zoom (float): lower bound, or None for no lower bound. + max_zoom (float): upper bound, or None for no upper bound. + + Returns: + bool: whether ``zoom`` lies within the range. + """ + if min_zoom is not None and zoom < min_zoom: + return False + if max_zoom is not None and zoom > max_zoom: + return False + return True + + +def evaluate(condition, context): + """Return whether an item is visible for ``condition`` under ``context``. + + Args: + condition (dict): the item's visibility condition, or a falsy value + when the item has no condition. + context (dict): ``{"zoom": float, "value": }`` -- + ``value`` is the already-read channel attribute value (the Maya + read happens in the caller), or None when there is no value / attr. + + Returns: + bool: True to show the item, False to hide it. Fail-open: an empty or + malformed condition, or a channel condition with no readable value, + returns True. + """ + if not condition: + return True + context = context or {} + mode = condition.get("mode") + + if mode == VIS_CHANNEL: + value = context.get("value") + if value is None: + return True + return compare( + value, + condition.get("operator", ">="), + condition.get("threshold", 0.0), + ) + + if mode == VIS_ZOOM: + return in_range( + context.get("zoom", 1.0), + condition.get("min_zoom"), + condition.get("max_zoom"), + ) + + # Unknown / VIS_NONE mode: fail open. + return True diff --git a/releaseLog.rst b/releaseLog.rst index 499da6c1..ebba76f5 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Conditional item visibility — a picker item can now be shown only when a condition passes, so a busy character picker declutters itself in animation mode. Two condition modes: **channel state** (show only when a Maya attribute passes a comparison, e.g. IK controls appear only when ``arm_L_ctl.ikBlend >= 0.5``) and **zoom level** (show only when the view zoom is within a range, e.g. fine detail controls appear once zoomed in). Set it from the inline panel's new **Visibility** section — a mode selector plus the attribute / operator / threshold for a channel condition, or min / max zoom (0 = no bound) with a **Capture current** button that reads the view's current zoom so you set a threshold by zooming to where the item should (dis)appear. Conditions re-evaluate on demand without polling — never per playback frame, so a heavy rig is not slowed: zoom conditions update on every zoom / fit, and channel conditions on selection change and on **mouse-over** the picker (like the Channel Master tool — moving the mouse over the UI re-reads the bound attributes, no focus needed, so a manual or animated channel change is picked up on hover; hovering also re-syncs interactive checkbox / slider widgets to the rig). **Edit mode always shows every item** so a condition never blocks authoring, and a missing / malformed condition fails open (stays visible) so a control is never lost. Persisted as one additive optional item key (``visibility``, emitted only when set), so older pickers load unchanged and unconditioned items are unaffected; a picker with no conditions incurs no per-refresh cost * Anim Picker: Interactive widgets + trace from selection — a picker item can now be an interactive **widget** instead of a plain button: a **checkbox** (click toggles a bound boolean attribute and/or runs an on/off script, drawn on/off), a **1D slider** (drag maps to a bound attribute over a ``[min, max]`` range, horizontal or vertical, the whole drag one undo), or a **2D slider** (the knob drives two attributes X/Y over their ranges, with an optional recenter-on-release). A widget can bind to a Maya attribute **and/or** a script — the script receives the state as ``__STATE__`` / ``__VALUE__`` / ``__X__`` / ``__Y__`` — and reflects the bound attribute's value on the picker's existing selection-change refresh (no per-frame polling). Locked / connected / missing attributes warn and are skipped rather than throwing. Set it from the inline panel's new **Widget** section (type, attribute(s) + min/max, slider orientation, 2D recenter, per-state Edit script). Separately, a tool-strip **Trace** command (with a Front / Side / Top drop-down) builds one button per selected control shaped like the control's projected **silhouette** (convex hull), positioned to match the rig and **colored from the control** — a fast first pass of a picker from the rig. Persisted as additive optional item keys (``widget`` / ``binding`` / ``scripts``, emitted only for non-button items), so older pickers load unchanged and plain buttons are unaffected; traced buttons are ordinary items and add no new keys. Widgets render as compact, modern controls (a self-styled rounded body, a thin accent-filled groove with a fixed-size round handle / 2D knob that never stretches, and a clear checkmark), are created by **dragging a tile** (Button / Checkbox / Slider / 2D Slider) from the left tool strip onto the canvas at the drop position, and come seeded with a "just print the value" script so they work immediately and double as an editable example. The script editor's variable reference now documents the widget variables (`__STATE__` / `__VALUE__` / `__X__` / `__Y__`) with samples, opens focused on the code area, and seeds an empty widget script with a per-state snippet. Tracing also auto-detects `_L` / `_R` control pairs (the same naming logic as pickWalk) and links the traced buttons as mirror pairs. Item **text** can be aligned (center / top / bottom / left / right) with a pixel offset so labels sit outside the button, and a pinned overlay item now **keeps its apparent size** when pinned (instead of snapping tiny). **Align & distribute** tools (an Align section in the left tool strip) line up or evenly space the selected items. New **backdrop containers** — drag a Backdrop tile onto the canvas to add a titled, colored, adjustable-transparency panel with round or straight corners that sits behind the buttons; dragging a backdrop moves everything inside it together, backdrops can be nested (the inner one stays selectable), and the title bar follows the rounded corners * Anim Picker: Tab reorder + multi-tab view — tabs can now be **dragged to reorder** on the tab bar in edit mode (the right-click Move actions remain as a fallback), and the order saves exactly as before. A new **View** selector (Tabbed / Grid / Rows / Columns) in the character bar switches the picker area between the one-at-a-time tabbed view (default) and a **multi-tab tiled view** that shows every tab's picker at once — in a grid, a vertical stack, or a horizontal row — each cell a fully live, interactive picker (selecting controls / running actions works as usual). Clicking a cell marks it the active picker (highlighted header) so per-view actions (add item, fit) target it. The chosen mode + grid column count persist via user settings and are never written into the picker data, so the ``.pkr`` is unchanged and older pickers load identically * Anim Picker: Pinned / overlay HUD buttons — a picker item can be **pinned to the viewport** so it stays at a fixed screen position and constant size, ignoring canvas pan and zoom (e.g. a global reset, a space switch, a settings button locked to a corner). A pinned item is placed by a **3x3 anchor** (corners / edges / center) plus an inward pixel **offset**, and keeps all its normal button behavior (control selection, custom action, menus, color, text, shape). Set it from the inline panel's new **Pin** section (Pinned checkbox, 3x3 anchor picker, X / Y offset) or the tool strip's **Pin** quick command (anchors to the item's current screen region); in edit mode you can also drag a pinned item on the canvas to set its offset (the anchor snaps to the nearest region). Pinned items are excluded from the canvas' pan / zoom extent, so a HUD overlay never enlarges the scrollable scene or gets framed by "reset view". Persisted as additive optional item keys (``pinned`` / ``anchor`` / ``offset``), so older pickers load unchanged and non-pinned items are unaffected @@ -19,6 +20,7 @@ Release Log * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) **Bug Fix** + * Anim Picker: Fix slider / 2D-slider widget knob leaving a ghost of its previous position after a drag — the fixed-size knob overhangs a thin track, but the widget's bounding rect stopped at the track, so the knob's overhang was never repainted; the bounding rect now includes the knob radius so the old position is cleared * Anim Picker: Fix "Picker shape from curve not working" #598 — remove the JSON-to-Python-literal replace hack that corrupted control names/paths containing "true", and guard shape-less transforms during curves-to-picker conversion * Anim Picker: Fix latent bugs in picker_node / maya_handlers — a .format() typo, a setAttr that never hid the data node, an assertion that never fired on referenced nodes, and an always-false dictionary type check From 65378792b47078e623644f020dc9b111f6978262 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Sun, 12 Jul 2026 11:47:29 +0900 Subject: [PATCH 14/25] AnimPicker: Shapes: Add SVG vector shapes with drag-and-drop and partial import A picker item can now be a real vector shape -- smooth curves and holes, not just a straight-line polygon or circle. Drop an .svg file on the canvas in edit mode (or use the Import SVG command), and it becomes one curved item at the drop position, editable like any item: select on its silhouette, color, mirror, scale and rotate (the transform is baked into the vector geometry, the analog of moving polygon handles). Import is gracefully partial: the supported geometry (path with full curve/arc commands, rect, circle, ellipse, polygon, polyline, line, nested in g groups with transforms and viewBox) is kept, while unsupported elements (text, images, gradients, filters, clip/mask, use, style, script, animation) are discarded and reported. A mixed file still imports its shapes and lists what it dropped; an all-unsupported file creates nothing and says why; a malformed file never raises. Vector shapes render filled or as lines of an adjustable thickness (stroke); the mode is auto-detected from the SVG's fill/stroke on import (line-art comes in as strokes, so it is not filled to nothing) and is switchable per item in the Shape panel. Vector items hide the polygon "show handles" option and show an "SVG" badge on hover instead of the border highlight. The SVG parser ships as a reusable, Qt/Maya-free mgear.core.svg_import (subpaths + suggested render mode; a flip_y option for y-up views) so other tools can use it. Persisted as one additive optional item key (svg), so older pickers load unchanged and polygon items are unaffected; the existing curve<->picker conversion is kept. --- .../scripts/mgear/anim_picker/main_window.py | 20 + release/scripts/mgear/anim_picker/view.py | 133 +++- .../mgear/anim_picker/widgets/edit_panel.py | 110 ++- .../mgear/anim_picker/widgets/graphics.py | 149 ++++ .../anim_picker/widgets/item_manipulator.py | 27 +- .../mgear/anim_picker/widgets/item_model.py | 10 + .../mgear/anim_picker/widgets/picker_item.py | 110 ++- release/scripts/mgear/core/svg_import.py | 725 ++++++++++++++++++ releaseLog.rst | 1 + 9 files changed, 1252 insertions(+), 33 deletions(-) create mode 100644 release/scripts/mgear/core/svg_import.py diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index bb95c46a..5998f4f8 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -407,6 +407,13 @@ def add_tab_widget(self, name="default"): self._cmd_add_item, tool_bar.mgear_icon("mgear_plus-square"), ) + self.left_toolbar.add_command( + "SVG", + "Import an .svg file as a vector shape " + "(or drag one onto the canvas)", + self._cmd_import_svg, + tool_bar.mgear_icon("mgear_image"), + ) self._selection_commands = [ self.left_toolbar.add_command( "Dup", @@ -674,6 +681,19 @@ def _cmd_add_item(self): view.add_picker_item_gui(QtCore.QPointF(0, 0)) self._after_command() + def _cmd_import_svg(self): + """Import one or more .svg files as vector items (file dialog).""" + view = self._current_view() + if view is None: + return + paths, _ = QtWidgets.QFileDialog.getOpenFileNames( + self, "Import SVG", "", "SVG files (*.svg)" + ) + for path in paths or []: + view.add_svg_item(path, view.get_center_pos()) + if paths: + self._after_command() + def _cmd_duplicate(self): items = self._selected_items() if items: diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 59df3916..47470e5b 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -34,6 +34,7 @@ from mgear.anim_picker.widgets import overlay from mgear.anim_picker.widgets import silhouette from mgear.anim_picker.widgets import widget_binding +from mgear.core import svg_import from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import maya_handlers @@ -768,6 +769,66 @@ def add_widget_item(self, widget_type, mouse_pos=None): self.scene().select_picker_items([ctrl]) return ctrl + def add_svg_item(self, path, scene_pos=None): + """Import an ``.svg`` file as a vector picker item. + + Parses the SVG's supported geometry (discarding and reporting + unsupported elements), and creates one vector item at ``scene_pos``. + Nothing is created when the file has no supported geometry; either way + the import never raises. + + Args: + path (str): path to the ``.svg`` file. + scene_pos (QPointF, optional): scene position for the new item + (defaults to the view center). + + Returns: + PickerItem: the created (and selected) vector item, or None when the + SVG had no supported geometry. + """ + name = os.path.basename(path) + try: + with open(path, "r") as svg_file: + text = svg_file.read() + except IOError as exc: + mgear.log( + "anim_picker: could not read '{}' ({})".format(path, exc), + mgear.sev_warning, + ) + return None + + # flip_y: the picker view is y-up, so the SVG (y-down) is flipped to + # import upright. mode is a suggested fill / stroke render (line-art + # icons come back as stroke, so they are not filled to nothing). + subpaths, dropped, mode = svg_import.parse_svg(text, flip_y=True) + if not subpaths: + reason = ( + "ignored unsupported: {}".format(", ".join(dropped)) + if dropped + else "no supported geometry" + ) + mgear.log( + "anim_picker: '{}' imported nothing ({})".format(name, reason), + mgear.sev_warning, + ) + return None + if dropped: + mgear.log( + "anim_picker: imported '{}'; ignored unsupported: {}".format( + name, ", ".join(dropped) + ), + mgear.sev_warning, + ) + + ctrl = self.add_picker_item() + if scene_pos is not None: + ctrl.setPos(scene_pos) + ctrl.set_data( + {"svg": {"name": name, "subpaths": subpaths, "mode": mode}} + ) + self.scene().select_picker_items([ctrl]) + return ctrl + def add_backdrop_item(self, mouse_pos=None, fit_items=None): """Create a backdrop container, sent behind the picker items. @@ -858,34 +919,64 @@ def _is_palette_drag(self, event): tool_bar.WIDGET_MIME ) + def _svg_drop_paths(self, event): + """Return the ``.svg`` local file paths in a file-URL drag (edit mode). + + Empty when not in edit mode, not a URL drag, or no dropped file is an + ``.svg`` -- so a non-SVG file drop falls through to the default. + """ + if not __EDIT_MODE__.get(): + return [] + mime = event.mimeData() + if not mime.hasUrls(): + return [] + paths = [] + for url in mime.urls(): + local = url.toLocalFile() + if local and local.lower().endswith(".svg"): + paths.append(local) + return paths + + def _drop_view_pos(self, event): + """Return the drop position in view coords (Qt5 pos / Qt6 position).""" + # Qt6 (Maya 2025+) drops pos() in favor of position(); support both. + try: + return event.position().toPoint() + except AttributeError: + return event.pos() + def dragEnterEvent(self, event): - """Accept a palette drag (widget/item tile) in edit mode.""" - if self._is_palette_drag(event): + """Accept a palette drag or an ``.svg`` file drag in edit mode.""" + if self._is_palette_drag(event) or self._svg_drop_paths(event): event.acceptProposedAction() else: QtWidgets.QGraphicsView.dragEnterEvent(self, event) def dragMoveEvent(self, event): - """Keep accepting the palette drag while it hovers the canvas.""" - if self._is_palette_drag(event): + """Keep accepting a palette / ``.svg`` drag while it hovers the canvas.""" + if self._is_palette_drag(event) or self._svg_drop_paths(event): event.acceptProposedAction() else: QtWidgets.QGraphicsView.dragMoveEvent(self, event) def dropEvent(self, event): - """Create the dropped widget/item at the drop position (edit mode).""" - if not self._is_palette_drag(event): - QtWidgets.QGraphicsView.dropEvent(self, event) + """Create the dropped widget / SVG item at the drop position (edit).""" + if self._is_palette_drag(event): + widget_type = bytes( + event.mimeData().data(tool_bar.WIDGET_MIME) + ).decode("utf-8") + scene_pos = self.mapToScene(self._drop_view_pos(event)) + self.add_widget_item(widget_type, scene_pos) + event.acceptProposedAction() return - mime = event.mimeData() - widget_type = bytes(mime.data(tool_bar.WIDGET_MIME)).decode("utf-8") - # Qt6 (Maya 2025+) drops pos() in favor of position(); support both. - try: - view_pos = event.position().toPoint() - except AttributeError: - view_pos = event.pos() - self.add_widget_item(widget_type, self.mapToScene(view_pos)) - event.acceptProposedAction() + svg_paths = self._svg_drop_paths(event) + if svg_paths: + scene_pos = self.mapToScene(self._drop_view_pos(event)) + for path in svg_paths: + self.add_svg_item(path, scene_pos) + event.acceptProposedAction() + return + QtWidgets.QGraphicsView.dropEvent(self, event) def add_picker_item_selected(self, mouse_pos=None): """Add new PickerItem to current view""" @@ -1548,8 +1639,14 @@ def _mirror_item_to(self, src, dst): pos = mirror.mirror_position([src.x(), src.y()], axis) dst.setPos(pos[0], pos[1]) dst.setRotation(mirror.mirror_rotation(src.rotation())) - src_handles = [[h.x(), h.y()] for h in src.handles] - dst.set_handles(mirror.mirror_handles(src_handles)) + if src.is_vector_shape(): + dst.set_svg_shape(dict(src.get_svg_shape())) + dst.set_svg_subpaths( + svg_import.scale_subpaths(src.get_svg_subpaths(), -1.0, 1.0) + ) + else: + src_handles = [[h.x(), h.y()] for h in src.handles] + dst.set_handles(mirror.mirror_handles(src_handles)) dst.set_text(src.get_text()) dst.update() diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index b83da46a..f0d318d3 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -27,6 +27,7 @@ from mgear.anim_picker.widgets import graphics from mgear.anim_picker.widgets import widget_binding from mgear.anim_picker.widgets import visibility +from mgear.core import svg_import from mgear.anim_picker.widgets.dialogs.handles_window import ( HandlesPositionWindow, ) @@ -69,6 +70,9 @@ class ItemEditPanel(QtWidgets.QWidget): visibility.VIS_ZOOM, ) + # Vector render mode combo order (index -> svg_import mode). + _SVG_MODE_ORDER = (svg_import.MODE_FILL, svg_import.MODE_STROKE) + def __init__(self, parent=None, main_window=None): super().__init__(parent=parent) self.main_window = main_window @@ -98,6 +102,11 @@ def __init__(self, parent=None, main_window=None): self.text_offset_sb = None self.count_sb = None self.handles_cb = None + self._shape_polygon_box = None + self._shape_vector_box = None + self._shape_vector_label = None + self.svg_mode_combo = None + self.svg_width_sb = None self.control_list = None self.menus_list = None self.custom_action_cb = None @@ -309,22 +318,26 @@ def _build_appearance_section(self): def _build_shape_section(self): section = self._add_section("Shape") + # Polygon point editing -- hidden for a vector (SVG) item, which has no + # per-point handles (including the "Show handles" toggle). + self._shape_polygon_box = QtWidgets.QWidget() + poly_layout = QtWidgets.QVBoxLayout(self._shape_polygon_box) + poly_layout.setContentsMargins(0, 0, 0, 0) self.handles_cb = QtWidgets.QCheckBox("Show handles") self.handles_cb.setTristate(True) self.handles_cb.clicked.connect(self._apply_show_handles) self._fields.append(self.handles_cb) - section.addWidget(self.handles_cb) - + poly_layout.addWidget(self.handles_cb) count_row = QtWidgets.QHBoxLayout() count_row.addWidget(QtWidgets.QLabel("Vtx count")) self.count_sb = self._int_spin(self._apply_point_count, 2, 200) count_row.addWidget(self.count_sb) - section.addLayout(count_row) - + poly_layout.addLayout(count_row) handles_btn = basic.CallbackButton(callback=self._edit_handles) handles_btn.setText("Handles Positions...") self._fields.append(handles_btn) - section.addWidget(handles_btn) + poly_layout.addWidget(handles_btn) + section.addWidget(self._shape_polygon_box) shapes_btn = basic.CallbackButton(callback=self._open_shape_library) shapes_btn.setText("Shapes...") @@ -332,6 +345,48 @@ def _build_shape_section(self): self._fields.append(shapes_btn) section.addWidget(shapes_btn) + # Vector (SVG) import: create a curved item from an .svg file (or drag + # a file onto the canvas). + import_btn = basic.CallbackButton(callback=self._import_svg) + import_btn.setText("Import SVG...") + import_btn.setToolTip( + "Import an .svg file as a vector shape " + "(or drag one onto the canvas)" + ) + self._fields.append(import_btn) + section.addWidget(import_btn) + + # Vector-only controls (render fill vs stroke + a summary), shown only + # when the active item is a vector shape. + self._shape_vector_box = QtWidgets.QWidget() + vec_layout = QtWidgets.QVBoxLayout(self._shape_vector_box) + vec_layout.setContentsMargins(0, 0, 0, 0) + self._shape_vector_label = QtWidgets.QLabel("") + self._shape_vector_label.setWordWrap(True) + vec_layout.addWidget(self._shape_vector_label) + render_row = QtWidgets.QHBoxLayout() + render_row.addWidget(QtWidgets.QLabel("Render")) + self.svg_mode_combo = QtWidgets.QComboBox() + self.svg_mode_combo.addItems(["Fill", "Stroke (lines)"]) + self.svg_mode_combo.setToolTip( + "Fill the shape, or draw it as lines of a given thickness " + "(better for line-art icons)" + ) + self.svg_mode_combo.currentIndexChanged.connect(self._apply_svg_mode) + self._fields.append(self.svg_mode_combo) + render_row.addWidget(self.svg_mode_combo) + self.svg_width_sb = basic.CallBackDoubleSpinBox( + callback=self._apply_svg_stroke_width, + value=2.0, + min=0.1, + max=100.0, + ) + self.svg_width_sb.setDecimals(1) + self.svg_width_sb.setToolTip("Line thickness (stroke mode)") + render_row.addWidget(self.svg_width_sb) + vec_layout.addLayout(render_row) + section.addWidget(self._shape_vector_box) + def _build_pin_section(self): section = self._add_section("Pin") @@ -1050,6 +1105,51 @@ def _populate_shape(self): ) self._set_tristate(self.handles_cb, status, status_mixed) + # Show polygon point editing only for a polygon item; the vector render + # controls (fill / stroke + width) only for a vector item. + item = self._active_item() + is_vector = item is not None and item.is_vector_shape() + self._shape_polygon_box.setVisible(not is_vector) + self._shape_vector_box.setVisible(is_vector) + if not is_vector: + return + svg = item.get_svg_shape() + self._shape_vector_label.setText( + "Vector: {} ({} subpaths)".format( + svg.get("name", "(svg)"), len(svg.get("subpaths", [])) + ) + ) + self._set_enum_combo( + self.svg_mode_combo, self._SVG_MODE_ORDER, item.get_svg_mode() + ) + self.svg_width_sb.setValue(item.get_svg_stroke_width()) + + def _apply_svg_mode(self, *args, **kwargs): + if self._syncing or not self.items: + return + index = self.svg_mode_combo.currentIndex() + if index < 0: + return + mode = self._SVG_MODE_ORDER[index] + for item in self.items: + if item.is_vector_shape(): + item.set_svg_mode(mode) + self._repaint_view() + + def _apply_svg_stroke_width(self, *args, **kwargs): + if self._syncing or not self.items: + return + width = self.svg_width_sb.value() + for item in self.items: + if item.is_vector_shape(): + item.set_svg_stroke_width(width) + self._repaint_view() + + def _import_svg(self): + """Open the main window's SVG import (file dialog).""" + if self.main_window is not None: + self.main_window._cmd_import_svg() + def _populate_controls(self): self.control_list.clear() item = self._active_item() diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index a5d63e96..d0959b0f 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -9,6 +9,7 @@ from mgear.vendor.Qt import QtWidgets from mgear.anim_picker.widgets import widget_binding +from mgear.core import svg_import class DefaultPolygon(QtWidgets.QGraphicsObject): @@ -694,6 +695,154 @@ def _paint_title(self, painter, rect, color, radius): painter.restore() +class VectorGraphic(DefaultPolygon): + """Vector (curved) shape drawn as the item's body, from imported SVG. + + A vector item hides its plain polygon and shows this instead: a compound + ``QPainterPath`` (with curves and holes) built from the item's normalized + subpaths, drawn either **filled** in the item's color or **stroked** as + lines of a given width (so line-art icons read correctly). Clicks fall + through to the parent item, but it accepts hover so a hovered vector item + shows an "SVG" badge (a distinct hover cue, not a border lighten). + ``shape()`` delegates hit-testing to the path. Subpaths / mode / width are + set by the item from its ``svg`` data. + """ + + __DEFAULT_SELECT_COLOR__ = QtGui.QColor(230, 230, 230, 240) + _BADGE = QtCore.QRectF(0.0, 0.0, 34.0, 18.0) + + def __init__(self, parent=None): + DefaultPolygon.__init__(self, parent=parent) + self.subpaths = [] + self._path = QtGui.QPainterPath() + self.selected = False + self.mode = svg_import.MODE_FILL + self.stroke_width = 2.0 + # Accept hover (for the SVG badge) but no mouse buttons, so a click + # falls through to the parent PickerItem for selection. + self.setAcceptHoverEvents(True) + self.setAcceptedMouseButtons(QtCore.Qt.NoButton) + self.setVisible(False) + + def set_subpaths(self, subpaths): + """Set the normalized subpaths and rebuild the cached path.""" + self.prepareGeometryChange() + self.subpaths = [list(sub) for sub in (subpaths or [])] + self._path = self._build_path(self.subpaths) + self.update() + + def set_mode(self, mode): + """Set the render mode (``MODE_FILL`` / ``MODE_STROKE``).""" + self.prepareGeometryChange() + self.mode = mode or svg_import.MODE_FILL + self.update() + + def set_stroke_width(self, width): + """Set the stroke width (used in ``MODE_STROKE``).""" + self.prepareGeometryChange() + self.stroke_width = max(0.1, float(width)) + self.update() + + @staticmethod + def _build_path(subpaths): + """Build a ``QPainterPath`` from M / L / C / Z segments.""" + path = QtGui.QPainterPath() + # Even-odd so overlapping subpaths cut holes (typical icon glyphs). + path.setFillRule(QtCore.Qt.OddEvenFill) + for sub in subpaths: + for segment in sub: + command = segment[0] + if command == "M": + path.moveTo(segment[1], segment[2]) + elif command == "L": + path.lineTo(segment[1], segment[2]) + elif command == "C": + path.cubicTo( + segment[1], + segment[2], + segment[3], + segment[4], + segment[5], + segment[6], + ) + elif command == "Z": + path.closeSubpath() + return path + + def boundingRect(self): + # Pad for the selection border / stroke width / antialias so a moving + # selection or a thick stroke never leaves a paint ghost. + margin = max(2.0, self.stroke_width) + return self._path.boundingRect().adjusted( + -margin, -margin, margin, margin + ) + + def shape(self): + return self._path + + def set_selected_state(self, state): + if state == self.selected: + return + self.selected = state + self.update() + + def _fill_color(self): + parent = self.parentItem() + if parent is not None: + return parent.get_color() + return QtGui.QColor(self.color) + + def paint(self, painter, options, widget=None): + """Draw the path filled or stroked, plus selection / hover cues.""" + if self._path.isEmpty(): + return + painter.setRenderHint(QtGui.QPainter.Antialiasing) + color = QtGui.QColor(self._fill_color()) + if self.mode == svg_import.MODE_STROKE: + pen = QtGui.QPen(color) + pen.setWidthF(self.stroke_width) + pen.setCapStyle(QtCore.Qt.RoundCap) + pen.setJoinStyle(QtCore.Qt.RoundJoin) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawPath(self._path) + else: + painter.fillPath(self._path, QtGui.QBrush(color)) + if self.selected: + painter.fillPath( + self._path, QtGui.QBrush(QtGui.QColor(255, 255, 255, 50)) + ) + if self.selected: + border = QtGui.QPen(self.__DEFAULT_SELECT_COLOR__) + border.setWidthF(2.0) + painter.setPen(border) + painter.setBrush(QtCore.Qt.NoBrush) + painter.drawPath(self._path) + if self._hovered: + self._paint_svg_badge(painter) + + def _paint_svg_badge(self, painter): + """Draw a small upright "SVG" badge over the shape center on hover.""" + badge = QtCore.QRectF(self._BADGE) + badge.moveCenter(self._path.boundingRect().center()) + painter.save() + # Counter the view's Y-flip so the label reads upright. + center = badge.center() + painter.translate(center) + painter.scale(1.0, -1.0) + painter.translate(-center) + painter.setPen(QtCore.Qt.NoPen) + painter.setBrush(QtGui.QBrush(QtGui.QColor(20, 20, 20, 175))) + painter.drawRoundedRect(badge, 4.0, 4.0) + painter.setPen(QtGui.QPen(QtGui.QColor(235, 235, 235, 255))) + font = painter.font() + font.setBold(True) + font.setPointSizeF(9.0) + painter.setFont(font) + painter.drawText(badge, QtCore.Qt.AlignCenter, "SVG") + painter.restore() + + # Text placement relative to the item, plus an inward/outward gap. "center" # keeps the legacy origin-centered behavior; the edge alignments place the text # just outside that edge of the item so it does not overlap the button. diff --git a/release/scripts/mgear/anim_picker/widgets/item_manipulator.py b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py index cd66ce1c..e937c2af 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_manipulator.py +++ b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py @@ -17,6 +17,7 @@ from mgear.vendor.Qt import QtGui from mgear.anim_picker.widgets import manipulator_transform +from mgear.core import svg_import class ItemManipulator(object): @@ -114,8 +115,12 @@ def begin_drag(self, mode, handle, x, y): self._orig_items = [] for item in self.selected_items(): handles = [[h.x(), h.y()] for h in item.handles] + # Capture the vector subpaths too, so a vector item scales its + # curve (baked into the subpaths) rather than its hidden handles. + svg_subpaths = item.get_svg_subpaths() self._orig_items.append( - (item, item.x(), item.y(), item.rotation(), handles) + (item, item.x(), item.y(), item.rotation(), handles, + svg_subpaths) ) def update_drag(self, x, y, keep_aspect=False): @@ -129,16 +134,22 @@ def _update_scale(self, x, y, keep_aspect): anchor_x, anchor_y, sx, sy = manipulator_transform.scale_factors( self._orig_bounds, self._drag_handle, x, y, keep_aspect ) - for item, ox, oy, _orot, ohandles in self._orig_items: + for item, ox, oy, _orot, ohandles, osvg in self._orig_items: item.setPos( anchor_x + (ox - anchor_x) * sx, anchor_y + (oy - anchor_y) * sy, ) - # Scale each item's handles in its own (axis-aligned) local frame, - # rebuilt from the captured originals. Non-uniform scale of a - # rotated item shears it (accepted v1 limitation); uniform is exact. - for handle, (hx, hy) in zip(item.handles, ohandles): - handle.setPos(hx * sx, hy * sy) + # Scale the body geometry in the item's own (axis-aligned) local + # frame, rebuilt from the captured originals. A vector item scales + # its subpaths (baked); a polygon scales its handles. Non-uniform + # scale of a rotated item shears it (v1 limitation); uniform exact. + if item.is_vector_shape(): + item.set_svg_subpaths( + svg_import.scale_subpaths(osvg, sx, sy) + ) + else: + for handle, (hx, hy) in zip(item.handles, ohandles): + handle.setPos(hx * sx, hy * sy) item.update() def _update_rotate(self, x, y): @@ -150,7 +161,7 @@ def _update_rotate(self, x, y): # Rigid rotation about the group center: orbit each item's position and # add the same angle to its rotation. A single item's center is its own # bbox center, so it rotates in place. - for item, ox, oy, orot, _ohandles in self._orig_items: + for item, ox, oy, orot, _ohandles, _osvg in self._orig_items: nx, ny = manipulator_transform.rotate_point(ox, oy, cx, cy, angle) item.setPos(nx, ny) item.setRotation(orot + angle) diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py index 39e689a1..97a9f03a 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_model.py +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -34,6 +34,8 @@ corner_radius float (optional backdrop corner radius; 0 = straight) visibility dict (optional condition; show only when a channel / zoom test passes -- see ``widgets.visibility``) + svg dict (optional vector shape imported from SVG; normalized + subpaths + render mode -- see ``mgear.core.svg_import``) """ @@ -67,6 +69,7 @@ def __init__(self): self.title = None self.corner_radius = None self.visibility = None + self.svg = None @classmethod def from_dict(cls, data): @@ -125,6 +128,8 @@ def from_dict(cls, data): model.corner_radius = data.get("corner_radius") if data.get("visibility"): model.visibility = dict(data["visibility"]) + if data.get("svg"): + model.svg = dict(data["svg"]) return model @@ -204,4 +209,9 @@ def to_dict(self): if self.visibility: data["visibility"] = dict(self.visibility) + # Vector (SVG) shape (additive optional key; only emitted for a vector + # item so old readers and polygon items are unaffected). + if self.svg: + data["svg"] = dict(self.svg) + return data diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 107a0aac..394185f4 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -24,6 +24,7 @@ from mgear.anim_picker.widgets.graphics import GraphicText from mgear.anim_picker.widgets.graphics import WidgetGraphic from mgear.anim_picker.widgets.graphics import BackdropGraphic +from mgear.anim_picker.widgets.graphics import VectorGraphic from mgear.anim_picker.widgets.dialogs.item_options import ItemOptionsWindow from mgear.anim_picker.widgets.dialogs.search_replace_dialog import ( SearchAndReplaceDialog, @@ -34,6 +35,7 @@ from mgear.anim_picker.widgets import overlay from mgear.anim_picker.widgets import widget_binding from mgear.anim_picker.widgets import visibility +from mgear.core import svg_import from mgear.anim_picker.handlers import __EDIT_MODE__ from mgear.anim_picker.handlers import __SELECTION__ from mgear.anim_picker.handlers import python_handlers @@ -98,6 +100,10 @@ def __init__( # the plain polygon when the item is a backdrop, hidden otherwise. self.backdrop_graphic = BackdropGraphic(parent=self) + # Vector (curved) body imported from SVG; replaces the plain polygon + # when the item is a vector shape, hidden otherwise. + self.vector_graphic = VectorGraphic(parent=self) + # Interactive-widget affordance (checkbox / slider); drawn above the # polygon and below the text, hidden unless the item is a widget. self.widget_graphic = WidgetGraphic(parent=self) @@ -157,10 +163,18 @@ def __init__( # always visible. Evaluated by the view on zoom / selection / time. self.visibility = {} + # Vector (SVG) shape (optional): normalized subpaths imported from an + # SVG. When set, the item is a curved shape (polygon hidden). Empty + # means the item is a plain polygon. + self.svg = {} + def shape(self): path = QtGui.QPainterPath() - if self.polygon: + # A vector item is hit-tested on its curved silhouette, not the polygon. + if self.svg: + path.addPath(self.vector_graphic.shape()) + elif self.polygon: path.addPath(self.polygon.shape()) # Stop here in default mode @@ -1159,6 +1173,83 @@ def evaluate_visibility(self, zoom): visibility.evaluate(condition, {"zoom": zoom, "value": value}) ) + # ========================================================================= + # Vector (SVG) shape --- + def is_vector_shape(self): + """Return True when the item is a vector (SVG-imported) shape.""" + return bool(self.svg) + + def get_svg_shape(self): + """Return the item's vector shape dict (empty when not a vector).""" + return self.svg + + def set_svg_shape(self, svg): + """Set (or clear) the item's vector shape. + + A vector shape swaps the plain polygon body for the curved + ``vector_graphic`` (like the backdrop swap). Passing a falsy value + reverts the item to its polygon. + + Args: + svg (dict): ``{"subpaths": [...], "name": ...}`` from + ``svg_import.parse_svg``, or a falsy value to clear it. + """ + self.svg = dict(svg) if svg else {} + subpaths = self.svg.get("subpaths", []) if self.svg else [] + # set_subpaths rebuilds the vector path and repaints the child. + self.vector_graphic.set_subpaths(subpaths) + self.vector_graphic.set_mode( + self.svg.get("mode", svg_import.MODE_FILL) if self.svg else None + ) + self.vector_graphic.set_stroke_width(self.svg.get("stroke_width", 2.0)) + # The polygon is kept as the fallback body; hide its drawing while the + # vector graphic is shown (mirrors the backdrop swap). Handles stay + # hidden for a vector item (no per-point editing in this version). A + # vector body takes precedence over a backdrop (one visible body). + self.polygon.setVisible(not self.svg) + self.vector_graphic.setVisible(bool(self.svg)) + if self.svg: + self.backdrop_graphic.setVisible(False) + # The item's shape()/boundingRect derive from the vector path, so tell + # the scene of the geometry change (the item itself paints nothing). + self.prepareGeometryChange() + + def get_svg_subpaths(self): + """Return the vector shape's subpaths (empty when not a vector).""" + return self.svg.get("subpaths", []) if self.svg else [] + + def set_svg_subpaths(self, subpaths): + """Replace the vector shape's subpaths (scale / mirror bake here).""" + if not self.svg: + return + self.svg["subpaths"] = subpaths + self.vector_graphic.set_subpaths(subpaths) + self.prepareGeometryChange() + + def get_svg_mode(self): + """Return the vector render mode (``fill`` / ``stroke``).""" + return self.svg.get("mode", svg_import.MODE_FILL) if self.svg else ( + svg_import.MODE_FILL + ) + + def set_svg_mode(self, mode): + """Set the vector render mode (fill vs stroke).""" + if not self.svg: + return + self.svg["mode"] = mode + self.vector_graphic.set_mode(mode) + + def get_svg_stroke_width(self): + """Return the vector stroke width (used in stroke mode).""" + return self.svg.get("stroke_width", 2.0) if self.svg else 2.0 + + def set_svg_stroke_width(self, width): + """Set the vector stroke width (used in stroke mode).""" + if not self.svg: + return + self.svg["stroke_width"] = width + self.vector_graphic.set_stroke_width(width) + # ========================================================================= # Backdrop container --- def get_backdrop(self): @@ -1229,7 +1320,12 @@ def mirror_rotation(self, angle=None): self.update() def mirror_shape(self): - """Will mirror polygon handles position on X axis""" + """Mirror the item's shape on X (vector subpaths or handles).""" + if self.svg: + self.set_svg_subpaths( + svg_import.scale_subpaths(self.get_svg_subpaths(), -1.0, 1.0) + ) + return handles = [[handle.x(), handle.y()] for handle in self.handles] self.set_handles(mirror.mirror_handles(handles)) @@ -1513,6 +1609,9 @@ def is_selected(self): def set_selected_state(self, state): """Will set border color feedback based on selection state""" self.polygon.set_selected_state(state) + # Route selection to the vector body too (it draws its own border). + if self.svg: + self.vector_graphic.set_selected_state(state) def run_selection_check(self): """Will set selection state based on selection status""" @@ -1623,6 +1722,10 @@ def set_data(self, data): if model.visibility: self.set_visibility(model.visibility) + # Vector (SVG) shape (optional, additive key). + if model.svg: + self.set_svg_shape(model.svg) + def get_data(self): """Get picker item data in dictionary form. @@ -1676,4 +1779,7 @@ def get_data(self): if self.visibility: model.visibility = dict(self.visibility) + if self.svg: + model.svg = dict(self.svg) + return model.to_dict() diff --git a/release/scripts/mgear/core/svg_import.py b/release/scripts/mgear/core/svg_import.py new file mode 100644 index 00000000..d1f27a40 --- /dev/null +++ b/release/scripts/mgear/core/svg_import.py @@ -0,0 +1,725 @@ +"""Qt/Maya-free SVG geometry import. + +Parses a defined *subset* of SVG geometry into a normalized subpath list any +tool can turn into a ``QPainterPath`` (or other renderer), and reports which +unsupported elements were discarded -- a graceful partial import: an SVG that +mixes supported and unsupported content still imports its supported shapes, and +a malformed element never raises. Lives in ``mgear.core`` so tools beyond the +anim picker can reuse it. + +Supported geometry (converted): ``path`` (full ``d`` command set), ``rect`` +(with ``rx`` / ``ry``), ``circle``, ``ellipse``, ``polygon``, ``polyline``, +``line`` -- nested in ``svg`` / ``g`` containers whose ``transform`` (and the +root ``viewBox``) are applied. Everything else (``text``, ``image``, ``use``, +gradients, ``filter``, ``clipPath``, ``mask``, ``pattern``, ``style``, +``script``, animation) is discarded and its tag collected into the report. + +Output segment vocabulary (coordinates absolute, in the fitted space):: + + ("M", x, y) start a subpath + ("L", x, y) line + ("C", x1, y1, x2, y2, x, y) cubic Bezier + ("Z",) close the subpath + +Higher-level commands are normalized to these: ``H`` / ``V`` -> ``L``; smooth +``S`` / ``T`` reflected; quadratic ``Q`` / ``T`` -> cubic; elliptical arc ``A`` +-> a sequence of cubics; ``rect`` / ``circle`` / ``ellipse`` / ``polygon`` / +``polyline`` / ``line`` -> the equivalent segments. + +``parse_svg`` also returns a suggested render *mode* (``fill`` or ``stroke``) +from the source's fill / stroke presentation attributes, so line-art icons +(``fill:none; stroke:...``) can be drawn as strokes rather than filled to +nothing. ``flip_y`` negates y for callers whose view is y-up (e.g. the anim +picker); the default keeps SVG's native y-down. + +Pure string / math only (no Qt, no Maya), so the tokenizer, arc conversion, +transform composition, and fit are unit-testable standalone. +""" + +import math +import re +import xml.etree.ElementTree as ElementTree + + +# Default fitted extent: the imported shape's larger side is scaled to this. +DEFAULT_SIZE = 40.0 + +# Suggested render modes returned alongside the geometry. +MODE_FILL = "fill" +MODE_STROKE = "stroke" + +# Geometry element tags this module converts (local names, namespace-stripped). +SUPPORTED_TAGS = ( + "path", + "rect", + "circle", + "ellipse", + "polygon", + "polyline", + "line", +) + +# Container tags recursed into (their geometry children are converted). +_CONTAINER_TAGS = ("svg", "g") + +# A cubic quarter-circle control-point constant (kappa) for arc / ellipse fits. +_KAPPA = 0.5522847498307936 + + +def _local(tag): + """Return an element tag without its ``{namespace}`` prefix.""" + if "}" in tag: + return tag.rsplit("}", 1)[1] + return tag + + +# ============================================================================= +# Number / token parsing +# ============================================================================= +_NUMBER_RE = re.compile( + r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?" +) + + +def _numbers(text): + """Return every number in ``text`` as a list of floats.""" + return [float(match) for match in _NUMBER_RE.findall(text or "")] + + +# Path command letters and how many numbers each takes per repetition. +_PATH_ARGS = { + "M": 2, + "L": 2, + "H": 1, + "V": 1, + "C": 6, + "S": 4, + "Q": 4, + "T": 2, + "A": 7, + "Z": 0, +} + +_PATH_TOKEN_RE = re.compile( + r"([MmLlHhVvCcSsQqTtAaZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)" +) + + +def _tokenize_path(data): + """Split a path ``d`` string into (command, number) tokens. + + Returns a flat list of items, each either a one-character command string + or a float. Malformed characters are ignored. + """ + tokens = [] + for command, number in _PATH_TOKEN_RE.findall(data or ""): + if command: + tokens.append(command) + elif number: + tokens.append(float(number)) + return tokens + + +# ============================================================================= +# Transforms (2x3 affine as (a, b, c, d, e, f)) +# ============================================================================= +_IDENTITY = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) + + +def _mat_mul(m1, m2): + """Return the product of two 2x3 affine matrices (``m1`` then ``m2``).""" + a1, b1, c1, d1, e1, f1 = m1 + a2, b2, c2, d2, e2, f2 = m2 + return ( + a1 * a2 + c1 * b2, + b1 * a2 + d1 * b2, + a1 * c2 + c1 * d2, + b1 * c2 + d1 * d2, + a1 * e2 + c1 * f2 + e1, + b1 * e2 + d1 * f2 + f1, + ) + + +def _apply(mat, x, y): + """Apply a 2x3 affine matrix to a point, returning ``(x, y)``.""" + a, b, c, d, e, f = mat + return (a * x + c * y + e, b * x + d * y + f) + + +def _parse_transform(text): + """Parse an SVG ``transform`` attribute into a single 2x3 matrix.""" + mat = _IDENTITY + if not text: + return mat + for name, args in re.findall(r"(\w+)\s*\(([^)]*)\)", text): + values = _numbers(args) + mat = _mat_mul(mat, _transform_matrix(name, values)) + return mat + + +def _transform_matrix(name, values): + """Return the 2x3 matrix for a single transform function.""" + if name == "translate": + tx = values[0] if values else 0.0 + ty = values[1] if len(values) > 1 else 0.0 + return (1.0, 0.0, 0.0, 1.0, tx, ty) + if name == "scale": + sx = values[0] if values else 1.0 + sy = values[1] if len(values) > 1 else sx + return (sx, 0.0, 0.0, sy, 0.0, 0.0) + if name == "rotate" and values: + angle = math.radians(values[0]) + cos_a = math.cos(angle) + sin_a = math.sin(angle) + rot = (cos_a, sin_a, -sin_a, cos_a, 0.0, 0.0) + if len(values) >= 3: + cx, cy = values[1], values[2] + rot = _mat_mul((1.0, 0.0, 0.0, 1.0, cx, cy), rot) + rot = _mat_mul(rot, (1.0, 0.0, 0.0, 1.0, -cx, -cy)) + return rot + if name == "matrix" and len(values) >= 6: + return tuple(values[:6]) + if name == "skewX" and values: + return (1.0, 0.0, math.tan(math.radians(values[0])), 1.0, 0.0, 0.0) + if name == "skewY" and values: + return (1.0, math.tan(math.radians(values[0])), 0.0, 1.0, 0.0, 0.0) + return _IDENTITY + + +# ============================================================================= +# Arc -> cubic +# ============================================================================= +def _arc_to_cubics(x0, y0, rx, ry, phi_deg, large_arc, sweep, x, y): + """Convert an SVG elliptical arc to a list of cubic segments. + + Returns a list of ``(x1, y1, x2, y2, ex, ey)`` cubic control/end tuples + starting from the current point ``(x0, y0)``; an empty list when the arc is + degenerate (caller should fall back to a line). + """ + if rx == 0 or ry == 0: + return [] + rx, ry = abs(rx), abs(ry) + phi = math.radians(phi_deg % 360.0) + cos_phi, sin_phi = math.cos(phi), math.sin(phi) + + dx = (x0 - x) / 2.0 + dy = (y0 - y) / 2.0 + x1p = cos_phi * dx + sin_phi * dy + y1p = -sin_phi * dx + cos_phi * dy + + # Correct out-of-range radii. + lam = (x1p * x1p) / (rx * rx) + (y1p * y1p) / (ry * ry) + if lam > 1.0: + scale = math.sqrt(lam) + rx *= scale + ry *= scale + + denom = (rx * rx * y1p * y1p) + (ry * ry * x1p * x1p) + num = (rx * rx * ry * ry) - (rx * rx * y1p * y1p) - (ry * ry * x1p * x1p) + factor = math.sqrt(max(0.0, num / denom)) if denom else 0.0 + if large_arc == sweep: + factor = -factor + cxp = factor * rx * y1p / ry + cyp = -factor * ry * x1p / rx + + cx = cos_phi * cxp - sin_phi * cyp + (x0 + x) / 2.0 + cy = sin_phi * cxp + cos_phi * cyp + (y0 + y) / 2.0 + + def angle(ux, uy, vx, vy): + dot = ux * vx + uy * vy + length = math.hypot(ux, uy) * math.hypot(vx, vy) + value = 0.0 if length == 0 else max(-1.0, min(1.0, dot / length)) + result = math.acos(value) + if ux * vy - uy * vx < 0: + result = -result + return result + + theta1 = angle(1.0, 0.0, (x1p - cxp) / rx, (y1p - cyp) / ry) + delta = angle( + (x1p - cxp) / rx, + (y1p - cyp) / ry, + (-x1p - cxp) / rx, + (-y1p - cyp) / ry, + ) + if not sweep and delta > 0: + delta -= 2.0 * math.pi + elif sweep and delta < 0: + delta += 2.0 * math.pi + + segments = max(1, int(math.ceil(abs(delta) / (math.pi / 2.0)))) + result = [] + step = delta / segments + t = (4.0 / 3.0) * math.tan(step / 4.0) + for index in range(segments): + a1 = theta1 + index * step + a2 = a1 + step + cos1, sin1 = math.cos(a1), math.sin(a1) + cos2, sin2 = math.cos(a2), math.sin(a2) + + def point(cos_a, sin_a): + px = cos_phi * rx * cos_a - sin_phi * ry * sin_a + cx + py = sin_phi * rx * cos_a + cos_phi * ry * sin_a + cy + return px, py + + p1x, p1y = point(cos1, sin1) + p2x, p2y = point(cos2, sin2) + c1x = p1x + t * (cos_phi * -rx * sin1 - sin_phi * ry * cos1) + c1y = p1y + t * (sin_phi * -rx * sin1 + cos_phi * ry * cos1) + c2x = p2x - t * (cos_phi * -rx * sin2 - sin_phi * ry * cos2) + c2y = p2y - t * (sin_phi * -rx * sin2 + cos_phi * ry * cos2) + result.append((c1x, c1y, c2x, c2y, p2x, p2y)) + return result + + +# ============================================================================= +# Path "d" -> normalized subpaths +# ============================================================================= +def _parse_path_d(data): + """Convert a path ``d`` string to subpaths of M/L/C/Z segments.""" + tokens = _tokenize_path(data) + subpaths = [] + current = [] + index = 0 + length = len(tokens) + + cx = cy = 0.0 # current point + sx = sy = 0.0 # subpath start + prev_cmd = "" + prev_c2 = None # previous cubic 2nd control (for S) + prev_q = None # previous quadratic control (for T) + + def flush(): + if current: + subpaths.append(list(current)) + + while index < length: + token = tokens[index] + if isinstance(token, str): + command = token + index += 1 + else: + # Implicit repeat: reuse the previous command; a repeated moveto + # becomes a lineto (SVG spec), preserving relative/absolute case. + command = prev_cmd + if command in ("M", "m"): + command = "l" if command.islower() else "L" + upper = command.upper() + relative = command.islower() + need = _PATH_ARGS.get(upper, 0) + args = tokens[index : index + need] + if len(args) < need or any(isinstance(a, str) for a in args): + break + index += need + + if upper == "M": + x, y = args + if relative: + x, y = cx + x, cy + y + flush() + current = [("M", x, y)] + cx, cy = sx, sy = x, y + elif upper == "L": + x, y = args + if relative: + x, y = cx + x, cy + y + current.append(("L", x, y)) + cx, cy = x, y + elif upper == "H": + x = args[0] + (cx if relative else 0.0) + current.append(("L", x, cy)) + cx = x + elif upper == "V": + y = args[0] + (cy if relative else 0.0) + current.append(("L", cx, y)) + cy = y + elif upper == "C": + pts = _rel_points(args, cx, cy, relative) + current.append(("C",) + pts) + prev_c2 = (pts[2], pts[3]) + cx, cy = pts[4], pts[5] + elif upper == "S": + c1 = _reflect(prev_c2, cx, cy, prev_cmd.upper() in ("C", "S")) + pts = _rel_points(args, cx, cy, relative) + current.append(("C", c1[0], c1[1], pts[0], pts[1], pts[2], pts[3])) + prev_c2 = (pts[0], pts[1]) + cx, cy = pts[2], pts[3] + elif upper == "Q": + pts = _rel_points(args, cx, cy, relative) + seg = _quad_to_cubic(cx, cy, pts[0], pts[1], pts[2], pts[3]) + current.append(seg) + prev_q = (pts[0], pts[1]) + cx, cy = pts[2], pts[3] + elif upper == "T": + qc = _reflect(prev_q, cx, cy, prev_cmd.upper() in ("Q", "T")) + x, y = args + if relative: + x, y = cx + x, cy + y + seg = _quad_to_cubic(cx, cy, qc[0], qc[1], x, y) + current.append(seg) + prev_q = qc + cx, cy = x, y + elif upper == "A": + rx, ry, rot, large, sweep, x, y = args + if relative: + x, y = cx + x, cy + y + cubics = _arc_to_cubics( + cx, cy, rx, ry, rot, int(large), int(sweep), x, y + ) + if cubics: + for c in cubics: + current.append(("C",) + c) + else: + current.append(("L", x, y)) + cx, cy = x, y + elif upper == "Z": + current.append(("Z",)) + cx, cy = sx, sy + + if upper not in ("C", "S"): + prev_c2 = None + if upper not in ("Q", "T"): + prev_q = None + prev_cmd = command + + flush() + return subpaths + + +def _rel_points(args, cx, cy, relative): + """Return ``args`` as absolute coordinates (offset by current point).""" + if not relative: + return tuple(args) + out = [] + for i, value in enumerate(args): + out.append(value + (cx if i % 2 == 0 else cy)) + return tuple(out) + + +def _reflect(prev_ctrl, cx, cy, had_prev): + """Return the reflected control point for a smooth ``S`` / ``T``.""" + if had_prev and prev_ctrl is not None: + return (2.0 * cx - prev_ctrl[0], 2.0 * cy - prev_ctrl[1]) + return (cx, cy) + + +def _quad_to_cubic(x0, y0, qx, qy, x, y): + """Return a cubic ``C`` segment equivalent to a quadratic Bezier.""" + c1x = x0 + 2.0 / 3.0 * (qx - x0) + c1y = y0 + 2.0 / 3.0 * (qy - y0) + c2x = x + 2.0 / 3.0 * (qx - x) + c2y = y + 2.0 / 3.0 * (qy - y) + return ("C", c1x, c1y, c2x, c2y, x, y) + + +# ============================================================================= +# Basic shapes -> normalized subpaths +# ============================================================================= +def _attr_float(element, name, default=0.0): + """Return a float attribute, or ``default`` when absent / unparseable.""" + try: + return float(element.get(name, default)) + except (TypeError, ValueError): + return default + + +def _paint_prop(element, name, inherited): + """Return a paint property (``fill`` / ``stroke``) with SVG inheritance. + + Reads the ``style="fill:...;stroke:..."`` declaration first, then the + presentation attribute, else the value inherited from the parent. + """ + style = element.get("style", "") + if style: + for declaration in style.split(";"): + key, _, value = declaration.partition(":") + if key.strip() == name and value.strip(): + return value.strip().lower() + value = element.get(name) + if value: + return value.strip().lower() + return inherited + + +def _rect_subpaths(element): + x = _attr_float(element, "x") + y = _attr_float(element, "y") + width = _attr_float(element, "width") + height = _attr_float(element, "height") + if width <= 0 or height <= 0: + return [] + rx = element.get("rx") + ry = element.get("ry") + rx = _attr_float(element, "rx") if rx is not None else 0.0 + ry = _attr_float(element, "ry") if ry is not None else rx + if rx <= 0 and ry <= 0: + return [ + [ + ("M", x, y), + ("L", x + width, y), + ("L", x + width, y + height), + ("L", x, y + height), + ("Z",), + ] + ] + rx = min(rx or ry, width / 2.0) + ry = min(ry or rx, height / 2.0) + ox, oy = rx * _KAPPA, ry * _KAPPA + right, bottom = x + width, y + height + top, left = y, x + segs = [("M", x + rx, top)] + segs.append(("L", right - rx, top)) + segs.append( + ("C", right - rx + ox, top, right, top + ry - oy, right, y + ry) + ) + segs.append(("L", right, bottom - ry)) + segs.append( + ("C", right, bottom - ry + oy, right - rx + ox, bottom, + right - rx, bottom) + ) + segs.append(("L", x + rx, bottom)) + segs.append( + ("C", x + rx - ox, bottom, left, bottom - ry + oy, left, bottom - ry) + ) + segs.append(("L", left, y + ry)) + segs.append(("C", left, y + ry - oy, x + rx - ox, top, x + rx, top)) + segs.append(("Z",)) + return [segs] + + +def _ellipse_subpaths(cx, cy, rx, ry): + if rx <= 0 or ry <= 0: + return [] + ox, oy = rx * _KAPPA, ry * _KAPPA + return [ + [ + ("M", cx + rx, cy), + ("C", cx + rx, cy + oy, cx + ox, cy + ry, cx, cy + ry), + ("C", cx - ox, cy + ry, cx - rx, cy + oy, cx - rx, cy), + ("C", cx - rx, cy - oy, cx - ox, cy - ry, cx, cy - ry), + ("C", cx + ox, cy - ry, cx + rx, cy - oy, cx + rx, cy), + ("Z",), + ] + ] + + +def _poly_subpaths(element, close): + numbers = _numbers(element.get("points")) + if len(numbers) < 4: + return [] + segs = [("M", numbers[0], numbers[1])] + for i in range(2, len(numbers) - 1, 2): + segs.append(("L", numbers[i], numbers[i + 1])) + if close: + segs.append(("Z",)) + return [segs] + + +def _element_subpaths(element): + """Return the normalized subpaths for a supported geometry element.""" + tag = _local(element.tag) + if tag == "path": + return _parse_path_d(element.get("d")) + if tag == "rect": + return _rect_subpaths(element) + if tag == "circle": + r = _attr_float(element, "r") + return _ellipse_subpaths( + _attr_float(element, "cx"), _attr_float(element, "cy"), r, r + ) + if tag == "ellipse": + return _ellipse_subpaths( + _attr_float(element, "cx"), + _attr_float(element, "cy"), + _attr_float(element, "rx"), + _attr_float(element, "ry"), + ) + if tag == "polygon": + return _poly_subpaths(element, close=True) + if tag == "polyline": + return _poly_subpaths(element, close=False) + if tag == "line": + return [ + [ + ("M", _attr_float(element, "x1"), _attr_float(element, "y1")), + ("L", _attr_float(element, "x2"), _attr_float(element, "y2")), + ] + ] + return [] + + +# ============================================================================= +# Tree walk + transform application +# ============================================================================= +def _map_segment(segment, fn): + """Return ``segment`` with ``fn(x, y)`` applied to each coord pair. + + A ``Z`` (close) segment carries no coordinates and passes through. + """ + if segment[0] == "Z": + return segment + coords = segment[1:] + out = [segment[0]] + for i in range(0, len(coords), 2): + nx, ny = fn(coords[i], coords[i + 1]) + out.append(nx) + out.append(ny) + return tuple(out) + + +def _transform_segment(segment, mat): + """Return ``segment`` with every coordinate pair passed through ``mat``.""" + return _map_segment(segment, lambda x, y: _apply(mat, x, y)) + + +def _walk(element, matrix, paint, subpaths, dropped, votes): + """Recurse ``element``, converting supported geometry under ``matrix``. + + ``paint`` is the inherited ``(fill, stroke)`` pair; ``votes`` is a mutable + ``[fill_count, stroke_count]`` accumulated per geometry element to hint a + render mode. + """ + tag = _local(element.tag) + local_matrix = _mat_mul(matrix, _parse_transform(element.get("transform"))) + fill = _paint_prop(element, "fill", paint[0]) + stroke = _paint_prop(element, "stroke", paint[1]) + + if tag in _CONTAINER_TAGS: + for child in list(element): + _walk( + child, local_matrix, (fill, stroke), subpaths, dropped, votes + ) + return + if tag in SUPPORTED_TAGS: + try: + element_subpaths = _element_subpaths(element) + except Exception: + dropped.add(tag) + return + for sub in element_subpaths: + subpaths.append( + [_transform_segment(seg, local_matrix) for seg in sub] + ) + # Vote fill vs stroke. SVG's default fill is solid (None -> filled), so + # an element counts as stroke-only when fill is explicitly "none" and a + # stroke is set. + if fill == "none" and stroke and stroke != "none": + votes[1] += 1 + else: + votes[0] += 1 + return + # Anything else (text/image/gradient/filter/use/style/script/...) is + # discarded, but its tag is reported. + dropped.add(tag) + + +# ============================================================================= +# Fit +# ============================================================================= +def _iter_coords(subpaths): + """Yield every ``(x, y)`` coordinate pair across ``subpaths``.""" + for sub in subpaths: + for segment in sub: + coords = segment[1:] + for i in range(0, len(coords), 2): + yield coords[i], coords[i + 1] + + +def _bounds(subpaths): + """Return ``(minx, miny, maxx, maxy)`` over every coordinate, or None.""" + xs = [] + ys = [] + for x, y in _iter_coords(subpaths): + xs.append(x) + ys.append(y) + if not xs: + return None + return (min(xs), min(ys), max(xs), max(ys)) + + +def _map_subpaths(subpaths, fn): + """Return ``subpaths`` with ``fn(x, y)`` applied to every coord pair.""" + return [[_map_segment(seg, fn) for seg in sub] for sub in subpaths] + + +def _fit(subpaths, size, flip_y): + """Center on the origin and scale the larger side to ``size``. + + When ``flip_y`` is True the geometry is negated in y (SVG is y-down; a y-up + view -- e.g. the anim picker -- needs it flipped to import upright). + """ + box = _bounds(subpaths) + if box is None: + return subpaths + minx, miny, maxx, maxy = box + extent = max(maxx - minx, maxy - miny) + scale = (size / extent) if extent else 1.0 + cx = (minx + maxx) / 2.0 + cy = (miny + maxy) / 2.0 + sy = -scale if flip_y else scale + return _map_subpaths( + subpaths, lambda x, y: ((x - cx) * scale, (y - cy) * sy) + ) + + +# ============================================================================= +# Subpath transforms (scale / mirror -- bake into the geometry) +# ============================================================================= +def scale_subpaths(subpaths, sx, sy): + """Return ``subpaths`` with every coordinate scaled by ``(sx, sy)``. + + Baked into the geometry (the vector analog of moving polygon handles), so a + scale or mirror persists. A vertical-axis mirror is ``scale_subpaths(subs, + -1.0, 1.0)``. Scaling the Bezier control points is an exact affine scale. + + Args: + subpaths (list): normalized subpaths. + sx (float): x scale factor. + sy (float): y scale factor. + + Returns: + list: the scaled subpaths. + """ + return _map_subpaths(subpaths, lambda x, y: (x * sx, y * sy)) + + +# ============================================================================= +# Public entry point +# ============================================================================= +def parse_svg(text, size=DEFAULT_SIZE, flip_y=False): + """Parse SVG ``text`` into ``(subpaths, dropped, mode)``. + + Args: + text (str): the SVG document. + size (float): the fitted extent of the imported shape's larger side. + flip_y (bool): negate y (for a y-up view, e.g. the anim picker). + + Returns: + tuple: ``(subpaths, dropped, mode)`` where ``subpaths`` is a list of + subpaths (each a list of ``M`` / ``L`` / ``C`` / ``Z`` segment tuples, + fitted into the target space), ``dropped`` is a sorted list of the + unsupported element tags discarded, and ``mode`` is the suggested + render mode (``MODE_STROKE`` for line-art whose geometry is mostly + stroked, else ``MODE_FILL``). ``subpaths`` is empty when the SVG has no + supported geometry (or is unparseable). + """ + try: + root = ElementTree.fromstring(text) + except ElementTree.ParseError: + return ([], [""], MODE_FILL) + + subpaths = [] + dropped = set() + votes = [0, 0] # [fill, stroke] + + # Apply the root viewBox as an initial translate so geometry sits near the + # origin before fitting (the actual scale is handled by _fit). + matrix = _IDENTITY + view_box = _numbers(root.get("viewBox")) + if len(view_box) == 4: + matrix = (1.0, 0.0, 0.0, 1.0, -view_box[0], -view_box[1]) + + _walk(root, matrix, (None, None), subpaths, dropped, votes) + mode = MODE_STROKE if votes[1] > votes[0] else MODE_FILL + if not subpaths: + return ([], sorted(dropped), mode) + return (_fit(subpaths, size, flip_y), sorted(dropped), mode) diff --git a/releaseLog.rst b/releaseLog.rst index ebba76f5..7084d58c 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Vector shapes from SVG — a picker item can now be a real **vector shape** with smooth curves and holes (a gear, an eye, an arrow, a logo), not just a straight-line polygon or circle. **Drag an ``.svg`` file** from your file browser onto the picker canvas in edit mode (or use the tool strip's **Import SVG...** button / the Shape panel), and it becomes one curved item at the drop position, editable like any other — select on its silhouette, color, mirror, scale / rotate. Vector shapes can be drawn **filled** or as **lines with an adjustable thickness** (stroke), chosen per item in the Shape panel; on import the mode is auto-detected from the SVG's fill / stroke (line-art icons come in as strokes so they are not filled to nothing). Import is **gracefully partial**: the supported geometry (``path`` with full curve/arc commands, ``rect``, ``circle``, ``ellipse``, ``polygon``, ``polyline``, ``line``, nested in ``g`` groups with transforms and ``viewBox``) is kept, while unsupported elements (text, images, gradients, filters, clip / mask, ``use``, style, script, animation) are **discarded and reported** — so a file that mixes supported shapes with, say, a text label still imports its shapes and tells you what it dropped, an all-unsupported file creates nothing and says why, and a malformed file never errors. Imported art is fit to a consistent size and oriented upright. Vector items hide the polygon "show handles" option (they have no per-point handles) and show a small **"SVG" badge on hover** instead of the border highlight, so they read as vector at a glance. Persisted as one additive optional item key (``svg``, emitted only for vector items), so older pickers load unchanged and polygon items are unaffected; the existing curve↔picker conversion is kept. The SVG parser ships as a reusable, Qt/Maya-free ``mgear.core.svg_import`` so other tools can use it. (This version imports and transforms vector shapes; per-anchor curve editing is not yet included.) * Anim Picker: Conditional item visibility — a picker item can now be shown only when a condition passes, so a busy character picker declutters itself in animation mode. Two condition modes: **channel state** (show only when a Maya attribute passes a comparison, e.g. IK controls appear only when ``arm_L_ctl.ikBlend >= 0.5``) and **zoom level** (show only when the view zoom is within a range, e.g. fine detail controls appear once zoomed in). Set it from the inline panel's new **Visibility** section — a mode selector plus the attribute / operator / threshold for a channel condition, or min / max zoom (0 = no bound) with a **Capture current** button that reads the view's current zoom so you set a threshold by zooming to where the item should (dis)appear. Conditions re-evaluate on demand without polling — never per playback frame, so a heavy rig is not slowed: zoom conditions update on every zoom / fit, and channel conditions on selection change and on **mouse-over** the picker (like the Channel Master tool — moving the mouse over the UI re-reads the bound attributes, no focus needed, so a manual or animated channel change is picked up on hover; hovering also re-syncs interactive checkbox / slider widgets to the rig). **Edit mode always shows every item** so a condition never blocks authoring, and a missing / malformed condition fails open (stays visible) so a control is never lost. Persisted as one additive optional item key (``visibility``, emitted only when set), so older pickers load unchanged and unconditioned items are unaffected; a picker with no conditions incurs no per-refresh cost * Anim Picker: Interactive widgets + trace from selection — a picker item can now be an interactive **widget** instead of a plain button: a **checkbox** (click toggles a bound boolean attribute and/or runs an on/off script, drawn on/off), a **1D slider** (drag maps to a bound attribute over a ``[min, max]`` range, horizontal or vertical, the whole drag one undo), or a **2D slider** (the knob drives two attributes X/Y over their ranges, with an optional recenter-on-release). A widget can bind to a Maya attribute **and/or** a script — the script receives the state as ``__STATE__`` / ``__VALUE__`` / ``__X__`` / ``__Y__`` — and reflects the bound attribute's value on the picker's existing selection-change refresh (no per-frame polling). Locked / connected / missing attributes warn and are skipped rather than throwing. Set it from the inline panel's new **Widget** section (type, attribute(s) + min/max, slider orientation, 2D recenter, per-state Edit script). Separately, a tool-strip **Trace** command (with a Front / Side / Top drop-down) builds one button per selected control shaped like the control's projected **silhouette** (convex hull), positioned to match the rig and **colored from the control** — a fast first pass of a picker from the rig. Persisted as additive optional item keys (``widget`` / ``binding`` / ``scripts``, emitted only for non-button items), so older pickers load unchanged and plain buttons are unaffected; traced buttons are ordinary items and add no new keys. Widgets render as compact, modern controls (a self-styled rounded body, a thin accent-filled groove with a fixed-size round handle / 2D knob that never stretches, and a clear checkmark), are created by **dragging a tile** (Button / Checkbox / Slider / 2D Slider) from the left tool strip onto the canvas at the drop position, and come seeded with a "just print the value" script so they work immediately and double as an editable example. The script editor's variable reference now documents the widget variables (`__STATE__` / `__VALUE__` / `__X__` / `__Y__`) with samples, opens focused on the code area, and seeds an empty widget script with a per-state snippet. Tracing also auto-detects `_L` / `_R` control pairs (the same naming logic as pickWalk) and links the traced buttons as mirror pairs. Item **text** can be aligned (center / top / bottom / left / right) with a pixel offset so labels sit outside the button, and a pinned overlay item now **keeps its apparent size** when pinned (instead of snapping tiny). **Align & distribute** tools (an Align section in the left tool strip) line up or evenly space the selected items. New **backdrop containers** — drag a Backdrop tile onto the canvas to add a titled, colored, adjustable-transparency panel with round or straight corners that sits behind the buttons; dragging a backdrop moves everything inside it together, backdrops can be nested (the inner one stays selectable), and the title bar follows the rounded corners * Anim Picker: Tab reorder + multi-tab view — tabs can now be **dragged to reorder** on the tab bar in edit mode (the right-click Move actions remain as a fallback), and the order saves exactly as before. A new **View** selector (Tabbed / Grid / Rows / Columns) in the character bar switches the picker area between the one-at-a-time tabbed view (default) and a **multi-tab tiled view** that shows every tab's picker at once — in a grid, a vertical stack, or a horizontal row — each cell a fully live, interactive picker (selecting controls / running actions works as usual). Clicking a cell marks it the active picker (highlighted header) so per-view actions (add item, fit) target it. The chosen mode + grid column count persist via user settings and are never written into the picker data, so the ``.pkr`` is unchanged and older pickers load identically From 9b69c46454a4e2e4e153a66dff33eea40e393be8 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Mon, 13 Jul 2026 13:49:20 +0900 Subject: [PATCH 15/25] AnimPicker: HDPI: Scale scene text and edit handles for high-DPI displays On a high-DPI display (4K / Retina, or Windows / Maya UI scaling at 150% / 200%) the picker's on-canvas item labels and edit-mode handles stayed at their small 96-DPI size while the window chrome grew, leaving them hard to read and hard to hit. They now scale with the display through the same mgear.core.pyqt.dpi_scale the chrome already uses. Scene text (item label, edit-mode handle index, backdrop title, vector "SVG" badge) and screen-fixed affordances (point handles, item / background transform manipulator pick + rotate radii) grow with the display DPI. Sizes that already scale with the canvas zoom (widget groove / knob, backdrop bar, badge box, fit margin, polygon default) are left as-is so nothing is enlarged twice. Consolidated the scattered raw point-size / pixel literals into named constants routed through one cached DPI seam. The scaling is a no-op at 100% (standard-DPI pickers render exactly as before, since dpi_scale clamps the factor to [1x, 2x]), and the authored text size is stored display-independently (GraphicText keeps the authored point size separate from the DPI-scaled font), so a picker made on one monitor renders correctly on another. --- .../widgets/background_manipulator.py | 10 ++- .../mgear/anim_picker/widgets/graphics.py | 88 +++++++++++++++---- .../anim_picker/widgets/item_manipulator.py | 10 ++- releaseLog.rst | 1 + 4 files changed, 90 insertions(+), 19 deletions(-) diff --git a/release/scripts/mgear/anim_picker/widgets/background_manipulator.py b/release/scripts/mgear/anim_picker/widgets/background_manipulator.py index d910f13f..b993d72e 100644 --- a/release/scripts/mgear/anim_picker/widgets/background_manipulator.py +++ b/release/scripts/mgear/anim_picker/widgets/background_manipulator.py @@ -12,6 +12,7 @@ from mgear.vendor.Qt import QtGui from mgear.anim_picker.widgets import background_transform +from mgear.core import pyqt class BackgroundManipulator(object): @@ -34,11 +35,16 @@ def __init__(self, view): # -- helpers -------------------------------------------------------- def _px_to_scene(self): - """Return the scene units per screen pixel for the current zoom.""" + """Return DPI-scaled scene units per screen pixel for the current zoom. + + The DPI factor (no-op at 100%) is folded in here so every screen-pixel + handle / pick constant that multiplies this value grows on a high-DPI + display, staying visible and hittable. + """ m11 = self.view.transform().m11() if not m11: return 1.0 - return 1.0 / abs(m11) + return pyqt.dpi_scale(1.0) / abs(m11) def _layers(self): return self.view.background_layers diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index d0959b0f..5aa28562 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -12,6 +12,41 @@ from mgear.core import svg_import +# High-DPI sizing. Scene fonts and screen-fixed affordances are multiplied by +# ``mgear.core.pyqt.dpi_scale`` so they grow on a high-DPI display to match the +# already-scaled window chrome. The factor is clamped to [1x, 2x] and cached +# for the session, so this is a no-op at 100% scaling. ``pyqt`` is imported +# lazily (it touches the Maya main window) to keep this module import-free of +# Maya at load, and the resolved factor is cached here after the first call. +_DPI_FACTOR = None + +# Base scene-text point sizes (authored / 96-DPI); DPI-scaled at render. +DEFAULT_TEXT_PT = 10.0 +INDEX_PT = 8.0 +TITLE_PT = 9.0 +BADGE_PT = 9.0 + +# Base screen-pixel handle size (DPI-scaled; ``ItemIgnoresTransformations``). +HANDLE_PX = 8.0 + + +def _dpi(value): + """Return ``value`` scaled for the display DPI (no-op at 100%). + + Wraps ``mgear.core.pyqt.dpi_scale`` with a session cache; falls back to the + unscaled value when Maya / the DPI query is unavailable. + """ + global _DPI_FACTOR + if _DPI_FACTOR is None: + try: + from mgear.core import pyqt + + _DPI_FACTOR = pyqt.dpi_scale(1.0) + except Exception: + _DPI_FACTOR = 1.0 + return value * _DPI_FACTOR + + class DefaultPolygon(QtWidgets.QGraphicsObject): """Default polygon class, with move and hover support""" @@ -87,7 +122,9 @@ class PointHandle(DefaultPolygon): __DEFAULT_COLOR__ = QtGui.QColor(30, 30, 30, 200) - def __init__(self, x=0, y=0, size=8, color=None, parent=None, index=0): + def __init__( + self, x=0, y=0, size=HANDLE_PX, color=None, parent=None, index=0 + ): DefaultPolygon.__init__(self, parent) @@ -164,12 +201,22 @@ def setY(self, value=0): # ========================================================================= # Graphic item methods # ========================================================================= + def _draw_size(self): + """Return the on-screen handle size, DPI-scaled (no-op at 100%). + + The handle ignores the view transform, so ``self.size`` is a screen + size; scaling it here (not on the stored ``size``) keeps handle copies + from compounding the DPI factor. + """ + return _dpi(self.size) + def shape(self): """Return default handle square shape based on specified size""" path = QtGui.QPainterPath() + half = self._draw_size() / 2.0 rectangle = QtCore.QRectF( - QtCore.QPointF(-self.size / 2.0, self.size / 2.0), - QtCore.QPointF(self.size / 2.0, -self.size / 2.0), + QtCore.QPointF(-half, half), + QtCore.QPointF(half, -half), ) # path.addRect(rectangle) path.addEllipse(rectangle) @@ -198,7 +245,7 @@ def paint(self, painter, options, widget=None): # if not edit_mode: return # Paint center cross - cross_size = self.size / 2 - 2 + cross_size = self._draw_size() / 2 - 2 painter.setPen(QtGui.QColor(0, 0, 0, 180)) painter.drawLine(-cross_size, 0, cross_size, 0) painter.drawLine(0, cross_size, 0, -cross_size) @@ -364,10 +411,10 @@ def __init__(self, parent=None, scene=None, index=0): self.setText(index) - def set_size(self, value=8.0): - """Set pointSizeF for text""" + def set_size(self, value=INDEX_PT): + """Set the index text point size (DPI-scaled).""" font = self.font() - font.setPointSizeF(value) + font.setPointSizeF(_dpi(value)) self.setFont(font) def set_color(self, color=None): @@ -405,7 +452,9 @@ class WidgetGraphic(DefaultPolygon): _HANDLE = QtGui.QColor(228, 228, 228, 255) _CHECK = QtGui.QColor(120, 200, 120, 255) - # Fixed pixel sizes so handles never stretch with the item. + # Fixed sizes so the knob / groove never stretch with the item. These are + # scene units (they scale with the view zoom and render crisp on HDPI), so + # they are deliberately NOT DPI-scaled -- doing so would enlarge them twice. _HANDLE_R = 6.0 _GROOVE_T = 4.0 @@ -685,7 +734,7 @@ def _paint_title(self, painter, rect, color, radius): painter.translate(-strip.center()) painter.setPen(QtGui.QPen(QtGui.QColor(235, 235, 235, 255))) font = painter.font() - font.setPointSizeF(9.0) + font.setPointSizeF(_dpi(TITLE_PT)) painter.setFont(font) painter.drawText( strip.adjusted(6.0, 0.0, -6.0, 0.0), @@ -837,7 +886,7 @@ def _paint_svg_badge(self, painter): painter.setPen(QtGui.QPen(QtGui.QColor(235, 235, 235, 255))) font = painter.font() font.setBold(True) - font.setPointSizeF(9.0) + font.setPointSizeF(_dpi(BADGE_PT)) painter.setFont(font) painter.drawText(badge, QtCore.Qt.AlignCenter, "SVG") painter.restore() @@ -866,6 +915,10 @@ def __init__(self, parent=None, scene=None): self.align = TEXT_ALIGN_CENTER self.offset = 0.0 + # Authored (DPI-independent) point size; the font is set to the + # DPI-scaled size, but this is what is stored / shown. + self.point_size = DEFAULT_TEXT_PT + # Init default size self.set_size() self.set_color(GraphicText.__DEFAULT_COLOR__) @@ -882,10 +935,15 @@ def get_text(self): """Return element text""" return str(self.text()) - def set_size(self, value=10.0): - """Set pointSizeF for text""" + def set_size(self, value=DEFAULT_TEXT_PT): + """Set the text size from an authored (DPI-independent) point size. + + The authored value is stored; the font is set to the DPI-scaled size so + the label grows on a high-DPI display (a no-op at 100%). + """ + self.point_size = value font = self.font() - font.setPointSizeF(value) + font.setPointSizeF(_dpi(value)) self.setFont(font) self._reposition() @@ -903,8 +961,8 @@ def _reposition(self): self.align_on_parent(self.align, self.offset) def get_size(self): - """Return text pointSizeF""" - return self.font().pointSizeF() + """Return the authored (DPI-independent) text point size.""" + return self.point_size def get_color(self): """Return text color""" diff --git a/release/scripts/mgear/anim_picker/widgets/item_manipulator.py b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py index e937c2af..5aafca6f 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_manipulator.py +++ b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py @@ -18,6 +18,7 @@ from mgear.anim_picker.widgets import manipulator_transform from mgear.core import svg_import +from mgear.core import pyqt class ItemManipulator(object): @@ -41,11 +42,16 @@ def __init__(self, view): # -- helpers -------------------------------------------------------- def _px_to_scene(self): - """Return the scene units per screen pixel for the current zoom.""" + """Return DPI-scaled scene units per screen pixel for the current zoom. + + The DPI factor (no-op at 100%) is folded in here so every screen-pixel + handle / pick constant that multiplies this value grows on a high-DPI + display, staying visible and hittable. + """ m11 = self.view.transform().m11() if not m11: return 1.0 - return 1.0 / abs(m11) + return pyqt.dpi_scale(1.0) / abs(m11) def selected_items(self): """Return the currently selected picker items (live from the scene).""" diff --git a/releaseLog.rst b/releaseLog.rst index 7084d58c..58947dce 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: High-DPI text + handle scaling — on a high-DPI display (a 4K / Retina monitor, or Windows / Maya UI scaling at 150% / 200%) the picker's on-canvas **item labels and edit-mode handles** now scale with the display like the rest of the window, instead of staying at their small 96-DPI size. Item label text, the edit-mode handle index, the backdrop title and the vector "SVG" badge grow with the display, and the point handles and the item / background transform manipulators enlarge so they stay visible and easy to grab. Sizes that already scale with the canvas zoom (widget groove / knob, backdrop bar, badge box) are left as-is so nothing is enlarged twice. The scaling is a **no-op at 100%** (standard-DPI pickers render exactly as before), and the authored **text size is stored display-independently**, so a picker made on one monitor looks right on another. Uses the same ``mgear.core.pyqt.dpi_scale`` the window chrome already uses * Anim Picker: Vector shapes from SVG — a picker item can now be a real **vector shape** with smooth curves and holes (a gear, an eye, an arrow, a logo), not just a straight-line polygon or circle. **Drag an ``.svg`` file** from your file browser onto the picker canvas in edit mode (or use the tool strip's **Import SVG...** button / the Shape panel), and it becomes one curved item at the drop position, editable like any other — select on its silhouette, color, mirror, scale / rotate. Vector shapes can be drawn **filled** or as **lines with an adjustable thickness** (stroke), chosen per item in the Shape panel; on import the mode is auto-detected from the SVG's fill / stroke (line-art icons come in as strokes so they are not filled to nothing). Import is **gracefully partial**: the supported geometry (``path`` with full curve/arc commands, ``rect``, ``circle``, ``ellipse``, ``polygon``, ``polyline``, ``line``, nested in ``g`` groups with transforms and ``viewBox``) is kept, while unsupported elements (text, images, gradients, filters, clip / mask, ``use``, style, script, animation) are **discarded and reported** — so a file that mixes supported shapes with, say, a text label still imports its shapes and tells you what it dropped, an all-unsupported file creates nothing and says why, and a malformed file never errors. Imported art is fit to a consistent size and oriented upright. Vector items hide the polygon "show handles" option (they have no per-point handles) and show a small **"SVG" badge on hover** instead of the border highlight, so they read as vector at a glance. Persisted as one additive optional item key (``svg``, emitted only for vector items), so older pickers load unchanged and polygon items are unaffected; the existing curve↔picker conversion is kept. The SVG parser ships as a reusable, Qt/Maya-free ``mgear.core.svg_import`` so other tools can use it. (This version imports and transforms vector shapes; per-anchor curve editing is not yet included.) * Anim Picker: Conditional item visibility — a picker item can now be shown only when a condition passes, so a busy character picker declutters itself in animation mode. Two condition modes: **channel state** (show only when a Maya attribute passes a comparison, e.g. IK controls appear only when ``arm_L_ctl.ikBlend >= 0.5``) and **zoom level** (show only when the view zoom is within a range, e.g. fine detail controls appear once zoomed in). Set it from the inline panel's new **Visibility** section — a mode selector plus the attribute / operator / threshold for a channel condition, or min / max zoom (0 = no bound) with a **Capture current** button that reads the view's current zoom so you set a threshold by zooming to where the item should (dis)appear. Conditions re-evaluate on demand without polling — never per playback frame, so a heavy rig is not slowed: zoom conditions update on every zoom / fit, and channel conditions on selection change and on **mouse-over** the picker (like the Channel Master tool — moving the mouse over the UI re-reads the bound attributes, no focus needed, so a manual or animated channel change is picked up on hover; hovering also re-syncs interactive checkbox / slider widgets to the rig). **Edit mode always shows every item** so a condition never blocks authoring, and a missing / malformed condition fails open (stays visible) so a control is never lost. Persisted as one additive optional item key (``visibility``, emitted only when set), so older pickers load unchanged and unconditioned items are unaffected; a picker with no conditions incurs no per-refresh cost * Anim Picker: Interactive widgets + trace from selection — a picker item can now be an interactive **widget** instead of a plain button: a **checkbox** (click toggles a bound boolean attribute and/or runs an on/off script, drawn on/off), a **1D slider** (drag maps to a bound attribute over a ``[min, max]`` range, horizontal or vertical, the whole drag one undo), or a **2D slider** (the knob drives two attributes X/Y over their ranges, with an optional recenter-on-release). A widget can bind to a Maya attribute **and/or** a script — the script receives the state as ``__STATE__`` / ``__VALUE__`` / ``__X__`` / ``__Y__`` — and reflects the bound attribute's value on the picker's existing selection-change refresh (no per-frame polling). Locked / connected / missing attributes warn and are skipped rather than throwing. Set it from the inline panel's new **Widget** section (type, attribute(s) + min/max, slider orientation, 2D recenter, per-state Edit script). Separately, a tool-strip **Trace** command (with a Front / Side / Top drop-down) builds one button per selected control shaped like the control's projected **silhouette** (convex hull), positioned to match the rig and **colored from the control** — a fast first pass of a picker from the rig. Persisted as additive optional item keys (``widget`` / ``binding`` / ``scripts``, emitted only for non-button items), so older pickers load unchanged and plain buttons are unaffected; traced buttons are ordinary items and add no new keys. Widgets render as compact, modern controls (a self-styled rounded body, a thin accent-filled groove with a fixed-size round handle / 2D knob that never stretches, and a clear checkmark), are created by **dragging a tile** (Button / Checkbox / Slider / 2D Slider) from the left tool strip onto the canvas at the drop position, and come seeded with a "just print the value" script so they work immediately and double as an editable example. The script editor's variable reference now documents the widget variables (`__STATE__` / `__VALUE__` / `__X__` / `__Y__`) with samples, opens focused on the code area, and seeds an empty widget script with a per-state snippet. Tracing also auto-detects `_L` / `_R` control pairs (the same naming logic as pickWalk) and links the traced buttons as mirror pairs. Item **text** can be aligned (center / top / bottom / left / right) with a pixel offset so labels sit outside the button, and a pinned overlay item now **keeps its apparent size** when pinned (instead of snapping tiny). **Align & distribute** tools (an Align section in the left tool strip) line up or evenly space the selected items. New **backdrop containers** — drag a Backdrop tile onto the canvas to add a titled, colored, adjustable-transparency panel with round or straight corners that sits behind the buttons; dragging a backdrop moves everything inside it together, backdrops can be nested (the inner one stays selectable), and the title bar follows the rounded corners From d669d7748ef3697268935535f96f60340e21fc90 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Mon, 13 Jul 2026 21:30:06 +0900 Subject: [PATCH 16/25] AnimPicker: Editing: Fix backdrop title and SVG badge clipping on high-DPI displays --- .../mgear/anim_picker/widgets/graphics.py | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index 5aa28562..d638a816 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -20,11 +20,11 @@ # Maya at load, and the resolved factor is cached here after the first call. _DPI_FACTOR = None -# Base scene-text point sizes (authored / 96-DPI); DPI-scaled at render. +# Base scene-text point sizes (authored / 96-DPI); DPI-scaled at render. The +# backdrop title / SVG badge are NOT here: they are scene geometry, sized from +# their container height (so they never outgrow it) rather than DPI-scaled. DEFAULT_TEXT_PT = 10.0 INDEX_PT = 8.0 -TITLE_PT = 9.0 -BADGE_PT = 9.0 # Base screen-pixel handle size (DPI-scaled; ``ItemIgnoresTransformations``). HANDLE_PX = 8.0 @@ -734,12 +734,19 @@ def _paint_title(self, painter, rect, color, radius): painter.translate(-strip.center()) painter.setPen(QtGui.QPen(QtGui.QColor(235, 235, 235, 255))) font = painter.font() - font.setPointSizeF(_dpi(TITLE_PT)) + # Size the title from the strip height (scene units) so it always fits + # -- it scales with zoom and stays crisp on HDPI. Not DPI-scaled: the + # strip is scene geometry, so a scaled font would outgrow it and cut. + font.setPixelSize(max(1, int(self._TITLE_H * 0.6))) painter.setFont(font) + text_rect = strip.adjusted(6.0, 0.0, -6.0, 0.0) + title = QtGui.QFontMetricsF(font).elidedText( + self.title, QtCore.Qt.ElideRight, text_rect.width() + ) painter.drawText( - strip.adjusted(6.0, 0.0, -6.0, 0.0), + text_rect, QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft, - self.title, + title, ) painter.restore() @@ -872,7 +879,21 @@ def paint(self, painter, options, widget=None): def _paint_svg_badge(self, painter): """Draw a small upright "SVG" badge over the shape center on hover.""" - badge = QtCore.QRectF(self._BADGE) + text = "SVG" + # Font sized from the reference badge height (scene units) so it scales + # with zoom and stays crisp on HDPI; not DPI-scaled (scene geometry). + font = QtGui.QFont(painter.font()) + font.setBold(True) + font.setPixelSize(max(1, int(self._BADGE.height() * 0.55))) + # Size the pill to the text so it never clips (grows past the reference + # width for a wider font / label). + metrics = QtGui.QFontMetricsF(font) + try: + text_w = metrics.horizontalAdvance(text) + except AttributeError: + text_w = metrics.width(text) + width = max(self._BADGE.width(), text_w + 12.0) + badge = QtCore.QRectF(0.0, 0.0, width, self._BADGE.height()) badge.moveCenter(self._path.boundingRect().center()) painter.save() # Counter the view's Y-flip so the label reads upright. @@ -884,11 +905,8 @@ def _paint_svg_badge(self, painter): painter.setBrush(QtGui.QBrush(QtGui.QColor(20, 20, 20, 175))) painter.drawRoundedRect(badge, 4.0, 4.0) painter.setPen(QtGui.QPen(QtGui.QColor(235, 235, 235, 255))) - font = painter.font() - font.setBold(True) - font.setPointSizeF(_dpi(BADGE_PT)) painter.setFont(font) - painter.drawText(badge, QtCore.Qt.AlignCenter, "SVG") + painter.drawText(badge, QtCore.Qt.AlignCenter, text) painter.restore() From c25d191f288ee5268fc8e59885372de697ff5567 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Mon, 13 Jul 2026 21:32:50 +0900 Subject: [PATCH 17/25] AnimPicker: Editing: Editor undo/redo across all edits, keyboard shortcuts, and expand/contract tools Add a single snapshot-based undo/redo stack (widgets/edit_undo.py) that every edit-mode operation records to via the view's baseline diff: add/delete/dup/ paste/cut, move/scale/rotate/align/distribute/expand/contract/nudge, color/ text/shape/point/pin/mirror/widget/backdrop/visibility/svg/control/menu edits, and z-order. A whole gesture (drag, manipulator) is one step; restore uses the existing item serialization (no data-format change). Replaces the move-only undo_move_order. Adds focus-scoped keyboard shortcuts (Ctrl+Z/Shift+Z/Y, C/V/X, D, Shift+D, Delete/Backspace, Ctrl+A, arrows, F, Esc) and keeps edit-mode keyboard focus on the canvas so they fire after clicking an item. Also adds the Align strip's expand/contract spacing tools (fine-tune the spread of a selection, backed by alignment.scale_spread_offsets). --- release/icons/mgear_contract_h.svg | 1 + release/icons/mgear_contract_v.svg | 1 + release/icons/mgear_expand_h.svg | 1 + release/icons/mgear_expand_v.svg | 1 + .../scripts/mgear/anim_picker/main_window.py | 37 ++ release/scripts/mgear/anim_picker/view.py | 416 +++++++++++++----- .../mgear/anim_picker/widgets/alignment.py | 30 ++ .../widgets/dialogs/handles_window.py | 8 + .../widgets/dialogs/item_options.py | 45 +- .../mgear/anim_picker/widgets/edit_panel.py | 34 +- .../mgear/anim_picker/widgets/edit_undo.py | 71 +++ .../mgear/anim_picker/widgets/picker_item.py | 6 + releaseLog.rst | 3 + 13 files changed, 516 insertions(+), 138 deletions(-) create mode 100644 release/icons/mgear_contract_h.svg create mode 100644 release/icons/mgear_contract_v.svg create mode 100644 release/icons/mgear_expand_h.svg create mode 100644 release/icons/mgear_expand_v.svg create mode 100644 release/scripts/mgear/anim_picker/widgets/edit_undo.py diff --git a/release/icons/mgear_contract_h.svg b/release/icons/mgear_contract_h.svg new file mode 100644 index 00000000..0cf22a29 --- /dev/null +++ b/release/icons/mgear_contract_h.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_contract_v.svg b/release/icons/mgear_contract_v.svg new file mode 100644 index 00000000..76cf350f --- /dev/null +++ b/release/icons/mgear_contract_v.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_expand_h.svg b/release/icons/mgear_expand_h.svg new file mode 100644 index 00000000..325d4e81 --- /dev/null +++ b/release/icons/mgear_expand_h.svg @@ -0,0 +1 @@ + diff --git a/release/icons/mgear_expand_v.svg b/release/icons/mgear_expand_v.svg new file mode 100644 index 00000000..72828b04 --- /dev/null +++ b/release/icons/mgear_expand_v.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 5998f4f8..5ea01411 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -486,6 +486,14 @@ def add_tab_widget(self, name="default"): partial(self._distribute_selected, "h")), ("mgear_distribute_v", "Distribute vertically (3+ to redistribute)", partial(self._distribute_selected, "v")), + ("mgear_expand_h", "Expand horizontal spacing (spread apart)", + partial(self._expand_selected, "h")), + ("mgear_expand_v", "Expand vertical spacing (spread apart)", + partial(self._expand_selected, "v")), + ("mgear_contract_h", "Contract horizontal spacing (bring closer)", + partial(self._contract_selected, "h")), + ("mgear_contract_v", "Contract vertical spacing (bring closer)", + partial(self._contract_selected, "v")), ) self.left_toolbar.add_button_grid( [ @@ -669,6 +677,9 @@ def _after_command(self): """Refresh the canvas, inline panel and command states after an op.""" view = self._current_view() if view is not None: + # Record the command's item changes as one editor undo step (a + # no-op when the command changed nothing). + view.commit_edit() view.viewport().update() panel = getattr(self, "edit_panel", None) if panel is not None: @@ -803,6 +814,32 @@ def _distribute_selected(self, axis): 2, partial(alignment.distribute_offsets, axis=axis) ) + # Spread step for the expand / contract fine-tune tools. Contract is the + # inverse of expand, so an expand then a contract returns near the start. + _SPREAD_STEP = 1.1 + + def _expand_selected(self, axis): + """Spread the selection apart along ``axis`` a step (fine-tune, 2+).""" + self._apply_item_offsets( + 2, + partial( + alignment.scale_spread_offsets, + axis=axis, + factor=self._SPREAD_STEP, + ), + ) + + def _contract_selected(self, axis): + """Pull the selection closer along ``axis`` a step (fine-tune, 2+).""" + self._apply_item_offsets( + 2, + partial( + alignment.scale_spread_offsets, + axis=axis, + factor=1.0 / self._SPREAD_STEP, + ), + ) + def _cmd_trace(self, plane): """Trace a silhouette button per selected control onto ``plane``. diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 47470e5b..75df47dc 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -29,6 +29,7 @@ from mgear.anim_picker.widgets import background_model from mgear.anim_picker.widgets import background_manipulator from mgear.anim_picker.widgets import item_manipulator +from mgear.anim_picker.widgets import edit_undo from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.widgets import mirror from mgear.anim_picker.widgets import overlay @@ -145,9 +146,18 @@ def __init__(self, namespace=None, main_window=None): self.fit_margin = 8 - # # undo list --------------------------------------------------------- - self.undo_move_order = [] - self.undo_move_order_index = -1 + # Editor undo: one snapshot-based stack that every edit records to. A + # snapshot is the item serialization (get_data / set_data) + z-order; + # item identity is the per-item uuid, preserved when an item is + # recreated so add / delete round-trip. ``_undo_baseline`` is the last + # committed state, so each edit's "before" is implicit -- a gesture + # (drag / manipulator) commits once at release for a single step. + self._undo_stack = edit_undo.UndoStack() + self._undo_baseline = None + + # Keep keyboard focus on the canvas so edit shortcuts / undo get key + # events (an item click would otherwise not focus the view widget). + self.setFocusPolicy(QtCore.Qt.StrongFocus) def get_center_pos(self): return self.mapToScene( @@ -155,6 +165,11 @@ def get_center_pos(self): ) def mousePressEvent(self, event): + # Keep keyboard focus on the canvas in edit mode so the edit shortcuts + # and undo receive key events after any click (a plain item click would + # otherwise leave focus elsewhere and the shortcuts would not fire). + if __EDIT_MODE__.get(): + self.setFocus() # Background layer manipulation intercepts left-click when active. if self.background_edit and self._bg_mouse_press(event): return @@ -191,17 +206,10 @@ def mousePressEvent(self, event): if picker_at: if __EDIT_MODE__.get(): self.item_selected = True - # undo --------------------------------------------------- + # An item move is committed as one undo step on release; + # the pre-drag state is the current undo baseline, so + # nothing is captured here (see mouseReleaseEvent). self.__move_prompt = False - # open undo chunk - self.tmp_picker_pos_info = {} - pickers = self.scene().get_selected_items() - if picker_at not in pickers: - pickers.append(picker_at) - for picker in pickers: - pt = [picker.x(), picker.y(), picker.rotation()] - self.tmp_picker_pos_info[picker.uuid] = pt - # undo --------------------------------------------------- else: self.modified_select = True picker_widgets.select_picker_controls([picker_at], event) @@ -327,31 +335,14 @@ def mouseReleaseEvent(self, event): else: self.scene().select_picker_items([picker_at], event) - # add moved pickers to undo_move_order list --------------------------- + # Commit an item drag-move as one undo step ------------------------- if not self.drag_active and self.__move_prompt: - for picker_uuid in list(self.tmp_picker_pos_info.keys()): - picker = self.scene().get_picker_by_uuid(picker_uuid) - if picker is None: - continue - pt = [picker.x(), picker.y(), picker.rotation()] - self.tmp_picker_pos_info[picker_uuid].extend(pt) - if self.undo_move_order_index in [-1]: - self.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - else: - self.undo_move_order = self.undo_move_order[ - : self.undo_move_order_index - ] - self.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - self.undo_move_order_index = -1 # Live-mirror the moved selection to any linked partners (covers a - # plain item drag-move, not just the manipulator). + # plain item drag-move, not just the manipulator) before the + # snapshot, so the partners' new positions are part of the step. self.apply_mirror_for(self.scene().get_selected_items()) + self.commit_edit("Move items") self.__move_prompt = None - self.tmp_picker_pos_info = {} # undo ---------------------------------------------------------------- # Area selection @@ -440,73 +431,276 @@ def wheelEvent(self, event): # Zoom changed; re-evaluate zoom-level visibility conditions. self.refresh_item_visibility() - # undo -------------------------------------------------------------------- - def undo_move(self): - """go through (reversed) the undo_move_order list and move pickers - back to their previously stored location + # ===================================================================== + # Editor undo/redo --- + def _item_state(self, item): + """Return a restorable snapshot of one item (its data + z-order).""" + return {"data": copy.deepcopy(item.get_data()), "z": item.zValue()} + + def _snapshot_items(self): + """Return ``{uuid: item-state}`` for every picker item in the scene.""" + return { + item.uuid: self._item_state(item) + for item in self.scene().get_picker_items() + } + + def _diff_snapshot(self, before, after): + """Return a ``{"before", "after"}`` record of the items that changed. + + Only items that were added, removed, or whose state differs are kept, + each mapped to its state (or None when absent on that side). Returns + None when nothing changed, so a no-op edit pushes no undo step. + """ + changed_before = {} + changed_after = {} + for key in set(before) | set(after): + was = before.get(key) + now = after.get(key) + if was != now: + changed_before[key] = was + changed_after[key] = now + if not changed_before and not changed_after: + return None + return {"before": changed_before, "after": changed_after} + + def _reset_undo_baseline(self): + """Re-baseline the undo diff to the current item set.""" + self._undo_baseline = self._snapshot_items() + + def commit_edit(self, label=None): + """Push one undo step for changes since the last commit / baseline. + + The pre-edit state is the current baseline, so a whole gesture (an item + drag, a manipulator scale / rotate) commits once at its end for a + single step. A commit with no change (baseline already current) is a + harmless no-op, so redundant calls do not create empty steps. + + Args: + label (str, optional): a human label for the step (diagnostics). """ - undo_len = len(self.undo_move_order) - if undo_len == 0: + current = self._snapshot_items() + if self._undo_baseline is None: + self._undo_baseline = current return + record = self._diff_snapshot(self._undo_baseline, current) + self._undo_baseline = current + if record is not None: + record["label"] = label + self._undo_stack.push(record) + + def record_edit(self, label, mutate_fn): + """Run ``mutate_fn`` then commit its changes as one undo step. + + Args: + label (str): a human label for the step. + mutate_fn (callable): performs the edit; its return is passed back. + """ + result = mutate_fn() + self.commit_edit(label) + return result - if self.undo_move_order_index == -1: - self.undo_move_order_index = undo_len - elif self.undo_move_order_index == 0: + def _recreate_item(self, item_uuid): + """Recreate a removed item, preserving its uuid identity.""" + item = picker_widgets.PickerItem( + main_window=self.main_window, namespace=self.namespace + ) + item.uuid = item_uuid + item.setParent(self) + self.scene().addItem(item) + return item + + def _apply_undo_snapshot(self, snapshot): + """Restore items to ``snapshot`` (``{uuid: state-or-None}``). + + Items mapped to a state are updated in place (or recreated when + missing); items mapped to None are removed. Only the items named in the + snapshot are touched, so unrelated items are left alone. + """ + # One uuid -> item map for the whole restore (get_picker_by_uuid is a + # linear scan, so looking it up per snapshot item would be O(n*m)). + by_uuid = { + item.uuid: item for item in self.scene().get_picker_items() + } + for key, state in snapshot.items(): + item = by_uuid.get(key) + if state is None: + if item is not None: + item.remove() + else: + if item is None: + item = self._recreate_item(key) + item.set_data(copy.deepcopy(state["data"])) + item.setZValue(state["z"]) + + def undo(self): + """Undo the last editor edit (restore its 'before' snapshot).""" + record = self._undo_stack.undo() + if record is None: return - if self.undo_move_order_index > 0: - self.undo_move_order_index = self.undo_move_order_index - 1 - undo_items = self.undo_move_order[self.undo_move_order_index].items() - for picker_uuid, undo_pos in undo_items: - picker = self.scene().get_picker_by_uuid(picker_uuid) - if not picker: - continue - picker.setPos(undo_pos[0], undo_pos[1]) - picker.setRotation(undo_pos[2]) + self._apply_undo_snapshot(record["before"]) + self._after_undo_redo() - def redo_move(self): - """go through the undo_move_order restoring picker locations""" - undo_len = len(self.undo_move_order) - if undo_len == 0: + def redo(self): + """Redo the next editor edit (restore its 'after' snapshot).""" + record = self._undo_stack.redo() + if record is None: return + self._apply_undo_snapshot(record["after"]) + self._after_undo_redo() - if self.undo_move_order_index == -1: + def _after_undo_redo(self): + """Refresh derived state and re-baseline after an undo / redo.""" + self._reset_undo_baseline() + self._recompute_conditional_flag() + self.refresh_item_visibility() + self._notify_item_selection() + self.viewport().update() + + # ===================================================================== + # Selection / clipboard shortcuts --- + def _selection_anchor(self): + """Return a selected item to anchor a selection-wide op, or None.""" + items = self.scene().get_selected_items() + return items[0] if items else None + + def select_all_items(self): + """Select every picker item (selection is not an undo step).""" + self.scene().select_picker_items(self.get_picker_items()) + self._notify_item_selection() + self.viewport().update() + + def clear_selection(self): + """Clear the picker (and Maya) selection.""" + self.scene().clear_picker_selection() + cmds.select(cl=True) + self._notify_item_selection() + self.viewport().update() + + def delete_selection(self): + """Delete the selected items as one undo step.""" + items = self.scene().get_selected_items() + if not items: return - if self.undo_move_order_index < undo_len: - undo_index = self.undo_move_order[self.undo_move_order_index] - for picker_uuid, undo_pos in undo_index.items(): - picker = self.scene().get_picker_by_uuid(picker_uuid) - if not picker: - continue - picker.setPos(undo_pos[3], undo_pos[4]) - picker.setRotation(undo_pos[5]) - self.undo_move_order_index = self.undo_move_order_index + 1 - else: - self.undo_move_order_index = -1 + self.record_edit( + "Delete items", lambda: [item.remove() for item in items] + ) + + def cut_event(self): + """Copy then delete the selection as one undo step.""" + items = self.scene().get_selected_items() + if not items: + return + self.copy_event() + self.record_edit( + "Cut items", lambda: [item.remove() for item in items] + ) + + def duplicate_selection(self, mirror=False): + """Duplicate the selection (optionally mirrored) as one undo step.""" + if mirror: + # Reuse the toolbar command so the search/replace prompt and the + # persistent mirror linking / palette coloring stay in one place. + if self.main_window is not None: + self.main_window._cmd_duplicate_mirror() + return + anchor = self._selection_anchor() + if anchor is None: + return + self.record_edit("Duplicate items", anchor.duplicate_selected) + + # Arrow-key nudge distances, in scene units (Shift = the larger step). + _NUDGE_STEP = 1.0 + _NUDGE_LARGE = 10.0 + + def nudge_selection(self, key, large=False): + """Nudge the selection one step by an arrow key (one undo step). + + The scene is Y-up (the view flips Y), so Up increases y and Down + decreases it. + """ + items = self.scene().get_selected_items() + if not items: + return + step = self._NUDGE_LARGE if large else self._NUDGE_STEP + dx = dy = 0.0 + if key == QtCore.Qt.Key_Left: + dx = -step + elif key == QtCore.Qt.Key_Right: + dx = step + elif key == QtCore.Qt.Key_Up: + dy = step + elif key == QtCore.Qt.Key_Down: + dy = -step + + def _do(): + for item in items: + item.setPos(item.x() + dx, item.y() + dy) + self.apply_mirror_for(items) + + self.record_edit("Nudge items", _do) def keyPressEvent(self, event): - """keyboard press event override for custom shortcuts + """Editor keyboard shortcuts (only while the picker holds focus). + + Because this fires only when the view widget has keyboard focus, the + shortcuts never leak to Maya's global hotkeys; when the picker is + unfocused Qt routes the key elsewhere and this is not called. Most + shortcuts act on the selection and are edit-mode only. Args: - event (QtCore.QEvent): keyboard event + event (QtCore.QEvent): keyboard event. """ - if __EDIT_MODE__.get(): - modifiers = event.modifiers() - if ( - modifiers == QtCore.Qt.ControlModifier - and event.key() == QtCore.Qt.Key_Z - ): - self.undo_move() - event.accept() - elif ( - modifiers == QtCore.Qt.ControlModifier - and event.key() == QtCore.Qt.Key_Y - ): - self.redo_move() - event.accept() - else: - event.ignore() + key = event.key() + mods = event.modifiers() + ctrl = bool(mods & QtCore.Qt.ControlModifier) + shift = bool(mods & QtCore.Qt.ShiftModifier) + + # Undo / redo: Ctrl+Z, Ctrl+Shift+Z (and the legacy Ctrl+Y alias). + if ctrl and key == QtCore.Qt.Key_Z: + self.redo() if shift else self.undo() + event.accept() + return + if ctrl and key == QtCore.Qt.Key_Y: + self.redo() + event.accept() + return + + if not __EDIT_MODE__.get(): + event.ignore() + return QtWidgets.QGraphicsView.keyPressEvent(self, event) + + handled = True + if ctrl and key == QtCore.Qt.Key_C: + self.copy_event() + elif ctrl and key == QtCore.Qt.Key_V: + self.paste_event() + elif ctrl and key == QtCore.Qt.Key_X: + self.cut_event() + elif ctrl and key == QtCore.Qt.Key_D: + self.duplicate_selection(mirror=shift) + elif ctrl and key == QtCore.Qt.Key_A: + self.select_all_items() + elif key in (QtCore.Qt.Key_Delete, QtCore.Qt.Key_Backspace): + self.delete_selection() + elif key in ( + QtCore.Qt.Key_Left, + QtCore.Qt.Key_Right, + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + ): + self.nudge_selection(key, large=shift) + elif key == QtCore.Qt.Key_F: + self.fit_scene_content() + elif key == QtCore.Qt.Key_Escape: + self.clear_selection() + else: + handled = False + + if handled: + event.accept() else: event.ignore() + QtWidgets.QGraphicsView.keyPressEvent(self, event) # undo -------------------------------------------------------------------- @@ -641,6 +835,9 @@ def contextMenuEvent(self, event, mapped_pos=None): # Open context menu under mouse menu.exec_(event.globalPos()) + # Commit any item change a menu action made (add / paste / trace) as + # one editor undo step; a no-op when nothing changed. + self.commit_edit("Edit") def resizeEvent(self, *args, **kwargs): """Overload to force scale scene content to fit view""" @@ -1158,18 +1355,25 @@ def paste_event(self): Make new pickers selected """ global _CLIPBOARD - [ - x.set_selected_state(False) - for x in self.scene().get_selected_items() - ] - for data in _CLIPBOARD: - ctrl = self.add_picker_item(event=None) - ctrl.set_data(data) - ctrl.set_selected_state(True) - # A pasted item may carry a visibility condition copied from another - # picker, so refresh the "any conditioned item?" gate and re-apply. - self._recompute_conditional_flag() - self.refresh_item_visibility() + if not _CLIPBOARD: + return + + def _do(): + [ + x.set_selected_state(False) + for x in self.scene().get_selected_items() + ] + for data in _CLIPBOARD: + ctrl = self.add_picker_item(event=None) + ctrl.set_data(data) + ctrl.set_selected_state(True) + # A pasted item may carry a visibility condition copied from + # another picker, so refresh the "any conditioned item?" gate and + # re-apply. + self._recompute_conditional_flag() + self.refresh_item_visibility() + + self.record_edit("Paste items", _do) def toggle_all_handles_event(self, event=None): new_status = None @@ -1198,6 +1402,13 @@ def toggle_mode_event(self, event=None): # Toggle mode __EDIT_MODE__.toggle() + # Entering edit mode starts a fresh editing session: clear the undo + # history and baseline the diff to the current item set so the first + # edit is recorded correctly. + if __EDIT_MODE__.get(): + self._undo_stack.clear() + self._reset_undo_baseline() + # Reset size to default self.main_window.reset_default_size() self.main_window.refresh() @@ -1584,8 +1795,11 @@ def _item_mouse_release(self, event): self._item_dragging = False # Grow the pan bounds to the items' new extent (no view refit). self._update_scene_rect() - # Live-mirror the transformed selection to any linked partners. + # Live-mirror the transformed selection to any linked partners, then + # commit the whole scale / rotate drag as a single undo step (the + # pre-drag state is the baseline, so mid-drag frames are not recorded). self.apply_mirror_for(self.scene().get_selected_items()) + self.commit_edit("Transform items") self._notify_item_selection() self.viewport().update() return True @@ -2001,6 +2215,10 @@ def set_data(self, data): self._recompute_conditional_flag() self.refresh_item_visibility() + # Baseline the undo diff to the freshly-loaded item set (a load is not + # itself an undoable edit). + self._reset_undo_baseline() + def drawBackground(self, painter, rect): """Draw the tab's background image layers (back to front)""" # Run default method diff --git a/release/scripts/mgear/anim_picker/widgets/alignment.py b/release/scripts/mgear/anim_picker/widgets/alignment.py index 53737ba7..e935df61 100644 --- a/release/scripts/mgear/anim_picker/widgets/alignment.py +++ b/release/scripts/mgear/anim_picker/widgets/alignment.py @@ -118,3 +118,33 @@ def distribute_offsets(rects, axis, gap=8.0): offsets[index] = (delta, 0.0) if axis == "h" else (0.0, delta) cursor += sizes[index] + use_gap return offsets + + +def scale_spread_offsets(rects, axis, factor): + """Return per-rect ``(dx, dy)`` scaling the selection spread about center. + + Each item's center is moved away from (``factor`` > 1) or toward + (``factor`` < 1) the selection's bounding-box center on ``axis``, scaling + the spacing while keeping the arrangement. Backs the expand / contract + fine-tune tools (repeat clicks nudge the spread step by step). Items at the + center do not move; a single item (or none) is a no-op. + + Args: + rects (list): ``(x0, y0, x1, y1)`` scene bounding boxes. + axis (str): ``"h"`` (horizontal) or ``"v"`` (vertical). + factor (float): spread multiplier (> 1 expands, < 1 contracts). + + Returns: + list: ``(dx, dy)`` per rect. + """ + count = len(rects) + if count < 2: + return [(0.0, 0.0)] * count + lo_index, hi_index = (0, 2) if axis == "h" else (1, 3) + centers = [(r[lo_index] + r[hi_index]) / 2.0 for r in rects] + origin = (min(centers) + max(centers)) / 2.0 + offsets = [] + for center in centers: + delta = (center - origin) * (factor - 1.0) + offsets.append((delta, 0.0) if axis == "h" else (0.0, delta)) + return offsets diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py b/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py index f49c476f..4a08b2a8 100644 --- a/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py @@ -97,6 +97,14 @@ def display_handles_index(self, status=True): def closeEvent(self, *args, **kwargs): self.display_handles_index(status=False) + # Commit the handle-position edits made here as one editor undo step + # (handles are part of the item serialization, so the snapshot diff + # captures the change). + view = None + if self.picker_item is not None: + view = self.picker_item.scene().parent() + if view is not None and hasattr(view, "commit_edit"): + view.commit_edit("Edit handle positions") return QtWidgets.QMainWindow.closeEvent(self, *args, **kwargs) def show(self, *args, **kwargs): diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py b/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py index 6626c7a8..b9919b69 100644 --- a/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py @@ -3,8 +3,6 @@ Extracted from picker_widgets.py during the Phase 2 decomposition. """ -import copy - from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets @@ -34,15 +32,9 @@ def __init__(self, parent=None, picker_item=None): self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) self.picker_item = picker_item - # undo ---------------------------------------------------------------- + # Undo: this window edits the item live; the whole session is committed + # as one editor undo step on close (see closeEvent). self.main_view = self.picker_item.scene().parent() - self.tmp_picker_pos_info = {} - self.tmp_picker_pos_info[picker_item.uuid] = [ - picker_item.x(), - picker_item.y(), - picker_item.rotation(), - ] - # undo ---------------------------------------------------------------- # Define size self.default_width = 270 @@ -116,34 +108,11 @@ def closeEvent(self, *args, **kwargs): except Exception: pass - # undo ---------------------------------------------------------------- - current_position = [ - self.picker_item.x(), - self.picker_item.y(), - self.picker_item.rotation(), - ] - - orig_position = self.tmp_picker_pos_info.get( - self.picker_item.uuid, None - ) - if orig_position is not None and orig_position != current_position: - self.tmp_picker_pos_info[self.picker_item.uuid].extend( - current_position - ) - if self.main_view.undo_move_order_index in [-1]: - self.main_view.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - else: - self.main_view.undo_move_order = self.undo_move_order[ - : self.main_view.undo_move_order_index - ] - self.main_view.undo_move_order.append( - copy.deepcopy(self.tmp_picker_pos_info) - ) - self.undo_move_order_index = -1 - self.tmp_picker_pos_info = {} - # undo ---------------------------------------------------------------- + # Commit any change made in this window as one editor undo step. + if self.main_view is not None and hasattr( + self.main_view, "commit_edit" + ): + self.main_view.commit_edit("Edit item options") QtWidgets.QMainWindow.closeEvent(self, *args, **kwargs) diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index f0d318d3..c39f8ec4 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -809,6 +809,17 @@ def _current_view(self): return None return getter() + def _commit_edit(self, label=None): + """Record the just-applied panel change as one editor undo step. + + The view owns the undo stack and diffs against its own baseline, so + this only needs to signal that an edit committed; a call with no change + is a harmless no-op. + """ + view = self._view + if view is not None and hasattr(view, "commit_edit"): + view.commit_edit(label) + def sync(self): """Rebind to the current active view's selection.""" self.sync_from_view(self._current_view()) @@ -1190,13 +1201,19 @@ def _set_swatch(self, button, color): # Apply helpers (write to every selected item) # ------------------------------------------------------------------ def _repaint_view(self): - """Propagate edits to mirror partners, then repaint the canvas.""" + """Propagate edits to mirror partners, then repaint the canvas. + + Also records the just-applied field edit as one editor undo step: every + transform / appearance / text / shape / widget / backdrop / visibility + edit ends here, so this is the single seam that makes them undoable. + """ if self._view is None: return # Live-mirror the edit to any linked partners (guarded against loops), # then repaint so both sides refresh. self._view.apply_mirror_for(self.items) self._view.viewport().update() + self._commit_edit() def _committed(self, spin): """Return a committed spin value, or None while the field is mixed. @@ -1431,6 +1448,7 @@ def _link_mirror(self): self._view.link_mirror_pair(self.items[0], self.items[1]) self._view.viewport().update() self._guarded(self._populate_mirror) + self._commit_edit("Link mirror") def _unlink_mirror(self): if self._view is None: @@ -1439,6 +1457,7 @@ def _unlink_mirror(self): self._view.unlink_mirror(item) self._view.viewport().update() self._guarded(self._populate_mirror) + self._commit_edit("Unlink mirror") def _make_symmetric(self): if self._view is None: @@ -1448,6 +1467,7 @@ def _make_symmetric(self): for item in self.items: self._view.apply_mirror_for([item]) self._view.viewport().update() + self._commit_edit("Make symmetric") # -- pin ------------------------------------------------------------ def _apply_pinned(self, *args, **kwargs): @@ -1458,6 +1478,7 @@ def _apply_pinned(self, *args, **kwargs): self._view.set_item_pinned(item, state) self._view.viewport().update() self._guarded(self._populate_pin) + self._commit_edit("Pin item") def _apply_anchor(self, code, *args, **kwargs): if self._syncing or not self.items or self._view is None: @@ -1466,6 +1487,7 @@ def _apply_anchor(self, code, *args, **kwargs): item.set_anchor(code) self._view._update_pinned_items() self._view.viewport().update() + self._commit_edit("Set anchor") def _apply_offset(self, *args, **kwargs): if self._syncing or not self.items or self._view is None: @@ -1479,6 +1501,7 @@ def _apply_offset(self, *args, **kwargs): ) self._view._update_pinned_items() self._view.viewport().update() + self._commit_edit("Set pin offset") # -- widget --------------------------------------------------------- def _apply_widget_type(self, *args, **kwargs): @@ -1541,6 +1564,7 @@ def _edit_widget_script(self, key): scripts = dict(target.get_widget_scripts() or {}) scripts[key] = cmd target.set_widget_scripts(scripts) + self._commit_edit("Edit widget script") # -- backdrop ------------------------------------------------------- def _apply_backdrop_title(self, *args, **kwargs): @@ -1628,6 +1652,7 @@ def _add_selected_controls(self): for item in self.items: item.add_selected_controls() self._populate_controls() + self._commit_edit("Add controls") def _remove_controls(self): item = self._active_item() @@ -1636,6 +1661,7 @@ def _remove_controls(self): for row in self.control_list.selectedItems(): item.remove_control(row.node()) self._populate_controls() + self._commit_edit("Remove controls") def _search_replace(self): if not self.items: @@ -1646,6 +1672,7 @@ def _search_replace(self): for item in self.items: item.search_and_replace_controls(search=search, replace=replace) self._populate_controls() + self._commit_edit("Search & replace controls") # -- action --------------------------------------------------------- def _apply_action_mode(self, *args, **kwargs): @@ -1654,6 +1681,7 @@ def _apply_action_mode(self, *args, **kwargs): custom = self._resolve_tristate(self.custom_action_cb) for item in self.items: item.set_custom_action_mode(custom) + self._commit_edit("Set action mode") def _edit_action_script(self): item = self._active_item() @@ -1666,6 +1694,7 @@ def _edit_action_script(self): return for target in self.items: target.set_custom_action_script(cmd) + self._commit_edit("Edit action script") def _edit_menu(self, list_item): item = self._active_item() @@ -1682,6 +1711,7 @@ def _edit_menu(self, list_item): menus[index] = [name, cmd] item.set_custom_menus(menus) self._populate_action() + self._commit_edit("Edit custom menu") def _new_menu(self): item = self._active_item() @@ -1694,6 +1724,7 @@ def _new_menu(self): menus.append([name, cmd]) item.set_custom_menus(menus) self._populate_action() + self._commit_edit("Add custom menu") def _remove_menu(self): item = self._active_item() @@ -1706,6 +1737,7 @@ def _remove_menu(self): menus.pop(index) item.set_custom_menus(menus) self._populate_action() + self._commit_edit("Remove custom menu") # ------------------------------------------------------------------ def closeEvent(self, event): diff --git a/release/scripts/mgear/anim_picker/widgets/edit_undo.py b/release/scripts/mgear/anim_picker/widgets/edit_undo.py new file mode 100644 index 00000000..bfb1f6c0 --- /dev/null +++ b/release/scripts/mgear/anim_picker/widgets/edit_undo.py @@ -0,0 +1,71 @@ +"""Bounded undo/redo stack for the anim picker editor. + +A tiny, Qt- and Maya-free stack of opaque records. The editor pushes one +record per committed edit; a record is whatever the caller stores. The picker +view stores a ``{"before": snapshot, "after": snapshot}`` pair of item-state +snapshots (the item serialization plus z-order), so restoring an edit is just +re-applying one side of the pair. + +The stack only manages ordering: pushing a new record truncates any redo tail +(the records that were undone), and the oldest records are evicted once the +bound is exceeded. Kept free of Qt / Maya so the ordering logic is testable in +isolation, matching the ``alignment`` / ``overlay`` / ``visibility`` modules. +""" + +# Default cap on the number of retained edits. Snapshots are small dicts, but a +# fixed bound keeps memory predictable on a long editing session. +DEFAULT_MAX_STEPS = 50 + + +class UndoStack(object): + """A bounded linear undo/redo stack of opaque records.""" + + def __init__(self, max_steps=DEFAULT_MAX_STEPS): + self._records = [] + # Index of the last-applied record; -1 means nothing left to undo. + self._index = -1 + self._max_steps = max(1, int(max_steps)) + + def clear(self): + """Drop every record (e.g. on load or when entering edit mode).""" + self._records = [] + self._index = -1 + + def can_undo(self): + """Return True when there is a record to undo.""" + return self._index >= 0 + + def can_redo(self): + """Return True when there is an undone record to redo.""" + return self._index < len(self._records) - 1 + + def push(self, record): + """Append a record, truncating any redo tail and bounding the size. + + Args: + record (object): the opaque payload the caller restores on + undo / redo. + """ + # Discard the redo tail (undone records) before the new branch. + del self._records[self._index + 1:] + self._records.append(record) + # Evict the oldest records past the bound, keeping the newest. + overflow = len(self._records) - self._max_steps + if overflow > 0: + del self._records[:overflow] + self._index = len(self._records) - 1 + + def undo(self): + """Return the record to reverse and step back, or None.""" + if not self.can_undo(): + return None + record = self._records[self._index] + self._index -= 1 + return record + + def redo(self): + """Step forward and return the record to re-apply, or None.""" + if not self.can_redo(): + return None + self._index += 1 + return self._records[self._index] diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 394185f4..36b2508d 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -577,6 +577,12 @@ def edit_context_menu(self, event): # OFFSET offseted_pos = event.pos() + QtCore.QPoint(5, 0) menu.exec_(offseted_pos) + # Commit any item change a menu action made (move / mirror / dup / + # remove / z-order / paste) as one editor undo step. A no-op when the + # action changed nothing (Copy, Select associated controls). + view = self.parent() + if view is not None and hasattr(view, "commit_edit"): + view.commit_edit("Edit item") return True def default_context_menu(self, event): diff --git a/releaseLog.rst b/releaseLog.rst index 58947dce..e9bd8a75 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,8 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Editor undo/redo across all edits + keyboard shortcuts — the picker editor now has a single **undo/redo stack that every edit records to**, so any change in edit mode can be reverted and re-applied: add / delete / duplicate / paste / cut, move / scale / rotate / align / distribute / expand / contract / nudge, color / text / shape / point edits, pin / anchor / offset, mirror link / unlink / make-symmetric, widget / backdrop / visibility / SVG / control-association / custom-menu edits, and z-order (move to back / front). A whole gesture is **one step** — a drag, or a manipulator scale / rotate, commits once on release, not per mouse-move — and undo restores item state via the existing item serialization, so there is **no data-format change**. The stack is bounded (50 steps), kept **per tab**, and reset when you enter edit mode or load a picker; animation-mode widget interactions (slider / checkbox driving Maya attributes) keep using Maya's own undo. Alongside it, **keyboard shortcuts** that fire only while the picker holds focus, so they never leak to Maya's global hotkeys: **Ctrl+Z** / **Ctrl+Shift+Z** (and the legacy **Ctrl+Y**) undo / redo, **Ctrl+C** / **Ctrl+V** / **Ctrl+X** copy / paste / cut, **Ctrl+D** duplicate, **Ctrl+Shift+D** duplicate & mirror, **Delete** / **Backspace** delete, **Ctrl+A** select all, **arrow keys** nudge (**Shift** = larger step), **F** frame, **Esc** clear selection. A focus fix keeps keyboard focus on the canvas after clicking an item in edit mode (previously an item click handed focus to the Maya main window, so the shortcuts and even undo often did nothing). The snapshot-based undo core ships as a small Qt/Maya-free ``edit_undo`` module + * Anim Picker: Expand / contract spacing tools — the Align section of the tool strip gains four buttons that **spread the selected items apart or pull them closer**, horizontally or vertically, a small step per click, so you can fine-tune the spacing of a row / column of buttons without dragging each one. Each click scales the selection's spread about its bounding-box center (contract is the exact inverse of expand, so an expand then a contract returns to the start); items at the center stay put, and it needs 2+ selected. Backed by a Qt/Maya-free ``alignment.scale_spread_offsets`` and one undo step per click * Anim Picker: High-DPI text + handle scaling — on a high-DPI display (a 4K / Retina monitor, or Windows / Maya UI scaling at 150% / 200%) the picker's on-canvas **item labels and edit-mode handles** now scale with the display like the rest of the window, instead of staying at their small 96-DPI size. Item label text, the edit-mode handle index, the backdrop title and the vector "SVG" badge grow with the display, and the point handles and the item / background transform manipulators enlarge so they stay visible and easy to grab. Sizes that already scale with the canvas zoom (widget groove / knob, backdrop bar, badge box) are left as-is so nothing is enlarged twice. The scaling is a **no-op at 100%** (standard-DPI pickers render exactly as before), and the authored **text size is stored display-independently**, so a picker made on one monitor looks right on another. Uses the same ``mgear.core.pyqt.dpi_scale`` the window chrome already uses * Anim Picker: Vector shapes from SVG — a picker item can now be a real **vector shape** with smooth curves and holes (a gear, an eye, an arrow, a logo), not just a straight-line polygon or circle. **Drag an ``.svg`` file** from your file browser onto the picker canvas in edit mode (or use the tool strip's **Import SVG...** button / the Shape panel), and it becomes one curved item at the drop position, editable like any other — select on its silhouette, color, mirror, scale / rotate. Vector shapes can be drawn **filled** or as **lines with an adjustable thickness** (stroke), chosen per item in the Shape panel; on import the mode is auto-detected from the SVG's fill / stroke (line-art icons come in as strokes so they are not filled to nothing). Import is **gracefully partial**: the supported geometry (``path`` with full curve/arc commands, ``rect``, ``circle``, ``ellipse``, ``polygon``, ``polyline``, ``line``, nested in ``g`` groups with transforms and ``viewBox``) is kept, while unsupported elements (text, images, gradients, filters, clip / mask, ``use``, style, script, animation) are **discarded and reported** — so a file that mixes supported shapes with, say, a text label still imports its shapes and tells you what it dropped, an all-unsupported file creates nothing and says why, and a malformed file never errors. Imported art is fit to a consistent size and oriented upright. Vector items hide the polygon "show handles" option (they have no per-point handles) and show a small **"SVG" badge on hover** instead of the border highlight, so they read as vector at a glance. Persisted as one additive optional item key (``svg``, emitted only for vector items), so older pickers load unchanged and polygon items are unaffected; the existing curve↔picker conversion is kept. The SVG parser ships as a reusable, Qt/Maya-free ``mgear.core.svg_import`` so other tools can use it. (This version imports and transforms vector shapes; per-anchor curve editing is not yet included.) * Anim Picker: Conditional item visibility — a picker item can now be shown only when a condition passes, so a busy character picker declutters itself in animation mode. Two condition modes: **channel state** (show only when a Maya attribute passes a comparison, e.g. IK controls appear only when ``arm_L_ctl.ikBlend >= 0.5``) and **zoom level** (show only when the view zoom is within a range, e.g. fine detail controls appear once zoomed in). Set it from the inline panel's new **Visibility** section — a mode selector plus the attribute / operator / threshold for a channel condition, or min / max zoom (0 = no bound) with a **Capture current** button that reads the view's current zoom so you set a threshold by zooming to where the item should (dis)appear. Conditions re-evaluate on demand without polling — never per playback frame, so a heavy rig is not slowed: zoom conditions update on every zoom / fit, and channel conditions on selection change and on **mouse-over** the picker (like the Channel Master tool — moving the mouse over the UI re-reads the bound attributes, no focus needed, so a manual or animated channel change is picked up on hover; hovering also re-syncs interactive checkbox / slider widgets to the rig). **Edit mode always shows every item** so a condition never blocks authoring, and a missing / malformed condition fails open (stays visible) so a control is never lost. Persisted as one additive optional item key (``visibility``, emitted only when set), so older pickers load unchanged and unconditioned items are unaffected; a picker with no conditions incurs no per-refresh cost @@ -22,6 +24,7 @@ Release Log * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) **Bug Fix** + * Anim Picker: Fix the backdrop title and vector "SVG" badge being clipped on high-DPI displays — after the High-DPI change scaled these labels by device DPI, the text could outgrow its scene-unit strip / pill and get cut off. Both now size their font from the container height (a scene-coupled pixel size, so it tracks the canvas zoom and is DPI-independent), the backdrop title elides with "…" when it still does not fit, and the SVG badge pill grows its width to the text — so neither label is ever clipped * Anim Picker: Fix slider / 2D-slider widget knob leaving a ghost of its previous position after a drag — the fixed-size knob overhangs a thin track, but the widget's bounding rect stopped at the track, so the knob's overhang was never repainted; the bounding rect now includes the knob radius so the old position is cleared * Anim Picker: Fix "Picker shape from curve not working" #598 — remove the JSON-to-Python-literal replace hack that corrupted control names/paths containing "true", and guard shape-less transforms during curves-to-picker conversion * Anim Picker: Fix latent bugs in picker_node / maya_handlers — a .format() typo, a setAttr that never hid the data node, an assertion that never fired on referenced nodes, and an always-false dictionary type check From 752ba606012952c1f22255816f9c87c21dcf6bbd Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 11:43:31 +0900 Subject: [PATCH 18/25] AnimPicker: Editing: Shape library with vector templates, drag-create, and create-from-selection Extend the shape library to hold polygon and vector (SVG) shapes, split across Polygons / SVG tabs with previews that render real curves (shared graphics.build_vector_path). Ship 16 bundled templates (gear, face, hand, foot, 2-/4-dir arrows, rounded square/rect, heart, plus, minus, check, cross, ring, eye, lock). Add a tool-strip library button (grouped with Add/SVG, always enabled), drag-a-tile-to-create at the drop point, and right-click create-from-selection (row/column, one linked item per Maya control). Save current shape stores polygon or imported-SVG shapes (live selection) into the matching tab. All create ops are single undo steps. Vector items show no dead polygon handles (Toggle handles is a no-op for them); removed the redundant right-click Options entry (item options live in the inline panel). Extracts a shared DragTileButton (palette + shape tiles) and promotes the vector path builder to graphics.build_vector_path. --- .../scripts/mgear/anim_picker/main_window.py | 90 +++--- .../mgear/anim_picker/shapes/arrow_2dir.svg | 1 + .../mgear/anim_picker/shapes/arrow_4dir.svg | 1 + .../mgear/anim_picker/shapes/check.svg | 1 + .../mgear/anim_picker/shapes/cross.svg | 1 + .../anim_picker/shapes/default_shapes.json | 64 ++++ .../scripts/mgear/anim_picker/shapes/eye.svg | 1 + .../scripts/mgear/anim_picker/shapes/face.svg | 1 + .../scripts/mgear/anim_picker/shapes/foot.svg | 1 + .../scripts/mgear/anim_picker/shapes/gear.svg | 1 + .../scripts/mgear/anim_picker/shapes/hand.svg | 1 + .../mgear/anim_picker/shapes/heart.svg | 1 + .../scripts/mgear/anim_picker/shapes/lock.svg | 1 + .../mgear/anim_picker/shapes/minus.svg | 1 + .../scripts/mgear/anim_picker/shapes/plus.svg | 1 + .../scripts/mgear/anim_picker/shapes/ring.svg | 1 + .../mgear/anim_picker/shapes/rounded_rect.svg | 1 + .../anim_picker/shapes/rounded_square.svg | 1 + release/scripts/mgear/anim_picker/view.py | 97 +++++- .../widgets/dialogs/shape_library_dialog.py | 282 +++++++++++++----- .../mgear/anim_picker/widgets/edit_panel.py | 24 +- .../mgear/anim_picker/widgets/graphics.py | 61 ++-- .../mgear/anim_picker/widgets/picker_item.py | 54 +++- .../anim_picker/widgets/shape_library.py | 193 +++++++++--- .../mgear/anim_picker/widgets/tool_bar.py | 47 ++- releaseLog.rst | 3 + 26 files changed, 714 insertions(+), 217 deletions(-) create mode 100644 release/scripts/mgear/anim_picker/shapes/arrow_2dir.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/arrow_4dir.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/check.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/cross.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/eye.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/face.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/foot.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/gear.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/hand.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/heart.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/lock.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/minus.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/plus.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/ring.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/rounded_rect.svg create mode 100644 release/scripts/mgear/anim_picker/shapes/rounded_square.svg diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 5ea01411..fd617c8c 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -414,37 +414,45 @@ def add_tab_widget(self, name="default"): self._cmd_import_svg, tool_bar.mgear_icon("mgear_image"), ) + # Shape library: a create / browse hub grouped with the other create + # tools (Add, SVG). Always enabled -- it also drag-creates and creates + # from the Maya selection, so it needs no picker selection. + self.left_toolbar.add_command( + "Lib", + "Open the shape library (apply, drag to create, or " + "create-from-selection)", + self._cmd_shapes, + tool_bar.mgear_icon("mgear_grid"), + ) + dup_btn = self.left_toolbar.add_command( + "Dup", + "Duplicate selected items", + self._cmd_duplicate, + tool_bar.mgear_icon("mgear_copy"), + ) + dup_mirror_btn = self.left_toolbar.add_command( + "DupM", + "Duplicate and mirror the selection (linked as a pair)", + self._cmd_duplicate_mirror, + tool_bar.mgear_icon("mgear_duplicate_sym"), + ) + mirror_btn = self.left_toolbar.add_command( + "MirS", + "Mirror the selected shapes", + self._cmd_mirror_shape, + tool_bar.mgear_icon("mgear_mirror_controls"), + ) + pin_btn = self.left_toolbar.add_command( + "Pin", + "Pin / unpin the selection to the viewport (HUD overlay)", + self._cmd_toggle_pin, + tool_bar.mgear_icon("mgear_map-pin"), + ) self._selection_commands = [ - self.left_toolbar.add_command( - "Dup", - "Duplicate selected items", - self._cmd_duplicate, - tool_bar.mgear_icon("mgear_copy"), - ), - self.left_toolbar.add_command( - "DupM", - "Duplicate and mirror the selection (linked as a pair)", - self._cmd_duplicate_mirror, - tool_bar.mgear_icon("mgear_duplicate_sym"), - ), - self.left_toolbar.add_command( - "MirS", - "Mirror the selected shapes", - self._cmd_mirror_shape, - tool_bar.mgear_icon("mgear_mirror_controls"), - ), - self.left_toolbar.add_command( - "Shp", - "Apply a premade / saved shape to the selection", - self._cmd_shapes, - tool_bar.mgear_icon("mgear_replace_shape"), - ), - self.left_toolbar.add_command( - "Pin", - "Pin / unpin the selection to the viewport (HUD overlay)", - self._cmd_toggle_pin, - tool_bar.mgear_icon("mgear_map-pin"), - ), + dup_btn, + dup_mirror_btn, + mirror_btn, + pin_btn, ] # Trace: one silhouette button per selected Maya control. Not gated on # a picker selection (it reads the Maya selection); the drop-down picks @@ -712,20 +720,28 @@ def _cmd_duplicate(self): self._after_command() def _cmd_shapes(self): - items = self._selected_items() - if not items: - return - current = [[h.x(), h.y()] for h in items[-1].handles] dialog = ShapeLibraryDialog( parent=self, apply_callback=self._apply_shape_to_selection, - current_handles=current, + create_callback=self._create_shape_from_selection, + current_shape_getter=self._current_library_shape, ) dialog.show() - def _apply_shape_to_selection(self, handles): + def _current_library_shape(self): + """Return the current selection's save-able shape, or None (live).""" + items = self._selected_items() + return items[-1].get_library_shape() if items else None + + def _apply_shape_to_selection(self, shape): for item in self._selected_items(): - item.set_handles([list(point) for point in handles]) + item.apply_library_shape(shape) + self._after_command() + + def _create_shape_from_selection(self, shape, axis): + view = self._current_view() + if view is not None: + view.create_shape_from_selection(shape, axis) self._after_command() def _cmd_mirror(self, method_name): diff --git a/release/scripts/mgear/anim_picker/shapes/arrow_2dir.svg b/release/scripts/mgear/anim_picker/shapes/arrow_2dir.svg new file mode 100644 index 00000000..360230ee --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/arrow_2dir.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/arrow_4dir.svg b/release/scripts/mgear/anim_picker/shapes/arrow_4dir.svg new file mode 100644 index 00000000..63024a73 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/arrow_4dir.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/check.svg b/release/scripts/mgear/anim_picker/shapes/check.svg new file mode 100644 index 00000000..4b315794 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/check.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/cross.svg b/release/scripts/mgear/anim_picker/shapes/cross.svg new file mode 100644 index 00000000..43b7fa2d --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/cross.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/default_shapes.json b/release/scripts/mgear/anim_picker/shapes/default_shapes.json index 7d8ee6f0..d87b5f9b 100644 --- a/release/scripts/mgear/anim_picker/shapes/default_shapes.json +++ b/release/scripts/mgear/anim_picker/shapes/default_shapes.json @@ -239,5 +239,69 @@ -20 ] ] + }, + { + "name": "Gear", + "svg": "gear.svg" + }, + { + "name": "Face", + "svg": "face.svg" + }, + { + "name": "Hand", + "svg": "hand.svg" + }, + { + "name": "Foot", + "svg": "foot.svg" + }, + { + "name": "Arrow 2-Way", + "svg": "arrow_2dir.svg" + }, + { + "name": "Arrow 4-Way", + "svg": "arrow_4dir.svg" + }, + { + "name": "Rounded Square", + "svg": "rounded_square.svg" + }, + { + "name": "Rounded Rect", + "svg": "rounded_rect.svg" + }, + { + "name": "Heart", + "svg": "heart.svg" + }, + { + "name": "Plus", + "svg": "plus.svg" + }, + { + "name": "Minus", + "svg": "minus.svg" + }, + { + "name": "Cross", + "svg": "cross.svg" + }, + { + "name": "Check", + "svg": "check.svg" + }, + { + "name": "Ring", + "svg": "ring.svg" + }, + { + "name": "Eye", + "svg": "eye.svg" + }, + { + "name": "Lock", + "svg": "lock.svg" } ] \ No newline at end of file diff --git a/release/scripts/mgear/anim_picker/shapes/eye.svg b/release/scripts/mgear/anim_picker/shapes/eye.svg new file mode 100644 index 00000000..f22b6a3d --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/eye.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/face.svg b/release/scripts/mgear/anim_picker/shapes/face.svg new file mode 100644 index 00000000..b9974daf --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/face.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/foot.svg b/release/scripts/mgear/anim_picker/shapes/foot.svg new file mode 100644 index 00000000..07dfd0ec --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/foot.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/gear.svg b/release/scripts/mgear/anim_picker/shapes/gear.svg new file mode 100644 index 00000000..48303dec --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/gear.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/hand.svg b/release/scripts/mgear/anim_picker/shapes/hand.svg new file mode 100644 index 00000000..21539d71 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/hand.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/heart.svg b/release/scripts/mgear/anim_picker/shapes/heart.svg new file mode 100644 index 00000000..a91ad4ce --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/heart.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/lock.svg b/release/scripts/mgear/anim_picker/shapes/lock.svg new file mode 100644 index 00000000..f64c0d17 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/lock.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/minus.svg b/release/scripts/mgear/anim_picker/shapes/minus.svg new file mode 100644 index 00000000..472a02ff --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/minus.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/plus.svg b/release/scripts/mgear/anim_picker/shapes/plus.svg new file mode 100644 index 00000000..68e81a7a --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/plus.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/ring.svg b/release/scripts/mgear/anim_picker/shapes/ring.svg new file mode 100644 index 00000000..a6e5e474 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/ring.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/rounded_rect.svg b/release/scripts/mgear/anim_picker/shapes/rounded_rect.svg new file mode 100644 index 00000000..5a43cc18 --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/rounded_rect.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/shapes/rounded_square.svg b/release/scripts/mgear/anim_picker/shapes/rounded_square.svg new file mode 100644 index 00000000..67ecb53c --- /dev/null +++ b/release/scripts/mgear/anim_picker/shapes/rounded_square.svg @@ -0,0 +1 @@ + diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 75df47dc..8ac11033 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -30,6 +30,7 @@ from mgear.anim_picker.widgets import background_manipulator from mgear.anim_picker.widgets import item_manipulator from mgear.anim_picker.widgets import edit_undo +from mgear.anim_picker.widgets import shape_library from mgear.anim_picker.widgets import tool_bar from mgear.anim_picker.widgets import mirror from mgear.anim_picker.widgets import overlay @@ -1116,6 +1117,12 @@ def _is_palette_drag(self, event): tool_bar.WIDGET_MIME ) + def _is_shape_drag(self, event): + """Return True when ``event`` is a shape tile drag we accept.""" + return __EDIT_MODE__.get() and event.mimeData().hasFormat( + shape_library.SHAPE_MIME + ) + def _svg_drop_paths(self, event): """Return the ``.svg`` local file paths in a file-URL drag (edit mode). @@ -1143,21 +1150,29 @@ def _drop_view_pos(self, event): return event.pos() def dragEnterEvent(self, event): - """Accept a palette drag or an ``.svg`` file drag in edit mode.""" - if self._is_palette_drag(event) or self._svg_drop_paths(event): + """Accept a palette / shape / ``.svg`` file drag in edit mode.""" + if ( + self._is_palette_drag(event) + or self._is_shape_drag(event) + or self._svg_drop_paths(event) + ): event.acceptProposedAction() else: QtWidgets.QGraphicsView.dragEnterEvent(self, event) def dragMoveEvent(self, event): - """Keep accepting a palette / ``.svg`` drag while it hovers the canvas.""" - if self._is_palette_drag(event) or self._svg_drop_paths(event): + """Keep accepting a palette / shape / ``.svg`` drag over the canvas.""" + if ( + self._is_palette_drag(event) + or self._is_shape_drag(event) + or self._svg_drop_paths(event) + ): event.acceptProposedAction() else: QtWidgets.QGraphicsView.dragMoveEvent(self, event) def dropEvent(self, event): - """Create the dropped widget / SVG item at the drop position (edit).""" + """Create the dropped widget / shape / SVG item at the drop (edit).""" if self._is_palette_drag(event): widget_type = bytes( event.mimeData().data(tool_bar.WIDGET_MIME) @@ -1166,6 +1181,16 @@ def dropEvent(self, event): self.add_widget_item(widget_type, scene_pos) event.acceptProposedAction() return + if self._is_shape_drag(event): + name = bytes( + event.mimeData().data(shape_library.SHAPE_MIME) + ).decode("utf-8") + shape = shape_library.get_shape(name) + if shape is not None: + scene_pos = self.mapToScene(self._drop_view_pos(event)) + self.create_item_with_shape(shape, scene_pos) + event.acceptProposedAction() + return svg_paths = self._svg_drop_paths(event) if svg_paths: scene_pos = self.mapToScene(self._drop_view_pos(event)) @@ -1218,6 +1243,68 @@ def add_picker_item_per_selected(self, mouse_pos=None): return created_ctrls + def create_item_with_shape(self, shape, scene_pos=None): + """Create one item carrying ``shape`` at ``scene_pos`` (drag-create). + + Args: + shape (dict): a resolved ``shape_library`` entry. + scene_pos (QPointF, optional): drop position (scene coords). + + Returns: + PickerItem: the created (and selected) item. + """ + + def _do(): + ctrl = self.add_picker_item() + if scene_pos is not None: + ctrl.setPos(scene_pos) + ctrl.apply_library_shape(shape) + self.scene().select_picker_items([ctrl]) + return ctrl + + return self.record_edit("Add shape", _do) + + def create_shape_from_selection(self, shape, axis="vertical"): + """Stamp one item with ``shape`` per selected Maya control. + + The items are laid out in a column (``axis`` "vertical") or a row + ("horizontal"), centered on the canvas, and each is linked to (and + colored from) its control -- reusing the trace group-centering helper. + Fine-tune the spacing afterward with the expand / contract tools. + + Args: + shape (dict): a resolved ``shape_library`` entry. + axis (str): "vertical" (column) or "horizontal" (row). + + Returns: + list: the created PickerItem instances. + """ + selection = cmds.ls(sl=True) or [] + if not selection: + return [] + + def _do(): + gap = 50.0 + created = [] + for index, ctrl in enumerate(selection): + item = self.add_picker_item() + item.apply_library_shape(shape) + item.set_control_list([ctrl]) + item.set_color( + color=self.get_color_picker_override(item, ctrl) + ) + # Scene is Y-up: a column stacks downward (negative y). + if axis == "horizontal": + item.setPos(index * gap, 0) + else: + item.setPos(0, -index * gap) + created.append(item) + self._center_items_on(created, self.get_center_pos()) + self.scene().select_picker_items(created) + return created + + return self.record_edit("Create shapes from selection", _do) + def add_picker_item_trace(self, plane="front", mouse_pos=None): """Create one silhouette button per selected control (trace tool). diff --git a/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py index c05bb13f..c81c262c 100644 --- a/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py +++ b/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py @@ -1,12 +1,23 @@ """Shape library picker dialog for the anim picker. -A small popup grid of shape swatches (bundled + user shapes). Clicking a swatch -applies that shape to the current selection via the supplied callback; the -current item's shape can be saved as a named custom shape, and user shapes can -be deleted from their right-click menu. The shape data / persistence lives in -the Qt-free ``widgets.shape_library`` module. +Two tabs -- **Polygons** (editable straight-line shapes) and **SVG** (curved +vector shapes: the bundled ``.svg`` templates plus any imported ones saved by +the user). A tile can be: + +- **clicked** to apply that shape to the current selection; +- **dragged** onto the canvas to create a new item with that shape at the drop + point; +- **right-clicked** for Apply / Create-from-selection (vertical / horizontal) + and, for a user shape, Delete. + +The current item's shape can be saved as a named custom shape; a vector item +(e.g. an imported SVG) is stored in the SVG tab, a polygon item in the Polygons +tab. Icon previews render the real geometry (curves included) via the shared +``graphics.build_vector_path``. The shape data / persistence lives in the +Qt-free ``widgets.shape_library`` module. """ +import math from functools import partial from mgear.vendor.Qt import QtGui @@ -14,30 +25,59 @@ from mgear.vendor.Qt import QtWidgets from mgear.anim_picker.widgets import shape_library +from mgear.anim_picker.widgets import tool_bar +from mgear.anim_picker.widgets import graphics class ShapeLibraryDialog(QtWidgets.QDialog): - """Popup grid to apply a premade / custom shape to the selection.""" + """Tabbed grid to apply / drag-create / stamp a premade or custom shape.""" _ICON_SIZE = 44 _COLUMNS = 4 + _ICON_MARGIN = 6.0 # padding inside a tile's icon box, in pixels + _TILE_W = 100 # fixed tile size so the grid aligns into clean columns + _TILE_H = 78 - def __init__(self, parent=None, apply_callback=None, current_handles=None): + def __init__( + self, + parent=None, + apply_callback=None, + create_callback=None, + current_shape_getter=None, + ): super(ShapeLibraryDialog, self).__init__(parent) self.setWindowTitle("Shape Library") self.setAttribute(QtCore.Qt.WA_DeleteOnClose, True) + self.resize(460, 440) + # apply_callback(shape): apply to selection. create_callback(shape, + # axis): stamp one per selected control. current_shape_getter(): return + # the active item's shape ({handles} or {subpaths, mode}) live, for + # "Save current" -- a getter (not a snapshot) so it reflects the + # selection even after the (modeless) dialog is already open. self._apply_callback = apply_callback - self._current_handles = current_handles + self._create_callback = create_callback + self._current_shape_getter = current_shape_getter self.main_layout = QtWidgets.QVBoxLayout(self) - self.grid_host = QtWidgets.QWidget() - self.grid_layout = QtWidgets.QGridLayout(self.grid_host) - self.main_layout.addWidget(self.grid_host) + hint = QtWidgets.QLabel( + "Click: apply to selection Drag: create on canvas " + "Right-click: create from selection" + ) + hint.setStyleSheet("color: #9a9a9a;") + self.main_layout.addWidget(hint) + + # Two tabs: editable polygons and curved vector (SVG) shapes. + self.tabs = QtWidgets.QTabWidget() + self.poly_grid = self._make_tab("Polygons") + self.svg_grid = self._make_tab("SVG") + self.main_layout.addWidget(self.tabs) button_row = QtWidgets.QHBoxLayout() self.save_button = QtWidgets.QPushButton("Save current shape...") - self.save_button.setEnabled(bool(current_handles)) + # Enabled whenever a getter is wired; the live selection is checked on + # click (so it is not stuck disabled from an empty open-time snapshot). + self.save_button.setEnabled(current_shape_getter is not None) self.save_button.clicked.connect(self._save_current) button_row.addWidget(self.save_button) button_row.addStretch() @@ -48,93 +88,173 @@ def __init__(self, parent=None, apply_callback=None, current_handles=None): self._rebuild_grid() + def _make_tab(self, title): + """Add a scrollable grid tab and return its ``QGridLayout``.""" + host = QtWidgets.QWidget() + grid = QtWidgets.QGridLayout(host) + grid.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(host) + self.tabs.addTab(scroll, title) + return grid + def _rebuild_grid(self): - """Repopulate the swatch grid from the shape library.""" - while self.grid_layout.count(): - item = self.grid_layout.takeAt(0) - widget = item.widget() - if widget is not None: - widget.deleteLater() - - for index, shape in enumerate(shape_library.load_shapes()): - button = QtWidgets.QToolButton() - button.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon) - button.setIcon(self._shape_icon(shape["handles"])) - button.setIconSize(QtCore.QSize(self._ICON_SIZE, self._ICON_SIZE)) - button.setText(shape["name"]) - button.setAutoRaise(True) - button.clicked.connect( - partial(self._apply_shape, shape["handles"]) - ) - if not shape["builtin"]: - button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - button.customContextMenuRequested.connect( - partial(self._user_shape_menu, shape["name"]) - ) - button.setToolTip("User shape (right-click to delete)") - self.grid_layout.addWidget( - button, index // self._COLUMNS, index % self._COLUMNS - ) + """Repopulate both tab grids from the shape library, split by kind.""" + for grid in (self.poly_grid, self.svg_grid): + while grid.count(): + item = grid.takeAt(0) + widget = item.widget() + if widget is not None: + widget.deleteLater() + + poly_index = 0 + svg_index = 0 + for shape in shape_library.load_shapes(): + if shape.get("kind") == shape_library.KIND_VECTOR: + self._add_tile(self.svg_grid, svg_index, shape) + svg_index += 1 + else: + self._add_tile(self.poly_grid, poly_index, shape) + poly_index += 1 + + def _add_tile(self, grid, index, shape): + """Add one shape tile to ``grid`` at the running ``index``.""" + button = tool_bar.DragTileButton( + shape_library.SHAPE_MIME, shape["name"], icon_size=self._ICON_SIZE + ) + button.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon) + button.setIcon(self._shape_icon(shape)) + button.setIconSize(QtCore.QSize(self._ICON_SIZE, self._ICON_SIZE)) + button.setText(shape["name"]) + button.setAutoRaise(True) + button.setFixedSize(QtCore.QSize(self._TILE_W, self._TILE_H)) + button.clicked.connect(partial(self._apply_shape, shape)) + button.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + button.customContextMenuRequested.connect( + partial(self._tile_menu, shape) + ) + tip = "Click: apply Drag: create on canvas Right-click: more" + if not shape["builtin"]: + tip += " (user shape)" + button.setToolTip(tip) + grid.addWidget( + button, index // self._COLUMNS, index % self._COLUMNS + ) - def _shape_icon(self, handles, size=None): - """Render a shape's outline into a QIcon preview.""" + # -- icon rendering ------------------------------------------------- + def _shape_path(self, shape): + """Build a ``QPainterPath`` for a shape (polygon or vector). + + Coordinates are the item's scene coordinates (Y-up); ``_draw_path_fit`` + fits and flips them for the icon. + """ + if shape.get("kind") == shape_library.KIND_VECTOR: + return graphics.build_vector_path(shape.get("subpaths", [])) + handles = shape.get("handles", []) + path = QtGui.QPainterPath() + if len(handles) == 2: + # A 2-handle polygon is a native circle (center + radius point). + (cx, cy), (rx, ry) = handles + radius = math.hypot(rx - cx, ry - cy) or 1.0 + path.addEllipse(QtCore.QPointF(cx, cy), radius, radius) + elif handles: + path.moveTo(handles[0][0], handles[0][1]) + for hx, hy in handles[1:]: + path.lineTo(hx, hy) + path.closeSubpath() + return path + + def _shape_icon(self, shape, size=None): + """Render a shape (polygon or vector, real curves) into a QIcon.""" size = size or self._ICON_SIZE + path = self._shape_path(shape) pixmap = QtGui.QPixmap(size, size) pixmap.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(pixmap) painter.setRenderHint(QtGui.QPainter.Antialiasing) - painter.setPen(QtGui.QPen(QtGui.QColor(220, 220, 220), 1.5)) - - margin = 6.0 - span = size - 2.0 * margin - if len(handles) == 2: - # Native circle: center + radius point -> a centered ellipse. - painter.drawEllipse( - QtCore.QRectF(margin, margin, span, span) - ) - else: - xs = [point[0] for point in handles] - ys = [point[1] for point in handles] - min_x, max_x = min(xs), max(xs) - min_y, max_y = min(ys), max(ys) - width = (max_x - min_x) or 1.0 - height = (max_y - min_y) or 1.0 - scale = min(span / width, span / height) - # Center the scaled shape; flip Y (scene is Y-up, icon Y-down). - off_x = margin + (span - width * scale) / 2.0 - off_y = margin + (span - height * scale) / 2.0 - polygon = QtGui.QPolygonF() - for hx, hy in handles: - polygon.append( - QtCore.QPointF( - off_x + (hx - min_x) * scale, - off_y + (max_y - hy) * scale, - ) - ) - painter.drawPolygon(polygon) + pen = QtGui.QPen(QtGui.QColor(220, 220, 220), 1.5) + pen.setCosmetic(True) # constant width despite the fit scale + painter.setPen(pen) + painter.setBrush(QtCore.Qt.NoBrush) + self._draw_path_fit(painter, path, size) painter.end() return QtGui.QIcon(pixmap) - def _apply_shape(self, handles): + def _draw_path_fit(self, painter, path, size): + """Draw ``path`` fit + centered in the icon box (upright).""" + rect = path.boundingRect() + if rect.isEmpty(): + return + margin = self._ICON_MARGIN + span = size - 2.0 * margin + width = rect.width() or 1.0 + height = rect.height() or 1.0 + scale = min(span / width, span / height) + off_x = margin + (span - width * scale) / 2.0 + off_y = margin + (span - height * scale) / 2.0 + # Flip Y (scene is Y-up, the icon Y-down) so the shape draws upright. + transform = QtGui.QTransform() + transform.translate( + off_x - rect.left() * scale, off_y + rect.bottom() * scale + ) + transform.scale(scale, -scale) + painter.save() + painter.setTransform(transform, True) + painter.drawPath(path) + painter.restore() + + # -- actions -------------------------------------------------------- + def _apply_shape(self, shape): if self._apply_callback is not None: - self._apply_callback(handles) + self._apply_callback(shape) self.accept() + def _tile_menu(self, shape, pos): + """Right-click menu: apply / create-from-selection / delete (user).""" + menu = QtWidgets.QMenu(self) + apply_action = menu.addAction("Apply to selection") + create_v = menu.addAction("Create from selection (vertical)") + create_h = menu.addAction("Create from selection (horizontal)") + delete_action = None + if not shape["builtin"]: + menu.addSeparator() + delete_action = menu.addAction( + "Delete '{}'".format(shape["name"]) + ) + chosen = menu.exec_(QtGui.QCursor.pos()) + if chosen is None: + return + if chosen == apply_action: + self._apply_shape(shape) + elif chosen in (create_v, create_h): + if self._create_callback is not None: + axis = "vertical" if chosen == create_v else "horizontal" + self._create_callback(shape, axis) + elif chosen == delete_action: + shape_library.remove_user_shape(shape["name"]) + self._rebuild_grid() + def _save_current(self): - if not self._current_handles: + shape = None + if self._current_shape_getter is not None: + shape = self._current_shape_getter() + if not shape: + QtWidgets.QMessageBox.information( + self, "Save shape", "Select a picker item first." + ) return name, ok = QtWidgets.QInputDialog.getText( self, "Save shape", "Shape name" ) if not (ok and name): return - shape_library.save_user_shape(str(name), self._current_handles) + shape_library.save_user_shape( + str(name), + handles=shape.get("handles"), + subpaths=shape.get("subpaths"), + mode=shape.get("mode"), + ) self._rebuild_grid() - - def _user_shape_menu(self, name, pos): - menu = QtWidgets.QMenu(self) - delete_action = menu.addAction("Delete '{}'".format(name)) - chosen = menu.exec_(QtGui.QCursor.pos()) - if chosen == delete_action: - shape_library.remove_user_shape(name) - self._rebuild_grid() + # Show the tab the saved shape landed in (vector -> the SVG tab). + self.tabs.setCurrentIndex(1 if shape.get("subpaths") else 0) diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index c39f8ec4..f850cd39 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -1408,24 +1408,30 @@ def _edit_handles(self): self.handles_window.raise_() def _open_shape_library(self): - if not self.items: - return - active = self._active_item() - current = None - if active is not None: - current = [[h.x(), h.y()] for h in active.handles] dialog = ShapeLibraryDialog( parent=self, apply_callback=self._apply_shape, - current_handles=current, + create_callback=self._create_shape_from_selection, + current_shape_getter=self._current_library_shape, ) dialog.show() - def _apply_shape(self, handles): + def _current_library_shape(self): + """Return the active item's save-able shape, or None (live).""" + active = self._active_item() + return active.get_library_shape() if active else None + + def _apply_shape(self, shape): if not self.items: return for item in self.items: - item.set_handles([list(point) for point in handles]) + item.apply_library_shape(shape) + self._repaint_view() + + def _create_shape_from_selection(self, shape, axis): + view = self._view + if view is not None and hasattr(view, "create_shape_from_selection"): + view.create_shape_from_selection(shape, axis) self._repaint_view() # -- mirror --------------------------------------------------------- diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index d638a816..3ce69010 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -751,6 +751,44 @@ def _paint_title(self, painter, rect, color, radius): painter.restore() +def build_vector_path(subpaths): + """Build a ``QPainterPath`` from ``svg_import`` M / L / C / Z subpaths. + + Curves are real (``cubicTo``) and overlapping subpaths cut holes (even-odd + fill). Shared by the vector item body and the shape-library icon preview so + both render identical geometry. + + Args: + subpaths (list): normalized subpaths, each a list of command tuples + (``("M", x, y)`` / ``("L", x, y)`` / ``("C", ...)`` / ``("Z",)``). + + Returns: + QtGui.QPainterPath: the compound path. + """ + path = QtGui.QPainterPath() + # Even-odd so overlapping subpaths cut holes (typical icon glyphs). + path.setFillRule(QtCore.Qt.OddEvenFill) + for sub in subpaths or []: + for segment in sub: + command = segment[0] + if command == "M": + path.moveTo(segment[1], segment[2]) + elif command == "L": + path.lineTo(segment[1], segment[2]) + elif command == "C": + path.cubicTo( + segment[1], + segment[2], + segment[3], + segment[4], + segment[5], + segment[6], + ) + elif command == "Z": + path.closeSubpath() + return path + + class VectorGraphic(DefaultPolygon): """Vector (curved) shape drawn as the item's body, from imported SVG. @@ -802,28 +840,7 @@ def set_stroke_width(self, width): @staticmethod def _build_path(subpaths): """Build a ``QPainterPath`` from M / L / C / Z segments.""" - path = QtGui.QPainterPath() - # Even-odd so overlapping subpaths cut holes (typical icon glyphs). - path.setFillRule(QtCore.Qt.OddEvenFill) - for sub in subpaths: - for segment in sub: - command = segment[0] - if command == "M": - path.moveTo(segment[1], segment[2]) - elif command == "L": - path.lineTo(segment[1], segment[2]) - elif command == "C": - path.cubicTo( - segment[1], - segment[2], - segment[3], - segment[4], - segment[5], - segment[6], - ) - elif command == "Z": - path.closeSubpath() - return path + return build_vector_path(subpaths) def boundingRect(self): # Pad for the selection border / stroke width / antialias so a moving diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 36b2508d..48694874 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -457,11 +457,7 @@ def edit_context_menu(self, event): # Init context menu menu = QtWidgets.QMenu(self.parent()) - # Build edit context menu - options_action = QtWidgets.QAction("Options", None) - options_action.triggered.connect(self.edit_options) - menu.addAction(options_action) - + # Build edit context menu (item options live in the inline edit panel). handles_action = QtWidgets.QAction("Toggle handles", None) handles_action.triggered.connect(self.toggle_edit_status) menu.addAction(handles_action) @@ -707,6 +703,10 @@ def _open_options_modal(self): def set_edit_status(self, status): """Set picker item edit status (handle visibility etc.)""" + # A vector (SVG) item has no editable per-point handles; force them + # hidden so "Toggle handles" is a no-op and never shows dead handles. + if self.is_vector_shape(): + status = False self._edit_status = status for handle in self.handles: @@ -1216,6 +1216,11 @@ def set_svg_shape(self, svg): self.vector_graphic.setVisible(bool(self.svg)) if self.svg: self.backdrop_graphic.setVisible(False) + # A vector item has no per-point handles; hide any that were shown + # (e.g. when converting a polygon whose handles were toggled on). + self._edit_status = False + for handle in self.handles: + handle.setVisible(False) # The item's shape()/boundingRect derive from the vector path, so tell # the scene of the geometry change (the item itself paints nothing). self.prepareGeometryChange() @@ -1256,6 +1261,45 @@ def set_svg_stroke_width(self, width): self.svg["stroke_width"] = width self.vector_graphic.set_stroke_width(width) + def apply_library_shape(self, shape): + """Apply a shape-library entry to this item (polygon or vector). + + A vector entry (carrying ``subpaths``) swaps in the curved shape; a + polygon entry replaces the handle points, reverting any vector body + first. Both stay editable afterward and round-trip in the item data. + + Args: + shape (dict): a resolved ``shape_library`` entry -- vector with + ``subpaths`` (+ ``mode``), or polygon with ``handles``. + """ + if shape.get("subpaths"): + self.set_svg_shape( + { + "name": shape.get("name", ""), + "subpaths": shape["subpaths"], + "mode": shape.get("mode", svg_import.MODE_FILL), + } + ) + else: + # Revert a vector body to a polygon before setting handle points. + if self.is_vector_shape(): + self.set_svg_shape(None) + self.set_handles([list(point) for point in shape["handles"]]) + + def get_library_shape(self): + """Return this item's shape as a save-able library dict, or None. + + A vector item yields ``{subpaths, mode}``; a polygon item yields + ``{handles}`` -- the shape the library's "Save current shape" stores. + """ + if self.is_vector_shape(): + subpaths = self.get_svg_subpaths() + if subpaths: + return {"subpaths": subpaths, "mode": self.get_svg_mode()} + return None + handles = [[handle.x(), handle.y()] for handle in self.handles] + return {"handles": handles} if handles else None + # ========================================================================= # Backdrop container --- def get_backdrop(self): diff --git a/release/scripts/mgear/anim_picker/widgets/shape_library.py b/release/scripts/mgear/anim_picker/widgets/shape_library.py index 21717b20..590bfdbe 100644 --- a/release/scripts/mgear/anim_picker/widgets/shape_library.py +++ b/release/scripts/mgear/anim_picker/widgets/shape_library.py @@ -1,17 +1,24 @@ """Qt/Maya-free shape library for picker items. -A shape is a named list of handle coordinates -- the same ``[[x, y], ...]`` a -``PickerItem`` stores -- so applying a library shape is just ``set_handles`` and -saving one is just reading ``get_data()["handles"]``. Two sources are merged: +A library shape is one of two kinds: -- **bundled** premade shapes shipped as JSON next to the package - (``anim_picker/shapes/default_shapes.json``), read-only; +- **polygon** -- a named list of handle points (``[[x, y], ...]``, the same + a ``PickerItem`` stores); applying it is just ``set_handles``. +- **vector** -- curved subpaths, either a bundled ``.svg`` template (parsed via + ``mgear.core.svg_import``) or a saved vector item's subpaths; applying it is + ``set_svg_shape``. + +Two sources are merged: + +- **bundled** premade shapes shipped next to the package: polygon entries in + ``anim_picker/shapes/default_shapes.json`` (an entry may name an ``.svg`` + template in the same folder), read-only; - **user** shapes saved to ``mGear_anim_picker_shapes.json`` in the Maya user preferences directory (alongside ``mGear_user_settings.ini``), editable. No hard Qt or Maya dependency (Maya is imported lazily only to resolve the -prefs dir, with a home-dir fallback), so the load/save round-trip is -unit-testable; the Qt picker UI lives in +prefs dir, ``svg_import`` lazily only to parse a bundled template), so the +load/save round-trip is unit-testable; the Qt picker UI lives in ``widgets/dialogs/shape_library_dialog.py``. """ @@ -21,6 +28,21 @@ _USER_SHAPES_FILE = "mGear_anim_picker_shapes.json" +# Drag-and-drop mime type carrying a shape *name* from a library tile to the +# canvas; the view resolves the name and creates the item at the drop point. +SHAPE_MIME = "application/x-mgear-anim-picker-shape" + +# Resolved shape kinds. +KIND_POLYGON = "polygon" +KIND_VECTOR = "vector" + +# Default vector render mode (kept in sync with svg_import.MODE_FILL so the +# heavy import stays lazy). +_DEFAULT_MODE = "fill" + +# Cache of parsed bundled ``.svg`` templates: {filename: (subpaths, mode)}. +_SVG_CACHE = {} + def bundled_shapes_path(): """Return the path to the bundled default shapes JSON.""" @@ -52,8 +74,18 @@ def _legacy_user_shapes_path(): ) -def _read_shapes(path): - """Read a shapes JSON file, returning [] on missing / invalid content.""" +def _shapes_dir(): + """Return the bundled shapes directory (holds JSON + .svg templates).""" + return os.path.dirname(bundled_shapes_path()) + + +def _read_raw_shapes(path): + """Read a shapes JSON, returning raw entries ([] on missing / invalid). + + An entry is kept when it has a name and one geometry field: ``handles`` + (polygon), ``subpaths`` (a saved vector) or ``svg`` (a bundled .svg name). + The raw entry is preserved so the user file round-trips both kinds. + """ if not (path and os.path.exists(path)): return [] try: @@ -63,28 +95,90 @@ def _read_shapes(path): return [] shapes = [] for entry in data or []: - name = entry.get("name") - handles = entry.get("handles") - if name and handles: - shapes.append({"name": name, "handles": handles}) + if not entry.get("name"): + continue + if entry.get("handles") or entry.get("subpaths") or entry.get("svg"): + shapes.append(entry) return shapes +def _load_bundled_svg(svg_name): + """Parse a bundled ``.svg`` template into ``(subpaths, mode)``, cached. + + Returns None when the file is missing or has no supported geometry, so a + bad template is skipped rather than fatal. + """ + if svg_name in _SVG_CACHE: + return _SVG_CACHE[svg_name] + result = None + path = os.path.join(_shapes_dir(), svg_name) + try: + with open(path, "r") as svg_file: + text = svg_file.read() + except (IOError, OSError): + text = None + if text: + # svg_import is Qt/Maya-free; import it lazily so the module load stays + # light. flip_y: templates are authored y-down (SVG) and the picker + # scene is y-up, matching how dropped .svg files are imported. + from mgear.core import svg_import + + subpaths, _dropped, mode = svg_import.parse_svg(text, flip_y=True) + if subpaths: + result = (subpaths, mode) + _SVG_CACHE[svg_name] = result + return result + + +def _resolve_entry(entry, builtin): + """Resolve a raw entry to a normalized shape (with ``kind``), or None. + + Args: + entry (dict): a raw entry (``handles`` / ``subpaths`` / ``svg``). + builtin (bool): True for a bundled entry. + + Returns: + dict: ``{name, kind, builtin, ...geometry}`` or None when unresolvable. + """ + name = entry.get("name") + if not name: + return None + resolved = {"name": name, "builtin": builtin} + if entry.get("handles"): + resolved.update(kind=KIND_POLYGON, handles=entry["handles"]) + return resolved + if entry.get("subpaths"): + resolved.update( + kind=KIND_VECTOR, + subpaths=entry["subpaths"], + mode=entry.get("mode", _DEFAULT_MODE), + ) + return resolved + svg_name = entry.get("svg") + if svg_name: + parsed = _load_bundled_svg(svg_name) + if parsed is not None: + subpaths, mode = parsed + resolved.update(kind=KIND_VECTOR, subpaths=subpaths, mode=mode) + return resolved + return None + + def load_bundled_shapes(): - """Return the bundled premade shapes.""" - return _read_shapes(bundled_shapes_path()) + """Return the raw bundled shape entries.""" + return _read_raw_shapes(bundled_shapes_path()) def load_user_shapes(): - """Return the user's saved custom shapes. + """Return the user's raw saved shape entries. Reads from the Maya prefs dir; on first run, a pre-existing legacy file (``~/mgear/anim_picker/user_shapes.json``) is migrated to the new location. """ path = user_shapes_path() if os.path.exists(path): - return _read_shapes(path) - legacy = _read_shapes(_legacy_user_shapes_path()) + return _read_raw_shapes(path) + legacy = _read_raw_shapes(_legacy_user_shapes_path()) if legacy: try: _write_user_shapes(legacy) @@ -94,27 +188,30 @@ def load_user_shapes(): def load_shapes(): - """Return all shapes, each tagged ``builtin`` (bundled) True/False.""" + """Return all shapes resolved to normalized entries (with ``kind``). + + Each entry has ``name``, ``kind`` (polygon / vector), the geometry + (``handles`` or ``subpaths`` + ``mode``) and ``builtin`` (bundled) flag. + Entries that fail to resolve (e.g. a missing / empty ``.svg``) are skipped. + """ + sources = [(entry, True) for entry in load_bundled_shapes()] + sources += [(entry, False) for entry in load_user_shapes()] shapes = [] - for shape in load_bundled_shapes(): - shapes.append( - { - "name": shape["name"], - "handles": shape["handles"], - "builtin": True, - } - ) - for shape in load_user_shapes(): - shapes.append( - { - "name": shape["name"], - "handles": shape["handles"], - "builtin": False, - } - ) + for entry, builtin in sources: + resolved = _resolve_entry(entry, builtin) + if resolved is not None: + shapes.append(resolved) return shapes +def get_shape(name): + """Return the resolved shape named ``name`` (bundled or user), or None.""" + for shape in load_shapes(): + if shape["name"] == name: + return shape + return None + + def _write_user_shapes(shapes): """Write the user shapes list to disk, creating the directory.""" path = user_shapes_path() @@ -125,21 +222,33 @@ def _write_user_shapes(shapes): json.dump(shapes, shape_file, indent=2) -def save_user_shape(name, handles): - """Add or replace a user shape by name. +def save_user_shape(name, handles=None, subpaths=None, mode=None): + """Add or replace a user shape by name (polygon handles or vector). Args: name (str): shape name. - handles (list): list of ``[x, y]``. + handles (list, optional): ``[[x, y], ...]`` for a polygon shape. + subpaths (list, optional): subpaths for a vector shape. + mode (str, optional): vector render mode (fill / stroke). Returns: bool: True on success. """ - if not (name and handles): + if not name: + return False + if handles: + entry = {"name": name, "handles": handles} + elif subpaths: + entry = { + "name": name, + "subpaths": subpaths, + "mode": mode or _DEFAULT_MODE, + } + else: return False shapes = load_user_shapes() - shapes = [shape for shape in shapes if shape["name"] != name] - shapes.append({"name": name, "handles": handles}) + shapes = [shape for shape in shapes if shape.get("name") != name] + shapes.append(entry) _write_user_shapes(shapes) return True @@ -147,7 +256,7 @@ def save_user_shape(name, handles): def remove_user_shape(name): """Remove a user shape by name. Returns True if one was removed.""" shapes = load_user_shapes() - kept = [shape for shape in shapes if shape["name"] != name] + kept = [shape for shape in shapes if shape.get("name") != name] if len(kept) == len(shapes): return False _write_user_shapes(kept) diff --git a/release/scripts/mgear/anim_picker/widgets/tool_bar.py b/release/scripts/mgear/anim_picker/widgets/tool_bar.py index 1a00a9f8..ed6f4a81 100644 --- a/release/scripts/mgear/anim_picker/widgets/tool_bar.py +++ b/release/scripts/mgear/anim_picker/widgets/tool_bar.py @@ -45,19 +45,20 @@ def mgear_icon(name): return QtGui.QIcon() -class PaletteButton(QtWidgets.QToolButton): - """A draggable palette tile that creates a picker item/widget on drop. +class DragTileButton(QtWidgets.QToolButton): + """A draggable tile: a drag carries ``payload`` as ``mime`` to the drop. - Dragging the tile onto the canvas starts a drag carrying the widget-type - payload (``WIDGET_MIME``); the view's drop handler creates the item at the - drop position. A plain click does nothing (creation is drag-driven). + The drop target (the picker view) reads the payload and creates the item at + the drop position. A plain click still emits ``clicked``; an optional + double-click callback offers a create-at-center alternative to dragging. + Shared by the left-strip palette tiles and the shape-library tiles. """ - _ICON_SIZE = 22 - - def __init__(self, payload, parent=None): - super(PaletteButton, self).__init__(parent) + def __init__(self, mime, payload, icon_size=22, parent=None): + super(DragTileButton, self).__init__(parent) + self._mime = mime self._payload = payload + self._icon_size = icon_size self._press_pos = None self._double_callback = None @@ -68,36 +69,50 @@ def set_double_callback(self, callback): def mouseDoubleClickEvent(self, event): if self._double_callback is not None: self._double_callback() - super(PaletteButton, self).mouseDoubleClickEvent(event) + super(DragTileButton, self).mouseDoubleClickEvent(event) def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self._press_pos = event.pos() - super(PaletteButton, self).mousePressEvent(event) + super(DragTileButton, self).mousePressEvent(event) def mouseMoveEvent(self, event): - # Start the drag once the cursor has moved past the drag threshold. + # Start the drag once the cursor has moved past the drag threshold; + # below it, fall through so a plain click still registers. if ( not (event.buttons() & QtCore.Qt.LeftButton) or self._press_pos is None ): - super(PaletteButton, self).mouseMoveEvent(event) + super(DragTileButton, self).mouseMoveEvent(event) return moved = (event.pos() - self._press_pos).manhattanLength() if moved < QtWidgets.QApplication.startDragDistance(): - super(PaletteButton, self).mouseMoveEvent(event) + super(DragTileButton, self).mouseMoveEvent(event) return drag = QtGui.QDrag(self) mime = QtCore.QMimeData() - mime.setData(WIDGET_MIME, self._payload.encode("utf-8")) + mime.setData(self._mime, self._payload.encode("utf-8")) drag.setMimeData(mime) icon = self.icon() if not icon.isNull(): - drag.setPixmap(icon.pixmap(self._ICON_SIZE, self._ICON_SIZE)) + drag.setPixmap(icon.pixmap(self._icon_size, self._icon_size)) drag.exec_(QtCore.Qt.CopyAction) self._press_pos = None +class PaletteButton(DragTileButton): + """A palette tile that creates a picker item / widget on drop. + + A thin ``DragTileButton`` carrying the widget type as ``WIDGET_MIME`` (the + view's drop handler creates the item at the drop position). + """ + + def __init__(self, payload, parent=None): + super(PaletteButton, self).__init__( + WIDGET_MIME, payload, parent=parent + ) + + class PickerToolBar(QtWidgets.QWidget): """Vertical tool strip that drives the active picker tool.""" diff --git a/releaseLog.rst b/releaseLog.rst index e9bd8a75..9a4e85b2 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -7,6 +7,7 @@ Release Log **Enhancements** * Anim Picker: Editor undo/redo across all edits + keyboard shortcuts — the picker editor now has a single **undo/redo stack that every edit records to**, so any change in edit mode can be reverted and re-applied: add / delete / duplicate / paste / cut, move / scale / rotate / align / distribute / expand / contract / nudge, color / text / shape / point edits, pin / anchor / offset, mirror link / unlink / make-symmetric, widget / backdrop / visibility / SVG / control-association / custom-menu edits, and z-order (move to back / front). A whole gesture is **one step** — a drag, or a manipulator scale / rotate, commits once on release, not per mouse-move — and undo restores item state via the existing item serialization, so there is **no data-format change**. The stack is bounded (50 steps), kept **per tab**, and reset when you enter edit mode or load a picker; animation-mode widget interactions (slider / checkbox driving Maya attributes) keep using Maya's own undo. Alongside it, **keyboard shortcuts** that fire only while the picker holds focus, so they never leak to Maya's global hotkeys: **Ctrl+Z** / **Ctrl+Shift+Z** (and the legacy **Ctrl+Y**) undo / redo, **Ctrl+C** / **Ctrl+V** / **Ctrl+X** copy / paste / cut, **Ctrl+D** duplicate, **Ctrl+Shift+D** duplicate & mirror, **Delete** / **Backspace** delete, **Ctrl+A** select all, **arrow keys** nudge (**Shift** = larger step), **F** frame, **Esc** clear selection. A focus fix keeps keyboard focus on the canvas after clicking an item in edit mode (previously an item click handed focus to the Maya main window, so the shortcuts and even undo often did nothing). The snapshot-based undo core ships as a small Qt/Maya-free ``edit_undo`` module * Anim Picker: Expand / contract spacing tools — the Align section of the tool strip gains four buttons that **spread the selected items apart or pull them closer**, horizontally or vertically, a small step per click, so you can fine-tune the spacing of a row / column of buttons without dragging each one. Each click scales the selection's spread about its bounding-box center (contract is the exact inverse of expand, so an expand then a contract returns to the start); items at the center stay put, and it needs 2+ selected. Backed by a Qt/Maya-free ``alignment.scale_spread_offsets`` and one undo step per click + * Anim Picker: Richer shape library with drag-to-create and create-from-selection — the shape library now holds **vector templates** (real curves, not just straight-line polygons) alongside the existing polygon shapes, split across two tabs (**Polygons** and **SVG**) with previews that render each shape's true outline (curves included), and adds two fast ways to build with a shape. **16 new bundled templates** ship — gear, face, hand, foot, 2- and 4-direction arrows, rounded square / rectangle, heart, plus, minus, check, cross, ring, eye, and lock — the curved / organic ones as **vector** (a bundled ``.svg`` parsed by the SVG importer), the simple geometric ones as editable polygons. Open the library from the tool strip's **Shp** button (or the Shape panel), then: **click** a tile to apply the shape to the selection (as before), **drag** a tile onto the canvas to create a new item with that shape at the drop point, or **right-click** a tile to **create from selection** — one item with that shape per selected Maya control, laid out in a column or a row and centered, each linked to (and colored from) its control (fine-tune the spacing afterward with the expand / contract tools). Applying a vector template makes the item a curved vector shape (colorable / mirrorable / scalable, fill or stroke); applying a polygon template keeps editable points; **Save current shape** stores whichever kind the active item is. All create actions are a single undo step. Backward compatible: existing polygon shapes and the apply-to-selection flow are unchanged, and the library gains only additive vector entries — the ``mgear.core.svg_import`` parser powers the vector templates * Anim Picker: High-DPI text + handle scaling — on a high-DPI display (a 4K / Retina monitor, or Windows / Maya UI scaling at 150% / 200%) the picker's on-canvas **item labels and edit-mode handles** now scale with the display like the rest of the window, instead of staying at their small 96-DPI size. Item label text, the edit-mode handle index, the backdrop title and the vector "SVG" badge grow with the display, and the point handles and the item / background transform manipulators enlarge so they stay visible and easy to grab. Sizes that already scale with the canvas zoom (widget groove / knob, backdrop bar, badge box) are left as-is so nothing is enlarged twice. The scaling is a **no-op at 100%** (standard-DPI pickers render exactly as before), and the authored **text size is stored display-independently**, so a picker made on one monitor looks right on another. Uses the same ``mgear.core.pyqt.dpi_scale`` the window chrome already uses * Anim Picker: Vector shapes from SVG — a picker item can now be a real **vector shape** with smooth curves and holes (a gear, an eye, an arrow, a logo), not just a straight-line polygon or circle. **Drag an ``.svg`` file** from your file browser onto the picker canvas in edit mode (or use the tool strip's **Import SVG...** button / the Shape panel), and it becomes one curved item at the drop position, editable like any other — select on its silhouette, color, mirror, scale / rotate. Vector shapes can be drawn **filled** or as **lines with an adjustable thickness** (stroke), chosen per item in the Shape panel; on import the mode is auto-detected from the SVG's fill / stroke (line-art icons come in as strokes so they are not filled to nothing). Import is **gracefully partial**: the supported geometry (``path`` with full curve/arc commands, ``rect``, ``circle``, ``ellipse``, ``polygon``, ``polyline``, ``line``, nested in ``g`` groups with transforms and ``viewBox``) is kept, while unsupported elements (text, images, gradients, filters, clip / mask, ``use``, style, script, animation) are **discarded and reported** — so a file that mixes supported shapes with, say, a text label still imports its shapes and tells you what it dropped, an all-unsupported file creates nothing and says why, and a malformed file never errors. Imported art is fit to a consistent size and oriented upright. Vector items hide the polygon "show handles" option (they have no per-point handles) and show a small **"SVG" badge on hover** instead of the border highlight, so they read as vector at a glance. Persisted as one additive optional item key (``svg``, emitted only for vector items), so older pickers load unchanged and polygon items are unaffected; the existing curve↔picker conversion is kept. The SVG parser ships as a reusable, Qt/Maya-free ``mgear.core.svg_import`` so other tools can use it. (This version imports and transforms vector shapes; per-anchor curve editing is not yet included.) * Anim Picker: Conditional item visibility — a picker item can now be shown only when a condition passes, so a busy character picker declutters itself in animation mode. Two condition modes: **channel state** (show only when a Maya attribute passes a comparison, e.g. IK controls appear only when ``arm_L_ctl.ikBlend >= 0.5``) and **zoom level** (show only when the view zoom is within a range, e.g. fine detail controls appear once zoomed in). Set it from the inline panel's new **Visibility** section — a mode selector plus the attribute / operator / threshold for a channel condition, or min / max zoom (0 = no bound) with a **Capture current** button that reads the view's current zoom so you set a threshold by zooming to where the item should (dis)appear. Conditions re-evaluate on demand without polling — never per playback frame, so a heavy rig is not slowed: zoom conditions update on every zoom / fit, and channel conditions on selection change and on **mouse-over** the picker (like the Channel Master tool — moving the mouse over the UI re-reads the bound attributes, no focus needed, so a manual or animated channel change is picked up on hover; hovering also re-syncs interactive checkbox / slider widgets to the rig). **Edit mode always shows every item** so a condition never blocks authoring, and a missing / malformed condition fails open (stays visible) so a control is never lost. Persisted as one additive optional item key (``visibility``, emitted only when set), so older pickers load unchanged and unconditioned items are unaffected; a picker with no conditions incurs no per-refresh cost @@ -24,6 +25,8 @@ Release Log * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) **Bug Fix** + * Anim Picker: Vector (SVG) picker items no longer show dead polygon handles — a vector item has no editable per-point handles, so "Toggle handles" (right-click and the panel option) is now a no-op for it and any handles left visible from a converted polygon are hidden. Also removed the redundant right-click **Options** entry (item options live in the inline edit panel; double-click still opens the legacy window) + * Anim Picker: Fix the shape library's **Save current shape** staying disabled when an item was selected — it now reads the live selection when clicked (from either the tool-strip library button or the item panel) rather than an open-time snapshot, and tells you to select an item if none is * Anim Picker: Fix the backdrop title and vector "SVG" badge being clipped on high-DPI displays — after the High-DPI change scaled these labels by device DPI, the text could outgrow its scene-unit strip / pill and get cut off. Both now size their font from the container height (a scene-coupled pixel size, so it tracks the canvas zoom and is DPI-independent), the backdrop title elides with "…" when it still does not fit, and the SVG badge pill grows its width to the text — so neither label is ever clipped * Anim Picker: Fix slider / 2D-slider widget knob leaving a ghost of its previous position after a drag — the fixed-size knob overhangs a thin track, but the widget's bounding rect stopped at the track, so the knob's overhang was never repainted; the bounding rect now includes the knob radius so the old position is cleared * Anim Picker: Fix "Picker shape from curve not working" #598 — remove the JSON-to-Python-literal replace hack that corrupted control names/paths containing "true", and guard shape-less transforms during curves-to-picker conversion From f3bf296522fa35c8b59b004a05c2f5a2aa098889 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 12:19:07 +0900 Subject: [PATCH 19/25] AnimPicker: Visibility: Checkbox master-toggle for item groups A checkbox widget can show / hide a named group of picker items directly in the picker (no rig attribute required). Items gain an additive 'group' tag; the checkbox binding gains 'visibility_group' + 'visibility_invert'. The view caches an 'any group controller?' gate, resolves each controller to whether it shows its group (checked XOR invert), and passes a group-hidden set into each item's evaluate_visibility(zoom, group_hidden) -- composing group AND the item's own condition (edit mode shows all). Consistent on load and the existing selection / hover / zoom refresh, and immediate on toggle. Authoring: a Group field in the Visibility section and a checkbox 'Controls group' + invert, undoable via the panel seam. Additive optional keys keep older pickers unaffected. --- release/scripts/mgear/anim_picker/view.py | 57 +++++++-- .../mgear/anim_picker/widgets/edit_panel.py | 119 ++++++++++++++++-- .../mgear/anim_picker/widgets/item_model.py | 10 ++ .../mgear/anim_picker/widgets/picker_item.py | 63 +++++++++- .../anim_picker/widgets/widget_binding.py | 9 +- releaseLog.rst | 1 + 6 files changed, 237 insertions(+), 22 deletions(-) diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 8ac11033..5b47f3e9 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -145,6 +145,10 @@ def __init__(self, namespace=None, main_window=None): # when no item carries a visibility condition (the common case). self._has_conditional_items = False + # Group controllers: a cached "any checkbox controls a group?" flag so + # the group show/hide pass is skipped when no controller exists. + self._has_group_controllers = False + self.fit_margin = 8 # Editor undo: one snapshot-based stack that every edit records to. A @@ -2016,24 +2020,60 @@ def _update_pinned_items(self): # -- conditional visibility ----------------------------------------- def _recompute_conditional_flag(self): - """Refresh the cached "any conditioned item?" flag (on edit / load).""" + """Refresh the cached visibility gates (on edit / load). + + Two cheap flags keep ``refresh_item_visibility`` a no-op when nothing + needs it: any item with a visibility condition, and any checkbox that + master-controls a group. + """ + items = self.get_picker_items() self._has_conditional_items = any( - item.has_visibility_condition() for item in self.get_picker_items() + item.has_visibility_condition() for item in items + ) + self._has_group_controllers = any( + item.controls_group() for item in items ) + def _group_hidden_items(self): + """Return the set of items a group controller currently hides. + + Resolves each group-controller checkbox to whether it shows its group + (checked XOR invert); an item is hidden when its group is controlled + and currently not shown. Multiple controllers for one group apply in + item order (last-applied wins, deterministic). + """ + if not self._has_group_controllers: + return set() + shown = {} + for item in self.get_picker_items(): + target = item.controls_group() + if target: + shown[target] = item.group_shows() + if not shown: + return set() + hidden = set() + for item in self.get_picker_items(): + group = item.get_group() + if group and shown.get(group) is False: + hidden.add(item) + return hidden + def refresh_item_visibility(self): - """Show / hide each conditioned item for the current zoom + rig state. + """Show / hide each item for the current zoom, rig state, and groups. Mirrors ``_update_pinned_items``: the view owns *when* (called from the - zoom / pan / fit hooks and the selection / time callbacks) and computes - the zoom once, while each item owns its own decision (channel read + - pure evaluation). A no-op when no item carries a condition. + zoom / pan / fit hooks, the selection / hover callbacks, and a group + toggle) and computes the zoom + group gates once, while each item owns + its own decision. An item is visible only when its controlling group is + shown AND its own condition passes. A no-op when nothing is conditioned + or group-controlled. """ - if not self._has_conditional_items: + if not (self._has_conditional_items or self._has_group_controllers): return zoom = abs(self.viewportTransform().m11()) + hidden = self._group_hidden_items() for item in self.scene().get_picker_items(): - item.evaluate_visibility(zoom) + item.evaluate_visibility(zoom, item in hidden) def refresh_widget_states(self): """Re-read every interactive widget's bound attribute into its display. @@ -2237,6 +2277,7 @@ def clear(self): self._has_mirror_links = False self._has_pinned_items = False self._has_conditional_items = False + self._has_group_controllers = False def get_picker_items(self): """Return scene picker items in proper order (back to front)""" diff --git a/release/scripts/mgear/anim_picker/widgets/edit_panel.py b/release/scripts/mgear/anim_picker/widgets/edit_panel.py index f850cd39..cfc92a85 100644 --- a/release/scripts/mgear/anim_picker/widgets/edit_panel.py +++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py @@ -129,12 +129,16 @@ def __init__(self, parent=None, main_window=None): self.widget_min_y_sb = None self.widget_max_y_sb = None self.widget_recenter_cb = None + self.widget_group_combo = None + self.widget_invert_cb = None self._wx_attr_row = None self._wx_checkbox_box = None + self._wx_group_box = None self._wx_slider_box = None self._wx_2d_box = None self.backdrop_title_field = None self.backdrop_radius_sb = None + self.group_combo = None self.vis_mode_combo = None self.vis_attr_field = None self.vis_operator_combo = None @@ -632,6 +636,30 @@ def _build_widget_section(self): cb_row.addWidget(self._widget_script_button("Off Script...", "off")) section.addWidget(self._wx_checkbox_box) + # Checkbox: master-toggle a named item group's visibility. + self._wx_group_box = QtWidgets.QWidget() + group_layout = QtWidgets.QVBoxLayout(self._wx_group_box) + group_layout.setContentsMargins(0, 0, 0, 0) + cg_form = QtWidgets.QFormLayout() + self.widget_group_combo = QtWidgets.QComboBox() + self.widget_group_combo.setEditable(True) + self.widget_group_combo.setToolTip( + "Toggling this checkbox shows / hides every item tagged with this " + "group (leave empty for no group control)." + ) + self.widget_group_combo.activated.connect(self._apply_binding) + self.widget_group_combo.lineEdit().editingFinished.connect( + self._apply_binding + ) + self._fields.append(self.widget_group_combo) + cg_form.addRow("Controls group", self.widget_group_combo) + group_layout.addLayout(cg_form) + self.widget_invert_cb = QtWidgets.QCheckBox("Invert (show when off)") + self.widget_invert_cb.clicked.connect(self._apply_binding) + self._fields.append(self.widget_invert_cb) + group_layout.addWidget(self.widget_invert_cb) + section.addWidget(self._wx_group_box) + # 1D slider range + orientation + value script. self._wx_slider_box = QtWidgets.QWidget() slider_layout = QtWidgets.QVBoxLayout(self._wx_slider_box) @@ -730,6 +758,21 @@ def _capture_zoom_button(self, which): def _build_visibility_section(self): section = self._add_section("Visibility") + # Item group tag: a checkbox widget can master-toggle every item that + # shares this group name (editable combo of the names already in use). + group_form = QtWidgets.QFormLayout() + self.group_combo = QtWidgets.QComboBox() + self.group_combo.setEditable(True) + self.group_combo.setToolTip( + "Tag this item into a named group; a checkbox widget can then " + "show / hide the whole group at once. Leave empty for no group." + ) + self.group_combo.activated.connect(self._apply_group) + self.group_combo.lineEdit().editingFinished.connect(self._apply_group) + self._fields.append(self.group_combo) + group_form.addRow("Group", self.group_combo) + section.addLayout(group_form) + mode_form = QtWidgets.QFormLayout() self.vis_mode_combo = QtWidgets.QComboBox() self.vis_mode_combo.addItems(["None", "Channel state", "Zoom level"]) @@ -899,12 +942,64 @@ def _populate_backdrop(self): radius = item.get_corner_radius() if is_backdrop else 0.0 self._set_spin(self.backdrop_radius_sb, round(radius, 4), False) + def _refresh_visibility(self): + """Re-gate the view, re-apply group / conditional visibility, repaint. + + The shared tail of the edits that change a visibility gate (group tag, + checkbox controls-group binding, per-item condition): the display is + unchanged while editing (edit mode shows all), but this keeps the + runtime state correct and records one undo step. + """ + if self._view is not None: + self._view._recompute_conditional_flag() + self._view.refresh_item_visibility() + self._repaint_view() + + def _existing_groups(self): + """Return the sorted group names in use across the view's items.""" + view = self._view + if view is None: + return [] + names = set() + for item in view.get_picker_items(): + group = item.get_group() + if group: + names.add(group) + return sorted(names) + + def _fill_group_combo(self, combo, current): + """Fill an editable group combo with existing names + a current value. + + Programmatic, so signals are blocked (must not look like a user edit). + """ + combo.blockSignals(True) + combo.lineEdit().blockSignals(True) + combo.clear() + combo.addItems(self._existing_groups()) + combo.setCurrentText(current or "") + combo.lineEdit().blockSignals(False) + combo.blockSignals(False) + + def _apply_group(self, *args, **kwargs): + if self._syncing or not self.items: + return + name = str(self.group_combo.currentText()).strip() + for item in self.items: + item.set_group(name or None) + # A group tag change affects the controllers' member sets. + self._refresh_visibility() + def _update_visibility_mode(self, mode): """Show only the sub-box relevant to ``mode`` (VIS_NONE hides both).""" self._vx_channel_box.setVisible(mode == visibility.VIS_CHANNEL) self._vx_zoom_box.setVisible(mode == visibility.VIS_ZOOM) def _populate_visibility(self): + group, group_mixed = self._shared(lambda item: item.get_group() or "") + self._fill_group_combo( + self.group_combo, "" if group_mixed else group + ) + mode, mode_mixed = self._shared( lambda item: item.get_visibility().get("mode", visibility.VIS_NONE) ) @@ -939,6 +1034,9 @@ def _update_widget_visibility(self, widget_type): self._wx_checkbox_box.setVisible( widget_type == widget_binding.WIDGET_CHECKBOX ) + self._wx_group_box.setVisible( + widget_type == widget_binding.WIDGET_CHECKBOX + ) self._wx_slider_box.setVisible( widget_type == widget_binding.WIDGET_SLIDER ) @@ -974,6 +1072,12 @@ def _populate_widget(self): self.widget_min_y_sb.setValue(binding.get("min_y", -1.0)) self.widget_max_y_sb.setValue(binding.get("max_y", 1.0)) self.widget_recenter_cb.setChecked(bool(binding.get("recenter"))) + self._fill_group_combo( + self.widget_group_combo, binding.get("visibility_group", "") + ) + self.widget_invert_cb.setChecked( + bool(binding.get("visibility_invert")) + ) self._update_widget_visibility(None if wtype_mixed else wtype) @@ -1543,6 +1647,10 @@ def _collect_binding(self): "min_y": self.widget_min_y_sb.value(), "max_y": self.widget_max_y_sb.value(), "recenter": self.widget_recenter_cb.isChecked(), + "visibility_group": str( + self.widget_group_combo.currentText() + ).strip(), + "visibility_invert": self.widget_invert_cb.isChecked(), } def _apply_binding(self, *args, **kwargs): @@ -1551,7 +1659,8 @@ def _apply_binding(self, *args, **kwargs): binding = self._collect_binding() for item in self.items: item.set_binding(binding) - self._repaint_view() + # The controls-group binding may change the set of group controllers. + self._refresh_visibility() def _edit_widget_script(self, key): """Edit the widget script for ``key`` and apply it to the selection.""" @@ -1631,13 +1740,7 @@ def _apply_visibility(self, *args, **kwargs): self._update_visibility_mode( condition.get("mode", visibility.VIS_NONE) ) - # Refresh the view's "any conditioned item?" gate and re-apply the - # show/hide. The display is unchanged while editing (edit mode forces - # everything visible), but this keeps the runtime state correct. - if self._view is not None: - self._view._recompute_conditional_flag() - self._view.refresh_item_visibility() - self._repaint_view() + self._refresh_visibility() def _capture_zoom(self, which): """Set a zoom bound from the active view's current zoom scale.""" diff --git a/release/scripts/mgear/anim_picker/widgets/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py index 97a9f03a..3e4d98e0 100644 --- a/release/scripts/mgear/anim_picker/widgets/item_model.py +++ b/release/scripts/mgear/anim_picker/widgets/item_model.py @@ -36,6 +36,8 @@ passes -- see ``widgets.visibility``) svg dict (optional vector shape imported from SVG; normalized subpaths + render mode -- see ``mgear.core.svg_import``) + group str (optional visibility-group tag; a checkbox widget can + master-toggle every item sharing the name) """ @@ -70,6 +72,7 @@ def __init__(self): self.corner_radius = None self.visibility = None self.svg = None + self.group = None @classmethod def from_dict(cls, data): @@ -130,6 +133,8 @@ def from_dict(cls, data): model.visibility = dict(data["visibility"]) if data.get("svg"): model.svg = dict(data["svg"]) + if data.get("group"): + model.group = data["group"] return model @@ -214,4 +219,9 @@ def to_dict(self): if self.svg: data["svg"] = dict(self.svg) + # Visibility-group tag (additive optional key; only emitted when set so + # old readers and ungrouped items are unaffected). + if self.group: + data["group"] = self.group + return data diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index 48694874..c58cb305 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -131,6 +131,10 @@ def __init__( self.item_id = None self.mirror_id = None + # Visibility-group tag (optional): a checkbox widget can master-toggle + # every item sharing this group name (see set_group / controls_group). + self.group = None + # Viewport pin (optional HUD overlay): when pinned the item ignores the # canvas pan/zoom and is repositioned by the view to ``anchor`` + # ``offset`` (a 3x3 viewport anchor code + inward pixel offset). The @@ -1043,6 +1047,12 @@ def _widget_toggle(self): "on" if state else "off", {"__STATE__": state} ) self.widget_graphic.update() + # If this checkbox master-controls a group, reapply group visibility + # immediately (the view AND-s it with each member's own condition). + if self.controls_group(): + view = self.parent() + if view is not None and hasattr(view, "refresh_item_visibility"): + view.refresh_item_visibility() def _apply_widget_value(self, norm): """Write a normalized slider value to the bound attribute(s)/script. @@ -1150,21 +1160,28 @@ def has_visibility_condition(self): """Return True when the item carries a visibility condition.""" return bool(self.visibility) - def evaluate_visibility(self, zoom): + def evaluate_visibility(self, zoom, group_hidden=False): """Show / hide the item for its condition at the current ``zoom``. Edit mode always shows the item so a condition can never block editing. - A channel condition reads its attribute here (namespace applied, safe - read); the pure show/hide decision is delegated to - ``widgets.visibility``. A no-op decision (fail-open) keeps the item - visible. + Otherwise the item is visible only when its controlling group is shown + *and* its own condition passes (the group gate AND the item condition): + ``group_hidden`` forces it hidden ignoring the condition. A channel + condition reads its attribute here (namespace applied, safe read); the + pure show/hide decision is delegated to ``widgets.visibility``. A no-op + decision (fail-open) keeps the item visible. Args: zoom (float): the view's current zoom scale. + group_hidden (bool): True when a controlling checkbox hides the + item's group (the group gate is closed). """ if __EDIT_MODE__.get(): self.setVisible(True) return + if group_hidden: + self.setVisible(False) + return condition = self.visibility if not condition: self.setVisible(True) @@ -1179,6 +1196,36 @@ def evaluate_visibility(self, zoom): visibility.evaluate(condition, {"zoom": zoom, "value": value}) ) + # ========================================================================= + # Visibility group (checkbox master-toggle) --- + def get_group(self): + """Return the item's visibility-group tag, or None.""" + return self.group + + def set_group(self, group): + """Set the item's visibility-group tag (a falsy value clears it).""" + self.group = group or None + + def controls_group(self): + """Return the group name this checkbox controls, or None. + + Only a checkbox widget with a ``visibility_group`` binding is a group + controller; every other item / widget returns None. + """ + if self.widget_type != widget_binding.WIDGET_CHECKBOX: + return None + return (self.binding or {}).get("visibility_group") or None + + def group_shows(self): + """Return True when this controller checkbox currently shows its group. + + ``checked XOR invert``: a normal controller shows the group when + checked; an inverted one shows it when unchecked. + """ + checked = bool(self.widget_graphic.checked) + invert = bool((self.binding or {}).get("visibility_invert")) + return checked != invert + # ========================================================================= # Vector (SVG) shape --- def is_vector_shape(self): @@ -1776,6 +1823,10 @@ def set_data(self, data): if model.svg: self.set_svg_shape(model.svg) + # Visibility-group tag (optional, additive key). + if model.group: + self.set_group(model.group) + def get_data(self): """Get picker item data in dictionary form. @@ -1832,4 +1883,6 @@ def get_data(self): if self.svg: model.svg = dict(self.svg) + model.group = self.group + return model.to_dict() diff --git a/release/scripts/mgear/anim_picker/widgets/widget_binding.py b/release/scripts/mgear/anim_picker/widgets/widget_binding.py index 5a85ae87..4110edde 100644 --- a/release/scripts/mgear/anim_picker/widgets/widget_binding.py +++ b/release/scripts/mgear/anim_picker/widgets/widget_binding.py @@ -16,7 +16,12 @@ {"attr": "node.attr", "min": 0.0, "max": 1.0, # checkbox / 1D "attr_x": "node.tx", "min_x": -1.0, "max_x": 1.0, # 2D X "attr_y": "node.ty", "min_y": -1.0, "max_y": 1.0, # 2D Y - "orientation": "horizontal", "recenter": False} + "orientation": "horizontal", "recenter": False, + "visibility_group": "", "visibility_invert": False} # checkbox only + + A checkbox with a ``visibility_group`` is a group controller: toggling it + shows / hides every item tagged with that group (``visibility_invert`` + flips the polarity). Both keys are optional, ignored by the other widgets. ``scripts`` Optional per-state scripts run in addition to (or instead of) the @@ -133,6 +138,8 @@ def default_binding(): "max_y": 1.0, "orientation": ORIENT_HORIZONTAL, "recenter": False, + "visibility_group": "", + "visibility_invert": False, } diff --git a/releaseLog.rst b/releaseLog.rst index 9a4e85b2..ab84822a 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Checkbox master-toggle for item groups — a **checkbox widget can now show / hide a whole group of picker items at once**, directly in the picker with no rig attribute required. Tag any items into a named **group** (a new **Group** field in the inline editor's Visibility section, an editable combo of the names already in use), then set a checkbox's new **"Controls group"** field to that name (with an optional **Invert**, so it shows the group when *off*). Toggling the checkbox in animation mode reveals / folds away every item in that group **immediately** — e.g. one "IK controls" checkbox that unhides the arm's IK buttons, or a "Face" checkbox that collapses the facial picker. The group's visibility is **consistent on load and on the existing refresh** (selection / mouse-over / zoom), so it is correct without a click, and it stays live as the checkbox is toggled; the checkbox can still also drive a Maya attribute and/or a script as before. It **composes with conditional visibility** (#5): a grouped item that also has its own channel / zoom condition is shown only when its group is shown **and** its condition passes. **Edit mode always shows every item** so group control never blocks authoring, and multiple checkboxes may target one group (last-applied wins). Persisted as additive optional keys (the item's `group`, and the checkbox binding's `visibility_group` / `visibility_invert`, emitted only when set), so older pickers load unchanged and ungrouped items are unaffected; a picker with no group controllers incurs no per-refresh cost (gated like the conditional-visibility flag) * Anim Picker: Editor undo/redo across all edits + keyboard shortcuts — the picker editor now has a single **undo/redo stack that every edit records to**, so any change in edit mode can be reverted and re-applied: add / delete / duplicate / paste / cut, move / scale / rotate / align / distribute / expand / contract / nudge, color / text / shape / point edits, pin / anchor / offset, mirror link / unlink / make-symmetric, widget / backdrop / visibility / SVG / control-association / custom-menu edits, and z-order (move to back / front). A whole gesture is **one step** — a drag, or a manipulator scale / rotate, commits once on release, not per mouse-move — and undo restores item state via the existing item serialization, so there is **no data-format change**. The stack is bounded (50 steps), kept **per tab**, and reset when you enter edit mode or load a picker; animation-mode widget interactions (slider / checkbox driving Maya attributes) keep using Maya's own undo. Alongside it, **keyboard shortcuts** that fire only while the picker holds focus, so they never leak to Maya's global hotkeys: **Ctrl+Z** / **Ctrl+Shift+Z** (and the legacy **Ctrl+Y**) undo / redo, **Ctrl+C** / **Ctrl+V** / **Ctrl+X** copy / paste / cut, **Ctrl+D** duplicate, **Ctrl+Shift+D** duplicate & mirror, **Delete** / **Backspace** delete, **Ctrl+A** select all, **arrow keys** nudge (**Shift** = larger step), **F** frame, **Esc** clear selection. A focus fix keeps keyboard focus on the canvas after clicking an item in edit mode (previously an item click handed focus to the Maya main window, so the shortcuts and even undo often did nothing). The snapshot-based undo core ships as a small Qt/Maya-free ``edit_undo`` module * Anim Picker: Expand / contract spacing tools — the Align section of the tool strip gains four buttons that **spread the selected items apart or pull them closer**, horizontally or vertically, a small step per click, so you can fine-tune the spacing of a row / column of buttons without dragging each one. Each click scales the selection's spread about its bounding-box center (contract is the exact inverse of expand, so an expand then a contract returns to the start); items at the center stay put, and it needs 2+ selected. Backed by a Qt/Maya-free ``alignment.scale_spread_offsets`` and one undo step per click * Anim Picker: Richer shape library with drag-to-create and create-from-selection — the shape library now holds **vector templates** (real curves, not just straight-line polygons) alongside the existing polygon shapes, split across two tabs (**Polygons** and **SVG**) with previews that render each shape's true outline (curves included), and adds two fast ways to build with a shape. **16 new bundled templates** ship — gear, face, hand, foot, 2- and 4-direction arrows, rounded square / rectangle, heart, plus, minus, check, cross, ring, eye, and lock — the curved / organic ones as **vector** (a bundled ``.svg`` parsed by the SVG importer), the simple geometric ones as editable polygons. Open the library from the tool strip's **Shp** button (or the Shape panel), then: **click** a tile to apply the shape to the selection (as before), **drag** a tile onto the canvas to create a new item with that shape at the drop point, or **right-click** a tile to **create from selection** — one item with that shape per selected Maya control, laid out in a column or a row and centered, each linked to (and colored from) its control (fine-tune the spacing afterward with the expand / contract tools). Applying a vector template makes the item a curved vector shape (colorable / mirrorable / scalable, fill or stroke); applying a polygon template keeps editable points; **Save current shape** stores whichever kind the active item is. All create actions are a single undo step. Backward compatible: existing polygon shapes and the apply-to-selection flow are unchanged, and the library gains only additive vector entries — the ``mgear.core.svg_import`` parser powers the vector templates From f14d976ed82832cd73301eb3b673c5572287fd56 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 12:24:15 +0900 Subject: [PATCH 20/25] AnimPicker: Undo: Clear an added optional key when undoing its addition The editor-undo restore applied the before-snapshot via set_data, which is partial (only sets the optional additive keys the dict carries) and so never cleared a key the item gained since the snapshot -- undoing the addition of a group tag / visibility condition / SVG shape / pin / widget type / backdrop / text / controls / menus / mirror link left it behind. The restore now first calls PickerItem.clear_keys_absent_from(data), which resets each optional key the restored data lacks, guarded to run only when the item has that key (a plain move / property undo does no extra work). Redo is symmetric. --- release/scripts/mgear/anim_picker/view.py | 9 +++- .../mgear/anim_picker/widgets/picker_item.py | 43 +++++++++++++++++++ releaseLog.rst | 1 + 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 5b47f3e9..eb85725a 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -532,9 +532,16 @@ def _apply_undo_snapshot(self, snapshot): if item is not None: item.remove() else: + data = copy.deepcopy(state["data"]) if item is None: + # A recreated item starts clean; set_data restores it. item = self._recreate_item(key) - item.set_data(copy.deepcopy(state["data"])) + else: + # set_data is partial (only sets present keys), so first + # clear any optional key the restored data no longer has -- + # else undoing an *added* key would leave it behind. + item.clear_keys_absent_from(data) + item.set_data(data) item.setZValue(state["z"]) def undo(self): diff --git a/release/scripts/mgear/anim_picker/widgets/picker_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py index c58cb305..1c9bb652 100644 --- a/release/scripts/mgear/anim_picker/widgets/picker_item.py +++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py @@ -1729,6 +1729,49 @@ def get_custom_menus(self): # ========================================================================= # Data handling --- + def clear_keys_absent_from(self, data): + """Reset the optional keys that ``data`` does not carry. + + ``set_data`` is partial -- it only *sets* the optional additive keys it + carries -- so on its own it cannot undo the *addition* of a key. + The editor-undo restore calls this first so a key added since the + snapshot (text / controls / menus / action / mirror / pin / widget / + backdrop / visibility / svg / group) is removed when the restored data + lacks it. Each reset is guarded to run only when the item actually has + the key, so a plain move / property undo does no extra work. + + Args: + data (dict): the picker-item data about to be restored. + """ + if self.get_text() and not data.get("text"): + self.set_text("") + if self.get_text_align() != "center" and not data.get("text_align"): + self.set_text_align("center") + if self.get_text_offset() and not data.get("text_offset"): + self.set_text_offset(0) + if self.get_custom_action_mode() and not data.get("action_mode"): + self.set_custom_action_mode(False) + if self.get_controls() and not data.get("controls"): + self.set_control_list([]) + if self.get_custom_menus() and not data.get("menus"): + self.set_custom_menus([]) + if self.item_id and not data.get("id"): + self.item_id = None + if self.mirror_id and not data.get("mirror"): + self.mirror_id = None + if self.pinned and not data.get("pinned"): + self.set_pinned(False) + if self.is_widget() and not data.get("widget"): + self.set_widget_type(widget_binding.WIDGET_BUTTON) + if self.get_backdrop() and not data.get("backdrop"): + self.set_backdrop(False) + if self.get_visibility() and not data.get("visibility"): + self.set_visibility(None) + if self.is_vector_shape() and not data.get("svg"): + self.set_svg_shape(None) + if self.get_group() and not data.get("group"): + self.set_group(None) + def set_data(self, data): """Set picker item from data dictionary. diff --git a/releaseLog.rst b/releaseLog.rst index ab84822a..c7a509f7 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -26,6 +26,7 @@ Release Log * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) **Bug Fix** + * Anim Picker: Fix editor undo not removing an *added* optional property — undoing an edit that first gave an item a group tag, a visibility condition, a vector (SVG) shape, a viewport pin, a widget type, a backdrop, text, controls, menus, or a mirror link now clears it (previously the undo restore only re-set the keys the snapshot carried, so an added key was left behind). Guarded so a plain move / property undo does no extra work * Anim Picker: Vector (SVG) picker items no longer show dead polygon handles — a vector item has no editable per-point handles, so "Toggle handles" (right-click and the panel option) is now a no-op for it and any handles left visible from a converted polygon are hidden. Also removed the redundant right-click **Options** entry (item options live in the inline edit panel; double-click still opens the legacy window) * Anim Picker: Fix the shape library's **Save current shape** staying disabled when an item was selected — it now reads the live selection when clicked (from either the tool-strip library button or the item panel) rather than an open-time snapshot, and tells you to select an item if none is * Anim Picker: Fix the backdrop title and vector "SVG" badge being clipped on high-DPI displays — after the High-DPI change scaled these labels by device DPI, the text could outgrow its scene-unit strip / pill and get cut off. Both now size their font from the container height (a scene-coupled pixel size, so it tracks the canvas zoom and is DPI-independent), the backdrop title elides with "…" when it still does not fit, and the SVG badge pill grows its width to the text — so neither label is ever clipped From 25dc90237d7b34268a2f6ea9b81802fdef2e7a82 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 15:17:07 +0900 Subject: [PATCH 21/25] AnimPicker: Passthrough: Mask floating window to item silhouettes for click-through Rework the long-broken 'Enable opacity passthrough' option into a predictable window mask. When the floating picker is transparent (opacity < 100%) with Auto opacity off, the window is masked down to the item buttons + tabs so every gap is see-through and click-through to the viewport behind. - Build the mask by rendering the view's visible items to a transparent QImage (view.render with the canvas background cleared, so orientation and strokes match the display), then a QRegion from its alpha. Cut on the real painted pixels, so vector/stroke SVGs and rounded backdrops follow their silhouette and hidden group items stay passthrough. - Additively boost alpha before masking so thin antialiased strokes (arrows, open SVGs) survive createAlphaMask's coverage threshold. - Rebuild per frame while active (via _notify_passthrough at the view's transform sites) so the holes track pan/zoom with no ghosting; the rebuild short-circuits cheaply when passthrough is off. - Add a top-right 'move' grip to drag the window while click-through. - Remove the old event-filter passthrough that made the whole window click-through and required holding Shift. - Selection/hover item border is now a cosmetic (constant-screen-width) outline so it stays readable when zoomed far out. --- release/scripts/mgear/anim_picker/gui.py | 2 - .../scripts/mgear/anim_picker/main_window.py | 296 +++++++++++------- release/scripts/mgear/anim_picker/menu.py | 34 +- release/scripts/mgear/anim_picker/view.py | 47 ++- .../mgear/anim_picker/widgets/graphics.py | 23 +- releaseLog.rst | 2 + 6 files changed, 255 insertions(+), 149 deletions(-) diff --git a/release/scripts/mgear/anim_picker/gui.py b/release/scripts/mgear/anim_picker/gui.py index f7735609..2bca2a88 100644 --- a/release/scripts/mgear/anim_picker/gui.py +++ b/release/scripts/mgear/anim_picker/gui.py @@ -17,7 +17,6 @@ from mgear.anim_picker.scene import OrderedGraphicsScene from mgear.anim_picker.view import GraphicViewWidget from mgear.anim_picker.tab_widget import ContextMenuTabWidget -from mgear.anim_picker.main_window import APPassthroughEventFilter from mgear.anim_picker.main_window import MainDockWindow from mgear.anim_picker.main_window import MainDockableWindow from mgear.anim_picker.main_window import load @@ -33,7 +32,6 @@ "OrderedGraphicsScene", "GraphicViewWidget", "ContextMenuTabWidget", - "APPassthroughEventFilter", "MainDockWindow", "MainDockableWindow", "load", diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index fd617c8c..e2bb9ad3 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -1,7 +1,6 @@ """Main dockable window and launcher for the anim picker. -Extracted from gui.py during the Phase 2 decomposition. Also hosts the -passthrough event filter used by the main window. +Extracted from gui.py during the Phase 2 decomposition. """ from functools import partial @@ -15,7 +14,6 @@ from mgear.core import callbackManager from mgear.vendor.Qt import QtGui from mgear.vendor.Qt import QtCore -from mgear.vendor.Qt import QtCompat from mgear.vendor.Qt import QtWidgets from mgear.anim_picker import menu @@ -41,41 +39,35 @@ from mgear.anim_picker.handlers import __SELECTION__ -class APPassthroughEventFilter(QtCore.QObject): - """AnimPicker eventFilter for MayaMainWindow when enabling - click passthrough for the GUI. +class _PassthroughMoveHandle(QtWidgets.QLabel): + """A small grip shown in opacity-passthrough mode. + + While passthrough masks the window down to the items + tabs, the frame is + gone, so this handle is the one solid spot left to drag the window by. """ - # Animpicker gui reference - APUI = None + def __init__(self, window): + super(_PassthroughMoveHandle, self).__init__("··· move", window) + self._window = window + self._grab = None + self.setToolTip("Drag to move the picker (opacity passthrough)") + self.setStyleSheet( + "background: rgba(30, 30, 30, 210); color: #dddddd;" + " border: 1px solid #555555; border-radius: 3px; padding: 1px 6px;" + ) + self.setCursor(QtCore.Qt.SizeAllCursor) + self.hide() - def eventFilter(self, QObject, event): - """Filter for changing the windowFlags on the animPicker gui""" - modifiers = None - if QtCompat.isValid(self.APUI): - modifiers = QtWidgets.QApplication.queryKeyboardModifiers() - auto_state = self.APUI.auto_opacity_btn.isChecked() - flag_state = self.APUI.testAttribute( - QtCore.Qt.WA_TransparentForMouseEvents - ) - if auto_state and modifiers == QtCore.Qt.ShiftModifier: - # if the window is passthrough enabled - if flag_state: - pos = QtGui.QCursor().pos() - widgetRect = self.APUI.geometry() - if widgetRect.contains(pos): - self.APUI.set_mouseEvent_passthrough(False) - # if the window is passthrough enabled and the feature disabled - elif flag_state and not menu.get_option_var_passthrough_state(): - self.APUI.set_mouseEvent_passthrough(False) - else: - pass - else: - try: - self.deleteLater() - except RuntimeError: - pass - return super().eventFilter(QObject, event) + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._grab = QtGui.QCursor.pos() - self._window.pos() + + def mouseMoveEvent(self, event): + if self._grab is not None: + self._window.move(QtGui.QCursor.pos() - self._grab) + + def mouseReleaseEvent(self, event): + self._grab = None class MainDockWindow(QtWidgets.QWidget): @@ -103,6 +95,11 @@ def __init__(self, parent=None, edit=False, dockable=False): # Active canvas tool (Photoshop-style left toolbar); the view reads # this to decide whether the transform manipulator is shown/active. self.active_tool = tool_bar.TOOL_SELECT + # Whether a click-through mask is applied (opacity passthrough) so we + # only clear it once when the mode turns off. + self._mask_applied = False + # Drag grip shown only in passthrough mode (created in setup). + self.move_handle = None __EDIT_MODE__.set_init(edit) self.is_dockable = dockable @@ -114,12 +111,6 @@ def __init__(self, parent=None, edit=False, dockable=False): self.cb_manager = callbackManager.CallbackManager() self.setup() - # experimental passthrough feature - self.original_flags = self.windowFlags() - self.passthrough_eventFilter_installed = False - self.ap_eventFilter = APPassthroughEventFilter() - self.ap_eventFilter.APUI = self - def setup(self): """Setup interface""" # Only the dockable window gets an object name: Maya derives its @@ -150,13 +141,21 @@ def setup(self): self.auto_opacity_btn = QtWidgets.QPushButton("Auto opacity") self.auto_opacity_btn.setCheckable(True) self.auto_opacity_btn.toggled.connect(self.change_opacity) - self.auto_opacity_btn.toggled.connect( - self.toggle_passthrough_eventFilter - ) + # Auto-opacity and passthrough are mutually exclusive (passthrough + # needs a static manual opacity); re-evaluate the mask when it + # toggles. + self.auto_opacity_btn.toggled.connect(self.update_passthrough_mask) self.installEventFilter(self) opacity_layout.addWidget(self.opacity_slider) opacity_layout.addWidget(self.auto_opacity_btn) self.main_vertical_layout.addLayout(opacity_layout) + # Drag grip for moving the (otherwise click-through) window while + # passthrough is active; hidden until then. + self.move_handle = _PassthroughMoveHandle(self) + # Switching tabs changes the visible items, so realign the mask. + tab_widget = getattr(self.tab_area, "tab_widget", None) + if tab_widget is not None: + tab_widget.currentChanged.connect(self.update_passthrough_mask) self.add_overlays() self.resize(self.default_width, self.default_height) @@ -164,39 +163,136 @@ def setup(self): # off before everything is created) self.ready = True - def toggle_passthrough_eventFilter(self): - """enable the eventFilter for changing the AP gui windowFlags state""" - # this feature is beta and is off by default - if ( - menu.get_option_var_passthrough_state() == 0 - or not self.window_parent - ): - return - if self.auto_opacity_btn.isChecked(): - self.window_parent.installEventFilter(self.ap_eventFilter) - self.passthrough_eventFilter_installed = True - else: - self.window_parent.removeEventFilter(self.ap_eventFilter) - self.passthrough_eventFilter_installed = False + # ===================================================================== + # Opacity passthrough (click-through on empty canvas) --- + def _passthrough_active(self): + """Return True when empty-area click-through should be masked in. + + Engaged only for a floating (non-dockable) window with the feature + enabled, auto-opacity off, and a manual transparency (opacity < 1) -- + matching "some transparency and auto opacity is off". + """ + # Order the cheap widget checks first so the common case (fully opaque) + # short-circuits before the Maya optionVar query -- this runs on every + # pan / zoom frame via the mask hook. + return ( + not self.is_dockable + and self.windowOpacity() < 1.0 + and not self.auto_opacity_btn.isChecked() + and bool(menu.get_option_var_passthrough_state()) + ) - def set_mouseEvent_passthrough(self, state): - """set the state of the passthrough feature for anim picker + def update_passthrough_mask(self, *args): + """Mask the window to the visible items + tabs + move grip (or clear). - Args: - state (bool): enable or disable + Everything else (the frame, canvas background, character selector and + opacity bar) is left out, so it is see-through and click-through -- you + see only the item buttons and the tabs. Runs per frame during a pan / + zoom so the holes follow the items (no ghost, no full-UI flash); cheap + when passthrough is off. """ - if state and self.passthrough_eventFilter_installed: - self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, True) - self.setWindowFlags( - self.original_flags & QtCore.Qt.WA_TransparentForMouseEvents + if not getattr(self, "ready", False) or self.move_handle is None: + return + if not self._passthrough_active(): + if self._mask_applied: + self.clearMask() + self.move_handle.hide() + self._mask_applied = False + return + region = QtGui.QRegion() + for view in self.tab_area.all_views(): + region = region.united(self._items_region(view)) + # Keep the tab bar (to switch tabs) and the move grip interactive. + region = region.united(self._tab_bar_region()) + self._place_move_handle() + region = region.united(self._widget_region(self.move_handle)) + self.setMask(region) + self._mask_applied = True + + def _items_region(self, view): + """Return a pixel-exact region of a view's rendered items (window). + + Renders the scene's *visible* items (no background) to a transparent + image and takes the region of the painted pixels, so the mask matches + exactly what is drawn -- glyph holes, curved / concave / stroke SVGs, + rounded backdrops, the selection border -- with no per-shape math. + Hidden items (e.g. a checkbox-toggled group that is off) are not drawn, + so they stay fully passthrough. + """ + if view is None or not view.isVisible(): + return QtGui.QRegion() + viewport = view.viewport() + if not viewport.isVisible() or viewport.size().isEmpty(): + return QtGui.QRegion() + image = QtGui.QImage( + viewport.size(), QtGui.QImage.Format_ARGB32_Premultiplied + ) + image.fill(QtCore.Qt.transparent) + painter = QtGui.QPainter(image) + # Render through the view (its exact paint path + transform, so strokes + # and the Y-flip are handled) but with the canvas background cleared, + # so only the item pixels are opaque. + saved_brush = view.backgroundBrush() + view.setBackgroundBrush(QtCore.Qt.NoBrush) + try: + view.render( + painter, + QtCore.QRectF(0.0, 0.0, image.width(), image.height()), + viewport.rect(), ) - self.show() - elif state and not self.passthrough_eventFilter_installed: - self.toggle_passthrough_eventFilter() + finally: + view.setBackgroundBrush(saved_brush) + painter.end() + # createAlphaMask() thresholds coverage near 50%, which erases thin + # antialiased strokes (arrows, open SVGs) whose pixels never reach it. + # Additively stack the render onto itself so any painted pixel is + # pushed to full alpha; then anything drawn survives the threshold. + boosted = image.copy() + boost = QtGui.QPainter(boosted) + boost.setCompositionMode(QtGui.QPainter.CompositionMode_Plus) + # 4 extra additive passes -> ~5x alpha, enough to clear the ~50% + # coverage threshold for even faint single-pixel strokes. + alpha_boost_passes = 4 + for _ in range(alpha_boost_passes): + boost.drawImage(0, 0, image) + boost.end() + alpha = boosted.createAlphaMask() + region = QtGui.QRegion(QtGui.QBitmap.fromImage(alpha)) + region.translate(viewport.mapTo(self, QtCore.QPoint(0, 0))) + return region + + def _widget_region(self, widget): + """Return a visible child widget's rect as a window-coord region.""" + if widget is None or not widget.isVisible(): + return QtGui.QRegion() + top_left = widget.mapTo(self, QtCore.QPoint(0, 0)) + return QtGui.QRegion(QtCore.QRect(top_left, widget.size())) + + def _tab_bar_widget(self): + """Return the tab bar widget (tabbed mode), or None.""" + tab_widget = getattr(self.tab_area, "tab_widget", None) + getter = getattr(tab_widget, "tabBar", None) + return getter() if getter is not None else None + + def _tab_bar_region(self): + """Return the tab bar's region so the tabs stay visible + clickable.""" + return self._widget_region(self._tab_bar_widget()) + + def _place_move_handle(self): + """Show the move grip at the right end, level with the tab bar.""" + handle = self.move_handle + handle.adjustSize() + margin = pyqt.dpi_scale(8) + x = self.width() - handle.width() - margin + tab_bar = self._tab_bar_widget() + if tab_bar is not None and tab_bar.isVisible(): + top = tab_bar.mapTo(self, QtCore.QPoint(0, 0)).y() + y = top + max(0, (tab_bar.height() - handle.height()) // 2) else: - self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, False) - self.setWindowFlags(self.original_flags) - self.show() + y = pyqt.dpi_scale(6) + handle.move(x, y) + handle.show() + handle.raise_() def eventFilter(self, QObject, event): """event filter for general override @@ -211,40 +307,17 @@ def eventFilter(self, QObject, event): Returns: bool: accepting event or not """ - modifiers = None + # Auto opacity: opaque while the mouse is over the window, faded to the + # slider value when it leaves. (Passthrough is handled by the window + # mask, and is mutually exclusive with auto opacity.) if self.auto_opacity_btn.isChecked(): - modifiers = QtWidgets.QApplication.queryKeyboardModifiers() - - if event.type() == QtCore.QEvent.Type.Enter: - shift_state = modifiers == QtCore.Qt.ShiftModifier - flag_state = self.testAttribute( - QtCore.Qt.WA_TransparentForMouseEvents - ) - if self.auto_opacity_btn.isChecked(): - if flag_state and shift_state: - self.setWindowOpacity(100) - return True - elif not flag_state: - self.setWindowOpacity(100) - return True - else: + if event.type() == QtCore.QEvent.Type.Enter: + self.setWindowOpacity(1.0) + return True if event.type() == QtCore.QEvent.Type.Leave: - opacity_state = self.auto_opacity_btn.isChecked() - flag_state = self.testAttribute( - QtCore.Qt.WA_TransparentForMouseEvents - ) - if opacity_state: - pos = QtGui.QCursor().pos() - widgetRect = self.geometry() - if not widgetRect.contains(pos): - self.change_opacity() - # check the option var if it is enabled - if menu.get_option_var_passthrough_state(): - self.set_mouseEvent_passthrough(True) - elif flag_state and not opacity_state: - self.set_mouseEvent_passthrough(False) - - # QtCore.QEvent.Type.ScreenChangeInternal + if not self.geometry().contains(QtGui.QCursor.pos()): + self.change_opacity() + # hide main tab widget for os compatibility if QObject in getattr(self, "overlays", []): if event.type() == QtCore.QEvent.Type.Show: @@ -257,9 +330,15 @@ def eventFilter(self, QObject, event): return False def change_opacity(self): - """Change the windows opacity""" + """Change the window opacity and re-evaluate the passthrough mask.""" opacity_value = self.opacity_slider.value() self.setWindowOpacity(opacity_value / 100.0) + self.update_passthrough_mask() + + def resizeEvent(self, event): + """Keep the passthrough mask aligned to the resized window.""" + super().resizeEvent(event) + self.update_passthrough_mask() def reset_default_size(self): """Reset window size to default""" @@ -693,6 +772,8 @@ def _after_command(self): if panel is not None: panel.sync() self.update_tool_commands() + # Items may have moved / been added or removed -- realign the mask. + self.update_passthrough_mask() def _cmd_add_item(self): view = self._current_view() @@ -1006,11 +1087,6 @@ def close(self): except Exception: pass - try: - self.window_parent.removeEventFilter(self.ap_eventFilter) - except Exception: - pass - # Only the dockable window owns a workspaceControl; closing it from the # floating window would tear down the (separate) docked picker. Derive # the name the same way pyqt.showDialog does (toolName + suffix). diff --git a/release/scripts/mgear/anim_picker/menu.py b/release/scripts/mgear/anim_picker/menu.py index 9da539da..8608a756 100644 --- a/release/scripts/mgear/anim_picker/menu.py +++ b/release/scripts/mgear/anim_picker/menu.py @@ -22,24 +22,24 @@ """ -def force_disable_passthrough(*args): - """force all the anim picker gui's to disable passthrough feature +def refresh_passthrough(*args): + """Re-evaluate the click-through mask on every open anim picker. + + Each floating picker applies or clears its empty-area passthrough mask + based on its current opacity / auto-opacity state. Args: *args: n/a """ - widgets = pyqt.get_top_level_widgets(class_name="MainDockWindow") for ap in widgets: - if ( - hasattr(ap, "__OBJ_NAME__") - and ap.__OBJ_NAME__ == "ctrl_picker_window" - ): - ap.set_mouseEvent_passthrough(False) + update = getattr(ap, "update_passthrough_mask", None) + if update is not None: + update() def get_option_var_passthrough_state(): - """set option var for the anim picker passthrough feature + """Return the anim picker opacity-passthrough option var. Returns: int: 0 or 1 @@ -51,18 +51,13 @@ def get_option_var_passthrough_state(): def set_mgear_ap_passthrough_state(state): - """set the override state with maya option variable + """Set the opacity-passthrough option var and refresh open pickers. Args: state (bool, int): 0, 1, True, False """ cmds.optionVar(intValue=("mgear_ap_passthrough_OV", int(state))) - if state: - print("---------------------------------------------------") - print("Anim Picker passthrough enabled. (Beta)") - print("Hold 'Shift' while hovering over the Anim Picker UI") - else: - force_disable_passthrough() + refresh_passthrough() def install(): @@ -85,10 +80,13 @@ def install(): pm.menuItem(divider=True) cmds.menuItem(label="Edit Anim Picker", command=str_open_edit_mode) pm.menuItem(divider=True) - msg = "Experimental passthrough click when auto opacity enabled." + msg = ( + "Click through empty picker areas to the viewport when the window is " + "transparent (opacity < 100%) and Auto opacity is off." + ) cmds.menuItem( "mgear_ap_passthrough_menuitem", - label="Enable opacity passthrough (Beta)", + label="Enable opacity passthrough", command=set_mgear_ap_passthrough_state, checkBox=state, ann=msg, diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index eb85725a..84ab621d 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -413,12 +413,12 @@ def mouseReleaseEvent(self, event): self.viewport().update() self.drag_active = False + # A pan / drag-zoom just ended (or an item moved) -- realign the mask. + self._notify_passthrough() return result def wheelEvent(self, event): """Wheel event to add zoom support""" - if self.window().testAttribute(QtCore.Qt.WA_TransparentForMouseEvents): - return False self.setTransformationAnchor(QtWidgets.QGraphicsView.AnchorUnderMouse) # Run default event @@ -435,6 +435,8 @@ def wheelEvent(self, event): self._update_pinned_items() # Zoom changed; re-evaluate zoom-level visibility conditions. self.refresh_item_visibility() + # Realign the opacity-passthrough mask to the new zoom. + self._notify_passthrough() # ===================================================================== # Editor undo/redo --- @@ -864,6 +866,8 @@ def resizeEvent(self, *args, **kwargs): # refreshed here: an auto-frame fit already did (via fit_scene_content), # and without a fit a resize does not change the zoom scale. self._update_pinned_items() + # The canvas resized -- realign the opacity-passthrough mask. + self._notify_passthrough() return result def fit_scene_content(self): @@ -873,6 +877,7 @@ def fit_scene_content(self): # The fit changed the view transform; re-anchor pins + re-eval zoom. self._update_pinned_items() self.refresh_item_visibility() + self._notify_passthrough() def set_auto_frame_view(self): """Enable auto fit when a resize event happens""" @@ -2014,16 +2019,31 @@ def _update_pinned_items(self): item's scene position, so it stays fixed to the viewport through pan / zoom / resize. A no-op when nothing is pinned. """ - if not self._has_pinned_items: - return - size = self._viewport_size() - # Scene accessor (no draw-order reverse) since order is irrelevant to - # repositioning; this runs per pan / zoom frame while pins exist. - for item in self.scene().get_picker_items(): - if not item.pinned: - continue - px, py = overlay.anchor_point(size, item.anchor, item.offset) - item.setPos(self.mapToScene(int(round(px)), int(round(py)))) + if self._has_pinned_items: + size = self._viewport_size() + # Scene accessor (no draw-order reverse) since order is irrelevant + # to repositioning; runs per pan / zoom frame while pins exist. + for item in self.scene().get_picker_items(): + if not item.pinned: + continue + px, py = overlay.anchor_point(size, item.anchor, item.offset) + item.setPos(self.mapToScene(int(round(px)), int(round(py)))) + # Per-frame during a pan: rebuild the passthrough mask each frame so + # the item holes track the moving content (no ghost); the rebuild is a + # cheap no-op when passthrough is off. + self._notify_passthrough() + + def _notify_passthrough(self): + """Ask the window to realign its click-through mask. + + Called on every viewport change including per pan frame (so the mask + follows moving items without ghosting); it is a cheap no-op when the + opacity-passthrough mode is not active. + """ + window = self.main_window + update = getattr(window, "update_passthrough_mask", None) + if update is not None: + update() # -- conditional visibility ----------------------------------------- def _recompute_conditional_flag(self): @@ -2081,6 +2101,9 @@ def refresh_item_visibility(self): hidden = self._group_hidden_items() for item in self.scene().get_picker_items(): item.evaluate_visibility(zoom, item in hidden) + # Items were shown / hidden (e.g. a checkbox group toggled) -- realign + # the passthrough mask so revealed items appear at once. + self._notify_passthrough() def refresh_widget_states(self): """Re-read every interactive widget's bound attribute into its display. diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index 3ce69010..6920314f 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -351,9 +351,11 @@ def paint(self, painter, options, widget=None): brush = QtGui.QBrush(color) painter.fillPath(path, brush) - # Border status feedback + # Border status feedback. Cosmetic so the selection / hover outline + # keeps a constant screen width and stays readable when zoomed far out. border_pen = QtGui.QPen(self.__DEFAULT_SELECT_COLOR__) border_pen.setWidthF(2) + border_pen.setCosmetic(True) if self.selected: painter.setPen(border_pen) @@ -660,7 +662,11 @@ def boundingRect(self): def shape(self): path = QtGui.QPainterPath() - path.addRect(self._item_rect()) + radius = max(0.0, self.corner_radius) + if radius > 0: + path.addRoundedRect(self._item_rect(), radius, radius) + else: + path.addRect(self._item_rect()) return path def _selected(self): @@ -692,13 +698,14 @@ def paint(self, painter, options, widget=None): ) pen = QtGui.QPen(border) pen.setWidthF(1.5) + # Cosmetic so the outline keeps a constant screen width at any zoom. + pen.setCosmetic(True) painter.setPen(pen) - radius = max(0.0, self.corner_radius) - if radius > 0: - painter.drawRoundedRect(rect, radius, radius) - else: - painter.drawRect(rect) + # Reuse shape() so the drawn outline and the click/mask silhouette are + # always the same rounded-or-square rect. + painter.drawPath(self.shape()) if self.title: + radius = max(0.0, self.corner_radius) self._paint_title(painter, rect, color, radius) def _paint_title(self, painter, rect, color, radius): @@ -888,6 +895,8 @@ def paint(self, painter, options, widget=None): if self.selected: border = QtGui.QPen(self.__DEFAULT_SELECT_COLOR__) border.setWidthF(2.0) + # Cosmetic: constant screen width so it reads when zoomed far out. + border.setCosmetic(True) painter.setPen(border) painter.setBrush(QtCore.Qt.NoBrush) painter.drawPath(self._path) diff --git a/releaseLog.rst b/releaseLog.rst index c7a509f7..acad0a4f 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,6 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** + * Anim Picker: Opacity passthrough — a floating HUD you click straight through — the long-standing "Enable opacity passthrough" option (mGear menu ▸ Anim Picker) now does something useful and predictable. When the floating picker has **some transparency** (the opacity slider below 100%) and **Auto opacity is off**, the window is **masked down to just the item buttons and the tabs** — everything else (the frame, the canvas background, the character selector, the opacity bar) becomes fully **see-through and click-through**, so the picker reads as buttons floating over the viewport and clicking any gap selects / manipulates the rig behind without moving the picker out of the way. Each item is cut on its **real shape**, so a vector (SVG) button follows its silhouette instead of leaving a square. A small **"move" grip** appears at the top-right, level with the tabs, so you can still drag the window while it is click-through, and switching tabs / panning / zooming keeps the mask aligned. Turn the option off (or set opacity back to 100%, or enable Auto opacity) to get the solid, fully-interactive window back. This replaces the previous experimental behavior that made the *whole* window click-through and required holding Shift (floating window only; the dockable picker manages its own opacity) * Anim Picker: Checkbox master-toggle for item groups — a **checkbox widget can now show / hide a whole group of picker items at once**, directly in the picker with no rig attribute required. Tag any items into a named **group** (a new **Group** field in the inline editor's Visibility section, an editable combo of the names already in use), then set a checkbox's new **"Controls group"** field to that name (with an optional **Invert**, so it shows the group when *off*). Toggling the checkbox in animation mode reveals / folds away every item in that group **immediately** — e.g. one "IK controls" checkbox that unhides the arm's IK buttons, or a "Face" checkbox that collapses the facial picker. The group's visibility is **consistent on load and on the existing refresh** (selection / mouse-over / zoom), so it is correct without a click, and it stays live as the checkbox is toggled; the checkbox can still also drive a Maya attribute and/or a script as before. It **composes with conditional visibility** (#5): a grouped item that also has its own channel / zoom condition is shown only when its group is shown **and** its condition passes. **Edit mode always shows every item** so group control never blocks authoring, and multiple checkboxes may target one group (last-applied wins). Persisted as additive optional keys (the item's `group`, and the checkbox binding's `visibility_group` / `visibility_invert`, emitted only when set), so older pickers load unchanged and ungrouped items are unaffected; a picker with no group controllers incurs no per-refresh cost (gated like the conditional-visibility flag) * Anim Picker: Editor undo/redo across all edits + keyboard shortcuts — the picker editor now has a single **undo/redo stack that every edit records to**, so any change in edit mode can be reverted and re-applied: add / delete / duplicate / paste / cut, move / scale / rotate / align / distribute / expand / contract / nudge, color / text / shape / point edits, pin / anchor / offset, mirror link / unlink / make-symmetric, widget / backdrop / visibility / SVG / control-association / custom-menu edits, and z-order (move to back / front). A whole gesture is **one step** — a drag, or a manipulator scale / rotate, commits once on release, not per mouse-move — and undo restores item state via the existing item serialization, so there is **no data-format change**. The stack is bounded (50 steps), kept **per tab**, and reset when you enter edit mode or load a picker; animation-mode widget interactions (slider / checkbox driving Maya attributes) keep using Maya's own undo. Alongside it, **keyboard shortcuts** that fire only while the picker holds focus, so they never leak to Maya's global hotkeys: **Ctrl+Z** / **Ctrl+Shift+Z** (and the legacy **Ctrl+Y**) undo / redo, **Ctrl+C** / **Ctrl+V** / **Ctrl+X** copy / paste / cut, **Ctrl+D** duplicate, **Ctrl+Shift+D** duplicate & mirror, **Delete** / **Backspace** delete, **Ctrl+A** select all, **arrow keys** nudge (**Shift** = larger step), **F** frame, **Esc** clear selection. A focus fix keeps keyboard focus on the canvas after clicking an item in edit mode (previously an item click handed focus to the Maya main window, so the shortcuts and even undo often did nothing). The snapshot-based undo core ships as a small Qt/Maya-free ``edit_undo`` module * Anim Picker: Expand / contract spacing tools — the Align section of the tool strip gains four buttons that **spread the selected items apart or pull them closer**, horizontally or vertically, a small step per click, so you can fine-tune the spacing of a row / column of buttons without dragging each one. Each click scales the selection's spread about its bounding-box center (contract is the exact inverse of expand, so an expand then a contract returns to the start); items at the center stay put, and it needs 2+ selected. Backed by a Qt/Maya-free ``alignment.scale_spread_offsets`` and one undo step per click @@ -26,6 +27,7 @@ Release Log * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) **Bug Fix** + * Anim Picker: The selection / hover highlight border on picker items is now a cosmetic (constant-screen-width) outline, so it stays readable when the canvas is zoomed far out instead of thinning to nothing * Anim Picker: Fix editor undo not removing an *added* optional property — undoing an edit that first gave an item a group tag, a visibility condition, a vector (SVG) shape, a viewport pin, a widget type, a backdrop, text, controls, menus, or a mirror link now clears it (previously the undo restore only re-set the keys the snapshot carried, so an added key was left behind). Guarded so a plain move / property undo does no extra work * Anim Picker: Vector (SVG) picker items no longer show dead polygon handles — a vector item has no editable per-point handles, so "Toggle handles" (right-click and the panel option) is now a no-op for it and any handles left visible from a converted polygon are hidden. Also removed the redundant right-click **Options** entry (item options live in the inline edit panel; double-click still opens the legacy window) * Anim Picker: Fix the shape library's **Save current shape** staying disabled when an item was selected — it now reads the live selection when clicked (from either the tool-strip library button or the item panel) rather than an open-time snapshot, and tells you to select an item if none is From 8146f68c44c62913282bdb3588ee691fb0204b90 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 15:26:11 +0900 Subject: [PATCH 22/25] AnimPicker: Vector shapes: Show the SVG hover badge only in edit mode The 'SVG' badge on a hovered vector item is an authoring cue, so it should not appear in animation mode. Gate VectorGraphic's badge paint on a new lazy _edit_mode_active() helper (imports __EDIT_MODE__ only when called, so graphics.py stays importable without Maya, matching the _dpi helper). Hovering a vector item in anim mode now shows nothing; edit mode still shows the badge. --- .../mgear/anim_picker/widgets/graphics.py | 18 +++++++++++++++++- releaseLog.rst | 1 + 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/release/scripts/mgear/anim_picker/widgets/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py index 6920314f..0fa9178a 100644 --- a/release/scripts/mgear/anim_picker/widgets/graphics.py +++ b/release/scripts/mgear/anim_picker/widgets/graphics.py @@ -47,6 +47,20 @@ def _dpi(value): return value * _DPI_FACTOR +def _edit_mode_active(): + """Return True when the picker is in edit mode. + + ``__EDIT_MODE__`` is imported lazily (the handlers package touches Maya) to + keep this module import-free of Maya at load; returns False if unavailable. + """ + try: + from mgear.anim_picker.handlers import __EDIT_MODE__ + + return __EDIT_MODE__.get() + except Exception: + return False + + class DefaultPolygon(QtWidgets.QGraphicsObject): """Default polygon class, with move and hover support""" @@ -900,7 +914,9 @@ def paint(self, painter, options, widget=None): painter.setPen(border) painter.setBrush(QtCore.Qt.NoBrush) painter.drawPath(self._path) - if self._hovered: + # The "SVG" badge is an authoring cue, so show it on hover only while + # editing -- in animation mode a hovered vector item stays uncluttered. + if self._hovered and _edit_mode_active(): self._paint_svg_badge(painter) def _paint_svg_badge(self, painter): diff --git a/releaseLog.rst b/releaseLog.rst index acad0a4f..6fd771a1 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -27,6 +27,7 @@ Release Log * Anim Picker: Store picker data on the PICKER_DATAS node as clean JSON instead of a stringified Python literal read back with eval() — safer, with no arbitrary code execution on load. **BREAKING**: pickers stored only in a scene node with no external .pkr file are not migrated and must be re-exported (.pkr files and file-backed pickers are unaffected) **Bug Fix** + * Anim Picker: The vector (SVG) "SVG" hover badge is now shown only in edit mode — it is an authoring cue, so hovering a vector item in animation mode no longer flashes the badge and keeps the picker uncluttered * Anim Picker: The selection / hover highlight border on picker items is now a cosmetic (constant-screen-width) outline, so it stays readable when the canvas is zoomed far out instead of thinning to nothing * Anim Picker: Fix editor undo not removing an *added* optional property — undoing an edit that first gave an item a group tag, a visibility condition, a vector (SVG) shape, a viewport pin, a widget type, a backdrop, text, controls, menus, or a mirror link now clears it (previously the undo restore only re-set the keys the snapshot carried, so an added key was left behind). Guarded so a plain move / property undo does no extra work * Anim Picker: Vector (SVG) picker items no longer show dead polygon handles — a vector item has no editable per-point handles, so "Toggle handles" (right-click and the panel option) is now a no-op for it and any handles left visible from a converted polygon are hidden. Also removed the redundant right-click **Options** entry (item options live in the inline edit panel; double-click still opens the legacy window) From d74ad6edfe417bf3edef58281940dd66bca13b3a Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 18:24:42 +0900 Subject: [PATCH 23/25] AnimPicker: Passthrough: In-picker per-window toggle, smooth pan/zoom, clean edges Refine the opacity-passthrough feature into a per-window, in-UI control. - In-UI toggle: a 'Passthrough' checkbox left of Sync Namespace, plus a floating one by the move grip while the character-selector row is masked out; both stay in sync. Checking an opaque window drops it to a transparent default so it engages in one click (last transparency remembered); Auto opacity is turned off (mutually exclusive). - Per-window: replace the global mgear_ap_passthrough_OV option var + the 'Enable opacity passthrough' menu item with a per-instance flag, so enabling passthrough on one open picker no longer affects the others. - Motion: during a pan / zoom the mask is dropped (the full window returns and moves smoothly) and re-applied once motion settles via a short timer, instead of reshaping the window every frame -- fixes the redraw glitch. - Edges: erode the 1-bit mask by 1px so the antialiased item edge (blended into the dark canvas) no longer shows as a dark hairline. --- .../scripts/mgear/anim_picker/main_window.py | 136 +++++++++++++++++- release/scripts/mgear/anim_picker/menu.py | 53 ------- release/scripts/mgear/anim_picker/view.py | 59 +++++--- releaseLog.rst | 2 +- 4 files changed, 174 insertions(+), 76 deletions(-) diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index e2bb9ad3..37e7d9e0 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -16,7 +16,6 @@ from mgear.vendor.Qt import QtCore from mgear.vendor.Qt import QtWidgets -from mgear.anim_picker import menu from mgear.anim_picker import version from mgear.anim_picker import picker_node from mgear.anim_picker.constants import ANIM_PICKER_TITLE @@ -100,6 +99,21 @@ def __init__(self, parent=None, edit=False, dockable=False): self._mask_applied = False # Drag grip shown only in passthrough mode (created in setup). self.move_handle = None + # Re-applies the mask a moment after a pan / zoom settles (created in + # setup); during motion the mask is dropped so the window is not + # reshaped every frame -- the full UI comes back until you stop. + self._pt_settle_timer = None + # Per-window opacity-passthrough enable (toggled from the in-UI + # checkbox only; affects this picker, not every open picker). + self._passthrough_enabled = False + # In-UI passthrough toggles: one lives in the character-selector row + # (normal), one floats by the move grip (passthrough, where the row is + # masked out). Both drive the same state and stay in sync. + self.passthrough_cb = None + self.passthrough_cb_float = None + # Opacity restored when the toggle re-activates passthrough from an + # opaque window (so it visibly engages without touching the slider). + self._last_passthrough_opacity = 70 __EDIT_MODE__.set_init(edit) self.is_dockable = dockable @@ -152,6 +166,23 @@ def setup(self): # Drag grip for moving the (otherwise click-through) window while # passthrough is active; hidden until then. self.move_handle = _PassthroughMoveHandle(self) + # Floating passthrough toggle shown by the move grip while + # passthrough masks out the character-selector row; hidden until + # then. Mirrors the in-row checkbox. + self.passthrough_cb_float = QtWidgets.QCheckBox( + "Passthrough", self + ) + self.passthrough_cb_float.setToolTip( + self.passthrough_cb.toolTip() + ) + self.passthrough_cb_float.toggled.connect(self._toggle_passthrough) + self.passthrough_cb_float.hide() + # Re-applies the click mask shortly after a pan / zoom stops, so + # the window shape is not reshaped every frame during motion. + self._pt_settle_timer = QtCore.QTimer(self) + self._pt_settle_timer.setSingleShot(True) + self._pt_settle_timer.setInterval(120) + self._pt_settle_timer.timeout.connect(self.update_passthrough_mask) # Switching tabs changes the visible items, so realign the mask. tab_widget = getattr(self.tab_area, "tab_widget", None) if tab_widget is not None: @@ -179,7 +210,7 @@ def _passthrough_active(self): not self.is_dockable and self.windowOpacity() < 1.0 and not self.auto_opacity_btn.isChecked() - and bool(menu.get_option_var_passthrough_state()) + and self._passthrough_enabled ) def update_passthrough_mask(self, *args): @@ -187,9 +218,9 @@ def update_passthrough_mask(self, *args): Everything else (the frame, canvas background, character selector and opacity bar) is left out, so it is see-through and click-through -- you - see only the item buttons and the tabs. Runs per frame during a pan / - zoom so the holes follow the items (no ghost, no full-UI flash); cheap - when passthrough is off. + see only the item buttons and the tabs. Applied at rest; during a pan / + zoom it is dropped (see ``suspend_passthrough_mask``) so the window is + not reshaped every frame. A cheap no-op when passthrough is off. """ if not getattr(self, "ready", False) or self.move_handle is None: return @@ -198,17 +229,80 @@ def update_passthrough_mask(self, *args): self.clearMask() self.move_handle.hide() self._mask_applied = False + if self.passthrough_cb_float is not None: + self.passthrough_cb_float.hide() return region = QtGui.QRegion() for view in self.tab_area.all_views(): region = region.united(self._items_region(view)) - # Keep the tab bar (to switch tabs) and the move grip interactive. + # Keep the tab bar, the move grip and the floating passthrough toggle + # interactive (the character-selector row is masked out here). region = region.united(self._tab_bar_region()) self._place_move_handle() region = region.united(self._widget_region(self.move_handle)) + self._place_passthrough_float() + region = region.united(self._widget_region(self.passthrough_cb_float)) self.setMask(region) self._mask_applied = True + def suspend_passthrough_mask(self): + """Drop the click mask during a pan / zoom, re-applying it once motion + settles -- so the window is not reshaped every frame (the redraw + glitch). The full window (all UI) comes back while you move. + """ + if not getattr(self, "ready", False) or self.move_handle is None: + return + if not self._passthrough_active(): + return + if self._mask_applied: + self.clearMask() + self.move_handle.hide() + self.passthrough_cb_float.hide() + self._mask_applied = False + if self._pt_settle_timer is not None: + self._pt_settle_timer.start() + + def _toggle_passthrough(self, checked): + """Activate / deactivate opacity passthrough for THIS picker. + + Enables it per-window (other open pickers are unaffected) and, so an + opaque window visibly engages, drops it to a transparent default + (restored on deactivate). Auto opacity is turned off -- mutually + exclusive. + """ + self._passthrough_enabled = checked + self._sync_passthrough_checks(checked) + if checked: + if self.auto_opacity_btn.isChecked(): + self.auto_opacity_btn.setChecked(False) + if self.opacity_slider.value() >= 100: + self.opacity_slider.setValue(self._last_passthrough_opacity) + else: + if self.opacity_slider.value() < 100: + self._last_passthrough_opacity = self.opacity_slider.value() + self.opacity_slider.setValue(100) + self.update_passthrough_mask() + + def _sync_passthrough_checks(self, checked): + """Set `checked` on both passthrough toggles without re-emitting.""" + for cb in (self.passthrough_cb, self.passthrough_cb_float): + if cb is not None and cb.isChecked() != checked: + cb.blockSignals(True) + cb.setChecked(checked) + cb.blockSignals(False) + + def _place_passthrough_float(self): + """Show the floating passthrough toggle just left of the move grip.""" + cb = self.passthrough_cb_float + cb.adjustSize() + gap = pyqt.dpi_scale(8) + handle = self.move_handle + x = handle.x() - cb.width() - gap + y = handle.y() + max(0, (handle.height() - cb.height()) // 2) + cb.move(x, y) + cb.show() + cb.raise_() + def _items_region(self, view): """Return a pixel-exact region of a view's rendered items (window). @@ -258,9 +352,28 @@ def _items_region(self, view): boost.end() alpha = boosted.createAlphaMask() region = QtGui.QRegion(QtGui.QBitmap.fromImage(alpha)) + # Trim the outer ring so the 1-bit mask edge lands just inside the + # solid fill, not on the item's antialiased edge (which the render + # blends into the dark canvas and would show as a dark hairline). + region = self._erode_region(region, 1) region.translate(viewport.mapTo(self, QtCore.QPoint(0, 0))) return region + def _erode_region(self, region, px): + """Return `region` shrunk by `px` pixels on every side. + + A pixel is kept only if it and its neighbours `px` away are all inside, + so the outer boundary ring is dropped. Used to shave the antialiased + fringe off the passthrough mask (setMask is 1-bit, so it cannot + antialias the edge itself). + """ + if region.isEmpty(): + return region + eroded = region + for dx, dy in ((px, 0), (-px, 0), (0, px), (0, -px)): + eroded = eroded.intersected(region.translated(dx, dy)) + return eroded + def _widget_region(self, widget): """Return a visible child widget's rect as a window-coord region.""" if widget is None or not widget.isVisible(): @@ -405,6 +518,17 @@ def add_character_selector(self): ) btns_layout.addItem(spacer) + # Passthrough toggle, to the left of the Sync Namespace checkbox (only + # the floating window supports the click-through mask). + self.passthrough_cb = QtWidgets.QCheckBox("Passthrough") + self.passthrough_cb.setToolTip( + "Click through the empty picker area (when the window is " + "transparent and Auto opacity is off)" + ) + self.passthrough_cb.toggled.connect(self._toggle_passthrough) + if not __EDIT_MODE__.get() and not self.is_dockable: + btns_layout.addWidget(self.passthrough_cb) + # sync checkbox self.checkbox = QtWidgets.QCheckBox("Sync Namespace") if not __EDIT_MODE__.get(): diff --git a/release/scripts/mgear/anim_picker/menu.py b/release/scripts/mgear/anim_picker/menu.py index 8608a756..12467e2b 100644 --- a/release/scripts/mgear/anim_picker/menu.py +++ b/release/scripts/mgear/anim_picker/menu.py @@ -3,7 +3,6 @@ import mgear import mgear.menu -from mgear.core import pyqt str_open_picker_mode = """ @@ -22,50 +21,10 @@ """ -def refresh_passthrough(*args): - """Re-evaluate the click-through mask on every open anim picker. - - Each floating picker applies or clears its empty-area passthrough mask - based on its current opacity / auto-opacity state. - - Args: - *args: n/a - """ - widgets = pyqt.get_top_level_widgets(class_name="MainDockWindow") - for ap in widgets: - update = getattr(ap, "update_passthrough_mask", None) - if update is not None: - update() - - -def get_option_var_passthrough_state(): - """Return the anim picker opacity-passthrough option var. - - Returns: - int: 0 or 1 - """ - if not cmds.optionVar(exists="mgear_ap_passthrough_OV"): - cmds.optionVar(intValue=("mgear_ap_passthrough_OV", 0)) - - return cmds.optionVar(query="mgear_ap_passthrough_OV") - - -def set_mgear_ap_passthrough_state(state): - """Set the opacity-passthrough option var and refresh open pickers. - - Args: - state (bool, int): 0, 1, True, False - """ - cmds.optionVar(intValue=("mgear_ap_passthrough_OV", int(state))) - refresh_passthrough() - - def install(): """Install Anim Picker gui menu""" pm.setParent(mgear.menu_id, menu=True) - state = get_option_var_passthrough_state() - cmds.setParent(mgear.menu_id, menu=True) pm.menuItem(divider=True) cmds.menuItem( @@ -79,15 +38,3 @@ def install(): cmds.menuItem(label="Anim Picker (Dockable)", command=str_open_dockable_mode) pm.menuItem(divider=True) cmds.menuItem(label="Edit Anim Picker", command=str_open_edit_mode) - pm.menuItem(divider=True) - msg = ( - "Click through empty picker areas to the viewport when the window is " - "transparent (opacity < 100%) and Auto opacity is off." - ) - cmds.menuItem( - "mgear_ap_passthrough_menuitem", - label="Enable opacity passthrough", - command=set_mgear_ap_passthrough_state, - checkBox=state, - ann=msg, - ) diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 84ab621d..838d269b 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -96,6 +96,9 @@ def __init__(self, namespace=None, main_window=None): self.drag_active = False self.pan_active = False self.zoom_active = False + # True while a wheel-zoom step runs, so the passthrough mask is + # suspended (full UI back) like a drag instead of reshaped per step. + self._wheel_zooming = False self.auto_frame_active = True # Disable scroll bars @@ -431,12 +434,17 @@ def wheelEvent(self, event): # Apply zoom self.scale(factor, factor) - # Keep viewport-pinned items locked to their screen anchors. - self._update_pinned_items() - # Zoom changed; re-evaluate zoom-level visibility conditions. - self.refresh_item_visibility() - # Realign the opacity-passthrough mask to the new zoom. - self._notify_passthrough() + # Suspend the passthrough mask for the wheel burst (re-applied once it + # settles) so the window is not reshaped on every wheel step. + self._wheel_zooming = True + try: + # Keep viewport-pinned items locked to their screen anchors. + self._update_pinned_items() + # Zoom changed; re-evaluate zoom-level visibility conditions. + self.refresh_item_visibility() + finally: + self._wheel_zooming = False + self._suspend_passthrough() # ===================================================================== # Editor undo/redo --- @@ -2028,23 +2036,42 @@ def _update_pinned_items(self): continue px, py = overlay.anchor_point(size, item.anchor, item.offset) item.setPos(self.mapToScene(int(round(px)), int(round(py)))) - # Per-frame during a pan: rebuild the passthrough mask each frame so - # the item holes track the moving content (no ghost); the rebuild is a - # cheap no-op when passthrough is off. - self._notify_passthrough() + # During a pan / zoom, drop the mask so the full window shows and is + # not reshaped every frame (avoids the glitch); re-apply it at rest. + self._notify_or_suspend_passthrough() + + def _notify_or_suspend_passthrough(self): + """Suspend the mask mid-gesture, else re-apply it at rest. + + During a pan / zoom / wheel the window shape is dropped (the full UI + comes back, no per-frame reshape); when not moving (fit / resize / + load / release) the mask is re-applied. + """ + if self.pan_active or self.zoom_active or self._wheel_zooming: + self._suspend_passthrough() + else: + self._notify_passthrough() def _notify_passthrough(self): - """Ask the window to realign its click-through mask. + """Ask the window to (re-)apply its click-through mask now. - Called on every viewport change including per pan frame (so the mask - follows moving items without ghosting); it is a cheap no-op when the - opacity-passthrough mode is not active. + A cheap no-op when the opacity-passthrough mode is not active. """ window = self.main_window update = getattr(window, "update_passthrough_mask", None) if update is not None: update() + def _suspend_passthrough(self): + """Ask the window to drop its click mask for the duration of a gesture. + + A cheap no-op when the opacity-passthrough mode is not active. + """ + window = self.main_window + suspend = getattr(window, "suspend_passthrough_mask", None) + if suspend is not None: + suspend() + # -- conditional visibility ----------------------------------------- def _recompute_conditional_flag(self): """Refresh the cached visibility gates (on edit / load). @@ -2102,8 +2129,8 @@ def refresh_item_visibility(self): for item in self.scene().get_picker_items(): item.evaluate_visibility(zoom, item in hidden) # Items were shown / hidden (e.g. a checkbox group toggled) -- realign - # the passthrough mask so revealed items appear at once. - self._notify_passthrough() + # the passthrough mask (or suspend it mid-zoom) so they appear at once. + self._notify_or_suspend_passthrough() def refresh_widget_states(self): """Re-read every interactive widget's bound attribute into its display. diff --git a/releaseLog.rst b/releaseLog.rst index 6fd771a1..e2f3b3fc 100644 --- a/releaseLog.rst +++ b/releaseLog.rst @@ -5,7 +5,7 @@ Release Log 5.3.4 ------ **Enhancements** - * Anim Picker: Opacity passthrough — a floating HUD you click straight through — the long-standing "Enable opacity passthrough" option (mGear menu ▸ Anim Picker) now does something useful and predictable. When the floating picker has **some transparency** (the opacity slider below 100%) and **Auto opacity is off**, the window is **masked down to just the item buttons and the tabs** — everything else (the frame, the canvas background, the character selector, the opacity bar) becomes fully **see-through and click-through**, so the picker reads as buttons floating over the viewport and clicking any gap selects / manipulates the rig behind without moving the picker out of the way. Each item is cut on its **real shape**, so a vector (SVG) button follows its silhouette instead of leaving a square. A small **"move" grip** appears at the top-right, level with the tabs, so you can still drag the window while it is click-through, and switching tabs / panning / zooming keeps the mask aligned. Turn the option off (or set opacity back to 100%, or enable Auto opacity) to get the solid, fully-interactive window back. This replaces the previous experimental behavior that made the *whole* window click-through and required holding Shift (floating window only; the dockable picker manages its own opacity) + * Anim Picker: Opacity passthrough — a floating HUD you click straight through — a new **"Passthrough" checkbox in the picker itself** (to the left of Sync Namespace) turns the floating window into a click-through overlay. When it is on and the window is **transparent** (opacity slider below 100%) with **Auto opacity off**, the window is **masked down to just the item buttons and the tabs** — the frame, the canvas background, the character selector and the opacity bar all become **see-through and click-through**, so the picker reads as buttons floating over the viewport and clicking any gap selects / manipulates the rig behind without moving the picker out of the way. Checking the box on a fully-opaque window drops it to a transparent default so it engages in one click; unchecking restores the solid window (your last transparency is remembered). Each item is cut on its **real shape** (a vector / SVG button follows its silhouette instead of leaving a square), and the mask edge is trimmed 1px so there is no dark hairline. A small **"move" grip** — and a second **"Passthrough" checkbox** beside it — sit at the top-right so you can drag the window and turn passthrough off while it is click-through (the in-row checkbox is masked out then). During a **pan or zoom the full window comes back** and moves smoothly (no per-frame reshaping), then re-masks the moment you stop. Passthrough is now **per-window** — enabling it on one open picker does not affect the others — and the old global "Enable opacity passthrough" menu item / option var is removed (floating window only; the dockable picker manages its own opacity). Replaces the previous experimental behavior that made the *whole* window click-through and required holding Shift * Anim Picker: Checkbox master-toggle for item groups — a **checkbox widget can now show / hide a whole group of picker items at once**, directly in the picker with no rig attribute required. Tag any items into a named **group** (a new **Group** field in the inline editor's Visibility section, an editable combo of the names already in use), then set a checkbox's new **"Controls group"** field to that name (with an optional **Invert**, so it shows the group when *off*). Toggling the checkbox in animation mode reveals / folds away every item in that group **immediately** — e.g. one "IK controls" checkbox that unhides the arm's IK buttons, or a "Face" checkbox that collapses the facial picker. The group's visibility is **consistent on load and on the existing refresh** (selection / mouse-over / zoom), so it is correct without a click, and it stays live as the checkbox is toggled; the checkbox can still also drive a Maya attribute and/or a script as before. It **composes with conditional visibility** (#5): a grouped item that also has its own channel / zoom condition is shown only when its group is shown **and** its condition passes. **Edit mode always shows every item** so group control never blocks authoring, and multiple checkboxes may target one group (last-applied wins). Persisted as additive optional keys (the item's `group`, and the checkbox binding's `visibility_group` / `visibility_invert`, emitted only when set), so older pickers load unchanged and ungrouped items are unaffected; a picker with no group controllers incurs no per-refresh cost (gated like the conditional-visibility flag) * Anim Picker: Editor undo/redo across all edits + keyboard shortcuts — the picker editor now has a single **undo/redo stack that every edit records to**, so any change in edit mode can be reverted and re-applied: add / delete / duplicate / paste / cut, move / scale / rotate / align / distribute / expand / contract / nudge, color / text / shape / point edits, pin / anchor / offset, mirror link / unlink / make-symmetric, widget / backdrop / visibility / SVG / control-association / custom-menu edits, and z-order (move to back / front). A whole gesture is **one step** — a drag, or a manipulator scale / rotate, commits once on release, not per mouse-move — and undo restores item state via the existing item serialization, so there is **no data-format change**. The stack is bounded (50 steps), kept **per tab**, and reset when you enter edit mode or load a picker; animation-mode widget interactions (slider / checkbox driving Maya attributes) keep using Maya's own undo. Alongside it, **keyboard shortcuts** that fire only while the picker holds focus, so they never leak to Maya's global hotkeys: **Ctrl+Z** / **Ctrl+Shift+Z** (and the legacy **Ctrl+Y**) undo / redo, **Ctrl+C** / **Ctrl+V** / **Ctrl+X** copy / paste / cut, **Ctrl+D** duplicate, **Ctrl+Shift+D** duplicate & mirror, **Delete** / **Backspace** delete, **Ctrl+A** select all, **arrow keys** nudge (**Shift** = larger step), **F** frame, **Esc** clear selection. A focus fix keeps keyboard focus on the canvas after clicking an item in edit mode (previously an item click handed focus to the Maya main window, so the shortcuts and even undo often did nothing). The snapshot-based undo core ships as a small Qt/Maya-free ``edit_undo`` module * Anim Picker: Expand / contract spacing tools — the Align section of the tool strip gains four buttons that **spread the selected items apart or pull them closer**, horizontally or vertically, a small step per click, so you can fine-tune the spacing of a row / column of buttons without dragging each one. Each click scales the selection's spread about its bounding-box center (contract is the exact inverse of expand, so an expand then a contract returns to the start); items at the center stay put, and it needs 2+ selected. Backed by a Qt/Maya-free ``alignment.scale_spread_offsets`` and one undo step per click From ea618a2c0adf1c55d38da3b6fdcc8cac6a194251 Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 18:25:11 +0900 Subject: [PATCH 24/25] AnimPicker: Docs: Add Anim Picker 2.0 user documentation New Sphinx page (animPickerUserDocumentation.rst, linked from the index) covering the 2.0 rewrite: opening modes, the animation window + opacity passthrough, edit mode (tool strip, auto-build-from-rig trace, align, drag-to-add), the shape library, and every Item Editor panel (transform, appearance, shape, controls, action, widgets, backdrop, visibility, pin, mirror), plus the palette and .pkr / PICKER_DATAS storage. Ships the 22 screenshots under images/animpicker/. --- docs/source/animPickerUserDocumentation.rst | 407 ++++++++++++++++++ docs/source/images/animpicker/2D_slider.png | Bin 0 -> 4824 bytes docs/source/images/animpicker/action.png | Bin 0 -> 5404 bytes docs/source/images/animpicker/align.png | Bin 0 -> 1686 bytes docs/source/images/animpicker/anim_picker.png | Bin 0 -> 58460 bytes docs/source/images/animpicker/appearence.png | Bin 0 -> 6508 bytes docs/source/images/animpicker/backdrop.png | Bin 0 -> 5274 bytes docs/source/images/animpicker/checkbox.png | Bin 0 -> 2763 bytes docs/source/images/animpicker/controls.png | Bin 0 -> 4323 bytes .../animpicker/drag_and_drop_toolbar.png | Bin 0 -> 2015 bytes docs/source/images/animpicker/editor.png | Bin 0 -> 116595 bytes docs/source/images/animpicker/mirror.png | Bin 0 -> 4950 bytes docs/source/images/animpicker/palette.png | Bin 0 -> 2132 bytes docs/source/images/animpicker/passthrough.png | Bin 0 -> 114299 bytes docs/source/images/animpicker/pin.png | Bin 0 -> 6934 bytes docs/source/images/animpicker/shape.png | Bin 0 -> 4700 bytes .../images/animpicker/shape_library.png | Bin 0 -> 16834 bytes docs/source/images/animpicker/slider.png | Bin 0 -> 3500 bytes docs/source/images/animpicker/toolbar.png | Bin 0 -> 4615 bytes .../images/animpicker/trace_silhoette.png | Bin 0 -> 1757 bytes docs/source/images/animpicker/transform.png | Bin 0 -> 6662 bytes docs/source/images/animpicker/visibility.png | Bin 0 -> 2550 bytes docs/source/images/animpicker/widget.png | Bin 0 -> 7155 bytes docs/source/index.rst | 1 + 24 files changed, 408 insertions(+) create mode 100644 docs/source/animPickerUserDocumentation.rst create mode 100644 docs/source/images/animpicker/2D_slider.png create mode 100644 docs/source/images/animpicker/action.png create mode 100644 docs/source/images/animpicker/align.png create mode 100644 docs/source/images/animpicker/anim_picker.png create mode 100644 docs/source/images/animpicker/appearence.png create mode 100644 docs/source/images/animpicker/backdrop.png create mode 100644 docs/source/images/animpicker/checkbox.png create mode 100644 docs/source/images/animpicker/controls.png create mode 100644 docs/source/images/animpicker/drag_and_drop_toolbar.png create mode 100644 docs/source/images/animpicker/editor.png create mode 100644 docs/source/images/animpicker/mirror.png create mode 100644 docs/source/images/animpicker/palette.png create mode 100644 docs/source/images/animpicker/passthrough.png create mode 100644 docs/source/images/animpicker/pin.png create mode 100644 docs/source/images/animpicker/shape.png create mode 100644 docs/source/images/animpicker/shape_library.png create mode 100644 docs/source/images/animpicker/slider.png create mode 100644 docs/source/images/animpicker/toolbar.png create mode 100644 docs/source/images/animpicker/trace_silhoette.png create mode 100644 docs/source/images/animpicker/transform.png create mode 100644 docs/source/images/animpicker/visibility.png create mode 100644 docs/source/images/animpicker/widget.png diff --git a/docs/source/animPickerUserDocumentation.rst b/docs/source/animPickerUserDocumentation.rst new file mode 100644 index 00000000..3d5a5999 --- /dev/null +++ b/docs/source/animPickerUserDocumentation.rst @@ -0,0 +1,407 @@ +Anim Picker User Documentation +############################### + +The **Anim Picker** is mGear's picker tool for animators: a graphical board of +buttons that select and drive a rig's controls, so you can pose a character +without hunting for controllers in the viewport. Version **2.0** is a full +rewrite of the original picker with a modern editor, vector (SVG) shapes, +interactive widgets, viewport pins, conditional visibility, mirroring and a +click-through "heads-up display" mode. + +.. image:: images/animpicker/anim_picker.png + :align: center + :scale: 60% + +A picker is stored **per Maya scene** on a ``PICKER_DATAS`` node (as clean +JSON), and can also be exported to / imported from a ``.pkr`` file to reuse it +across scenes and characters. + + +Opening the Anim Picker +======================== + +From the mGear menu (**mGear ▸ Anim Picker**) you have three entries: + +* **Anim Picker:** the floating animation window (the picker you pose with). +* **Anim Picker (Dockable):** the same animation window, dockable into Maya's + UI as a workspace control. +* **Edit Anim Picker:** the editor, where you build and lay out the picker. + +You can also open it from Python: + +.. code-block:: python + + from mgear import anim_picker + anim_picker.load() # animation mode (floating) + anim_picker.load(dockable=True) # animation mode (dockable) + anim_picker.load(edit=True) # edit mode + + +The Animation window +===================== + +.. image:: images/animpicker/anim_picker.png + :align: center + :scale: 55% + +The top bar drives which picker is loaded and how it behaves: + +* **Character Selector:** the collapsible header holds the picker-data combo + box (which ``PICKER_DATAS`` node to show) and a snapshot picture of the + character. Uncheck the group title to collapse it. +* **Passthrough:** turns this window into a click-through overlay (see + `Opacity passthrough`_). +* **Sync Namespace:** when on, the picker resolves its controls in the + namespace of your current selection, so one picker drives any referenced + instance of the rig. +* **Load / Refresh:** load a picker from a ``.pkr`` file, or re-read the data + node and re-sync the widgets to the rig. +* **View:** **Tabbed** shows one tab at a time; **Tiled** shows several tabs + side by side (the spinbox sets the column count). +* **Tabs:** a picker can hold several pages (e.g. *body*, *face*); click a tab + to switch. + +**Selecting controls:** click an item to select its associated control(s). +Hold **Shift** to add to the selection, **Ctrl** to toggle, and drag on empty +canvas to marquee-select. Panning is **middle-mouse drag**, zooming is the +**mouse wheel**, and **F** frames the content. + +At the bottom, the **opacity slider** fades the whole window, and **Auto +opacity** makes it fade automatically while the mouse is away and become opaque +on mouse-over — handy for keeping the picker on screen without hiding the +viewport. + + +Opacity passthrough +-------------------- + +.. image:: images/animpicker/passthrough.png + :align: center + :scale: 55% + +Passthrough turns the floating picker into a HUD you can click **straight +through**. Check the **Passthrough** box and, while the window is transparent +(opacity below 100%) with **Auto opacity off**, everything except the item +buttons and the tabs becomes see-through and **click-through** — clicking an +empty gap selects and manipulates the rig in the viewport behind, without +moving the picker aside. Each item is cut on its real shape, so vector buttons +follow their outline. + +* Checking the box on a fully opaque window drops it to a transparent default + so it engages in one click; unchecking restores the solid window (your last + transparency is remembered). +* A small **"··· move"** grip and a second **Passthrough** checkbox appear at + the top-right (the in-row checkbox is hidden while masked), so you can drag + the window and switch passthrough off without leaving the mode. +* During a **pan or zoom the full window comes back** and moves smoothly, then + re-masks the moment you stop. +* Passthrough is **per-window**: enabling it on one open picker does not affect + the others. It applies to the floating window only. + + +Edit mode +========= + +.. image:: images/animpicker/editor.png + :align: center + :scale: 45% + +The editor adds a **left tool strip**, a **color palette** along the bottom, +and the **Item Editor** panel on the right. **New** creates a fresh picker on a +data node; **Save** writes the current picker to a ``.pkr`` file. + +.. note:: + + Every change in edit mode is recorded to an **undo / redo stack** (per + tab). Use **Ctrl+Z** / **Ctrl+Shift+Z**, and the usual **Ctrl+C / V / X** + (copy / paste / cut), **Ctrl+D** (duplicate), **Ctrl+Shift+D** (duplicate & + mirror), **Delete**, **Ctrl+A** (select all), **arrow keys** to nudge + (**Shift** for a larger step), **F** to frame and **Esc** to clear the + selection. Shortcuts fire only while the picker has focus, so they never + leak into Maya's global hotkeys. + + +The tool strip +-------------- + +.. image:: images/animpicker/toolbar.png + :align: center + :scale: 100% + +The top of the strip holds the canvas tools: + +* **Select** and **Transform:** switch between selecting items and showing the + move / scale / rotate manipulator to transform them on the canvas. +* **Add item:** drop a new default button on the canvas. +* **Add background image:** add an image layer behind the items. +* **Shape library:** open the shape picker (see `Shapes`_). +* **Duplicate** and **Mirror:** copy the selection, or mirror it across the + symmetry axis (with an optional name search / replace so the copies target + the opposite-side controls). +* **Pin:** pin the selected items to a viewport corner as an on-screen HUD + (see `Pin`_). +* **Trace from rig:** auto-generate a picker from the rig's control layout + (see `Auto-build from the rig`_). + +Auto-build from the rig +----------------------- + +.. image:: images/animpicker/trace_silhoette.png + :align: center + :scale: 100% + +The camera drop-down at the bottom of the tool strip **builds a picker straight +from the rig**. It looks at the scene's controls from a **Front / Side / Top** +view and traces the **convex hull** of each control, creating a picker item per +control laid out to match the rig's spatial distribution — a fast way to get +the whole control layout in one step, which you then refine (shapes, colors, +grouping). + + +Aligning and distributing +------------------------- + +.. image:: images/animpicker/align.png + :align: center + :scale: 100% + +The **Align** section lines the selected items up (left / right / top / bottom +/ center, horizontally or vertically), distributes them evenly, and +**expands / contracts** their spacing a step at a time about the selection's +center — quick ways to tidy a row or column of buttons. + + +Drag to add — widgets & backdrops +--------------------------------- + +.. image:: images/animpicker/drag_and_drop_toolbar.png + :align: center + :scale: 100% + +The **Drag to add** section lets you drag ready-made items onto the canvas: +interactive **widgets** (checkbox, slider, 2D slider), a plain **button**, and +a **backdrop**. Drop one where you want it, then configure it in the Item +Editor. + + +Building items +============== + +Shapes +------ + +.. image:: images/animpicker/shape_library.png + :align: center + :scale: 80% + +The **Shape Library** (tool strip, or the Item Editor's **Shapes...** button) +holds ready shapes across two tabs — **Polygons** (square, circle, triangle, +diamond, pentagon, hexagon, octagon, star, arrow, rectangle …) and **SVG** +(curved / organic icons like gear, heart, eye, hand). There are three ways to +use a tile: + +* **Click** — apply that shape to the selected item(s). +* **Drag** — create a new item with that shape at the drop point. +* **Right-click** — **create from selection**: one item per selected Maya + control, laid out in a row or column and each linked to (and colored from) + its control. + +**Save current shape...** stores the active item's shape (polygon or vector) +into your user library for reuse. + +You can also build a shape from Maya curves: draw the outline as NURBS curves, +select them, and the picker traces them into an item's shape. + + +The Item Editor +=============== + +Selecting an item in edit mode fills the **Item Editor** panel on the right. +Its sections cover every property of the item; the most-used ones are open by +default. + + +Transform +--------- + +.. image:: images/animpicker/transform.png + :align: center + :scale: 100% + +Position (**X / Y**) and **Rotation** of the item, a **Reset Rotation** +button, and a **Factor** with **X / Y / XY** buttons to scale the selection by +that factor (uniformly or on one axis). **World space** toggles whether the +transform is read in scene or local space. + + +Appearance +---------- + +.. image:: images/animpicker/appearence.png + :align: center + :scale: 100% + +The item's **color** and **Alpha** (transparency), an optional **Text** label +with its **size**, **alignment** and **offset**, and the text's own color / +alpha. Text is stored display-independently so it looks right across monitors, +and scales on high-DPI screens. + + +Shape +----- + +.. image:: images/animpicker/shape.png + :align: center + :scale: 100% + +Controls for the item's outline: **Show handles** to edit the polygon points +on the canvas, the **Vtx count**, **Handles Positions...** for numeric point +entry, **Shapes...** to open the library, and **Import SVG...** to load a +vector shape from a ``.svg`` file. Vector items expose a **Render** mode +(**Fill** or **Stroke**) and a stroke width. (Vector items have no editable +points, so *Show handles* is a no-op for them.) + + +Controls +-------- + +.. image:: images/animpicker/controls.png + :align: center + :scale: 100% + +The Maya control(s) this item selects. **Add Selection** links the currently +selected controls, **Remove** unlinks the highlighted one, and **Search & +Replace** rewrites the names in bulk (e.g. ``_L0_`` → ``_R0_``) — the quick way +to retarget mirrored buttons. + + +Action +------ + +.. image:: images/animpicker/action.png + :align: center + :scale: 100% + +Give the item behaviour beyond selection. **Custom action (script)** runs a +Python script when the item is clicked (**Edit Action Script...**), and +**Custom Menus** add right-click menu entries with their own scripts. + + +Widgets +------- + +.. image:: images/animpicker/widget.png + :align: center + :scale: 100% + +The **Type** drop-down turns a plain button into an **interactive widget** that +reads and drives a rig attribute directly in the picker: + +* **Checkbox** — toggles a boolean attribute (or master-controls the + visibility of an item **group**, see `Visibility`_). +* **Slider** — drags a single attribute between a min and max. +* **2D slider** — drives two attributes (X / Y) at once from one handle. + +.. image:: images/animpicker/checkbox.png + :align: center + :scale: 90% + +.. image:: images/animpicker/slider.png + :align: center + :scale: 90% + +.. image:: images/animpicker/2D_slider.png + :align: center + :scale: 90% + +Each widget is bound to its attribute(s) and range in the editor, and stays in +sync with the rig on selection / time change. + + +Backdrop +-------- + +.. image:: images/animpicker/backdrop.png + :align: center + :scale: 60% + +A **backdrop** is a titled panel drawn behind other items to group them +visually. Set its **Title** and **Corners** (rounding); its color and +transparency come from **Appearance**. Dragging the backdrop moves everything +sitting on top of it together. + + +Visibility +---------- + +.. image:: images/animpicker/visibility.png + :align: center + :scale: 100% + +Show or hide the item conditionally: + +* **Group** — tag the item into a named group. A **checkbox** widget set to + *control* that group can then show / hide every item in it at once (with an + optional invert), right in the picker and with no rig attribute required. +* **Condition** — show the item only by **zoom level** or by a **channel + state** (e.g. an *IK* button visible only when the limb is in IK). Conditions + and groups compose: a grouped item with its own condition is shown only when + its group is shown **and** its condition passes. + +Edit mode always shows every item, so conditional visibility never blocks +authoring. + + +Pin +--- + +.. image:: images/animpicker/pin.png + :align: center + :scale: 100% + +**Pinned to viewport** locks the item to a fixed spot on the canvas — it keeps +its screen position and size through pan and zoom, so it works as an on-screen +HUD button (a corner reset, a space switch, a settings button…). Pick the +**Anchor** cell (3×3) it sticks to and an **Offset**, or just drag the item on +the canvas to set the offset (the anchor snaps to the nearest region). + + +Mirror +------ + +.. image:: images/animpicker/mirror.png + :align: center + :scale: 100% + +Link a left / right pair so edits propagate symmetrically. Set the **Axis X** +(the mirror line), **Link selected pair**, and thereafter moving or editing one +side updates its partner. **Make Symmetric** matches one side to the other, and +**Unlink** breaks the relationship. + + +Color palette +============= + +.. image:: images/animpicker/palette.png + :align: center + :scale: 100% + +The palette along the bottom of the editor holds quick colors — secondary / +primary for **Left / Center / Right** — so you can color a selection with one +click and keep a consistent left/right convention across the board. + + +Saving and sharing pickers +========================== + +A picker lives on a ``PICKER_DATAS`` node in the scene (stored as JSON), so it +travels with the file. Use **Save** to export the current picker to a ``.pkr`` +file and **Load** to bring one in — the way to reuse a picker across scenes or +share it with a team. + +.. note:: + + **mGear 5.x note:** picker data is stored as clean JSON. A picker that + lived **only** on a scene node in a much older version (with no ``.pkr`` + file) is not auto-migrated and should be re-exported once; ``.pkr`` files + and file-backed pickers are unaffected. diff --git a/docs/source/images/animpicker/2D_slider.png b/docs/source/images/animpicker/2D_slider.png new file mode 100644 index 0000000000000000000000000000000000000000..45f545111155ccc18828e92ca4a96fc2d4df9681 GIT binary patch literal 4824 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/align.png b/docs/source/images/animpicker/align.png new file mode 100644 index 0000000000000000000000000000000000000000..b6e8ce3dce0e5b0ae4ec26695182d70b4991278e GIT binary patch literal 1686 zcmYjSc{m$p7*B`RmAY0|=cud8DrG@S>I_PVP-PUYqmDX44{bJPYaG=@bxN=y%B%+> z9g~LC*%C=7&LDY8+a%Q@Vi_?>#6sI=`{VuI>-nDVd*9#hd%q$i!Uw3TuL=MFfW9YT z!E)^?mjUIy^3`5r_=#NYxfJZ<0U(SRzLPtOiS7aJ0KlUXHEE2J+*ips8GZ=>(6!i+ zJ-)%_s{p`WkT1+V2PGk0?4Zn)$YN6Llr3-17QH?jed7x2;fuQ$aZvs#twuWO z^{vIHEpv=*iFb5&V{F@Nr%z(17Wr+wVc6S=x~9$-gvF#LyixL}pa3SOi zONZPAD=U*?u^6eVt2;S48FDt)1PdK}R)9~YHB#@*8k?9*4Gwyr-G-hEepID`_+mol zFegk7l}RLL_yi+Hn+7g1K!hj zJ9wqNrMgi#jtK^T7h0 z&FTkfgEuuD9nQbBV2E*9A)08yNBj=#)T!q+k$z3J9K~uOkfh7Y%@I*iTkD?>NS&*q z3LTQ*63O+rn%dfjB_$d_TKCWfZm3QmkT)!JzTka!_^E9m0s*mZxYYs2ke~@h7^@a2 ztL_M2pmwY1Ps91L#Ds)Nk*K5d@#E1ttP^WKA)d=Q7TVg{`h@Yl-C|O%0csN&BPhLE zp1fl|)QL`uMiBeZ`K$H9`*5d)-;r>%xo-8@%brG}F%dd5pVZ_pVRLal3HD8CtB5{NHW$@TTR=UJ*r4|Hy%|iCT2?@#HwHm(k~`|Tn4XL2y)Uo5!V@5k z)N&0O9;j~0&4i}DnfcA1-Dp~oeS>&)SW>;8%=I*fR!|u4Zo}siW?&(vf*ru~hplQp zIdsJJ%gtgbtqqrGBsyvF@$OvNIQ%6B`7swiy zIaKlf-3J_<^5>h23=}(V(J}l z2BSQ6AnZcRqz)5{-O=#>Vv;G+wY7GqW5p|_Q!}w6iT0)pHD%08>KDupD}(_4yoMC2=^G<_xCr)TLSyx#zjgC zVZoJEL>Rky*jg9i**3@>QOJoQwe!hT{mH6{nG_PiKK{W-({jNkY1{r`_F@0M3qE+pk>A2MYK56&@}xQOuZ zpy}yp{jwk*HaV$XIxv9gB$I>W=e-Y6ElR=%&(Z1h3$d~FVI_8pL@?biUMbG)pQR-@ zMoxTZJu+7-;WN#b7P%)C$^uTXM!)ll8SL*`@6F!J8p*GK+Wl7r*v3YV^==j7cn-vk ziT%aP5Q~+Lj|bZe3!L(|CMHhV$Dq#Dta|9IHQ7L#!57uP1fzh>sMPEabe9k8z=H+` naeZ!^);GUCimX+W5AdEkbLI3$m%$VAzXagxg@6$}&Rzc(=M+-s literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/anim_picker.png b/docs/source/images/animpicker/anim_picker.png new file mode 100644 index 0000000000000000000000000000000000000000..41046169824ba907020c01c62f7a9c77231c0fd2 GIT binary patch literal 58460 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/appearence.png b/docs/source/images/animpicker/appearence.png new file mode 100644 index 0000000000000000000000000000000000000000..c1bac040cd94e5433a55774aaf36a2c3999baf18 GIT binary patch literal 6508 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/checkbox.png b/docs/source/images/animpicker/checkbox.png new file mode 100644 index 0000000000000000000000000000000000000000..1bc7aa8a8da7e76caab36cf406127d18ba5acff9 GIT binary patch literal 2763 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/controls.png b/docs/source/images/animpicker/controls.png new file mode 100644 index 0000000000000000000000000000000000000000..4a4a4979391122132b6c62ca236175f613962428 GIT binary patch literal 4323 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/drag_and_drop_toolbar.png b/docs/source/images/animpicker/drag_and_drop_toolbar.png new file mode 100644 index 0000000000000000000000000000000000000000..dc569f120a682bcbc20e3fee88cb047c4eb9c4dc GIT binary patch literal 2015 zcmb7Fc{CgN7EXu68f~Y>zC_Dd8u4CjgGMZkwPIJnplxVItgULTIVf7Hwvr05&Y-na zqN3K6=~PfvRO~dCR&1q8LqcBWy!XzW^UnLD^T++}{m$?F{NcH4J@WRDv|DWo2rOUPr0c>o=^VYfRHBO~7B0|geUiUiUj z*x6?$(pq<~jOBk7ql&KDK}E+>lB6YdG}xRSlsu26`tfrKiin+iA)uzalM!8982BUW zb#7Oe=~Z-Flp`CiV!osth#8x8OKpM zeIA$l3`n;FmF7tgns%i0(tNQ~e;kL^4jlx6aSyCK9GAi7uXdA0UftMQ( z0Z%6y7^^obsS!gcAmhFW*HFyMU1FS%fwRPGUqc7S@sON?$~uojPs)057uyO4rtdBu z1dJ>PhQVrmYGpPWrj4~5>z06>Ut%7zQ~p6nEebZMV;F?K+Q0u}mfS;h`@|a6R!ki? z2Y%Mxdgq-1uaP>w=Ha0;9+D?z!{9zCvg_rAa*sb6$9P?eUX79c9779T`?HP6;>Yq| z!a}Kt8_`8MNIY?k8<@L_J;i0!=$f$C&%ym#kX~7o>lN-`O_ASnmxG2njw*a4p=XCL zDlFH#m-(ad(tqjo|EgBHS9?^wqH!c0RcactxVR{(kxaSE?A0c|r+`49^_Ahuz16X^ zoc*0@vQU2+^a`XvnP!tTFhvQ-=)Aq%Q&nBPF+si>wM0KD`p_Kx(hQ17$PpwoU+tCP z?5+`JyW*!k8hi@vJ_-K&bE&UtcfiI>>!^OUykY3CBW=qEyK6AemHl6k6b;G)Wkjg1 zuI}FUV(-Q5)bkMueYtgdXo{!S8GFU^iAsvXU!3eJm=Cg{puW@D(C@wxl6)}rp5z{b z;EDHAte%V!6lA{b=9)`Hzg0N;L^Gb3(2nPI#dG!s@uT_CaYx+ALlKg4ZLlCAMGolv zX`a>+`*pK*oK1RueK{TJ{4m|!AZfrN&muFaMjyxcb!3_xw|`w;PVRbG6|T8XUnO1b zd7UppJh4VVNMa&c==l2b0D*ez)0|sLg=Px$t_9-j7Socm#Y`r_V;PL6ex?1F^il@A zsKJH$a^Rxk;>h_RlF6+%)X>?W!^6YJ9Z1(2sIa;6E_?eb=L;*3guu_m8@XCJ`wwGC zKSlJArkinuykk+H=0b)_;5V`)q+vdd4n+p}9+IAV=xc9^$vS>r6FVP=LZ+!95?t=? z+NJimeF|7YqAiZ?^L_ky2|U(?oDn#TVg7Sr;g1ZI1X1C|&`*T8mu6tjA*Y2ixY7$F zBJ+S(F4tT}R{$`~nhq$y2%F2U|CsUJe>~-+i58Q!dFtCaHE0&cJDP=$aahPxJ_QJ5 zGOb{xz~t8E#B@v8#&D%Il`pj0dW8t=tkHg2ufPYh%pRHT_ua|x!<$xff&?H$ya_am zI@=53y~>pTIT{ftOGh$j>6nu?Qi*>X%r_}plV^O}7UWaCTS6!M^3+-52LUR#(Axw$ zB7qye#%5Yl$z-xy7cKEU#UN~M-SkN&VeF2Ajh)hok2*9~vdrD(UY+mzP7A{ZtOv6p zlil+j(XNVd?%(dd|8Z&345r?zxj*E?^RRi-mIG@;h)#c5o$^!hrPE@DvFxqOv7eXj z#fHppBz8EKn%)c;(11##YZOoH9_-?Z9z6}tE z)U>qO57-G#2HXkct#-z0lG-f_6N`;^aBygyU1~fm;kDJC8gKZyi9ds_`Yk60vi$fv zs9dBxYNj*Ra0iG}Z=_054=lxBejd$GeO{NcSuD~xt|Gyw>8gfktld#y)@%|bKlg`) zn)}e;zRj?`32hB;P4sG0n;At|HRHdqz(naxw+e{oG`dEsrJ8J;$vHA*Bsl`hDF3ZY z^V@2m5tS;elazNGZ^u>7zdR4G;eD@| D*n7C6 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/editor.png b/docs/source/images/animpicker/editor.png new file mode 100644 index 0000000000000000000000000000000000000000..cdcbbf6f5fd11ed0e3da42cae5895215d003811a GIT binary patch literal 116595 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/mirror.png b/docs/source/images/animpicker/mirror.png new file mode 100644 index 0000000000000000000000000000000000000000..709afaa9e616047ccf1321e62d543c1786f731dd GIT binary patch literal 4950 zcmcgwcUV*Dm%hl1pp=M(DnwxH5QI?>gTw-&KLk`%L`uX6C@oS1X^A2TD#Z!{0wO~Y zL69OC1Bn#D5n{l=P$FQY#v~*Vl0b5kY*1%+W@q=={bQf~096?( zpwG{8dD+E-?%@ESxqk6cICSh6E&!}#9OrZY(o&Zt@DpY_Mv1MnZJKQ zydjE{5DnZeD}u-faGBCGgHFDl*F+wkhi&~zA8go+BboO>LPUAdS_78A*m*8jHq#EB zAjzc6PFN*;yet{(V^A^DlNBB1CgV460UymM?&&Vu_xEp7FWmx0MNJsK`ZNmTeaeOlmwrwn z#kfAj1)Hht_C#jMygD77Mm}X=o+;Z#*Oo&7hq0s0XbH%pOcY?H%M#E7j7Va|D%T{L z$mUf`9z5En9W3y;sSpFrI0π7(`yY@VyvLHaZektVjE8! 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/shape.png b/docs/source/images/animpicker/shape.png new file mode 100644 index 0000000000000000000000000000000000000000..48c51ad0167c05eb33583c22f61a4f752cee2d51 GIT binary patch literal 4700 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/shape_library.png b/docs/source/images/animpicker/shape_library.png new file mode 100644 index 0000000000000000000000000000000000000000..802bae69d4420b14de4846ea9b079f5ff218c9e8 GIT binary patch literal 16834 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/slider.png b/docs/source/images/animpicker/slider.png new file mode 100644 index 0000000000000000000000000000000000000000..e4037f22d1575bd4ad97d2f43cb184dcade9248a GIT binary patch literal 3500 zcmd5<_fyl^9{nP#EFz%tA}USkQl&qDXhCZiIl8Bm`wa0s>2sCPkE%1dxyg zmKu;I(pGve(jjz72!Z5fclKX+Gw+9c@A=G}x#fIjKIe1MR+dJb7jIq!0D#lP*uVw= zSl~>I`1t~Jjx)c^##~rnHb(kDAn0Sr^!+6X{&G{=eJ9aW57_%D5>UbaEdt?QHRo zW9{->{jS!$^BjDw*;@q+<^bG+HG0q3fT(Y;Cs?D-$7umhQU7P)y^jZ~g`!WbhVlcg zV9SBPJ4b2OBJvY#J$~LEa*{tiN|`g;?>D($K78aegIuHH7ssy4su)n8{>W`&9s41n5HOj{G1Ipd2dNcP3&j=5QRd-@F8O)Z)#Po`V-~S{UNrZf_ z(-K3E4w{`N@rt&b2&Fnv%S9bDg?spRQ$}j)Od{rlkCcFg;Ig2cZR4`C-lMOoF@-V?kr9b?2|Va?z3X_T*6@3}NK*Aw0Rh{LK=&$TPzz7(T_ z!BFk=3aCKJYnP3*kU@cT!*^s15w5sBPBsmcyg z{CjauAhsPDg3>$o(C~1*Cd-idfqpD#iw}8R{gl{LyIU52{G9(D!g-8-_Fi;nu7aLN z@8i)Jey8a-1h|z=sZYT?blQEZM98brIo!E_?rVc4>3vtiE*T}m`H|Um0VqdUXR@M|FLtN z&%SyCNtvk5S(|w;eg)7@4#7j6^BLhM%w*+fyu(M2(oXeM9*9J&Z7Ei?SXzhzxo_<8 z(1bz9{k{nI@oY$`(3i!J5?9Gn)ayLa7Fr+ z-w17%owKr=(r5xqbE(y~W^mky>X2=1 zOynR1N`ZiBL3na(I175xV^&}*rCOZa;_rZSyGaZWD~JzJPS}q=he|4|y7uwDk&c7sV8h-AlT8B_tCP=*&n?X0%d>-BYlGMjlBBq^?Lmz3P5B+UL}_?CiLfh(5+@TS9hq_t zc?uNGGX6<@T0z#GOsE!S-{Z~?6DPE#`BZzhP1SFFCw}YmFqp?fQjOc0pTf(O-qoe z=UFYq-YFrazHULVanTiT|IdrYxBA&(lKeLNf>P2R^9tVRi^ce`!${DWM=&FS3ci0R zL$d$-sw#os4Saold(hPighGc-cv*SeSx+rkJ8an#H0}Z0UPb!NJn)P_I|ucM0j4t} z7~A5)LcQ<%e_lkwOC4k2_n3MemP4+dwd15Rx_F@c4uP`btC=bAKF9{H(pKU^)Hjjq zo2duqn*ZuZns_J6Q%N-H9CF%UW&urj931n#9?eo3YnGdx&BUg$wyytmV-FF$c$1$g#W>A8sT8$`_wdjQh-XS3XPag*(A&T zOPR|WSz3>Z+{AzrmU?ovg!t1YDI|v_rKSY44qfz9Gk>Mt+dEk|wJ5u9DO?5FrLaErvxDw= zd(W>2&UpQQiyBzyFVxvh&~UjlqZ)iDr5pO@p%~1NLF?u&!+TLPwS-qjMNk`e0SyQr zZPlPr)G@%?y5E!$UKaqT{Rns&Ft*Mc(8pL#yz;2|iPWYM*;EmDfeY@`PK}?uLdyvw z1P`{Df5#o@Iz;k%k=$Ou!Ki%^Fo$_N=@Yjr3OZ zF(Sz)Ina0SyG9mwjIYdtCHx1FaIZsYWDseIM{1(|=yN6a$~ZSCLIv6p5&E@n@995Y zqxr19(RZv%>qSSTc^Qeoowrl}AXjDDK45lLtI9}~j)P8YQFl?tcui`TJLF`&73@yv z18EhqSxG)ww6Y}i3a>DolGN&iF3Dq{j(b;4_exHay;fnJ(eRbzX|7+!jzXJJny}~^ z)I>ky9GN1JnMpy2a<_~~Fv}&eW(RUMR&u0b(F19zVU{ULdpU%rOt*>j@XNNPv zM@C%MalZ1qhSO^C0*13uRH3!EjjdfXq`lt>h*{=1fjt_}@}ZSSJ9N-zTEoz#JZfxh z(;IJ3CJ^iuk$N%5c8ac92hM>B>Em`-G_!l~`=N}%og6mGRZEj&A$37QaSqVO;tljpn!hr9cs79JZvyZ?0jIovA0-1qj@}J2 Qe^`Ktp`}5kzT2z+0#q>0egFUf literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/toolbar.png b/docs/source/images/animpicker/toolbar.png new file mode 100644 index 0000000000000000000000000000000000000000..c7d105a6348ef952f9b3cd898765d5db66dc49ec GIT binary patch literal 4615 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 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/trace_silhoette.png b/docs/source/images/animpicker/trace_silhoette.png new file mode 100644 index 0000000000000000000000000000000000000000..932a51d51a79573587dd345f93ea01c6bc20c61c GIT binary patch literal 1757 zcmah~Yfw{H5WWe>LklPhDu_WqMy1ctGsZ6pAw$epL%KL?3T zj|T2$1Az6)8AAnyIt>AU(Xk*3iI%jtU+%ZnF=ENDA5w(hKTiSXEy#>G6JIIJN+-{) zsJOduAW8D*m~D1*R2{hK?50PH_pEiP-x=R)+~D=gH7Wjt0KYE3$?0v-@Fjiyb*B;Q z1nx;ya7VE6_3Qc$&xcxNctk|eg*K+g*sh;;ZmZjiafytKEFsc)-l@a_3kZc4r4Y^L zQ^R5LVM?bVmvPmM1ZNS^u{ zh*BJ=EEY>qWEX_P;TXvKaW*^u<;FD_)lCO6GU^Vfy}kV)n=RvTILbn+Eau25SBFqr z1Zr7%xsAXORwx*3E|)bhFd%2wlzm0(y6HGmm$yJn2xt~HENf%s<-O!#ubaR+I(KJp z`J{)@%-pUK{|5SHo=_fN;N+jwbRuK-?iG#A0bW6mK#kr&O5^~d>6<<#i0$Fc@BKJM9ue=|>(*vjKrx_hBF1Lv`-82u zJ>2fu226V>WAvirq%6H>)!92aAxp1uS82{&^C?zL=E>uS_7Z|ea=nyGKCn|>@N6uZ z=Jgw1qG~&iE8-sPs*$E$DNHO&7}R$fFqcBNbDX&_KSLXT|KYTIf7v|ODy?F@gcIol zEkBAW)+cwOkt9d(KzX7Ju((s|6Qx0og-mD1)uyh?l~87f5jnw@C28P!bLawf>z?3q z8CR0)4xs2u)cY8H4ov~OO_WMK?T*;{41DM>A_W8+Lg$oqN!2(q)%@QY={2$WsrQ7cY#H zefwi>U0;G4XXxQcuyeDSq=w0|Hp>+Fm1%sq_C;MT;jukvouy`8I#@#(zU8*^#=(%q z2rtI%7*fsPpMzC>DDfhA|8!XLG8&rM(r}4)Wz?B`Cqc%KnFlQ-i#cx#{rUN%Z7c+X z_Z%7qnMHM2A;{OL%C4%O7{g?$qihRG)Q^R;6czt@t=(BZPIai4A27m5FL1^+W491!V+#RpOpC#+Fo08V;` z8)|i#U1=Dg<|p=!v?og3<0NRI{JgXO<#bZN{ua&_Zn6z@iVt?v#&fk(li5DS&pOgc zjK&2XgN44?T@&L+EAQV(KyhrKYA<|F&#_}k>Zd*5{XS<_Br>8BCRFmg&EN}*!~CD= zJlDH|+fCb4T;BrUW@H(6s9Vx>70v{(Ff;sNEgoB7`kAh*PH$Q9sp4AeNh=$rh(1Z6 zb^rKyN3BF6aNzEZj0~F!dEVNZ=W{KUPw46BI7)arF;OkuSS8g?=g>;q`n6`QP$n6DlcMwdsmlW18s`k!CjBRU|gr^%?X+F4nCGw3wB=Mm)#t6nk@l^b1*kzRweKJb@=FM-uety-_odiuq z1G^?y(N5@wc-S5}c)l^JVnQl~Pct7~G=*|H$z(CLG3s(Gv0#wvG|aWGt%PEcF9`_R Lw4G8;-k1J2e@82g literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/transform.png b/docs/source/images/animpicker/transform.png new file mode 100644 index 0000000000000000000000000000000000000000..4644e10e48ffbe7e7c045a821d78d079ba5a83ce GIT binary patch literal 6662 zcmd5>XIPWjwvGu9st7U!q$yxwqzF+!2~9vm1xHX6DG?$>q=^_vB!H0_9Hohr2%%X3 zdnigvfPhjoKrBOVQA&^?BtalR2=@c@+?n${_dNICy+86}?{Dq3*IxU5SJ}Dwo0Gka z+ zSmM%Qw=fV$ar^3TgM+g+2Lw_kIoMjC#Cm-jI3BOkTz!o@;}1@Pc}d$y_uM^a_O@F+ zd+W|4UrKn%gsK*ZTU(>jM-pW}*U2sG8Tk4+>c$JH1=|*M7bG!{GmbooetOokV*q}! zW=1!9KG*+LMepOe2jNFQEiwiu5pBaOIhn~q)=I+i5D26uM6JjYpdd91J~MV7SQd0k z8!8+b5djrL#Sx%gcFYY{I>77^IV|Y6TQtZuXAyN!nkL81E2}I154UI3XdMmcwa-en zOk_=uEqv+~PT6hx;;CUL<_2j`Hf?3DFJvq)M(E(zOy3zXr^zuuZVm@U+KCD0hhmOP z3ChrnR*e{e-0z5{q_W}B)MhHa=GG$V^cZf0o8tx*CgNrhJX4T#f(BM)euU;e6thyZ zFvGBn&0m@vwiIw|IBzgt2tyv~xS5F#UJO|snLK~0Gpcq0*1#sF!?{ePSbDQDn*D*r z=}LSyKh_a8%{W!D;yxENi^d8k9E*<$O(55laAJA*n@#)8%-}K0v!^$O)AcKo>6mhV zNos;8qW0;z4E9yI#8AfOn=d;Q_&849P%hW_u;@#Ik7lu#OOEbkATv!;IKkzkO`YFjDm6M_kcwK9 z7hf5&pxrehgNqBSyf)yJo=pS7n58n+)cZP|j-wj0=)n)BhqS1Y0q4!qOnnr6z9@ET=O{Ts-8Ke9pcm=hj=efbun>nIS{9L5Cj50XUNp(1z+_>O2ksz}jHRIi8EqA+BPJCM)<`)^j@mWEIKc$Q!=Ib_9p%@{V9`>!QY*YKQ=cvk9~f9qbDgT>D}yTC}a{$VAwKe zwK(su)6kln#VT4KJ+#VV`MV1^xqd4rQ=A%Y{61OTT=L5|1v@!iEsl~VIPWHn%>suQ zR2(**O2W4;j-(zJM~KD#TwfNNrx9VFw2h-Aza|QkqDn`4j#vu$sDJ6y=gb8nV1zZ> zM7%6jE-aS@p&`$O48x9koi27SK0y}fgX{f*Ce`?)%(_nA`EM2rfc&6;`iOJJ18-36cETP3PQ zCBD&{&0$w67Z*4!$I3Q*UPBuitsxf-%~Vq1MW=J{?A- z7Cp78D8U*Tm`}OWEK@8cGI)9^j(rHl40sTr73Fs*;%vfkWvzM0^ByaDAE@Yy9|~|* zT{$8)udTme%L3o|EYp50$-@gaoCciddh`1XnY|IIzl(`P`+BsV&dYL(i#8;Qp`N{@ zp|gXEe&~|JUa)5BEj(6UC@aD+DZ1-BRMF^Wc7@E~JHQUeu%!6%xBo0P8rwBS4cJe=D9>; z9g(DNk7mD=|8gd~B9WmS+wQ>}eSXcido)M8%5zU#91VSBBVx|glj%D$ic&ODdA$lj z;F@7rT+!j?CQtogz?yX&p&>z*yj{aN*H?Xs-vK7)Nl~f4T#}sHadR7hWdMHq9G+9k zW5C39fMO&N5vgxh70wae0!KmysznO|^S>eJ?i-4V+3KpQB5VGx6@}r(&ua}Rh5{J- zXAsxBB;#d*^E8KcF%7jvEK=hs%a63Ll-|ogHWF{OteF9eSm^N|_tt7NIbaQ01F=2x zp6D7=!U)8n&a{R>qq{WJu62uLzL%3H%POQY=zx`f{-D7UvJtj4VX>fu?74C8Unl9y z8tIyCtnIk@^SDS|j?pA0g(YRV2e4%`p^G8v747aK2~PfTeB<|XTFFL(I_q~7eZc*0 zBX!TPUh)Ec-8XE$GbhnW=}v$KpEBIg3X_79nlc!UhA;GwZxrOD0MXr@q^cH&y4(!l z5e+!AFUwgDD=kW>i|k5>$6ZoH=GHI@>$@M)h}&}SK6u^1xMA;Cs@I}3?9!AE^8_4^DK_j3j=7Mu!y7A`ykjiz^7)PKiN}>=YrC!bkZ128n~7_e zDR?)g8y$FSp`kTCbWhX8jRG||kMS~xmta5IN8g$Jiw9HUif-s+k=9ZXvnEMknGR-dIJQG|1AqjOGcjNc{ zj`CYJVUcY+(RhCX!?e!{fcK#(=-IWnwxkc|cxJ5Y1CW}d>2k@$VO8I(9DnjS^&vo? zRpUb_o*d(~2ozN#QY(bB*++GFtU~T%)O#ryzq(K4XurOajU5xdU35^s4s%u_RJXma ziM8%kSDMiz&*{#pgbg48RgZkC&oSch4C6_OtN#0eY?p_qVSQpKu)W+Gn#u^}8n`sB zll2-D*ROw2(p2;Z#D8$6<>Xo@gUex;t3`4t4nC_2m*~32yo%s~>(0jS!*c=txpZS> zH*UN>EY>Xw9=aZnxKf%D9A63G`5y%hU$Ih*OrpNXjSW}}&~F_wlU3EQ8?HBiJV&sDd~{Gst9S+KwcbjyXYlTJ2) zdq=NNvP=006W(6FO4C&?iz8l+n{)JY+2?k6?U3+MlXyn0)p(`_$>aB*mgno!U7A{! ztpe8Ky-p=bJ8ncCtPy8An%KVekx@JQ_+MDm$N?kHO~ya~v&@ov$H>B0deH|`^DVr- zZxdGfZGnhIIL^-NsO#(_}rCM3u8plbX~Id}hB0YUwxSeimj)drH@DuCY-ng^dqQ4^^6 z*7aO%HLa) z=a3MxO`rRd-XT5&^#x%%Fq8CoSYa%!~il&it$;{}CYm znJj77va?}@m8Sb1w4ki?!O6m$Y|{N*VAjr*63SlMvNmUYGr@i((A4*#t6|L(Y?6~f zL)7i7{fvh!wzt|=DDchaGk%Xz5$umDQ?);a4Xm9Jk$?Yc)Zlg~I0`q?kex)u94Z=X zsKaZPDFcLwX9qV0BTcxIrfAFEbXUUML6ykb4_|SkqDPy| z%9B)6dHr-lVf;s4T@HR5ZPo@*(?TNh60o@N-R<3{xxYS8k3xhFWMReb%)>7`dQI9a z&IHBCJrS|sR*l9V94HT6-h};rJX_g-am((uR%m((23=_VNWRli(t{W6$`~g;S!s9_ zS^EzHdBVCR0JmroEwc0<2bOJ&Lh+4#xYAd88HF^merjNkYvo3QeVsUB5hhU-|M7q= zhNv;YCvKPTOl*X|_q8PHf3O8p5F~~;{!7?q>|fhBv9j6^9IRvX}!bjSrz>qG?d?<`u|?SBE9A0qnOIY7Hnzj*`bMQ4G?V2~21*i2T# zLVB@(KhWWLYjOUis@P}f^i=`h@KQl=f8~eX`g-e2s!QSsX5;eQw?0~W={+I0S;O7` z7J>ks46VDh)J8HP?Do}K9*7vqe0%=Rx38ayMi+V5aB+kcv&2T9!^kzK&$XEhZNlbD z5Sm|fTg5DWt6*j!O5Qfl7hMwp^9!;t#B~_EW39br~4NeHqzX)G&EuarAUCv zZ^BOAl6e{R`Y*3w?gB7*mt4CDyaPCjBf=X+2Xhn6j3zB&RDTGrHXARv*keq>4iR&YvJcJ=UP@5iuFbp4&)}Xss%KL6g&yh+2|Vtdz7lie@h-4 z=!1>rdU&DxuuI*Ph=34ezf`l89t^9(_CVsfq6BD`HgqsK)U!COiI`ls&tU3#bnSct zbvv)?)>^Makq}hwnR!=0%FKLc_{nXS*Dk0Vs|yQ#+uRp@$>D-=;xywbC50uT`4Mbs zSXUc~j?USeow75z4(&yiK}AnD_O+b3;0pCekzLCd=xm;p9ZISce}R%$92 za4O7`#H%K6x4tQ9Msdk+EEEDO!tt|zXr7Th{E|Xjxwzx9E}_mfFdvGeFcW9+fU5Y>`qTmu|Td2YNQy&GGB;O*y+8|UAV!Vd&p-yRH;KHuxc z96$|*lpgB3aTdt_GA_8?=aj>bCY8)T*v7Gnouom`LkaUC5LHT0F+QPB6moM7YF>jn zA4@`sbX7oy_Et`JdLb<&>~Tb@$V*`Gi^{n5puQ5&xuZ_6bzm+Y69d{C^nR=ak~#sp z1%c({C#)uwYr9vkS2sj7yWvITrAwPs85;>uSC0|GQ=m=iEU$E5f8zQVC;#>e_G+Wx z^XGfG>6-ZiWqm1qVxTKs2RO#MG#b#M4_$ko5Ganqq9TzKYOm*M zV5KKD;&|oWGq%ltWEv>?8K<+K)xM-`YX|srEvg&_RA8&+oL`|2PPxlYx4{J-A7=?X z&lNM~v^e|f%QxgQf=28F0bZLf0~u^7@Hkk5GUD^V(xkC_d8G}jJTRMCt98Fd9a+1l z+mH^safV6v4K|x;I7R~3RVR#)%Ty@>cw2l)wE z_@Mv5RY;W>y&O2p^1Z#hE6zX>L%e#P85>l^?-a8T5Sw2<5*xIb_8WRVuhP``Oy0NZ zI6Z3qwYhO)U{!6uD{`fDL$SsIQLjOKZQ@@v15~v{rbhJAL%t6cQkp^uC>9dcKrY=@_5oxs9}1Tz1e5zqv#H>k;MyB0{sPs^W2= z)@OM~?T_hn2TV+DVKc}>YUvk#$4>NE34CaF`^}4+VoeTdc{@r{-3?4hgYx`PZ$Dvl z#;_f8x18eA4y~HSo|PHf6}*u2Y+thIlB89RxfC_t6}v;NCc8wF>LUtE|V1PN3Qc-ttB@|2up96RELK*;X#M1owShZZ12=-Yl<`Y|;8WxMX4W)kbRH zo-4&rshOvn!=93|kw`YJFY(>Z2H_p3#LQFZ+MEW#-a%cvm8=e|xf~rOqP`t>dr#ff zJr&h&7iQ(KX(QGRf{FV6i5;vid*+G%PbJ*#3pqm{zSooWHZ}uEHv-1T$k;fxK>s9TZGk{ zmRQ>vL2&!T#QpsEsj47h#WWd5j@@)QXV3(+9dRg`J6Yp*2`wN%NI}qzT#0k(jg(ki}YXYc{7XJcVaOz6# zuj|V-v}DB=$`10jo-nWL+;`W-{#*aM{b`mVDo_7hJ+JH=hKNsIRfny*_ElDd9T$mp zVD_u3_Ud;Ar3RAnFU+Q)YzP=|40A=+KS~q`1|C?hkY%9|877I Mc22f04xYRIUl+^@T>t<8 literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/visibility.png b/docs/source/images/animpicker/visibility.png new file mode 100644 index 0000000000000000000000000000000000000000..8625ecc782b9b33136d6b5ea9aad360f7eb87dfe GIT binary patch literal 2550 zcma(TdpMN&`jfETCc6lWgkn`|`bc7|A#&LUyQIi{rnFMT*=H5u! z#uyE|jUh8=qP1m=Wz6R?J5$V#F)lfu=UnzV&pFR|&L6+udwbsZ_gQgh@uxaf2Ac&UgU=8xeUQ6^!ZxHh#@PgS#Hq2O0-F zsGNyn=hgY`DWxCj{Y7t6*p|5Qos~u-$sH(*WYXZI)!4Whg>|<3=)$mc&vv?Enr9}T ze5nOE{~}dlGcH})2zUaZ#+9v_c%bJ$kc>t z?rLkxjL{jj9hqzDgF?|3+kPr!Qc>T~BI01*XN}JN04g4diS$f9@8!cgnkj(6XWBBM z5GR*S`C7blO0C$546i-IPbWIt6&oJR83z?}UHr3OEhB))EG#vB@q^3ilAM!E^FppI z&Q-}w{j@yyrLq6)x{1Qc5X=)}FBw7!nZ~=-^gh^b(jNb=>wuEV93CKUacS9p%t{l` z+$H#KnY&HJ&A%AA-b!t)TSP;}cTy2kR48C{ENO3_{MVPPNHms`Y%XC}H*#Cghx zOHK5Pm?4qZs?ATr<&|mxaQYS;W~M10M77-3Nmva;BfFFEHK*FDEl*<@?8E|E7$>m1 z-Ss_F^BQ_;3^}0fi|1LZZU-82N%RG|_@o?mz}>wwEZ> z0%R-Zzf%o9R-hqiX`Z6b9|-)mi5xS=Oy7s2u9b4K4t`6i<+Ml{0pW)wj0>nC>0%$7 z&(w-Tp6a(C01;}>aZCrCw3WhnmK8deJxD7^rK1GszSke4UWGaH6JOkm!3ok`?u3K} zL@GY+2VRY-<6;Mb%bR8GCSSa;94;33&M_G&oSVM=*PNy>Yx@JCWqXwf9fJb8{hM({ z)tl3Y(?L7o7u}rbvIP>ql9n{QEwevW5EPX$(Y1!((7E@Jfvl3Zs*VJhg1mL}H(L19%p^05-XPT$~eDKB!uOrD2~q6jA=bh22>B`Fjo2Q4Qtl zvSN0Wk<1gc6a1;jH7k|#NGzq!0g4-A7Pxb-{;4U$e2R~t!Qc&uc5)&mrKEUrud@DP z&*+KmY(yRDhFN6Q858LCsXb6rc!xRUj7?>wHEx!1@}^?@Jt)n0X<~3VE{bgmIj26r zIyrP$z6j0ldcvc(S+e^VFF12Y1Cg%qgV(y#LTYFJbgwLQ@lX1_v4JVs&?2E{q zLanHq=qw%fXTua?)4OQXlsn zJZKKLg|Is1F~#Fn9jZ5{q;N+qjq#{2u~K#$qAiUCTii@kI+n&d@3R)n$LEsA;4G*1?%n}v zyw=i_k?OSYZ=aAoFfBdbl*`63Ep8@IXY3{t0p%f8_h$z?QNPSo9m4Z!(Vv>(Vng-# zlA(2n$AWl~B>BAP80?0*DTMFq-sYY3I|D1&AH0}J%CkE`jPE|k`Ay#<{FoUO+gNS_ zXMY06jQ?0iwqo%MWUAK)Kt@uov)we2o{@TNyp_m%0Qj|{@a?zPk3 zIsXH>aEooFfbaE`v7ab^A`om4qA^=j$|+pHju zw&#brQ{$B&JE=GQan)W|3B#rwS0P#8qrE8P>O+vn?DWLD-1>ZahU4AP24a*!O?~T6 z--i`|!wpv2{dzVZL@{?qvWG07JNe1ydu-Q3xL%-iY)$Y9r6N@u-kMM+ENN!AFKK4w zD4hBTy4(@;KEeH}_$O?}soIgZn9%SXN$CJh_iBQ{MC@p)5yT|P}Tno9k=3O`Dc^xW^A?iKZuNfP(>_in&8ts!~nC8LY`pvvTdHx7w8qS5x2 zSmhY|V8PEp+Y17)RQWc#d7FYkU7$kkBuPJNEo7c`FtMwhFzAO# zzs6K)Ti2&ANHosD-J6%A3^58QX*DP_UM-2-N_@E38VM#z+t1WKN`!=b&{cBJ8?K6! zOv8^@bLXpa69}GMaXj&vssY@d6F3C&{~H5%V2WP6aGjcTOFg86|JQUST)gP~%I0w{ lSuAunMz3e#-`1hdwfK!B@qjV)3B0QTUPpc0s7E5Q{sy3y5KsUB literal 0 HcmV?d00001 diff --git a/docs/source/images/animpicker/widget.png b/docs/source/images/animpicker/widget.png new file mode 100644 index 0000000000000000000000000000000000000000..9b55f78a323e26093b994a6e8e8774a1d78a4b6b GIT binary patch literal 7155 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 Pr~`Dh^fjw)*+={zR7_Lc literal 0 HcmV?d00001 diff --git a/docs/source/index.rst b/docs/source/index.rst index d18e4dfe..24356425 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -28,6 +28,7 @@ Welcome to mGear: Rigging Framework for Autodesk Maya official-unofficial-workflow shifterUserDocumentation animbitsUserDocumentation + animPickerUserDocumentation rigbitsUserDocumentation utilbitsUserDocumentation ueGearUserDocumentation From 9435e163e53ce9bd7fc1685f460699bf3fb14a4b Mon Sep 17 00:00:00 2001 From: miquelcampos Date: Tue, 14 Jul 2026 18:42:22 +0900 Subject: [PATCH 25/25] AnimPicker: Passthrough: Consolidate toggle + motion plumbing (SB-064/065/066) Behavior-preserving cleanup of the passthrough control / motion code. - SB-066: drop the _wheel_zooming flag -- a wheel flags itself as a brief zoom (zoom_active, saved/restored), and one _view_in_motion() helper is the single 'mid-gesture' check for pan / drag-zoom / wheel. - SB-065: extract _apply_passthrough_opacity() so _toggle_passthrough reads as intent and the opacity-slider / auto-button coupling lives in one helper. - SB-064: unify the in-row and floating passthrough checkboxes' creation through a _make_passthrough_checkbox() factory (both mirror the single _passthrough_enabled state). --- .../scripts/mgear/anim_picker/main_window.py | 43 ++++++++++++------- release/scripts/mgear/anim_picker/view.py | 24 +++++++---- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/release/scripts/mgear/anim_picker/main_window.py b/release/scripts/mgear/anim_picker/main_window.py index 37e7d9e0..7d73b61c 100644 --- a/release/scripts/mgear/anim_picker/main_window.py +++ b/release/scripts/mgear/anim_picker/main_window.py @@ -169,13 +169,7 @@ def setup(self): # Floating passthrough toggle shown by the move grip while # passthrough masks out the character-selector row; hidden until # then. Mirrors the in-row checkbox. - self.passthrough_cb_float = QtWidgets.QCheckBox( - "Passthrough", self - ) - self.passthrough_cb_float.setToolTip( - self.passthrough_cb.toolTip() - ) - self.passthrough_cb_float.toggled.connect(self._toggle_passthrough) + self.passthrough_cb_float = self._make_passthrough_checkbox(self) self.passthrough_cb_float.hide() # Re-applies the click mask shortly after a pan / zoom stops, so # the window shape is not reshaped every frame during motion. @@ -272,7 +266,17 @@ def _toggle_passthrough(self, checked): """ self._passthrough_enabled = checked self._sync_passthrough_checks(checked) - if checked: + self._apply_passthrough_opacity(checked) + self.update_passthrough_mask() + + def _apply_passthrough_opacity(self, on): + """Set the opacity so passthrough visibly engages / disengages. + + Activating an opaque window drops it to the remembered transparency + (Auto opacity is turned off -- mutually exclusive); deactivating + restores 100% and remembers the last transparency for next time. + """ + if on: if self.auto_opacity_btn.isChecked(): self.auto_opacity_btn.setChecked(False) if self.opacity_slider.value() >= 100: @@ -281,7 +285,6 @@ def _toggle_passthrough(self, checked): if self.opacity_slider.value() < 100: self._last_passthrough_opacity = self.opacity_slider.value() self.opacity_slider.setValue(100) - self.update_passthrough_mask() def _sync_passthrough_checks(self, checked): """Set `checked` on both passthrough toggles without re-emitting.""" @@ -303,6 +306,21 @@ def _place_passthrough_float(self): cb.show() cb.raise_() + def _make_passthrough_checkbox(self, parent=None): + """Build a passthrough toggle wired to the per-window enable. + + Shared by the in-row checkbox and the floating one (shown when + passthrough masks the character-selector row out); both mirror the + single ``_passthrough_enabled`` state. + """ + cb = QtWidgets.QCheckBox("Passthrough", parent) + cb.setToolTip( + "Click through the empty picker area (when the window is " + "transparent and Auto opacity is off)" + ) + cb.toggled.connect(self._toggle_passthrough) + return cb + def _items_region(self, view): """Return a pixel-exact region of a view's rendered items (window). @@ -520,12 +538,7 @@ def add_character_selector(self): # Passthrough toggle, to the left of the Sync Namespace checkbox (only # the floating window supports the click-through mask). - self.passthrough_cb = QtWidgets.QCheckBox("Passthrough") - self.passthrough_cb.setToolTip( - "Click through the empty picker area (when the window is " - "transparent and Auto opacity is off)" - ) - self.passthrough_cb.toggled.connect(self._toggle_passthrough) + self.passthrough_cb = self._make_passthrough_checkbox() if not __EDIT_MODE__.get() and not self.is_dockable: btns_layout.addWidget(self.passthrough_cb) diff --git a/release/scripts/mgear/anim_picker/view.py b/release/scripts/mgear/anim_picker/view.py index 838d269b..7486e3fc 100644 --- a/release/scripts/mgear/anim_picker/view.py +++ b/release/scripts/mgear/anim_picker/view.py @@ -96,9 +96,6 @@ def __init__(self, namespace=None, main_window=None): self.drag_active = False self.pan_active = False self.zoom_active = False - # True while a wheel-zoom step runs, so the passthrough mask is - # suspended (full UI back) like a drag instead of reshaped per step. - self._wheel_zooming = False self.auto_frame_active = True # Disable scroll bars @@ -434,16 +431,18 @@ def wheelEvent(self, event): # Apply zoom self.scale(factor, factor) - # Suspend the passthrough mask for the wheel burst (re-applied once it - # settles) so the window is not reshaped on every wheel step. - self._wheel_zooming = True + # A wheel is a brief zoom gesture: flag it as zoom so the passthrough + # mask is suspended (re-applied once it settles) instead of reshaped on + # every step. Save / restore in case a wheel lands mid drag-zoom. + was_zooming = self.zoom_active + self.zoom_active = True try: # Keep viewport-pinned items locked to their screen anchors. self._update_pinned_items() # Zoom changed; re-evaluate zoom-level visibility conditions. self.refresh_item_visibility() finally: - self._wheel_zooming = False + self.zoom_active = was_zooming self._suspend_passthrough() # ===================================================================== @@ -2047,11 +2046,20 @@ def _notify_or_suspend_passthrough(self): comes back, no per-frame reshape); when not moving (fit / resize / load / release) the mask is re-applied. """ - if self.pan_active or self.zoom_active or self._wheel_zooming: + if self._view_in_motion(): self._suspend_passthrough() else: self._notify_passthrough() + def _view_in_motion(self): + """True while the viewport is being interactively panned or zoomed. + + A wheel step flags itself as a zoom for its duration, so this single + check covers pan, drag-zoom and wheel -- and the passthrough mask is + suspended rather than rebuilt per frame. + """ + return self.pan_active or self.zoom_active + def _notify_passthrough(self): """Ask the window to (re-)apply its click-through mask now.