Can someone explain how QGraphicsItems interact with QGraphicsScene coordinates? (PySide6)

Question:

I am confused by Qt’s coordinate handling in a QGraphicsScene. I’m using PySide6, and create a QGraphicsScene with movable QGraphicsRectItems. I would like to limit the items to the boundaries of the scene. There is an example in the PySide docs, but it does not work (the example is auto-translated from C++ to Python, but it does not work for me even after correcting the translation errors).

I figured the general idea would be to monitor an item for movement, which can be done by implementing the itemChange event handler. A pseudo implementation (not bothering with the nitty gritty Qt details) would look like:

def itemChange(newPosition):
    if newPosition.x < scene.x:
        newPosition.x = scene.x
        return newPosition

I cannot figure out how to make this actually work. It seems like every graphics item has its own idea of origin, and I have not found the mapping function that will turn the item’s coordinates into scene coordinates. I have tried mapToScene and mapFromItem. But

I have reviewed several different questions on stackoverflow, but I still seem to be misunderstanding what’s happening.

A minimal working example with a square which prints out the X coordinates when the square is dragged. As you drag the rectangle to the left of the scene, I would expect at least one of the X values to approach zero.

import sys
from PySide6 import QtWidgets
from PySide6.QtCore import QPointF, QRectF
from PySide6.QtGui import QBrush, QColor, QPen
from PySide6.QtWidgets import QApplication, QMainWindow, QGraphicsScene, QGraphicsRectItem, QGraphicsItem, QGraphicsView


class BoundableItem(QGraphicsRectItem):
    def __init__(self, *args, **kwargs):
        super(BoundableItem, self).__init__(*args, **kwargs)
        # Our items are movable and they send geometry change events
        self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemIsMovable)
        self.setFlag(QtWidgets.QGraphicsItem.GraphicsItemFlag.ItemSendsGeometryChanges)

    def itemChange(self, change, value: QPointF):
        if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene():
            print(f"X=> As passed: {value.x()}   mapToScene: {self.mapToScene(value).x()}   mapFromItem: {self.mapFromItem(self, value).x()}")
        return super(BoundableItem, self).itemChange(change, value)


class MyWindow(QMainWindow):
    def __init__(self):
        super(MyWindow, self).__init__()
        # Basic window setup
        self.setWindowTitle("Bounding Test")
        self.setFixedSize(320, 320)
        # Set up a fixed-sized screen
        scene = QGraphicsScene(0, 0, 240, 240)
        scene.setBackgroundBrush(QBrush(QColor.fromRgb(24, 24, 48)))
        # Draw a border around the scene
        scene.addRect(scene.sceneRect(), QPen(QColor.fromRgb(255, 255, 255)))
        # A blue square, offset by 32
        rect = BoundableItem(QRectF(32.0, 32.0, 32.0, 32.0))
        rect.setBrush(QBrush(QColor.fromRgb(0, 0, 255)))
        scene.addItem(rect)

        # Make a graphics view for the scene
        self.view = QGraphicsView()
        self.view.setScene(scene)
        self.setCentralWidget(self.view)


if __name__ == '__main__':
    app = QApplication()
    window = MyWindow()
    window.show()
    sys.exit(app.exec())


Here is an example, with the console output, after dragging the square out of the bounds:

Example

X=> As passed: -40.0 mapToScene: -79.0 mapFromItem: -40.0

Edited for clarification: To actually change what happens in itemChange(), I know that you need to return the desired position (QPointF), I just didn’t do that for my example because I was trying to just print out the X coordinates.

Answers:

QGraphicsRectItems coordinates have well defined ogirin, it’s called pos(), you can change it with setPos() (this also moves item into that point on scene).

mapToScene maps items coordinates into scene coordinates, for example mapToScene(QPointF(0,0)) returns scene coordinates of item’s upper left corner, mapToScene(QPointF(w,h)) – scene coordinates of item’s bottom right corner. Assuming boundingRect is not overriden and returns rect with position (0,0).

self.mapToScene(value) inside itemChange doesn’t make sense because value is already in scene coordinates. mapFromItem(value) also doesn’t make sense because value is not in item’s coordinates. mapFromItem(QPointF(0,0)) makes sense and about equal to value.

When you override itemChange you suppose to return modified value.

def itemChange(self, change, value):
    if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange and self.scene():
        rect = self.boundingRect()
        w = rect.width()
        h = rect.height()
        topLeft = value
        bottomRight = value + QPointF(w, h)
        sceneRect = self.scene().sceneRect()
        if not sceneRect.contains(topLeft) or not sceneRect.contains(bottomRight):
            y = value.y()
            x = value.x()
            if y < sceneRect.top():
                y = sceneRect.top()
            if y > sceneRect.bottom() - h:
                y = sceneRect.bottom() - h
            if x < sceneRect.left():
                x = sceneRect.left()
            if x > sceneRect.right() - w:
                x = sceneRect.right() - w
            value.setX(x)
            value.setY(y)
            return value
    return super().itemChange(change, value)

I’d recomend to create QGraphicsRectItem with position 0,0 and then apply desired position using setPos, otherwise calculations get unnecessary complicated.

rect = BoundableItem(QRectF(0.0, 0.0, 32.0, 32.0))
rect.setPos(QPointF(32.0, 32.0))

That is because QGraphicsRectItem(32.0, 32.0, 32.0, 32.0) creates rect with local coordinates in which origin is offset from top left corner of rectangle by (-32, -32). So when you move it you need to correct for that offset. You can define QGraphicsItem‘s origin anywhere in relation to item, as long as it stays at the same point (just make sure that boundingRect and paint is aware of that relation). It’s advised to bind origin to some meaningful point – center or corner of the shape.

Here’s how local coordinates look like in both cases:

Local coordinates

Alternatively, you can keep QGraphicsRectItem(32.0, 32.0, 32.0, 32.0), and do this:

offset = self.boundingRect().topLeft()
topLeft = value + offset
bottomRight = value + offset + QPointF(w, h)
Answered By: mugiseyebrows

While the given answer provides a valid solution, it seems you’re still confused about graphics items coordinates, their usage and how they work, so I’m writing this as a further explanation to expand what written in the other post.

Coordinate systems of objects are always referred to the origin point of their closest parent: the coordinate system of the object in which the child is finally placed.

If a relative asks you where you put the car keys, you’ll probably give a reference based on a known and common coordinate system.
For instance: "on the coffee table" (the reference is the living room), "near the kitchen" (the reference is the home), "in the left pocket" (of your coat).
You certainly won’t tell them the latitude and longitude of the keys, nor their position within the known universe.

This concept is extremely important to understand, because an object normally keeps its "local" position even if its parent (the one used for its reference coordinate system) is moved.

Consider the case above: somebody moved your coat from the coffee table to the hangers near the entrance, but the keys are still in the left pocket of your coat. Their "local coordinates" remain unchanged, but their actual position within the house and the "global" coordinate system have changed.

Interestingly enough, it’s possible that, for some coordinate systems, the position is still the same: for instance, the house is actually a mobile home, and it was moved by a few meters in the meantime, so its global latitude and longitude may still be the same, even if the coat is in another place in that home.

QGraphicsItems, similarly to Qt widgets, have a strict coordinate system: an object position is always based on the origin point of its parent.

But that’s not enough. Unlike widgets, graphics items can have contents that are translated from their origin point.

Your main issue is in this line:

rect = BoundableItem(QRectF(32.0, 32.0, 32.0, 32.0))

The common (and comprehensibly, but wrong) assumption is that the item will be "put" at 32x32.

The reality is quite different (see the Item Coordinates documentation), and is more clear in the basic constructors QGraphicsItem provides. Take, for instance, addRect():

[…] Note that the item’s geometry is provided in item coordinates, and its position is initialized to (0, 0). For example, if a QRect(50, 50, 100, 100) is added, its top-left corner will be at 50, 50 relative to the origin in the item’s coordinate system.

This means that your BoundableItem instance will be visually shown at 32, 32, but its position will still be 0, 0.

This is valid for any QGraphicsItem: their origin point (pos()) is always 0, 0 unless setPos() is called.

That offset might seem very counter intuitive; the reality is that not only it’s effective, but it’s also extremely useful.

Unlike widgets (which are always based on orthogonal rectangles), graphics items may have completely arbitrary shapes: not only their contents must not always collide with their actual position, but in some cases they shouldn’t even, for instance due to some external or internal transformation, or due to changes in the shape itself.

Imagine that you want to draw a square that can increase or decrease its size while keeping its center at a fixed position: if the position of the item was the same as the origin point of the square, the result would be that the item would change its actual position every time the size changes, which is clearly problematic. In reality, you would just need to create a square that will exist "around" its center (its reference point).

Now, if you want to restrict the item position, you have to consider two aspects:

  1. whether the restriction is based on the actual contents of the item (considering a possible offset as explained above);
  2. if the reference of that restriction is the parent of the item;

For point 1, you only need to compute the offset based on the given position and eventually change the value accordingly.

When dealing with simple geometric shapes, it’s usually better to always construct the item with its points mapped to the origin, and eventually call setPos() afterwards:

class BoundableItem(QGraphicsRectItem):
    def __init__(self, rect):
        if rect.topLeft():
            pos = rect.topLeft()
            rect.translate(-pos)
        else:
            pos = QPointF()
        super().__init__(rect)
        self.setPos(pos)
        self.setFlag(QGraphicsItem.ItemIsMovable)
        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)

With the code above, the rectangle will be actually positioned at the first two values in the constructor:

rect = BoundableItem(QRectF(32.0, 32.0, 32.0, 32.0))

print(rect.rect())
# QPointF(32.0, 32.0)

print(rect.pos())
# QRectF(0.0, 0.0, 32.0, 32.0)

Now, restricting the position of an item requires understanding the relations between local, parent and scene coordinates.

Note that the other answer is based on the assumption that the item is actually part of the scene (it has no parent item) but it also assumes an important aspect that should never be forgotten: boundingRect() always considers the pen() used to paint the item, so the result is normally a bit larger than the actual rectangle. This may be a problem: if you want to place the rectangle precisely at 0, 0, you can’t, as it will always be put at penSize/2, penSize/2.

If you want to allow such coordinates, then the proper solution is to use the actual rect() of the item:

    def itemChange(self, change, value: QPointF):
        if change == QGraphicsItem.ItemPositionChange and self.scene():
            sceneRect = self.scene().sceneRect()
            rect = self.rect().translated(value)
            if rect.x() < sceneRect.x():
                value.setX(sceneRect.x())
            elif rect.right() > sceneRect.right():
                value.setX(sceneRect.right() - rect.width())
            if rect.y() < sceneRect.y():
                value.setY(sceneRect.y())
            elif rect.bottom() > sceneRect.bottom():
                value.setY(sceneRect.bottom() - rect.height())
            return value
        return super().itemChange(change, value)

But what happens if you want to do the same for an item that is a child of another, but while still keeping the limits of the scene?

The only way to achieve so is by converting the coordinate systems based on the reference (the parent), which is what is used for the actual item position, and then convert that value back if it needs corrections:

    def itemChange(self, change, value: QPointF):
        if change == QGraphicsItem.ItemPositionChange and self.scene():
            sceneRect = self.scene().sceneRect()
            mapRect = self.rect().translated(self.mapFromParent(value))
            transRect = self.mapToScene(mapRect).boundingRect()
            if transRect.x() < sceneRect.x():
                value.setX(self.mapToParent(self.mapFromScene(sceneRect.topLeft())).x())
            elif transRect.right() > sceneRect.right():
                value.setX(self.mapToParent(self.mapFromScene(sceneRect.topRight())).x() - self.rect().width())
            if transRect.y() < sceneRect.y():
                value.setY(self.mapToParent(self.mapFromScene(sceneRect.topLeft())).y())
            elif transRect.bottom() > sceneRect.bottom():
                value.setY(self.mapToParent(self.mapFromScene(sceneRect.bottomRight())).y() - self.rect().height())
            return value

    return super().itemChange(change, value)

If you use the above as a helper function, you can actually do something interesting: allow the parent to be moved, while restricting the position of the child in the meantime.

class BoundableItem(QGraphicsRectItem):
    def __init__(self, rect, parent=None):
        if rect.topLeft():
            pos = rect.topLeft()
            rect.translate(-pos)
        else:
            pos = QPointF()
        super().__init__(rect, parent)
        self.setPos(pos)
        self.setFlag(QGraphicsItem.ItemIsMovable)
        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)
        self.setFlag(QGraphicsItem.ItemSendsScenePositionChanges)

    def fixPosition(self, pos):
        sceneRect = self.scene().sceneRect()
        mapRect = self.rect().translated(self.mapFromParent(pos))
        transRect = self.mapToScene(mapRect).boundingRect()
        if transRect.x() < sceneRect.x():
            pos.setX(self.mapToParent(self.mapFromScene(sceneRect.topLeft())).x())
        elif transRect.right() > sceneRect.right():
            pos.setX(self.mapToParent(self.mapFromScene(sceneRect.topRight())).x() - self.rect().width())
        if transRect.y() < sceneRect.y():
            pos.setY(self.mapToParent(self.mapFromScene(sceneRect.topLeft())).y())
        elif transRect.bottom() > sceneRect.bottom():
            pos.setY(self.mapToParent(self.mapFromScene(sceneRect.bottomRight())).y() - self.rect().height())
        return pos

    def itemChange(self, change, value: QPointF):
        if change == QGraphicsItem.ItemPositionChange and self.scene():
            return self.fixPosition(value)
        elif (
            change == QGraphicsItem.ItemScenePositionHasChanged 
            and self.parentItem() is not None
        ):
            self.setPos(self.fixPosition(self.parentItem().mapFromScene(value)))
            return change, value
        return super().itemChange(change, value)


class MyWindow(QMainWindow):
    def __init__(self):
        ...
        container = scene.addRect(16, 16, 140, 140)
        container.setPen(QPen(Qt.red))
        container.setFlag(QGraphicsItem.ItemIsMovable)
        # A blue square, offset by 32
        rect = BoundableItem(QRectF(32.0, 32.0, 32.0, 32.0), container)
        ...
Answered By: musicamante
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.