Search code examples
pythonpyqtpysideqgraphicssceneqgraphicsrectitem

Rotated QGraphicsRectItem moves randomly when dragged using mouse


I have a movable QGraphicsRectItem which is rotated to 90 degrees and set to a scene. When I drag the item, it moves randomly and eventually disappear.

However, when I set the rotation to 0, the item moves flawlessly.

Here is my minimal reproducible example.

class main_window(QWidget):
    def __init__(self):
        super().__init__()
        self.rect = Rectangle(100, 100, 100, 100)
        self.rect.setRotation(90)

        self.view = QGraphicsView(self)
        self.scene = QGraphicsScene(self.view)
        self.scene.addItem(self.rect)

        self.view.setSceneRect(0, 0, 500,500)
        self.view.setScene(self.scene)

        self.slider = QSlider(QtCore.Qt.Horizontal)
        self.slider.setMinimum(0)
        self.slider.setMaximum(90)

        vbox = QVBoxLayout(self)
        vbox.addWidget(self.view)
        vbox.addWidget(self.slider)

        self.setLayout(vbox)

        self.slider.valueChanged.connect(self.rotate)

    def rotate(self, value):
        self.angle = int(value)
        self.rect.setRotation(self.angle)

class Rectangle(QGraphicsRectItem):
    def __init__(self, *args):
        super().__init__(*args)
        self.setFlag(QGraphicsItem.ItemIsMovable, True)
        self.setFlag(QGraphicsItem.ItemIsSelectable, True)
        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges, True)
        self.setPen(QPen(QBrush(QtGui.QColor('red')), 5))
        self.selected_edge = None
        self.first_pos = None
        self.click_rect = None

    def mousePressEvent(self, event):
        self.first_pos = event.pos()
 
        self.rect_shape = self.rect()
        self.click_rect = self.rect_shape
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        # Calculate how much the mouse has moved since the click.
        self.pos = event.pos()
        x_diff = self.pos.x() - self.first_pos.x()
        y_diff = self.pos.y() - self.first_pos.y()

        # Start with the rectangle as it was when clicked.
        self.rect_shape = QtCore.QRectF(self.click_rect)

        self.rect_shape.translate(x_diff, y_diff)

        self.setRect(self.rect_shape)
        self.setTransformOriginPoint(self.rect_shape.center())

(I included a slider at the bottom of the main window to conveniently rotate the item)

Why does this happen?


Solution

  • The issue is caused by various aspects:

    • setting a QRect at given coordinates while keeping the item at the same default position (0, 0);
    • changing the rectangle as consequence of a mouse move event;
    • changing the transformation origin point after that;
    • the mapping of the mouse coordinates between integer based point (on the screen) and floating (on the scene);
    • the transformation (rotation);
    • implementing the item movement without considering the above (while QGraphicsItem already provides it with the ItemIsMovable flag);

    Note that while rotation might seem a simple operation, it is achieved by using a combination of two transformations: shearing and scaling; this means that the transformation applies very complex computations that depend on the floating point precision.
    This becomes an issue when dealing with integer to floating conversion: the same mouse (integer based) coordinate can be mapped at a very different point depending on the transformation, and the "higher" the transformation is applied, the bigger the difference can be. As a result, the mapped mouse position can be very different, the rectangle is translated to a "wrong" point, and the transformation origin point moves the rectangle "away" by an increasing ratio.

    The solution is to completely change the way the rectangle is positioned and actually simplify the reference: the rectangle is always centered at the item position, so that we can keep the transformation origin point at the default (0, 0 in item coordinates).

    The only inconvenience with this approach is that the item's pos() will not be on its top left corner anymore, but that is not a real issue: when the item is rotated, its top left corner would not be at that position anyway.

    If you need to know the actual position of the item, you can then translate the rectangle based on the item scene position.
    If you want to position the rectangle based on its top left corner, you have to map the position from the scene and compute the delta of the reference point (the actual top left corner).

    I took the liberty of taking your previous question, which implemented the resizing, and improving it also to better show how the solution works.

    class Selection(QtWidgets.QGraphicsRectItem):
        Left, Top, Right, Bottom = 1, 2, 4, 8
        def __init__(self, *args):
            rect = QtCore.QRectF(*args)
            pos = rect.center()
            # move the center of the rectangle to 0, 0
            rect.translate(-rect.center())
            super().__init__(rect)
            self.setPos(pos)
            self.setPen(QtGui.QPen(QtCore.Qt.red, 5))
            self.setFlags(
                self.ItemIsMovable | 
                self.ItemIsSelectable | 
                self.ItemSendsGeometryChanges
            )
    
        def mapRect(self):
            return QtCore.QRectF(
                self.mapToScene(self.rect().topLeft()), 
                self.rect().size()
            )
    
        def setRectPosition(self, pos):
            localPos = self.mapFromScene(pos)
            delta = self.rect().topLeft() - localPos
            self.setPos(self.pos() + delta)
    
        def itemChange(self, change, value):
            if change in (self.ItemPositionHasChanged, self.ItemRotationHasChanged):
                print(self.mapRect())
            return super().itemChange(change, value)
    
        def mousePressEvent(self, event):
            super().mousePressEvent(event)
            pos = event.pos()
            rect = self.rect()
            margin = self.pen().width() / 2
            self.anchor = 0
    
            if pos.x() <= rect.x() + margin:
                self.anchor |= self.Left
            elif pos.x() >= rect.right() - margin:
                self.anchor |= self.Right
    
            if pos.y() <= rect.y() + margin:
                self.anchor |= self.Top
            elif pos.y() >= rect.bottom() - margin:
                self.anchor |= self.Bottom
    
            if self.anchor:
                self.clickAngle = QtCore.QLineF(QtCore.QPointF(), pos).angle()
            else:
                super().mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            if not self.anchor:
                super().mouseMoveEvent(event)
                return
            rect = self.rect()
            pos = event.pos()
            if self.anchor == self.Left:
                rect.setLeft(pos.x())
            elif self.anchor == self.Right:
                rect.setRight(pos.x())
            elif self.anchor == self.Top:
                rect.setTop(pos.y())
            elif self.anchor == self.Bottom:
                rect.setBottom(pos.y())
            else:
                # clicked on a corner, let's rotate
                angle = QtCore.QLineF(QtCore.QPointF(), pos).angle()
                rotation = max(0, min(90, self.rotation() + self.clickAngle - angle))
                self.setRotation(rotation)
                return
            pos = self.mapToScene(rect.center())
            self.setPos(pos)
            rect.moveCenter(QtCore.QPointF())
            self.setRect(rect)