Commit 7d1a41b8 authored by Simon Jokuschies's avatar Simon Jokuschies

Merge branch '1-create-initial-release' into 'master'

Resolve "create initial release"

Closes #1

See merge request !1
parents 15f1c394 eceb47ab
.DS_STORE
.DS_Store
.idea/
*.pyc
__docs/*
*.so
*.pyd
Changelog
=========
1.0.0
-----
- Initial release (#1)
Copyright (c) Simon Jokuschies
www.leafpictures.de / www.cragl.com
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Aligner
=======
version number: 1.0.0
author: Simon Jokuschies
website: www.cragl.com, www.leafpictures.de
contact: info@leafpictures.de
Overview
--------
The aligner package provides a window to align and evenly distribute objects
in three dimensional space. Select some objects, set the axis to align or
distribute to and click the needed align or distribute button. Lock the
elements to an additional axis if required.
Installation
------------
1) Add the aligner to your PYTHONPATH.
2) You will now have a new menu in your menubar: Scripts/aligner.
For more information please have a look at the documentation as well.
Feedback, bug reports
---------------------
Please send all requests to info@leafpictures.de and state your used operating
system and Maya version.
"""Align and evenly distribute objects in three dimensional space."""
# Import local modules
from aligner.align import AlignHandler
__all__ = [
"AlignHandler"
]
"""Align- and distribution handler that positions all selected elements.
The AlignHandler creates internal Elements instances for all selected objects.
It will then sort the elements by their coordinates and perform the correct
calculations to align or evenly distribute the objects based on the axis,
mode and lock.
Usage:
>>> from aligner import align
# Let's create an align handler instance.
>>> aligner = align.AlignHandler()
# We need to select some objects now and can then align/ distribute these.
# Let's align the objects in y-axis so that all selected objects will be
# moved to the top most object.
>>> aligner.align("y", "align_top")
# Let's evenly distribute multiple objects along the x axis.
>>> aligner.align("x", "distribute_horizontal")
"""
# Import built-in modules
from collections import namedtuple
# Import third-party modules
from maya import cmds # pylint: disable=import-error
# Element tuple which holds the underlying object and its coordinates.
Element = namedtuple('Element', 'object x y z')
class AlignHandler(object):
"""Align- and distribution handler that positions all selected elements."""
def __init__(self):
"""Initialize the AlignHandler instance."""
self._operators = {
"align_top": (-1, self._align_to_edge_object),
"align_bottom": (0, self._align_to_edge_object),
"align_left": (0, self._align_to_edge_object),
"align_right": (-1, self._align_to_edge_object),
"align_horizontal": (None, self._align_to_median),
"align_vertical": (None, self._align_to_median),
"distribute_top": (-1, self._distribute_from_edge_object),
"distribute_bottom": (0, self._distribute_from_edge_object),
"distribute_left": (-1, self._distribute_from_edge_object),
"distribute_right": (0, self._distribute_from_edge_object),
"distribute_horizontal": (0, self._distribute_from_edge_object),
"distribute_vertical": (0, self._distribute_from_edge_object),
}
@staticmethod
def load():
"""Load all selected objects as element instances.
Returns:
list: Elements instances of selected objects.
"""
selection = cmds.ls(selection=True)
elements = []
for element in selection:
x_pos = cmds.getAttr(element + ".translateX")
y_pos = cmds.getAttr(element + ".translateY")
z_pos = cmds.getAttr(element + ".translateZ")
elements.append(Element(element, x_pos, y_pos, z_pos))
return elements
def align(self, axis, mode, lock=None):
"""Align or distribute the elements by given axis, mode and lock.
Args:
elements (:obj:`list` of :obj:`aligner.mvc.model.Element`):
Selected elements as Element instances that include the
underlying object and its coordinates.
axis (str): Name of the axis to sort elements after.
mode (str): Name of the alignment/ distribution mode to perform.
lock (str | None): Optional axis to take into account when sorting.
This is to prevent from mismatched element sorts where we might
sometimes also take an additional axis into account.
"""
elements = self.load()
elements_sorted = self._sort_by_axis(elements, axis, lock)
index = self._operators[mode][0]
edge_element = None
if index is not None:
edge_element = elements_sorted[index]
function = self._operators[mode][1]
function(elements_sorted, edge_element, axis)
@staticmethod
def _sort_by_axis(elements, axis, lock):
"""Sort selection by given axis and optionally the lock.
Args:
axis (str): Name of the axis to sort elements after.
elements (:obj:`list` of :obj:`aligner.mvc.model.Element`):
Selected elements as Element instances that include the
underlying object and its coordinates.
lock (str | None): Optional axis to take into account when sorting.
This is to prevent from mismatched element sorts where we might
sometimes also take an additional axis into account.
"""
if lock:
sort = lambda element: (getattr(element, lock),
getattr(element, axis))
else:
sort = lambda element: getattr(element, axis)
return sorted(elements, key=sort)
@staticmethod
def _align_to_edge_object(elements, edge_element, axis):
"""Align all elements to the given edge_element.
Args:
elements (:obj:`list` of :obj:`aligner.mvc.model.Element`):
Selected elements as Element instances that include the
underlying object and its coordinates.
edge_element (:obj:`aligner.mvc.model.Element`): Element to
align to. This element won't be moved.
axis (str): Name of the axis to sort elements after.
"""
for element in elements:
cmds.setAttr("{}.translate{}".format(
element.object, axis.upper()), getattr(edge_element, axis))
@staticmethod
def _align_to_median(elements, _, axis):
"""Align the given elements into the median value of all elements.
Args:
elements (:obj:`list` of :obj:`aligner.mvc.model.Element`):
Selected elements as Element instances that include the
underlying object and its coordinates.
axis (str): Name of the axis to sort elements after.
"""
values = [getattr(element, axis) for element in elements]
all_values = 0
for value in values:
all_values += value
median = all_values / len(elements)
for element in elements:
cmds.setAttr("{}.translate{}".format(
element.object, axis.upper()), median)
@staticmethod
def _distribute_from_edge_object(elements, edge_element_start, axis):
"""Create an even distribution between first and last element.
Args:
elements (:obj:`list` of :obj:`aligner.mvc.model.Element`):
Selected elements as Element instances that include the
underlying object and its coordinates.
edge_element_start (:obj:`aligner.mvc.model.Element`): Element to
align to. This element won't be moved.
axis (str): Name of the axis to sort elements after.
"""
edge_element_end = elements[0]
if edge_element_start == elements[0]:
edge_element_end = elements[-1]
edge_position_1 = getattr(edge_element_start, axis)
edge_position_2 = getattr(edge_element_end, axis)
difference = abs(edge_position_1 - edge_position_2)
factor = len(elements) - 1
for index, element in enumerate(elements[1:-1]):
value = (difference / factor) * (index + 1)
if edge_position_1 > edge_position_2:
new_position = getattr(edge_element_start, axis) - value
else:
new_position = getattr(edge_element_start, axis) + value
position = "{}.translate{}".format(element.object, axis.upper())
cmds.setAttr(position, new_position)
"""Main entry point to create and set up the aligner window."""
# Import built-in modules
import sys
# Import third-party modules
try:
from PySide import QtGui as QtWidgets
except ImportError:
from PySide2 import QtWidgets
# Import local modules
from aligner.mvc import controller
from aligner.mvc import view
# Main window to show to the user.
_VIEW = None
def run():
"""Main entry point to create and set up the aligner window."""
app = QtWidgets.QApplication.instance()
if not app:
app = QtWidgets.QApplication(sys.argv)
# We need to make this global, otherwise, Maya will garbage collect the
# view and will not show it.
global _VIEW # pylint: disable=global-statement
_VIEW = view.View()
_VIEW.raise_()
_VIEW.show()
controller.Controller(_VIEW)
app.exec_()
"""Model, view, controller and gui related utilities of this package."""
"""Controller to drive the view."""
from aligner import align
from aligner.mvc import gui_helper
class Controller(object):
"""Drive the view instance."""
def __init__(self, view):
"""Initialize the Controller instance.
Args:
view (aligner.mvc.view.View): View to drive.
"""
self.view = view
self.lock = None
self.align_handler = align.AlignHandler()
self.axis = None
self.edge_object = None
self.set_default_values()
self.create_signals()
def create_signals(self):
"""Create signals."""
# We need anonymous functions in here, otherwise, the controller will
# be referring to a different function object and will not execute
# anything.
# pylint: disable=unnecessary-lambda
self.view.push_close.clicked.connect(self.view.close)
self.view.key_pressed.connect(
lambda key: self._handle_key_pressed(key))
self.view.axis_grid.button_clicked.connect(
lambda button: self.set_axis(button.objectName()))
self.view.align_grid.button_clicked.connect(
lambda button: self.process(button.objectName()))
self.view.check_lock_x.clicked.connect(
lambda: self._uncheck_other_locks(self.view.check_lock_x))
self.view.check_lock_y.clicked.connect(
lambda: self._uncheck_other_locks(self.view.check_lock_y))
self.view.check_lock_z.clicked.connect(
lambda: self._uncheck_other_locks(self.view.check_lock_z))
def process(self, mode):
"""Process the align/ distribute action.
Args:
mode (str): Mode of alignment/ distribution to perform.
"""
self.align_handler.align(self.axis, mode, lock=self.lock)
def _uncheck_other_locks(self, lock):
"""Un-check all other locks when checking one lock.
We want to lock only to one additional axis.
"""
if not lock.isChecked():
self.lock = None
return
self.lock = lock.text()
locks = (
self.view.check_lock_x,
self.view.check_lock_y,
self.view.check_lock_z,
)
for lock_ in locks:
lock_.setChecked(lock == lock_)
def set_default_values(self):
"""Set default values for view."""
self.set_axis("x")
def set_axis(self, axis):
"""Set axis member based on the clicked button.
Args:
axis (str): Name of the axis to set.
"""
self.axis = axis
self._set_axis_style()
def _set_axis_style(self):
"""Set the style for the clicked axis and the other axis."""
for button in self.view.axis_grid.buttons:
button.setProperty("style", "arrow")
if button.objectName() == self.axis:
button.setProperty("style", "arrow_highlight")
gui_helper.set_style_sheet(self.view.axis_grid)
def _handle_key_pressed(self, key):
"""Handle key presses events.
Args:
key (str): Name of the key being pressed.
"""
actions = {
"esc": self.view.close
}
actions[key]()
"""Gui related helper functionality."""
import os
try:
from PySide import QtCore
from PySide import QtGui
from PySide import QtGui as QtWidgets
except ImportError:
from PySide2 import QtCore
from PySide2 import QtGui
from PySide2 import QtWidgets
def show_message(window, message):
"""Show message box with message as content.
Args:
window (QtWidgets.QWidget): Parent window in order to keep the message
box on top of the parent window.
message (str): Message to display in the window.
"""
dialog = QtWidgets.QMessageBox()
dialog.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
dialog.information(window, 'information', message)
def set_style_sheet(widget, style="styles.qss"):
"""Apply css style sheet to given widget.
Args:
widget (Qtwidgets.QWidget): Widget to apply styles to.
style (str): Name of styles file to apply.
"""
this_dir = os.path.join(os.path.dirname(__file__))
styles = os.path.join(this_dir, "..", "styles", style)
styles = os.path.normpath(styles)
if os.path.isfile(styles):
with open(styles) as file_:
widget.setStyleSheet(file_.read())
def set_icon(widget, path):
"""Update the icon of the given widget to the given path.
Args:
widget (QtWidgets.QWidget): Widget to update the icon on.
path (str): Absolute path of icon to update to.
"""
widget.setIcon(QtGui.QIcon(path))
def load_icons():
"""Load all icons from the icons folder.
This scans the icons directory and creates an icon dictionary using the
file names without extension as keys.
Returns:
dict: Dictionary of icons in the format:
{
"icon1": "path1",
"icon2": "path2",
"icon3": "path3",
...
}
"""
this_dir = os.path.dirname(__file__)
dir_icon = os.path.join(this_dir, "..", "icons")
dir_icon = os.path.normpath(dir_icon)
icons = {}
for file_ in os.listdir(dir_icon):
name = os.path.splitext(file_)[0]
path = os.path.join(dir_icon, file_)
icons[name] = path
return icons
"""Main aligner window."""
# Import third-party modules
try:
from PySide import QtCore
from PySide import QtGui as QtWidgets
except ImportError:
from PySide2 import QtCore
from PySide2 import QtWidgets
# Import local modules
from aligner import utils
from aligner.mvc import gui_helper
# Icons for this package
ICONS = gui_helper.load_icons()
# We need multiple instance members here as we need to refer to them down the
# line. Additionally, in order to increase readability, we want to define the
# members in the location where they are used and not do a 'initialize
# everything in the __init__'.
# pylint: disable=too-many-instance-attributes, attribute-defined-outside-init
class View(QtWidgets.QDialog):
"""Main aligner view."""
key_pressed = QtCore.Signal(str)
def __init__(self, parent=None):
"""Initialize the View instance."""
super(View, self).__init__(parent=parent)
self.setup_ui()
self.build_ui()
self.create_tooltips()
gui_helper.set_style_sheet(self)
def setup_ui(self):
"""Setup ui."""
self.setWindowTitle("Aligner {}".format(utils.get_version()))
self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
self.setFixedSize(QtCore.QSize(500, 500))
def build_ui(self):
"""Build ui."""
self.create_widgets()
self.create_layouts()
def create_widgets(self):
"""Create widgets."""
self.axis_grid = AxisGrid()
self.align_grid = AlignGrid()
self.label_lock = QtWidgets.QLabel("lock")
self.check_lock_x = QtWidgets.QCheckBox("x")
self.check_lock_y = QtWidgets.QCheckBox("y")
self.check_lock_z = QtWidgets.QCheckBox("z")
self.push_close = QtWidgets.QPushButton("Close")
def create_layouts(self):
"""Create layouts."""
self.layout_main = QtWidgets.QVBoxLayout()
self.layout_lock = QtWidgets.QHBoxLayout()
self.layout_main.addWidget(self.axis_grid)
self.layout_main.addWidget(self.align_grid)
self.layout_main.addLayout(self.layout_lock)
self.layout_main.addWidget(self.push_close)
self.layout_lock.addStretch()
self.layout_lock.addWidget(self.label_lock)
self.layout_lock.addWidget(self.check_lock_x)
self.layout_lock.addWidget(self.check_lock_y)
self.layout_lock.addWidget(self.check_lock_z)
self.layout_lock.addStretch()
self.setLayout(self.layout_main)
def create_tooltips(self):
"""Create tooltips."""
self.check_lock_x.setToolTip("Lock elements sort also to X")
self.check_lock_y.setToolTip("Lock elements sort also to Y")
self.check_lock_z.setToolTip("Lock elements sort also to Z")
def keyPressEvent(self, event):
"""Overwrite keyPressEvent to close window when hitting escape."""
if event.key() == QtCore.Qt.Key_Escape:
self.key_pressed.emit("esc")
class AlignGrid(QtWidgets.QWidget):
"""Grid with arrow buttons."""
button_clicked = QtCore.Signal(QtWidgets.QWidget)
def __init__(self):
"""Initialize the AlignGrid instance."""
super(AlignGrid, self).__init__()
self.active_button = None
self.arrow_buttons = []
self.build_ui()
def build_ui(self):
"""Create widgets."""
self.layout_main = QtWidgets.QGridLayout()
image_names = (
"align_top", "align_horizontal", "align_bottom",
"align_left", "align_vertical", "align_right",
"distribute_left", "distribute_horizontal", "distribute_right",
"distribute_top", "distribute_vertical", "distribute_bottom"
)
row = 0
column = 0
for name in image_names:
button = QtWidgets.QPushButton()
button.setProperty("style", "arrow")
button.setIconSize(QtCore.QSize(30, 30))
button.setToolTip(name.replace("_", " "))
button.setObjectName(name)
gui_helper.set_icon(button, ICONS[name])
if column > 2:
column = 0
row += 1
self.layout_main.addWidget(button, row, column)
button.clicked.connect(
lambda button_=button: self.button_clicked.emit(button_))
self.arrow_buttons.append(button)
column += 1
self.setLayout(self.layout_main)
gui_helper.set_style_sheet(self)
class AxisGrid(QtWidgets.QWidget):
"""Grid with axis buttons."""
button_clicked = QtCore.Signal(QtWidgets.QWidget)
def __init__(self):
"""Initialize the AxisGrid instance."""
super(AxisGrid, self).__init__()
self.active_button = None
self.buttons = []
self.build_ui()
def build_ui(self):
"""Create widgets."""
self.layout_main = QtWidgets.QHBoxLayout()
for axis in ("x", "y", "z"):
button = QtWidgets.QPushButton(axis.upper())
button.setObjectName(axis)
self.layout_main.addWidget(button)
button.clicked.connect(
lambda button_=button: self.button_clicked.emit(button_))
self.buttons.append(button)
self.setLayout(self.layout_main)
gui_helper.set_style_sheet(self)