Seems impossible to rotate custom QGraphicsItem using QPainter (or any other method.)

523 views Asked by At

Here is the code. It runs. To exhibit this bug. Right-click the ellipse, scale it by click-dragging on the ellipse. Right-click it > "Done editing". Then do the same thing with "Rotate."

I've tried over 10 different permutations of using self.setRotation, self.setTransform, painter.rotate, and so on... The only time it did rotate was when I did self.setTransform(self.transform().rotate(r)) but the result was wrong: scale & rotate in the wrong order or something.

from PyQt5.QtWidgets import QGraphicsItem, QMenu
from PyQt5.QtGui import QTransform, QPen, QPainter, QColor, QBrush
from PyQt5.QtCore import Qt, QPointF, QRectF, QEvent
from math import sqrt


def scaleRect(rect, scale_x, scale_y):
    T = QTransform.fromScale(scale_x, scale_y)
    return T.mapRect(rect)

def debugPrintTransformMatrix(T):
    print(str(T.m11()) + '  ' + str(T.m12()) + '  ' + str(T.m13()))
    print(str(T.m21()) + '  ' + str(T.m22()) + '  ' + str(T.m23()))
    print(str(T.m31()) + '  ' + str(T.m32()) + '  ' + str(T.m33()))

# Assumes no shearing or stretching.   
# Only Rotation, Translation, and Scaling. 
def extractTransformScale(T):
    # This is math matrix notation transposed (debug print self.sceneTransform() to see)
    Sx = sqrt(T.m11()**2 + T.m12()**2)      
    Sy = sqrt(T.m21()**2 + T.m22()**2)
    return Sx, Sy    

def extractTransformTranslate(T):
    return T.m31(), T.m32()


class Object(QGraphicsItem):
    def sceneEvent(self, event):
        if event.type() == QEvent.GraphicsSceneMouseMove: 
            # move, scale, or rotate
            if self._mode in ['scale', 'rotate']:
                mouse_pos = event.scenePos()
                last_pos = event.lastScenePos()
                if self._mode == 'scale':
                    s = self.mouseScalingFactors(mouse_pos, last_pos)
                    self.setTransform(self.transform().scale(*s))
                if self._mode == 'rotate':
                    r = self.mouseRotationAngle(mouse_pos, last_pos)
                    self.setRotation(self.rotation() + r)
                return True

        return super().sceneEvent(event)

    def __init__(self):
        super().__init__()
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsFocusable | QGraphicsItem.ItemIsSelectable)
        self._selectionPen = QPen(Qt.black, 1.0, style=Qt.DotLine, cap=Qt.FlatCap)
        self._lastPos = QPointF(0, 0)
        self.setPos(self._lastPos)
        self._mode = 'neutral'

    def setRenderHints(self, painter):
        painter.setRenderHints(QPainter.SmoothPixmapTransform | QPainter.HighQualityAntialiasing | QPainter.TextAntialiasing)

    def boundingRectExtraScale(self):
        return (1.2 , 1.2) 

    def mouseScalingFactors(self, pos, last_pos):
        delta = pos - last_pos
        return (1.01 ** delta.x(), 1.01 ** delta.y())

    def mouseRotationAngle(self, pos, last_pos):
        return 1   #TODO

    def createDefaultContextMenu(self):
        menu = QMenu()
        if self._mode == 'neutral':
            menu.addAction('Scale').triggered.connect(lambda: self.setMode('scale'))
            menu.addAction('Rotate').triggered.connect(lambda: self.setMode('rotate'))
        else:
            menu.addAction('Done Editing').triggered.connect(lambda: self.setMode('neutral'))
        return menu

    def contextMenuEvent(self, event):
            menu = self.createDefaultContextMenu()
            menu.exec(event.screenPos())

    def setMode(self, mode):
        self._mode = mode

    def setSelected(self, selected):
        super().setSelected(selected)
        self.update()


class ShapedObject(Object):
    def __init__(self):
        super().__init__()
        self._shape = {
            'name' : 'ellipse',
            'radius': 35
        }
        self._brush = QBrush(Qt.darkGreen)
        self._pen = QPen(Qt.yellow, 3)        

    def shapeDef(self):
        return self._shape

    def boundingRect(self):
        rect = self.shapeRect()
        s = self.boundingRectExtraScale()
        return scaleRect(rect, *s)

    def shape(self):      #TODO QPainterPath shape for collision detection
        # Should call self.boundingRectExtraScale()
        return super().shape()

    def paint(self, painter, option, widget):
        self.setRenderHints(painter)
        #super().paint(painter, option, widget)

        shape = self.shapeDef()
        name = shape['name']

        painter.setBrush(self._brush)
        painter.setPen(self._pen)

        painter.save()

        # ********** HERE IS THE PROBLEM ************* 

        debugPrintTransformMatrix(painter.transform())
        painter.rotate(5)
        debugPrintTransformMatrix(painter.transform())

        rect = self.shapeRect()
        if name == 'ellipse':
            painter.drawEllipse(rect)


        painter.restore()  


    def shapeRect(self):
        shape = self.shapeDef()
        name = shape['name']
        if name == 'ellipse':
            r = shape['radius']
            rect = QRectF(-r,  -r, 2*r, 2*r)
        return rect


####

import sys
from PyQt5.QtWidgets import QMainWindow, QGraphicsScene, QGraphicsView, QApplication

if __name__ == '__main__':
    app = QApplication(sys.argv)

    window = QMainWindow()
    view = QGraphicsView()
    scene = QGraphicsScene()
    view.setScene(scene)
    window.setCentralWidget(view)

    ellipse = ShapedObject()
    scene.addItem(ellipse)

    window.show()

    sys.exit(app.exec_())
1

There are 1 answers

0
Daniel Donnelly On BEST ANSWER

Got it to work. It was the order of the operations rotate / scale. Never use an circle to test your rotation code, lol!

from PyQt5.QtWidgets import QGraphicsItem, QMenu, QGraphicsRotation, QGraphicsScale
from PyQt5.QtGui import QTransform, QPen, QPainter, QColor, QBrush, QPainterPath
from PyQt5.QtCore import Qt, QPointF, QRectF, QEvent
from math import sqrt


def scaleRect(rect, scale_x, scale_y):
    T = QTransform.fromScale(scale_x, scale_y)
    return T.mapRect(rect)

def debugPrintTransformMatrix(T):
    print(str(T.m11()) + '  ' + str(T.m12()) + '  ' + str(T.m13()))
    print(str(T.m21()) + '  ' + str(T.m22()) + '  ' + str(T.m23()))
    print(str(T.m31()) + '  ' + str(T.m32()) + '  ' + str(T.m33()))

# Assumes no shearing or stretching.   
# Only Rotation, Translation, and Scaling. 
def extractTransformScale(T):
    # This is math matrix notation transposed (debug print self.sceneTransform() to see)
    Sx = sqrt(T.m11()**2 + T.m12()**2)      
    Sy = sqrt(T.m21()**2 + T.m22()**2)
    return Sx, Sy    

def extractTransformTranslate(T):
    return T.m31(), T.m32()


class Object(QGraphicsItem):
    def sceneEvent(self, event):
        if event.type() == QEvent.GraphicsSceneMouseMove: 
            # move, scale, or rotate
            if self._mode in ['scale', 'rotate']:
                mouse_pos = event.scenePos()
                last_pos = event.lastScenePos()
                if self._mode == 'scale':
                    s = self.mouseScalingFactors(mouse_pos, last_pos)
                    self.applyScaleTransform(*s)
                if self._mode == 'rotate':
                    r = self.mouseRotationAngle(mouse_pos, last_pos)
                    self.applyRotateTransform(r)
                return True

        return super().sceneEvent(event)

    def __init__(self):
        super().__init__()
        self.setFlags(QGraphicsItem.ItemIsMovable | QGraphicsItem.ItemIsFocusable | QGraphicsItem.ItemIsSelectable)
        self._selectionPen = QPen(Qt.black, 1.0, style=Qt.DotLine, cap=Qt.FlatCap)
        self._lastPos = QPointF(0, 0)
        self.setPos(self._lastPos)
        self._mode = 'neutral'
        self._scale = QGraphicsScale()
        self._rotate = QGraphicsRotation()
        self.setTransformations([self._rotate, self._scale])

    def applyRotateTransform(self, angle):
        self._rotate.setAngle(self._rotate.angle() + 15)

    def applyScaleTransform(self, sx, sy):
        self._scale.setXScale(sx * self._scale.xScale())
        self._scale.setYScale(sy * self._scale.yScale())

    def setRenderHints(self, painter):
        painter.setRenderHints(QPainter.SmoothPixmapTransform | QPainter.HighQualityAntialiasing | QPainter.TextAntialiasing)

    def boundingRectExtraScale(self):
        return (1.2 , 1.2) 

    def mouseScalingFactors(self, pos, last_pos):
        delta = pos - last_pos
        return (1.01 ** delta.x(), 1.01 ** delta.y())

    def mouseRotationAngle(self, pos, last_pos):
        return 1   #TODO

    def createDefaultContextMenu(self):
        menu = QMenu()
        if self._mode == 'neutral':
            menu.addAction('Scale').triggered.connect(lambda: self.setMode('scale'))
            menu.addAction('Rotate').triggered.connect(lambda: self.setMode('rotate'))
        else:
            menu.addAction('Done Editing').triggered.connect(lambda: self.setMode('neutral'))
        return menu

    def contextMenuEvent(self, event):
            menu = self.createDefaultContextMenu()
            menu.exec(event.screenPos())

    def setMode(self, mode):
        self._mode = mode

    def setSelected(self, selected):
        super().setSelected(selected)
        self.update()


class ShapedObject(Object):
    def __init__(self):
        super().__init__()
        self._shape = {
            'name' : 'ellipse',
            'radius': 35
        }
        self._brush = QBrush(Qt.darkGreen)
        self._pen = QPen(Qt.yellow, 3)        

    def shapeDef(self):
        return self._shape

    def boundingRect(self):
        rect = self.shapeRect()
        s = self.boundingRectExtraScale()
        return scaleRect(rect, *s)

    def shape(self):      #TODO QPainterPath shape for collision detection
        # Should call self.boundingRectExtraScale()
        return super().shape()

    def paint(self, painter, option, widget):
        self.setRenderHints(painter)
        #super().paint(painter, option, widget)

        shape = self.shapeDef()
        name = shape['name']

        painter.setBrush(self._brush)
        painter.setPen(self._pen)

        painter.save()
        path = QPainterPath()

        if name == 'ellipse':
            r = shape['radius']
            path.addEllipse(QPointF(0, 0), r, r)

        painter.drawPath(path)
        painter.restore()  

    def shapeRect(self):
        shape = self.shapeDef()
        name = shape['name']
        if name == 'ellipse':
            r = shape['radius']
            rect = QRectF(-r,  -r, 2*r, 2*r)
        return rect


####

import sys
from PyQt5.QtWidgets import QMainWindow, QGraphicsScene, QGraphicsView, QApplication

if __name__ == '__main__':
    app = QApplication(sys.argv)

    window = QMainWindow()
    view = QGraphicsView()
    scene = QGraphicsScene()
    view.setScene(scene)
    window.setCentralWidget(view)

    ellipse = ShapedObject()
    scene.addItem(ellipse)

    window.show()

    sys.exit(app.exec_())