Interactive/GridArrangeDialog.py

##
# (C) Copyright 2012 Autodesk, Inc.  All rights reserved.
##
## Use of this software is subject to the terms of the Autodesk license
## agreement provided at the time of installation or download, or which
## otherwise accompanies this software in either electronic or hard copy
## form.
##

#
# This is part of the GridArrangeCustom user customization.
#
# See GridArrangeCustom.py for instructions on how to use this file.
#

import wx
import math

from awx.Button                  import Button
from awx.ConstrainedFloatControl import ConstrainedFloatControl
from awx.RadioButton             import RadioButton
from awx.Slider                  import Slider
from awx.StaticText              import StaticText
from awx.TitleSeparator          import TitleSeparator


from Dialogs.DialogButtonMixin   import CreateStandardButtonSizer
from EventPasser                 import EventPasser
from Messaging                   import printException
from awSupportApi                import Vector, Normal, project
from RunInUiThread               import RunInUiThread
from SceneGraphUtilities         import ComputeBoundingBox, GetNodesFromIds
from UiUtilities                 import InspectorWindow

import ModelIO

class GridArrangeDialog(InspectorWindow, EventPasser):
    __kDialogTitle = _('Arrange Items in a Grid')
    __kYZPlane = (Normal(0.0, 0.0, -1.0), Normal(0.0, 1.0, 0.0))
    __kXZPlane = (Normal(0.0, 0.0, -1.0), Normal(1.0, 0.0, 0.0))
    __kXYPlane = (Normal(0.0, -1.0, 0.0), Normal(1.0, 0.0, 0.0))

    __kMinScale = 0.01
    __kMaxScale = 10.0

    __kUndoChunkId = "GridDialogUndoChunk"

    def __init__(self, parent):
        InspectorWindow.__init__(self, parent, title=self.__kDialogTitle,
            name='ViewCubeProperties')

        self.__myModels = None              # this will provide our access to the scene graph
        self.__myMaxRows = 0
        self.__myNumRows = 0
        self.__myNumCols = 0

        (self.__myRowAxis, self.__myColumnAxis) = self.__kYZPlane

        self.__mySelectedNodes = []
        self.__myUndoMessages = None
        self.__myRowScaleFactor = 1.25
        self.__myColumnScaleFactor = 1.25

        self.CreateStandardLayout()
        self.SetSizerAndFit(self.GetMainSizer())

        # This allows us to receive messages via the DialogInterpreter
        self.sendMessage('DIALOG_INTERPRETER_ADD', (self,))

    #---------------------------------------------------------------
    # Overridden Methods
    #---------------------------------------------------------------
    def DoCreate(self):
        # Create all the controls that are going to be in our dialog box.
        # This is called indirectly by self.CreateStandardLayout().
        panel = wx.Panel(self)

        self.__myDimensionsTitle = TitleSeparator(panel, label=_('Dimensions'))
        self.__mySpacingTitle = TitleSeparator(panel, label=_('Grid Spacing'))

        # Create the slider between 0 and 1.0, we'll scale it by the max number of rows
        self.__myRowsLabel = StaticText(panel, label=_('Rows:'))
        self.__myRowsSlider = Slider(panel, -1, 1, 0, 1.0)
        self.__myRowsSlider.Bind(wx.EVT_COMMAND_SCROLL, self.__onRowsSlide)

        self.__myRowsText = ConstrainedFloatControl(panel, 
            wx.ID_ANY, size=wx.Size(50,-1), numDecimalPlaces=0, constraints=[self.__checkDimensions])
        self.__myRowsText.Bind(wx.EVT_KILL_FOCUS, self.__onRowsText)

        self.__myPlaneLabel = StaticText(panel, label=_('Grid plane:'))
        self.__myPlaneButtons = [
            RadioButton(panel, wx.ID_ANY, label="Y-Z", style=wx.RB_GROUP),
            RadioButton(panel, wx.ID_ANY, label="X-Z"),
            RadioButton(panel, wx.ID_ANY, label="X-Y")
        ]

        for button in self.__myPlaneButtons:
            button.Bind(wx.EVT_RADIOBUTTON, self.__onPlaneSelect)

        self.__myColsLabel = StaticText(panel, label=_('Columns:'))
        self.__myColsText = StaticText(panel, label=_('0'))

        self.__mySpacingButtons = [
            RadioButton(panel, wx.ID_ANY, label="Automatically calculate spacing", style=wx.RB_GROUP),
            RadioButton(panel, wx.ID_ANY, label="Manually enter spacing values")
        ]

        for button in self.__mySpacingButtons:
            button.Bind(wx.EVT_RADIOBUTTON, self.__onSpacingSelect)

        self.__myRowScaleLabel = StaticText(panel, label=_('Row scale factor:'))
        self.__myColumnScaleLabel = StaticText(panel, label=_('Column scale factor:'))

        self.__myRowScaleSlider = Slider(panel, -1, 1, self.__kMinScale, self.__kMaxScale)
        self.__myRowScaleSlider.Bind(wx.EVT_COMMAND_SCROLL, self.__onRowScaleSlide)

        self.__myRowScaleText = ConstrainedFloatControl(panel, wx.ID_ANY, 
            size=wx.Size(50, -1), numDecimalPlaces=3, constraints=[lambda x: x > 0])
        self.__myRowScaleText.Bind(wx.EVT_KILL_FOCUS, self.__onRowScaleText)
        self.__myRowScaleText.SetValue(1.0)

        self.__myColumnScaleSlider = Slider(panel, -1, 1, self.__kMinScale, self.__kMaxScale)
        self.__myColumnScaleSlider.Bind(wx.EVT_COMMAND_SCROLL, self.__onColumnScaleSlide)

        self.__myColumnScaleText = ConstrainedFloatControl(panel, wx.ID_ANY, 
            size=wx.Size(50, -1), numDecimalPlaces=3, constraints=[lambda x: x > 0])
        self.__myColumnScaleText.Bind(wx.EVT_KILL_FOCUS, self.__onColumnScaleText)
        self.__myColumnScaleText.SetValue(1.0)

        self.__myRowSpacingLabel = StaticText(panel, label=_('Rows:'))
        self.__myColumnSpacingLabel = StaticText(panel, label=_('Columns:'))
        self.__mySpacingUnitsLabel = StaticText(panel, label=_('cm'))

        self.__myRowSpacingText = ConstrainedFloatControl(panel, wx.ID_ANY, size=wx.Size(50, -1), numDecimalPlaces=3)
        self.__myRowSpacingText.Bind(wx.EVT_KILL_FOCUS, self.__onRowSpaceText)
        self.__myRowSpacingText.SetValue(10.0)

        self.__myColumnSpacingText = ConstrainedFloatControl(panel, wx.ID_ANY, size=wx.Size(50, -1), numDecimalPlaces=3)
        self.__myColumnSpacingText.Bind(wx.EVT_KILL_FOCUS, self.__onColumnSpaceText)
        self.__myColumnSpacingText.SetValue(10.0)

        self.__myObjectCountLabel = StaticText(panel, wx.ID_ANY, label=_('There are no objects selected.'))

        self.__myArrangeButton = Button(panel, wx.ID_ANY, label=_('Arrange'))
        self.__myArrangeButton.Bind(wx.EVT_BUTTON, self.__onArrange)

        self.__myCloseButton = Button(panel, wx.ID_CLOSE, label=_('Close'))
        self.__myCloseButton.Bind(wx.EVT_BUTTON, self.__onClose)

        self.__updateSpacingControls()
        self.__updateSelection()

        return panel

    def DoLayout(self):
        # Create the layout for the controls that are going in our dialog box.
        # This is called indirectly by self.CreateStandardLayout()

        sizer = wx.BoxSizer(wx.VERTICAL)

        def rowSlider():
            sizer = wx.BoxSizer(wx.HORIZONTAL)
            sizer.Add(self.__myRowsText, flag=wx.ALL, border=5)
            sizer.Add(self.__myRowsSlider, flag=wx.ALL, border=5)
            return sizer

        def rowColSizer():
            sizer = wx.BoxSizer(wx.HORIZONTAL)

            for item in (self.__myRowsLabel, rowSlider(), self.__myColsLabel, self.__myColsText):
                sizer.Add(item, flag=wx.ALIGN_CENTER_VERTICAL|wx.ALL, border=5)

            return sizer

        def planeSelector():
            sizer = wx.BoxSizer(wx.HORIZONTAL)

            sizer.Add(self.__myPlaneLabel, flag=wx.ALL, border=5)

            for item in self.__myPlaneButtons:
                sizer.Add(item, flag=wx.ALL|wx.EXPAND, border=5)

            return sizer

        def scaleSizer():
            sizer = wx.FlexGridSizer(rows=2, cols=3, hgap=5, vgap=5)

            sizer.Add(self.__myRowScaleLabel, flag=wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT)
            sizer.Add(self.__myRowScaleText)
            sizer.Add(self.__myRowScaleSlider, flag=wx.ALIGN_CENTER_VERTICAL)

            sizer.Add(self.__myColumnScaleLabel, flag=wx.ALIGN_CENTER_VERTICAL|wx.ALIGN_RIGHT)
            sizer.Add(self.__myColumnScaleText)
            sizer.Add(self.__myColumnScaleSlider, flag=wx.ALIGN_CENTER_VERTICAL)

            return sizer

        def spacingSizer():
            sizer = wx.BoxSizer(wx.HORIZONTAL)

            for item in (
                self.__myRowSpacingLabel,
                self.__myRowSpacingText,
                self.__myColumnSpacingLabel,
                self.__myColumnSpacingText,
                self.__mySpacingUnitsLabel):

                sizer.Add(item, flag=wx.ALL|wx.ALIGN_CENTER_VERTICAL, border=5)

            return sizer

        buttonSizer = CreateStandardButtonSizer( 
            (self.__myArrangeButton, self.__myCloseButton) )

        sizer.Add(self.__myDimensionsTitle, flag=wx.EXPAND)
        sizer.Add((-1, 5))

        sizer.Add(self.__myObjectCountLabel, border=5, flag=wx.ALL)
        sizer.Add(rowColSizer())
        sizer.Add(planeSelector(), border=5, flag=wx.ALL|wx.EXPAND)

        sizer.Add(self.__mySpacingTitle, flag=wx.EXPAND)
        sizer.Add((-1, 5))

        sizer.Add(self.__mySpacingButtons[0], border=5, flag=wx.ALL)
        sizer.Add(scaleSizer(), border=5, flag=wx.ALL)

        sizer.Add(self.__mySpacingButtons[1], border=5, flag=wx.ALL)
        sizer.Add(spacingSizer(), border=5, flag=wx.ALL)

        sizer.Add(buttonSizer, border=5, flag=wx.ALL)

        return sizer

    #---------------------------------------------------------------
    # Event Callbacks
    #---------------------------------------------------------------
    def __onClose(self, event):
        self.Show(False)

    def __onArrange(self, event):
        if self.__myNumRows == 0:
            return

        try:
            (rowVector, columnVector, gridCenter) = self.__computeGrid()

            self.sendMessage( "GRID_ARRANGE", 
                ( tuple(self.__mySelectedNodes)
                , self.__myNumRows 
                , self.__myNumCols
                , (rowVector, columnVector, gridCenter)
                ) )
        except:
            # Exceptions handled in the UI thread tend not to be printed
            # to the console window. This allows us to 
            printException()

    def __onPlaneSelect(self, event):
        # Figure out which button is selected and assign the appropriate plane
        for (i, plane) in enumerate( (self.__kYZPlane, self.__kXZPlane, self.__kXYPlane) ):
            if self.__myPlaneButtons[i].GetValue():
                (self.__myRowAxis, self.__myColumnAxis) = plane
                break

    def __onSpacingSelect(self, event):
        self.__updateSpacingControls()

    def __onRowsText(self, event):
        self.__myRowsText.constrainInput()
        self.__myNumRows = int( self.__myRowsText.GetValue() )

        # scale the value from 0 to 1.0
        scaledValue = float(self.__myRowsText.GetValue())/self.__myMaxRows
        self.__myRowsSlider.SetValue(scaledValue)
        self.__updateColumns()

    def __onRowScaleText(self, event):
        self.__myRowScaleText.constrainInput()

        value = float(self.__myRowScaleText.GetValue())
        self.__myRowScaleSlider.SetValue(value)

    def __onColumnScaleText(self, event):
        self.__myColumnScaleText.constrainInput()

        value = float(self.__myColumnScaleText.GetValue())
        self.__myColumnScaleSlider.SetValue(value)

    def __onRowScaleSlide(self, event):
        self.__myRowScaleText.SetValue( self.__myRowScaleSlider.GetValue() )

    def __onColumnScaleSlide(self, event):
        self.__myColumnScaleText.SetValue( self.__myColumnScaleSlider.GetValue() )

    def __onRowSpaceText(self, event):
        self.__myRowSpacingText.constrainInput()

    def __onColumnSpaceText(self, event):
        self.__myColumnSpacingText.constrainInput()

    def __onRowsSlide(self, event):
        self.__updateRowsFromSlider()

    #---------------------------------------------------------------
    # Helper Methods
    #---------------------------------------------------------------
    def __updateRowsFromSlider(self):
        numRows = int(self.__myRowsSlider.GetValue()*self.__myMaxRows)

        if numRows == 0:
            numRows = 1

        if self.__checkDimensions(numRows):
            numCols = int(math.ceil(self.__myMaxRows / float(numRows)))
            self.__myRowsText.SetValue(numRows)
            self.__myNumRows = numRows

            self.__updateColumns()

    def __updateSpacingControls(self):
        # Update the enable states of the spacing controls
        enableAutomatic = self.__mySpacingButtons[0].GetValue()
        enableManual = not enableAutomatic

        self.__myRowScaleSlider.Enable(enableAutomatic)
        self.__myRowScaleText.Enable(enableAutomatic)
        self.__myColumnScaleSlider.Enable(enableAutomatic)
        self.__myColumnScaleText.Enable(enableAutomatic)

        self.__myRowSpacingText.Enable(enableManual)
        self.__myColumnSpacingText.Enable(enableManual)

    def __updateColumns(self):
        if self.__myMaxRows == 0:
            self.__myNumCols = 0
        else:
            self.__myNumCols = int(math.ceil(self.__myMaxRows / float(self.__myNumRows)))

        self.__myColsText.SetLabel(str(self.__myNumCols))

    def __checkDimensions(self, numRows):
        numObjects = self.__myMaxRows

        if numRows < 1 or numRows > numObjects:
            return False

        numCols = int(math.ceil(self.__myMaxRows / float(numRows)))

        # This grid arrangement is only possible if either both of the number of
        # rows and the number of columns divide evenly into the number of objects
        # or neither of them do
        if (numObjects % numCols == 0) and (numObjects % numRows == 0) \
            or (numObjects % numCols != 0) and (numObjects % numRows != 0):
            return True
        return False

    def __computeGrid(self):
        """
        Computes the row vector, column vector, and grid center
        for the grid we want to create. Will use either automatic
        or manual spacing depending on the options selected in the UI.
        """
        # We have node id's, we want the actual nodes
        nodes = list(GetNodesFromIds(self.__mySelectedNodes))

        bbox = ComputeBoundingBox(nodes)
        defaultGrid = (Vector(0.0, 0.0, -1.0), Vector(0.0, 1.0, 0.0), Vector(0.0, 0.0, 0.0))

        # Always need to make sure the bounding box is valid just in case!
        if not bbox.isBounded():
            return defaultGrid

        gridCenter = Vector(bbox.mid().p)

        autoCompute = self.__mySpacingButtons[0].GetValue()

        if autoCompute:
            (rowSize, columnSize) = self.__autoComputeSpacing(nodes)
            rowScale = float(self.__myRowScaleText.GetValue())
            columnScale = float(self.__myColumnScaleText.GetValue())

            rowSize *= rowScale
            columnSize *= columnScale
        else:
            rowSize = float(self.__myRowSpacingText.GetValue())
            columnSize = float(self.__myColumnSpacingText.GetValue())

        # Now scale by the user specified scale factor
        rowVector = Vector(self.__myRowAxis)
        rowVector *= rowSize

        columnVector = Vector(self.__myColumnAxis)
        columnVector *= columnSize

        return (rowVector, columnVector, gridCenter)

    def __autoComputeSpacing(self, nodes):
        """
        Finds the minimum spacing so that objects don't overlap when
        placed in a grid
        """
        rowSize = 0
        columnSize = 0

        # now find the grid spacing requirements by inspecting the bounding box
        # of each selected object
        for node in nodes:
            bbox = ComputeBoundingBox([node])
            if not bbox.isBounded():
                continue

            # A vector representing the diagonal of the bounding box:
            boxSize = Vector(bbox.size().p)

            # Project the size vector onto our row/column axis
            tempRow = project(boxSize, self.__myRowAxis).length()
            tempColumn = project(boxSize, self.__myColumnAxis).length()

            if tempRow > rowSize:
                rowSize = tempRow
            if tempColumn > columnSize:
                columnSize = tempColumn

        return (rowSize, columnSize)

    def __updateSelection(self):
        # We're in the messaging thread, we need to do all UI work in the UI thread
        if self.__myModels:
            selectedNodes = self.__myModels.selected
        else:
            selectedNodes = []

        RunInUiThread(self.__updateSelected, selectedNodes)

    def __updateSelected(self, selectedNodes):
        self.__mySelectedNodes = selectedNodes
        self.__myMaxRows = len(self.__mySelectedNodes)
        self.__myObjectCountLabel.SetLabel('There are ' + str(self.__myMaxRows) + ' objects selected.')

        # max out the slider and update
        self.__myRowsSlider.SetValue(1.0)
        self.__updateRowsFromSlider()

        # Now enable and disable the UI as necessary
        if self.__myMaxRows == 0:
            self.__myRowsText.Enable(False)
            self.__myRowsSlider.Enable(False)
            self.__myArrangeButton.Enable(False)
        else:
            self.__myRowsText.Enable(True)
            self.__myRowsSlider.Enable(True)
            self.__myArrangeButton.Enable(True)

    def SET_DOCUMENT(self, message):
        document, = message.data
        self.__myModels = document.get(ModelIO.id)
        self.__updateSelection()

    def SELECTION_CHANGED(self, message):
        if not self.__myModels:
            return

        self.__updateSelection()

    def APPLICATION_CLOSE_SCENE(self, message):
        self.__myModels = None
        RunInUiThread(self.Show, False)