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 00000000..45f54511
Binary files /dev/null and b/docs/source/images/animpicker/2D_slider.png differ
diff --git a/docs/source/images/animpicker/action.png b/docs/source/images/animpicker/action.png
new file mode 100644
index 00000000..55225f5a
Binary files /dev/null and b/docs/source/images/animpicker/action.png differ
diff --git a/docs/source/images/animpicker/align.png b/docs/source/images/animpicker/align.png
new file mode 100644
index 00000000..b6e8ce3d
Binary files /dev/null and b/docs/source/images/animpicker/align.png differ
diff --git a/docs/source/images/animpicker/anim_picker.png b/docs/source/images/animpicker/anim_picker.png
new file mode 100644
index 00000000..41046169
Binary files /dev/null and b/docs/source/images/animpicker/anim_picker.png differ
diff --git a/docs/source/images/animpicker/appearence.png b/docs/source/images/animpicker/appearence.png
new file mode 100644
index 00000000..c1bac040
Binary files /dev/null and b/docs/source/images/animpicker/appearence.png differ
diff --git a/docs/source/images/animpicker/backdrop.png b/docs/source/images/animpicker/backdrop.png
new file mode 100644
index 00000000..c56c7695
Binary files /dev/null and b/docs/source/images/animpicker/backdrop.png differ
diff --git a/docs/source/images/animpicker/checkbox.png b/docs/source/images/animpicker/checkbox.png
new file mode 100644
index 00000000..1bc7aa8a
Binary files /dev/null and b/docs/source/images/animpicker/checkbox.png differ
diff --git a/docs/source/images/animpicker/controls.png b/docs/source/images/animpicker/controls.png
new file mode 100644
index 00000000..4a4a4979
Binary files /dev/null and b/docs/source/images/animpicker/controls.png differ
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 00000000..dc569f12
Binary files /dev/null and b/docs/source/images/animpicker/drag_and_drop_toolbar.png differ
diff --git a/docs/source/images/animpicker/editor.png b/docs/source/images/animpicker/editor.png
new file mode 100644
index 00000000..cdcbbf6f
Binary files /dev/null and b/docs/source/images/animpicker/editor.png differ
diff --git a/docs/source/images/animpicker/mirror.png b/docs/source/images/animpicker/mirror.png
new file mode 100644
index 00000000..709afaa9
Binary files /dev/null and b/docs/source/images/animpicker/mirror.png differ
diff --git a/docs/source/images/animpicker/palette.png b/docs/source/images/animpicker/palette.png
new file mode 100644
index 00000000..d0d586ae
Binary files /dev/null and b/docs/source/images/animpicker/palette.png differ
diff --git a/docs/source/images/animpicker/passthrough.png b/docs/source/images/animpicker/passthrough.png
new file mode 100644
index 00000000..508873dc
Binary files /dev/null and b/docs/source/images/animpicker/passthrough.png differ
diff --git a/docs/source/images/animpicker/pin.png b/docs/source/images/animpicker/pin.png
new file mode 100644
index 00000000..1ba1c5ba
Binary files /dev/null and b/docs/source/images/animpicker/pin.png differ
diff --git a/docs/source/images/animpicker/shape.png b/docs/source/images/animpicker/shape.png
new file mode 100644
index 00000000..48c51ad0
Binary files /dev/null and b/docs/source/images/animpicker/shape.png differ
diff --git a/docs/source/images/animpicker/shape_library.png b/docs/source/images/animpicker/shape_library.png
new file mode 100644
index 00000000..802bae69
Binary files /dev/null and b/docs/source/images/animpicker/shape_library.png differ
diff --git a/docs/source/images/animpicker/slider.png b/docs/source/images/animpicker/slider.png
new file mode 100644
index 00000000..e4037f22
Binary files /dev/null and b/docs/source/images/animpicker/slider.png differ
diff --git a/docs/source/images/animpicker/toolbar.png b/docs/source/images/animpicker/toolbar.png
new file mode 100644
index 00000000..c7d105a6
Binary files /dev/null and b/docs/source/images/animpicker/toolbar.png differ
diff --git a/docs/source/images/animpicker/trace_silhoette.png b/docs/source/images/animpicker/trace_silhoette.png
new file mode 100644
index 00000000..932a51d5
Binary files /dev/null and b/docs/source/images/animpicker/trace_silhoette.png differ
diff --git a/docs/source/images/animpicker/transform.png b/docs/source/images/animpicker/transform.png
new file mode 100644
index 00000000..4644e10e
Binary files /dev/null and b/docs/source/images/animpicker/transform.png differ
diff --git a/docs/source/images/animpicker/visibility.png b/docs/source/images/animpicker/visibility.png
new file mode 100644
index 00000000..8625ecc7
Binary files /dev/null and b/docs/source/images/animpicker/visibility.png differ
diff --git a/docs/source/images/animpicker/widget.png b/docs/source/images/animpicker/widget.png
new file mode 100644
index 00000000..9b55f78a
Binary files /dev/null and b/docs/source/images/animpicker/widget.png differ
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
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_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_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_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/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/__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/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 bce78060..2bca2a88 100644
--- a/release/scripts/mgear/anim_picker/gui.py
+++ b/release/scripts/mgear/anim_picker/gui.py
@@ -1,2392 +1,38 @@
-from __future__ import print_function
-from __future__ import absolute_import
-from __future__ import division
-from __future__ import unicode_literals
+"""Compatibility shim for the anim picker gui.
-# python
-import os
-import copy
-import json
-from functools import partial
-
-# 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 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
-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 -------------------------------------------------------------------
-try:
- _CLIPBOARD
-except NameError as e:
- _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(APPassthroughEventFilter, self).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, heith):
- """Will set scene size with proper center position"""
- self.setSceneRect(-width / 2, -heith / 2, width, heith)
-
- 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)
-
- # TODO
- # 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)):
- print("# background image not found: '{}'".format(path))
- 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"] = json.dumps(bg_fp).replace('"', "")
- 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)
-
- # deletes existing tab group and recreates it
- if pm.objExists(tab["name"]):
- pm.delete(tab["name"])
- picker_grp = pm.group(em=True, n=tab["name"], p=grp)
- 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:
- print(e)
-
- 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():
- new_data["tabs"].append({"name": tab_grp.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 [
- ic
- for ic in tab_grp.listRelatives()
- if ic.getShape().type() != "imagePlane"
- ]:
- 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}
-
- # 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)
-
- 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(MainDockWindow, self).__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(MainDockWindow, self).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(MainDockWindow, self).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(MainDockableWindow, self).__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 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
- 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 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",
+ "MainDockWindow",
+ "MainDockableWindow",
+ "load",
+]
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..43260404 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,51 +136,124 @@ 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
+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()
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 +264,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/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
new file mode 100644
index 00000000..7d73b61c
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/main_window.py
@@ -0,0 +1,1647 @@
+"""Main dockable window and launcher for the anim picker.
+
+Extracted from gui.py during the Phase 2 decomposition.
+"""
+
+from functools import partial
+
+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 QtWidgets
+
+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 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
+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__
+
+
+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.
+ """
+
+ 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 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):
+ __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 = []
+ # 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
+ # 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
+ # 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()
+ self.setup()
+
+ def setup(self):
+ """Setup interface"""
+ # 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
+ 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)
+ # 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)
+ # 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 = 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.
+ 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:
+ tab_widget.currentChanged.connect(self.update_passthrough_mask)
+
+ 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
+
+ # =====================================================================
+ # 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 self._passthrough_enabled
+ )
+
+ def update_passthrough_mask(self, *args):
+ """Mask the window to the visible items + tabs + move grip (or clear).
+
+ 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. 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
+ if not self._passthrough_active():
+ if self._mask_applied:
+ 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, 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)
+ 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:
+ 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)
+
+ 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 _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).
+
+ 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(),
+ )
+ 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))
+ # 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():
+ 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:
+ y = pyqt.dpi_scale(6)
+ handle.move(x, y)
+ handle.show()
+ handle.raise_()
+
+ 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
+ """
+ # 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():
+ if event.type() == QtCore.QEvent.Type.Enter:
+ self.setWindowOpacity(1.0)
+ return True
+ if event.type() == QtCore.QEvent.Type.Leave:
+ 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:
+ self.tab_area.hide()
+ return True
+ elif event.type() == QtCore.QEvent.Type.Hide:
+ self.tab_area.show()
+ return True
+
+ return False
+
+ def change_opacity(self):
+ """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"""
+ 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)
+ # Kept so add_tab_widget can append the view-mode selector here.
+ self.char_btns_layout = btns_layout
+
+ # Add horizont spacer
+ spacer = QtWidgets.QSpacerItem(
+ 10,
+ 0,
+ QtWidgets.QSizePolicy.Expanding,
+ QtWidgets.QSizePolicy.Minimum,
+ )
+ 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 = self._make_passthrough_checkbox()
+ 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():
+ 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)
+
+ # 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))
+
+ # 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_area)
+ 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)
+ # 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.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"),
+ )
+ # 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 = [
+ 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
+ # 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")),
+ ("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(
+ [
+ (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)
+ canvas_row.addWidget(self.left_toolbar)
+ 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)
+
+ # ensure the picker area retains its size when hidden (overlay filter)
+ sp_retain = self.tab_area.sizePolicy()
+ sp_retain.setRetainSizeWhenHidden(True)
+ self.tab_area.setSizePolicy(sp_retain)
+
+ # 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)
+
+ # 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)
+ 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.
+
+ Args:
+ name (str): a ``tool_bar`` tool id (TOOL_SELECT / TOOL_TRANSFORM).
+ """
+ self.active_tool = name
+ view = self.tab_area.active_view()
+ 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_area.active_view()
+
+ 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:
+ # 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:
+ 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()
+ if view is not None:
+ 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:
+ items[0].duplicate_selected()
+ self._after_command()
+
+ def _cmd_shapes(self):
+ dialog = ShapeLibraryDialog(
+ parent=self,
+ apply_callback=self._apply_shape_to_selection,
+ create_callback=self._create_shape_from_selection,
+ current_shape_getter=self._current_library_shape,
+ )
+ dialog.show()
+
+ 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.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):
+ """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 _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 _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)
+ )
+
+ # 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``.
+
+ 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.
+
+ 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)
+ # 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()
+
+ 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_area.get_current_picker_items()
+
+ def get_all_picker_items(self):
+ """Return all picker items for current picker"""
+ return self.tab_area.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
+
+ # 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):
+ """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_area.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"""
+ # 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")
+
+ 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_area.fit_contents()
+
+ # Sync the inline editor's visibility/content with the current mode.
+ self._sync_edit_panel()
+
+ # Set focus on view
+ 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"""
+ 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 (set_data returns the area to the tabbed presentation)
+ tabs_data = picker_data.get("tabs", {})
+ self.tab_area.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)
+
+ # Re-apply the persisted view mode over the freshly loaded tabs.
+ self._apply_saved_view_mode()
+
+ # Fit content
+ self.tab_area.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 (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
+
+ 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
+ )
+ # 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):
+ """
+ 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()
+
+ # 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):
+ def __init__(self, parent=None, edit=False, dockable=True):
+ # 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):
+ """Launch the anim picker UI (a fresh instance each call).
+
+ Args:
+ edit (bool, optional): open in edit mode.
+ dockable (bool, optional): open as a dockable workspaceControl.
+
+ Returns:
+ MainDockWindow: the created window instance.
+ """
+ if 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 = 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 483d3a35..12467e2b 100644
--- a/release/scripts/mgear/anim_picker/menu.py
+++ b/release/scripts/mgear/anim_picker/menu.py
@@ -1,14 +1,8 @@
-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
import mgear
import mgear.menu
-from mgear.core import pyqt
str_open_picker_mode = """
@@ -16,61 +10,21 @@
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)
"""
-def force_disable_passthrough(*args):
- """force all the anim picker gui's to disable passthrough feature
-
- 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)
-
-
-def get_option_var_passthrough_state():
- """set option var for the anim picker passthrough feature
-
- 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 override state with maya option variable
-
- 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()
-
-
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(
@@ -81,14 +35,6 @@ 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)
- msg = "Experimental passthrough click when auto opacity enabled."
- cmds.menuItem(
- "mgear_ap_passthrough_menuitem",
- label="Enable opacity passthrough (Beta)",
- command=set_mgear_ap_passthrough_state,
- checkBox=state,
- ann=msg,
- )
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/scene.py b/release/scripts/mgear/anim_picker/scene.py
new file mode 100644
index 00000000..96a1ecaf
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/scene.py
@@ -0,0 +1,224 @@
+"""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_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
+ 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.content_bounding_rect()
+
+ # 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 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)
+ 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/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
new file mode 100644
index 00000000..d87b5f9b
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/shapes/default_shapes.json
@@ -0,0 +1,307 @@
+[
+ {
+ "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
+ ]
+ ]
+ },
+ {
+ "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/tab_widget.py b/release/scripts/mgear/anim_picker/tab_widget.py
new file mode 100644
index 00000000..2d5c0e62
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/tab_widget.py
@@ -0,0 +1,215 @@
+"""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__
+
+
+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"""
+ # 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 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"""
+ 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"""
+ 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/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/view.py b/release/scripts/mgear/anim_picker/view.py
new file mode 100644
index 00000000..7486e3fc
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/view.py
@@ -0,0 +1,2798 @@
+"""Graphics view widget (picker canvas) for the anim picker.
+
+Extracted from gui.py during the Phase 2 decomposition.
+"""
+
+import os
+import copy
+import json
+import uuid
+from functools import partial
+
+from maya import cmds
+import mgear.pymaya as pm
+
+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
+
+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.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 shape_library
+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.core import svg_import
+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):
+ """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 = []
+
+
+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)
+
+ # 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)
+ # 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
+
+ # 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
+
+ # On-canvas picker item scale/rotate manipulator (edit mode only).
+ 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
+
+ # 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
+
+ # 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
+
+ # 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
+ # 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(
+ QtCore.QPoint(self.width() / 2, self.height() / 2)
+ )
+
+ 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
+ # 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
+ # 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()
+ 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)
+ 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)
+ picker_at = self._resolve_backdrop_pick(picker_at, scene_pos) or []
+ if picker_at:
+ if __EDIT_MODE__.get():
+ self.item_selected = True
+ # 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
+ 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):
+ # 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 (
+ 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
+ # Live-mirror the moving selection to linked partners in realtime.
+ self.apply_mirror_for(self.scene().get_selected_items())
+ # 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)
+ self._update_pinned_items()
+
+ 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
+ self._update_pinned_items()
+ # Zoom changed; re-evaluate zoom-level visibility conditions.
+ self.refresh_item_visibility()
+
+ return result
+
+ 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
+ # 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())
+
+ # 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)
+
+ # Commit an item drag-move as one undo step -------------------------
+ if not self.drag_active and self.__move_prompt:
+ # Live-mirror the moved selection to any linked partners (covers a
+ # 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
+ # 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._update_pinned_items()
+ 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)
+
+ # 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
+ # 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"""
+ 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)
+ # 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.zoom_active = was_zooming
+ self._suspend_passthrough()
+
+ # =====================================================================
+ # 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).
+ """
+ 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
+
+ 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:
+ data = copy.deepcopy(state["data"])
+ if item is None:
+ # A recreated item starts clean; set_data restores it.
+ item = self._recreate_item(key)
+ 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):
+ """Undo the last editor edit (restore its 'before' snapshot)."""
+ record = self._undo_stack.undo()
+ if record is None:
+ return
+ self._apply_undo_snapshot(record["before"])
+ self._after_undo_redo()
+
+ 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()
+
+ 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
+ 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):
+ """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.
+ """
+ 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 --------------------------------------------------------------------
+
+ 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("Add background layer", None)
+ background_action.triggered.connect(self.set_background_event)
+ menu.addAction(background_action)
+
+ 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 all backgrounds", 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())
+ # 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"""
+ # Fit scene content to view
+ if self.auto_frame_active:
+ self.fit_scene_content()
+
+ # 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). 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()
+ # The canvas resized -- realign the opacity-passthrough mask.
+ self._notify_passthrough()
+ 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 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"""
+ # 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)
+ 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
+
+ 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
+
+ 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_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_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.
+
+ 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 _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).
+
+ 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 / 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 / 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 / shape / SVG item at the drop (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
+ 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))
+ 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"""
+ 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 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).
+
+ 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
+ _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
+ 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
+ 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()
+
+ # 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()
+
+ 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 _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)
+ if not (path and os.path.exists(path)):
+ mgear.log(
+ "anim_picker: background image not found: '{}'".format(path),
+ mgear.sev_warning,
+ )
+ 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
+
+ def _update_scene_rect(self):
+ """Size the scene rect to all content so pan/zoom can reach it.
+
+ 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).
+ # Pinned HUD items are excluded so they never inflate the canvas.
+ content = self._bounding_rect
+ items_rect = self.scene().content_bounding_rect()
+ if not items_rect.isNull():
+ content = (
+ items_rect
+ if content is None
+ else content.united(items_rect)
+ )
+
+ if content is None:
+ self.scene().set_default_size()
+ return
+
+ margin = self.fit_margin
+ content = content.adjusted(-margin, -margin, margin, margin)
+ self.scene().set_rect(content.united(self.scene().default_rect()))
+
+ 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 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 _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 _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)."""
+ 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)
+ # 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
+
+ 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()
+ # 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
+
+ # -- 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()))
+ 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()
+
+ 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()
+
+ # -- 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 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))))
+ # 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._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.
+
+ 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).
+
+ 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 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 item for the current zoom, rig state, and groups.
+
+ Mirrors ``_update_pinned_items``: the view owns *when* (called from the
+ 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 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 in hidden)
+ # Items were shown / hidden (e.g. a checkbox group toggled) -- realign
+ # 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.
+
+ 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).
+
+ 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:
+ 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
+ if self.bg_ui:
+ try:
+ self.bg_ui.close()
+ self.bg_ui.deleteLater()
+ except Exception:
+ pass
+ 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 (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()
+
+ 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, index, width, height, keepAspectRatio=False, auto_update=True
+ ):
+ """Resize the draw size of the layer at index.
+
+ Args:
+ 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 (0 <= index < len(self.background_layers)):
+ return
+ 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._update_scene_size()
+
+ 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._layer_draw_size(self.background_layers[index])
+ self.resize_background_image(
+ index, width, current_height, keepAspectRatio=keepAspectRatio
+ )
+
+ 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._layer_draw_size(self.background_layers[index])
+ self.resize_background_image(
+ index, current_width, height, keepAspectRatio=keepAspectRatio
+ )
+
+ def get_background_layers(self):
+ """Return the ordered list of _LoadedBackground (back to front)."""
+ return self.background_layers
+
+ 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 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"""
+ old_scene = self.scene()
+ self.setScene(OrderedGraphicsScene(parent=self))
+ old_scene.deleteLater()
+ 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)"""
+ 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 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 = []
+ 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 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)
+
+ # 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()
+
+ # 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()
+
+ # 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()
+
+ # 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
+ result = QtWidgets.QGraphicsView.drawBackground(self, painter, 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
+
+ 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)
+ # 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)
+ # 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:
+ self.bg_manipulator.paint(painter)
+ self._draw_bg_marquee(painter)
+
+ 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
+ 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"]
+ )
+
+ # 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",
+ "float",
+ 0.5,
+ minValue=0,
+ maxValue=1,
+ )
+ 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"]:
+ 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": []}
+ # 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()
+ 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/alignment.py b/release/scripts/mgear/anim_picker/widgets/alignment.py
new file mode 100644
index 00000000..e935df61
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/alignment.py
@@ -0,0 +1,150 @@
+"""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
+
+
+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/background_manipulator.py b/release/scripts/mgear/anim_picker/widgets/background_manipulator.py
new file mode 100644
index 00000000..b993d72e
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/background_manipulator.py
@@ -0,0 +1,187 @@
+"""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
+from mgear.core import pyqt
+
+
+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 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 pyqt.dpi_scale(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_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/background_transform.py b/release/scripts/mgear/anim_picker/widgets/background_transform.py
new file mode 100644
index 00000000..09bada1f
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/background_transform.py
@@ -0,0 +1,81 @@
+"""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.
+
+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
+
+# 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):
+ """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 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_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 c614ff01..46d839ff 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__
@@ -424,82 +414,262 @@ 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(BackgroundOptionsDialog, self).__init__(parent)
- self.setWindowTitle("Set background size")
+ super().__init__(parent)
+ 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
+ # 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")
+ self.down_button = QtWidgets.QPushButton("Move Down")
- if not self.tabWidget.currentWidget().get_background(0):
- cmds.warning("Current view has no background!")
- return None
-
- 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)
+ # Activate on-canvas manipulation for the current view.
+ self._edit_view = self.gfx_view()
+ if self._edit_view:
+ self._edit_view.enter_background_edit()
- self.update_ui_width_value()
- self.update_ui_height_value()
+ def build_layout(self):
+ self.main_layout = QtWidgets.QVBoxLayout(self)
+ self.main_layout.addWidget(self.layer_list)
- self.connectSignals()
+ 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.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.layer_list.itemSelectionChanged.connect(
+ self.on_list_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 _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()
+ 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()
+
+ # 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()
+ 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 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 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 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:
+ 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 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/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/__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..4a08b2a8
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/dialogs/handles_window.py
@@ -0,0 +1,115 @@
+"""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)
+ # 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):
+ """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..b9919b69
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/dialogs/item_options.py
@@ -0,0 +1,901 @@
+"""Picker item options editor window.
+
+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.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: 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()
+
+ # 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
+
+ # 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)
+
+ 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..935a1d1e
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/dialogs/script_dialog.py
@@ -0,0 +1,314 @@
+"""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.core import pycodeeditor
+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)
+#
+# 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__)
+
+"""
+
+
+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)
+
+ # 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.setPlainText(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 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
+ 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
+
+ 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/dialogs/shape_library_dialog.py b/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py
new file mode 100644
index 00000000..c81c262c
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/dialogs/shape_library_dialog.py
@@ -0,0 +1,260 @@
+"""Shape library picker dialog for the anim picker.
+
+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
+from mgear.vendor.Qt import QtCore
+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):
+ """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,
+ 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._create_callback = create_callback
+ self._current_shape_getter = current_shape_getter
+
+ self.main_layout = QtWidgets.QVBoxLayout(self)
+ 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...")
+ # 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()
+ 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 _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 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
+ )
+
+ # -- 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)
+ 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 _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(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):
+ 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),
+ handles=shape.get("handles"),
+ subpaths=shape.get("subpaths"),
+ mode=shape.get("mode"),
+ )
+ 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
new file mode 100644
index 00000000..cfc92a85
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/edit_panel.py
@@ -0,0 +1,1858 @@
+"""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 functools import partial
+
+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 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.core import svg_import
+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,
+)
+from mgear.anim_picker.widgets.dialogs.shape_library_dialog import (
+ ShapeLibraryDialog,
+)
+
+
+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 = "-"
+
+ # 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,
+ )
+
+ # Visibility mode combo order (index -> visibility mode).
+ _VIS_MODE_ORDER = (
+ visibility.VIS_NONE,
+ visibility.VIS_CHANNEL,
+ 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
+ # 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.text_align_combo = 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
+ 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.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.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
+ 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()
+
+ # ------------------------------------------------------------------
+ # 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_pin_section()
+ self._build_mirror_section()
+ self._build_controls_section()
+ 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):
+ """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)
+
+ 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()
+ 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")
+
+ # 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)
+ 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)
+ poly_layout.addLayout(count_row)
+ handles_btn = basic.CallbackButton(callback=self._edit_handles)
+ handles_btn.setText("Handles Positions...")
+ self._fields.append(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...")
+ shapes_btn.setToolTip("Apply a premade / saved shape to the selection")
+ 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")
+
+ 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")
+
+ 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")
+
+ 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)
+
+ 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, 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(
+ "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(callback or 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)
+
+ # 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)
+ 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)
+
+ 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")
+
+ # 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"])
+ 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
+ # ------------------------------------------------------------------
+ def _current_view(self):
+ """Return the main window's active graphics view, or None."""
+ getter = getattr(self.main_window, "_current_view", None)
+ if getter is None:
+ 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())
+
+ 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_pin()
+ self._populate_mirror()
+ self._populate_controls()
+ self._populate_action()
+ self._populate_widget()
+ self._populate_backdrop()
+ self._populate_visibility()
+
+ 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 _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)
+ )
+ 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(
+ widget_type
+ in (widget_binding.WIDGET_CHECKBOX, widget_binding.WIDGET_SLIDER)
+ )
+ 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
+ )
+ 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._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.
+ 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._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)
+
+ 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)
+ 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)
+ 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 _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 _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))
+ 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)
+
+ align, align_mixed = self._shared(lambda item: item.get_text_align())
+ 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)
+ )
+ 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)
+
+ status, status_mixed = self._shared(
+ lambda item: item.get_edit_status()
+ )
+ 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()
+ 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._set_tristate(self.custom_action_cb, mode, mode_mixed)
+
+ 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):
+ """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.
+
+ 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
+
+ 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:
+ 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()
+
+ 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:
+ return
+ # A user click resolves the tristate; treat partial as "show".
+ show = self._resolve_tristate(self.handles_cb)
+ 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_()
+
+ def _open_shape_library(self):
+ dialog = ShapeLibraryDialog(
+ parent=self,
+ apply_callback=self._apply_shape,
+ create_callback=self._create_shape_from_selection,
+ current_shape_getter=self._current_library_shape,
+ )
+ dialog.show()
+
+ 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.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 ---------------------------------------------------------
+ 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)
+ self._commit_edit("Link 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)
+ self._commit_edit("Unlink 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()
+ self._commit_edit("Make symmetric")
+
+ # -- 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)
+ self._commit_edit("Pin item")
+
+ 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()
+ self._commit_edit("Set anchor")
+
+ 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()
+ self._commit_edit("Set pin offset")
+
+ # -- 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(),
+ "visibility_group": str(
+ self.widget_group_combo.currentText()
+ ).strip(),
+ "visibility_invert": self.widget_invert_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)
+ # 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."""
+ 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)
+ self._commit_edit("Edit widget script")
+
+ # -- 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()
+
+ # -- 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)
+ )
+ self._refresh_visibility()
+
+ 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:
+ return
+ 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()
+ if item is None:
+ return
+ 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:
+ 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()
+ self._commit_edit("Search & replace controls")
+
+ # -- action ---------------------------------------------------------
+ def _apply_action_mode(self, *args, **kwargs):
+ if self._syncing or not self.items:
+ return
+ 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()
+ 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)
+ self._commit_edit("Edit action script")
+
+ 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()
+ self._commit_edit("Edit custom menu")
+
+ 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()
+ self._commit_edit("Add custom menu")
+
+ 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()
+ self._commit_edit("Remove custom menu")
+
+ # ------------------------------------------------------------------
+ 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/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/graphics.py b/release/scripts/mgear/anim_picker/widgets/graphics.py
new file mode 100644
index 00000000..0fa9178a
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/graphics.py
@@ -0,0 +1,1086 @@
+"""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
+
+from mgear.anim_picker.widgets import widget_binding
+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. 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
+
+# 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
+
+
+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"""
+
+ __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=HANDLE_PX, 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 _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(-half, half),
+ QtCore.QPointF(half, -half),
+ )
+ # 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._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)
+
+ 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. 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)
+ 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=INDEX_PT):
+ """Set the index text point size (DPI-scaled)."""
+ font = self.font()
+ font.setPointSizeF(_dpi(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 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 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
+
+ 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):
+ # 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
+
+ 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()
+ 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):
+ 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)
+ # Cosmetic so the outline keeps a constant screen width at any zoom.
+ pen.setCosmetic(True)
+ painter.setPen(pen)
+ # 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):
+ """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()
+ # 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(
+ text_rect,
+ QtCore.Qt.AlignVCenter | QtCore.Qt.AlignLeft,
+ title,
+ )
+ 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.
+
+ 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."""
+ return build_vector_path(subpaths)
+
+ 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)
+ # 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)
+ # 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):
+ """Draw a small upright "SVG" badge over the shape center on hover."""
+ 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.
+ 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)))
+ painter.setFont(font)
+ painter.drawText(badge, QtCore.Qt.AlignCenter, text)
+ 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"""
+
+ __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)
+
+ # Placement relative to the item + a gap in pixels.
+ 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__)
+
+ def set_text(self, text):
+ """
+ Set current text
+ (Will reposition text on parent too)
+ """
+ self.setText(text)
+ self._reposition()
+
+ def get_text(self):
+ """Return element text"""
+ return str(self.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(_dpi(value))
+ self.setFont(font)
+ 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 the authored (DPI-independent) text point size."""
+ return self.point_size
+
+ 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)
+
+ 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_manipulator.py b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py
new file mode 100644
index 00000000..5aafca6f
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/item_manipulator.py
@@ -0,0 +1,212 @@
+"""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
+from mgear.core import svg_import
+from mgear.core import pyqt
+
+
+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 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 pyqt.dpi_scale(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]
+ # 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,
+ svg_subpaths)
+ )
+
+ 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, osvg in self._orig_items:
+ item.setPos(
+ anchor_x + (ox - anchor_x) * sx,
+ anchor_y + (oy - anchor_y) * 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):
+ 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, _osvg 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/item_model.py b/release/scripts/mgear/anim_picker/widgets/item_model.py
new file mode 100644
index 00000000..3e4d98e0
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/item_model.py
@@ -0,0 +1,227 @@
+"""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
+ 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)
+ 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``)
+ group str (optional visibility-group tag; a checkbox widget can
+ master-toggle every item sharing the name)
+"""
+
+
+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
+ 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
+ self.visibility = None
+ self.svg = None
+ self.group = 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"])
+ 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"):
+ 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
+ )
+ 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")
+ if data.get("visibility"):
+ model.visibility = dict(data["visibility"])
+ if data.get("svg"):
+ model.svg = dict(data["svg"])
+ if data.get("group"):
+ model.group = data["group"]
+
+ 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
+ # 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).
+ if self.item_id:
+ data["id"] = self.item_id
+ 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)
+ 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
+
+ # 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)
+
+ # 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)
+
+ # 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/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/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/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/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_item.py b/release/scripts/mgear/anim_picker/widgets/picker_item.py
new file mode 100644
index 00000000..1c9bb652
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/picker_item.py
@@ -0,0 +1,1931 @@
+"""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.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,
+)
+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.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
+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):
+ 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)
+
+ # 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)
+
+ # 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)
+
+ # 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()
+
+ # 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
+
+ # 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
+ # 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)
+ # 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
+
+ # 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 = {}
+
+ # 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()
+
+ # 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
+ 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):
+ # 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):
+ 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:
+ [
+ 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):
+ """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
+ ]
+ # 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).
+ 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
+ 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)
+ # 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 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.
+ 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 (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)
+
+ 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)
+ # 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):
+ """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
+
+ # 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):
+ # 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):
+ """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()
+ self.edit_window.deleteLater()
+ except Exception:
+ pass
+
+ self.edit_window = ItemOptionsWindow(
+ parent=self.main_window, picker_item=self
+ )
+ self.edit_window.show()
+ self.edit_window.raise_()
+
+ 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:
+ 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)
+
+ 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):
+ """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):
+ # 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()
+ # 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
+ 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, 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). 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
+ )
+ if self.pinned:
+ 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():
+ self.setFlag(
+ 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
+
+ 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)
+
+ # =========================================================================
+ # 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()
+ # 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.
+
+ 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()
+
+ # =========================================================================
+ # 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, 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.
+ 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)
+ 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})
+ )
+
+ # =========================================================================
+ # 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):
+ """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)
+ # 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()
+
+ 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)
+
+ 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):
+ """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):
+ """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()
+ self.setRotation(mirror.mirror_rotation(angle))
+ self.update()
+
+ def mirror_shape(self):
+ """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))
+
+ def mirror_color(self):
+ """Will reverse red/bleu rgb values for the polygon 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()
+ 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)
+
+ # 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
+ 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)
+ 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"""
+ 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)
+ # 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"""
+ 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 ---
+ 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 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.
+
+ 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"]))
+ 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:
+ 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)
+
+ # 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
+
+ # 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)
+ 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)
+
+ # Visibility condition (optional, additive key).
+ if model.visibility:
+ self.set_visibility(model.visibility)
+
+ # Vector (SVG) shape (optional, additive key).
+ 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.
+
+ 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()
+ 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
+
+ if self.pinned:
+ 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()
+
+ if self.visibility:
+ model.visibility = dict(self.visibility)
+
+ 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/picker_widgets.py b/release/scripts/mgear/anim_picker/widgets/picker_widgets.py
index 4102be61..78464030 100644
--- a/release/scripts/mgear/anim_picker/widgets/picker_widgets.py
+++ b/release/scripts/mgear/anim_picker/widgets/picker_widgets.py
@@ -1,2942 +1,53 @@
-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
-
-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
-
-# 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__,
- __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()
- # 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),
- )
- # 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(PickerItem, self).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(PickerItem, self).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",
+]
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..590bfdbe
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/shape_library.py
@@ -0,0 +1,263 @@
+"""Qt/Maya-free shape library for picker items.
+
+A library shape is one of two kinds:
+
+- **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, ``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``.
+"""
+
+import os
+import json
+
+
+_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."""
+ 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 _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:
+ with open(path, "r") as shape_file:
+ data = json.load(shape_file)
+ except (ValueError, IOError):
+ return []
+ shapes = []
+ for entry in data or []:
+ 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 raw bundled shape entries."""
+ return _read_raw_shapes(bundled_shapes_path())
+
+
+def load_user_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_raw_shapes(path)
+ legacy = _read_raw_shapes(_legacy_user_shapes_path())
+ if legacy:
+ try:
+ _write_user_shapes(legacy)
+ except (IOError, OSError):
+ pass
+ return legacy
+
+
+def load_shapes():
+ """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 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()
+ 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=None, subpaths=None, mode=None):
+ """Add or replace a user shape by name (polygon handles or vector).
+
+ Args:
+ name (str): shape name.
+ 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:
+ 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.get("name") != name]
+ shapes.append(entry)
+ _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.get("name") != name]
+ if len(kept) == len(shapes):
+ return False
+ _write_user_shapes(kept)
+ return True
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/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/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..ed6f4a81
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/tool_bar.py
@@ -0,0 +1,292 @@
+"""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.core import pyqt
+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"
+
+# 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``)."""
+ 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 DragTileButton(QtWidgets.QToolButton):
+ """A draggable tile: a drag carries ``payload`` as ``mime`` to the drop.
+
+ 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.
+ """
+
+ 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
+
+ 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(DragTileButton, self).mouseDoubleClickEvent(event)
+
+ def mousePressEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self._press_pos = event.pos()
+ super(DragTileButton, self).mousePressEvent(event)
+
+ def mouseMoveEvent(self, event):
+ # 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(DragTileButton, self).mouseMoveEvent(event)
+ return
+ moved = (event.pos() - self._press_pos).manhattanLength()
+ if moved < QtWidgets.QApplication.startDragDistance():
+ super(DragTileButton, self).mouseMoveEvent(event)
+ return
+ drag = QtGui.QDrag(self)
+ mime = QtCore.QMimeData()
+ 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.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."""
+
+ _BUTTON_SIZE = 34
+ _ICON_SIZE = 22
+
+ 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)
+
+ # Tools use the default Maya tool icons.
+ self._add_tool(
+ TOOL_SELECT,
+ "Sel",
+ "Select tool: click / marquee select and drag to move items",
+ maya_icon(":/aselect.png"),
+ checked=True,
+ )
+ self._add_tool(
+ TOOL_TRANSFORM,
+ "Xfrm",
+ "Transform tool: show on-canvas scale / rotate handles",
+ maya_icon(":/move_M.png"),
+ checked=False,
+ )
+
+ self.main_layout.addStretch()
+
+ 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)
+ 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, checked=False):
+ """Add an exclusive tool toggle button."""
+ button = QtWidgets.QToolButton()
+ button.setCheckable(True)
+ button.setChecked(checked)
+ 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=None):
+ """Add a non-exclusive quick-access command button below the tools.
+
+ Args:
+ label (str): short button label (used when ``icon`` is null).
+ tooltip (str): hover description.
+ callback (callable): invoked on click.
+ icon (QtGui.QIcon, optional): icon to show.
+
+ Returns:
+ QtWidgets.QToolButton: the created button.
+ """
+ button = QtWidgets.QToolButton()
+ 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)
+ 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)
+
+ 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/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/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..4110edde
--- /dev/null
+++ b/release/scripts/mgear/anim_picker/widgets/widget_binding.py
@@ -0,0 +1,230 @@
+"""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,
+ "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
+ 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,
+ "visibility_group": "",
+ "visibility_invert": 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/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/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 e278132e..e2f3b3fc 100644
--- a/releaseLog.rst
+++ b/releaseLog.rst
@@ -2,6 +2,41 @@ Release Log
===========
+5.3.4
+------
+**Enhancements**
+ * 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
+ * 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
+ * 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
+ * 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
+ * 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)
+
+**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)
+ * 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
+ * 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**