Search code examples
pythonpysideqgraphicsview

Mirror Selected QGraphicsItems based on boundingRect center


For the life of me I can not seem to figure out how to mirror the selected QGraphicsItems based on their selected center? Each attempt I've tried makes the items eventually shoot off screen. I've recreated this same setup in 3d applications and got it work but i'm missing something and can't figure out it. Any help would be much appreciated. The end goal is to be able to click the Mirror button and it flip them back and forth like the image below...

enter image description here

import sys
from PySide2 import QtWidgets, QtCore, QtGui
from PySide2.QtGui import QPixmap, QPainter, QColor
import os
import random


class ImageWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.resize(1500,1080)

        # controls
        self.scene = QtWidgets.QGraphicsScene(self)
        self.scene.setBackgroundBrush(QColor(40,40,40))
        
        self.graphicsView = QtWidgets.QGraphicsView(self)
        self.graphicsView.setSceneRect(-4000, -4000, 8000, 8000)
        self.graphicsView.setRenderHints(QPainter.Antialiasing | QPainter.SmoothPixmapTransform)
        self.graphicsView.setScene(self.scene)

        # align actions
        self.mirrorItemsHorizontalAct = QtWidgets.QAction('Mirror Horizontal', self)
        self.mirrorItemsHorizontalAct.triggered.connect(self.mirror_items_horizontal)

        transformToolbar = QtWidgets.QToolBar('Transform', self)
        transformToolbar.addAction(self.mirrorItemsHorizontalAct)
        self.addToolBar(transformToolbar)

        self.setCentralWidget(self.graphicsView)

        # Load images from subfolder
        self.create_shapes()


    def create_shapes(self):
        gradient = QtGui.QLinearGradient(0, 0, 100, 0)
        gradient.setColorAt(0, QtGui.QColor(0, 0, 255))
        gradient.setColorAt(1, QtGui.QColor(255, 0, 0))

        rectA = QtWidgets.QGraphicsRectItem(0,0,150,100)
        rectA.setBrush(QtGui.QBrush(gradient))
        rectA.setPen(QtGui.QPen(QtCore.Qt.NoPen))
        rectA.setPen(QtGui.QPen(QtCore.Qt.green, 10, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap, QtCore.Qt.RoundJoin))
        rectA.setFlags(QtWidgets.QGraphicsItem.ItemIsSelectable | QtWidgets.QGraphicsItem.ItemIsMovable)
        rectA.setSelected(True)
        rectA.setPos(-120,50)
        self.scene.addItem(rectA)

        rectB = QtWidgets.QGraphicsRectItem(0,0,70,35)
        rectB.setBrush(QtGui.QBrush(gradient))
        rectB.setPen(QtGui.QPen(QtCore.Qt.NoPen))
        rectB.setFlags(QtWidgets.QGraphicsItem.ItemIsSelectable | QtWidgets.QGraphicsItem.ItemIsMovable)
        rectB.setSelected(True)
        rectB.setPos(150,-150)
        self.scene.addItem(rectB)

        rectC = QtWidgets.QGraphicsRectItem(0,0,120,75)
        rectC.setBrush(QtGui.QBrush(gradient))
        rectC.setPen(QtGui.QPen(QtCore.Qt.NoPen))
        rectC.setFlags(QtWidgets.QGraphicsItem.ItemIsSelectable | QtWidgets.QGraphicsItem.ItemIsMovable)
        rectC.setSelected(True)
        rectC.setPos(400,70)
        self.scene.addItem(rectC)


    # Align
    def mirror_items_horizontal(self):
        items = self.scene.selectedItems()
        if not items:
            items = self.scene.items()   
        
        # Calculate the collective bounding box
        collectivePath = QtGui.QPainterPath()
        for item in items:
            collectivePath.addRect(item.sceneBoundingRect())
        collectiveRect = collectivePath.boundingRect()

        # Calculate the horizontal center of the collective bounding box
        centerX = collectiveRect.center().x()

        # print('centerX:', centerX)
        # print('collectiveRect:', collectiveRect)

        # Debug
        cItem = QtWidgets.QGraphicsRectItem(collectiveRect)
        cItem.setBrush(QtGui.QBrush(QtGui.QColor(255,0,0,128)))
        cItem.setZValue(-1000)
        self.scene.addItem(cItem)

        cItem = QtWidgets.QGraphicsEllipseItem(0, 0, 10, 10)
        cItem.setPos(collectiveRect.center() - QtCore.QPointF(cItem.boundingRect().width()*0.5, cItem.boundingRect().height()*0.5) )
        cItem.setBrush(QtGui.QBrush(QtGui.QColor(255,255,0,128)))
        cItem.setZValue(-1000)
        self.scene.addItem(cItem)

        for item in items:
            local_offset = item.boundingRect().center().x() * 2.0
            global_offset = collectiveRect.width() + item.boundingRect().x() * 2.0
            print('scenePos', item.scenePos())
            print('boundingRect', item.boundingRect())
            print('boundingRect center', item.boundingRect().center())
            print('local_offset', local_offset)
            print('global_offset', global_offset)

            scaleTm = QtGui.QTransform()
            scaleTm.translate(local_offset, 0)
            # scaleTm.translate(global_offset, 0)
            # scaleTm.translate(645, 0)
            scaleTm.scale(-1, 1)

            tm = item.transform() * scaleTm
            item.setTransform(tm)


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    mainWindow = ImageWindow()
    mainWindow.show()
    sys.exit(app.exec_())

Solution

  • You are not considering that a transform is always applied to its origin point (0, 0), while you want to mirror the items based on a reference point, which is the center of the "selection".

    The procedure to apply such transformations is:

    1. translate to the reference point;
    2. set the scale (rotation/shear/etc.);
    3. reset the translation to its origin;

    Then, there's another issue in your attempt, which originally confused me too. You are combining the new transform with the existing one, but that has to be done with extreme care, as explained in the documentation about the * operator:

    Note that matrix multiplication is not commutative, i.e. a*b != b*a.

    Unlike the common multiplication (for which we know that the order doesn't matter), due to the complex relations of transform matrices, this is not the case. Since the mirroring, which is based on the current transform (and can be mirrored and translated too), it has to be applied before it, so the order is actually inverted: scaleTm * item.transform().
    If you think about the order in which you would eventually map a point based on each transform step (map from the current, then map to the new one), it makes more sense (see this related post).

    Finally, note that, for consistency, you should always consider the full coordinates of the mapped point (not only the x) even if you're only applying an horizontal transform. Also, since transform() does not consider the item scale() and rotation(), you have to avoid them, and any further scale or rotation applied to the item has to be done within the current item transform() instead.

    In the following example I've added a custom class to show the "selection" as a separate item, so that there's no cumulative drawing.

    class CollectiveRect(QGraphicsObject):
        def __init__(self):
            super().__init__()
            self.setZValue(-1000)
            self.rect = QRectF()
            self.brush = QBrush(QColor(255, 0, 0, 128))
            self.center = QGraphicsEllipseItem(-5, -5, 10, 10, self)
            self.center.setBrush(QBrush(QColor(255, 255, 0, 128)))
    
            self.hideAni = QPropertyAnimation(self, b'opacity')
            self.hideAni.setDuration(1000)
            self.hideAni.setStartValue(1.)
            self.hideAni.setEndValue(0.)
    
            self.hideTimer = QTimer(self, singleShot=True, 
                interval=2000, timeout=self.hideAni.start)
    
            self.hide()
    
        def boundingRect(self):
            return self.rect | self.childrenBoundingRect()
    
        def setRect(self, *args):
            self.rect.setRect(*QRectF(*args).getRect())
            self.center.setPos(self.rect.center())
            self.prepareGeometryChange()
    
            if self.hideAni.state():
                self.hideAni.stop()
    
            self.setOpacity(1)
            self.show()
            
            self.hideTimer.start()
    
        def paint(self, qp, opt, widget=None):
            qp.setBrush(self.brush)
            qp.drawRect(self.rect)
    
    
    class ImageWindow(QMainWindow):
        ...
        def create_shapes(self):
            ...
            self.collectiveRect = CollectiveRect()
            self.scene.addItem(self.collectiveRect)
    
        def mirror_items_horizontal(self):
            items = self.scene.selectedItems()
            if not items:
                for item in self.scene.items():
                    if item.flags() & QGraphicsItem.ItemIsSelectable:
                        item.setSelected(True)
                        items.append(item)
                if not items:
                    return
    
            # Calculate the collective bounding box
            collectiveRect = QRectF()
            for item in items:
                collectiveRect |= item.sceneBoundingRect()
            self.collectiveRect.setRect(collectiveRect)
    
            center = collectiveRect.center()
    
            for item in items:
                reference = item.mapFromScene(center)
                scaleTm = QTransform()
                scaleTm.translate(reference.x(), reference.y())
                scaleTm.scale(-1, 1)
                scaleTm.translate(-reference.x(), -reference.y())
    
                item.setTransform(scaleTm * item.transform())