Search code examples
pythonqgraphicsscenepyqt6qgraphicsitemgroup

Trouble moving QGraphicsItemGroup with grid snapping


I am having trouble moving a subclass of QGraphicsItemGroup around a QGraphicsScene with the mouse. The scene has a grid that I want the group to snap to as it moves. To accomplish this I have reimplemented the itemChange function in my subclass as follows:

from os import environ

environ["QT_ENABLE_HIGHDPI_SCALING"] = "0"

from PyQt6.QtWidgets import *
from PyQt6.QtCore import *

app = QApplication([])

class GridSnappingQGIG(QGraphicsItemGroup):

    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsMovable, True)
        self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges, True)
        
    def boundingRect(self):
        return self.childrenBoundingRect()
        
    def itemChange(self, change, value):
        scene = self.scene()
        if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and scene:
            scene = self.scene()
            x, y = scene.snap_to_grid(value.x(), value.y(), scene.grid_size)
            bounding_rect = self.boundingRect()
            x, y = min(8192 - bounding_rect.width(), max(0, x)), min(8192 - bounding_rect.height(), max(0, y))
            return QPointF(x, y)
        return super().itemChange(change, value)

class Scene(QGraphicsScene):

    def __init__(self, *args):
        super().__init__(*args)
        self.grid_size = 32
        
    def snap_to_grid(self, x, y, grid_size):
        return [grid_size * round(x / grid_size), grid_size * round(y / grid_size)]
scene = Scene(0, 0, 8192, 8192)

rect1 = QGraphicsRectItem(0, 0, 32, 32)
scene.addItem(rect1)
rect1.setPos(4000,4000)

rect2 = QGraphicsRectItem(0, 0, 128, 256)
scene.addItem(rect2)
rect2.setPos(3680,3680)

group = GridSnappingQGIG()
scene.addItem(group)
group.addToGroup(rect1)
group.addToGroup(rect2)

view = QGraphicsView(scene)
view.showMaximized()    
app.exec()

snap_to_grid is a function in my scene class which finds the nearest point on a scene.grid_size x scene.grid_size grid to the input point (x, y). The entire scene is 8192x8192 pixels, so the min/max operations ensure the item is not moved outside the scene.

After calling scene.addToItem(ItemGroup), I can move the group, but only right or down, not up or left. This is the behavior I would expect if the boundingRectangle's top-left corner was (0, 0) (in scene coordinates). I implemented the virtual boundingRect() function to return the childrenBoundingRect(), but this didn't fix the problem. I figured maybe I needed to adjust the position after adding the group to the scene. I tried getting the top-left corner (x, y) of the group in scene coordinates by calling mapToScene on the top-left corner of the bounding rectangle and then calling setPos(x, y), but then the group got pushed in the down-right direction as soon as I tried to move it.

I have tried commenting out the itemChange function, in which case the movement behaves as expected (but without the grid-snapping, of course). The grid snapping implementation also behaves as expected for custom QGraphicsItems subclasses.

Some system information:

OS Name:                   Microsoft Windows 10 Home
OS Version:                10.0.19045 N/A Build 19045

Python 3.10.2

PyQt6                        6.4.2
pyqt6-plugins                6.4.2.2.3
PyQt6-Qt6                    6.4.3
PyQt6-sip                    13.5.2
pyqt6-tools                  6.4.2.3.3

qt6-applications             6.4.3.2.3
qt6-tools                    6.4.3.1.3

Solution

  • Note: the original question was misguiding, leading to believe that any down/right movement was impossible; in reality, the problem was about not being able to move items beyond the top/left of their initial position.

    The problem is mainly caused by a common misconception about QGraphicsItems: the position of an item doesn't always match that of their contents. As the QGraphicsScene documentation often reiterates, items position is always initialized to (0, 0), which is relative to their parent (which may be the scene if no parent is set).

    For example, scene.addRect(50, 50, 100, 100) will create a 100x100 square shown at (50, 50), but the QGraphicsRectItem position will still be (0, 0).

    Consider the following:

    rect1 = scene.addRect(50, 50, 100, 100)
    rect2 = scene.addRect(0, 0, 100, 100)
    rect2.setPos(50, 50)
    

    The above will result in two visually identical squares. Their positions do not match, though.

    The same happens for QGraphicsItemGroup.

    When you create a group, its original position will be the above mentioned (0, 0), and its items will still be positioned at their original pos(). When items are added to a group, their position is virtually unchanged, preserving the original one based on their scene coordinates and related to the group's parent (yeah, I know, it's not that intuitive, but it makes sense as soon as you understand the relations between items).

    This means that if you want to limit the items of that group within the scene area, you cannot use the group position as reference: you need to check whether the translated bounding rect would be within the scene limits, and eventually adjust the position based on the difference of the translation limited by the boundaries.

    Unfortunately, this brings in a lot of other aspects caused by relative geometries: I assumed that your items always have geometries that respect the snap size, but if that is not the case, the implementation may become much more complex. The problem comes from the fact that you have to decide the reference geometry used for snapping: let's assume that the grid_size is 32, what should happen if an item is positioned at 15 (x or y) and its group is moved?

    Then, there is another problem: the boundingRect() of items always is what the scene fundamentally uses for drawing purposes, which includes everything drawing needs: if you create a basic (0, 0, 10, 10) rectangle, its bounding rect will actually be (-0.5, -0.5, 11, 11), because it includes the default pen width (see the documentation). But, in your case, you actually need to consider the actual geometry of the rectangle no matter the drawing geometry, so using the boundingRect() of the group (or its childrenBoundingRect()) would be inconsistent for that purpose.

    As you can see, while not impossible, item geometry management may become quite complex unless you are aware of their behavior. All that shows the power and complexity of the graphics view framework.

    Still, if you have a self-contained system in which you have complete control over the above aspects, there are relatively simple solutions.

    In the following example, I'm using smaller scene and item sizes, with a function that considers both the scene "snap" and its boundaries.

    class GridSnappingQGIG(QGraphicsItemGroup):
    
        def __init__(self, **kwargs):
            super().__init__(**kwargs)
            self.setFlags(
                QGraphicsItem.GraphicsItemFlag.ItemIsMovable
                | QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges
            )
    
        def itemChange(self, change, value):
            scene = self.scene()
            if (
                scene and
                change == QGraphicsItem.GraphicsItemChange.ItemPositionChange
            ):
                # create the "real bounding rect" based on geometries of child
                # items, using their actual coordinates
                childrenRect = QRectF()
                for child in self.childItems():
                    if isinstance(child, (QGraphicsRectItem, QGraphicsEllipseItem)):
                        childRect = child.rect()
                    elif isinstance(child, QGraphicsPathItem):
                        childRect = child.path().boundingRect()
                    elif isinstance(child, QGraphicsLineItem):
                        childRect = QRectF(child.line().p1(), child.line().p2())
                    else:
                        # further implementation required for other item types, 
                        # in order to avoid issues based on pen widths, margins, 
                        # complex geometries, etc.
                        childRect = child.boundingRect()
                    # adjust the children rect with the child position translated
                    # to the *current* position (not the new one!)
                    childrenRect |= childRect.translated(child.pos() + self.pos())
    
                # get the new possible geometry, based on the position difference
                adjustedRect = scene.adjusted_grid_rect(
                    childrenRect.translated(value - self.pos()))
                diff = adjustedRect.topLeft() - childrenRect.topLeft()
                # return the difference between the computed moved rectangle and 
                # the current one, allowing proper "snapping"
                return self.pos() + diff
    
            return super().itemChange(change, value)
    
    class Scene(QGraphicsScene):
        grid_size = 32
        def adjusted_grid_rect(self, rect):
            x = round(rect.x() / self.grid_size) * self.grid_size
            y = round(rect.y() / self.grid_size) * self.grid_size
            newRect = QRectF(x, y, rect.width(), rect.height())
            sceneRect = self.sceneRect()
            if newRect.right() > sceneRect.right():
                newRect.moveRight(sceneRect.right())
            if newRect.x() < sceneRect.x():
                newRect.moveLeft(sceneRect.x())
            if newRect.bottom() > sceneRect.bottom():
                newRect.moveBottom(sceneRect.bottom())
            if newRect.y() < sceneRect.y():
                newRect.moveTop(sceneRect.y())
            return newRect
    
        def drawBackground(self, qp, rect):
            # for testing purposes, draw the snap grid
            gs = self.grid_size
            sceneRect = self.sceneRect()
            qp.fillRect(rect & sceneRect, QColor(127, 127, 127, 63))
            qp.setPen(Qt.GlobalColor.lightGray)
            left, top, width, height = (rect & sceneRect).getRect()
            right = left + width
            bottom = top + height
            x = left // gs * gs
            if x == sceneRect.x():
                x += gs
            while x < right:
                qp.drawLine(QLineF(x, top, x, bottom))
                x += gs
            y = top // gs * gs
            if y == sceneRect.y():
                y += gs
            while y < bottom:
                qp.drawLine(QLineF(left, y, right, y))
                y += gs
    
    app = QApplication([])
    
    scene = Scene(0, 0, 640, 480)
    
    rect1 = QGraphicsRectItem(0, 0, 32, 32)
    scene.addItem(rect1)
    rect1.setPos(128, 128)
    
    rect2 = QGraphicsRectItem(0, 0, 64, 128)
    scene.addItem(rect2)
    rect2.setPos(256, 256)
    
    group = GridSnappingQGIG()
    scene.addItem(group)
    group.addToGroup(rect1)
    group.addToGroup(rect2)
    
    view = QGraphicsView(scene)
    view.setRenderHint(QPainter.RenderHint.Antialiasing)
    view.resize(700, 700)
    view.show()
    QTimer.singleShot(10, lambda: 
        view.fitInView(scene.sceneRect(), Qt.AspectRatioMode.KeepAspectRatio))
    app.exec()
    

    Note that the example doesn't consider the click-origin point, so its result may not be completely intuitive (the result changes depending on the origin point of the mouse press event). Consider this aspect along with the scene size and eventual scaling. For future reference, think about these aspects, as creating a scene that big may create issues both in debugging and UX.