2

I am trying to create polygon with a border and change its color when mouse cursor is on top of it. The problem appear when I create more than one polygon and set a border size grater than 4. The first polygon behaves correctly but for other it seems like inner half of the polygon border is treated as not belonging to the polygon as the hoverLeaveEvent() is triggered when reaching that bit.

I could just draw without a border and instead use additional polygons on top of the ones I already have as a border, or draw lines but this becomes a bit messy. I wonder if there is a way to fix that issue without creating additional items.

Here is a small sample code showing the problem. It is better visible if you set border width >5

from PyQt6.QtWidgets import QMainWindow, QGraphicsScene, QGraphicsView, QGraphicsPolygonItem, QApplication
from PyQt6.QtGui import QColor, QPolygonF, QBrush, QPen
from PyQt6.QtCore import QPointF

class Polygon(QGraphicsPolygonItem):
    def __init__(self, parent):
        super().__init__(parent)
        self.setBrush(QBrush(QColor(255, 0, 0, 120)))
        self.setPen(QPen(QColor(255, 0, 0), 10))
        self.setAcceptHoverEvents(True)

    def hoverEnterEvent(self, event):
        self.setBrush(QBrush(QColor(255, 0, 0, 250)))

    def hoverLeaveEvent(self, event):
        self.setBrush(QBrush(QColor(255, 0, 0, 120)))

class MyWindow(QMainWindow):
    def __init__(self):
        super(MyWindow, self).__init__()
        self.setGeometry(200, 0, 500, 600)

        self.canvas = QGraphicsView()
        self.canvas.setScene(QGraphicsScene(self))

        polygon = QPolygonF([
            QPointF(0, 0),
            QPointF(100, 0),
            QPointF(100, 100),
            QPointF(0, 100)
            ])
        self.polygon_item = Polygon(polygon)
        self.canvas.scene().addItem(self.polygon_item)

        polygon = QPolygonF([
            QPointF(110, 110), 
            QPointF(150, 160), 
            QPointF(200, 250), 
            QPointF(200, 100)
            ])

        self.polygon_item = Polygon(polygon)
        self.canvas.scene().addItem(self.polygon_item)
    

        polygon = QPolygonF([
            QPointF(0, 200), 
            QPointF(0, 300), 
            QPointF(100, 300), 
            QPointF(100, 200)
            ])

        self.polygon_item = Polygon(polygon)
        self.canvas.scene().addItem(self.polygon_item)
        self.setCentralWidget(self.canvas)

if __name__ == '__main__':
    app = QApplication([])
    win = MyWindow()

    win.show()
    app.exec()
Michal
  • 23
  • 2
  • 1
    That is interesting... And it does seem to not effect one of the polygons although being drawn first doesn't seem to be what determines which one it doesn't effect. – Alexander Apr 23 '23 at 01:51
  • 1
    @Alexander This is interesting indeed. What I found out (see my answer) is that the problem is caused by multiple aspects and also affects basic shapes as rectangles whenever their *order* is not the expected one: the (indirect) issue about the second rectangle is that the vertexes are declared counterclockwise, but if they're changed to clockwise it works as expected. – musicamante Apr 23 '23 at 02:40

1 Answers1

1

This actually seems like a bug, but it also requires some further inspecting to realize the difficulty of the problem.

First of all, the issue does not depend on the creation order: if you move the first polygon after any of the others, the problem remains.

The problem is related to the following aspects:

  1. the most important problem: those polygons are not closed;
  2. the first and last polygons, while similar and orthogonal (which makes things easier for collision detection), have a different order of vertices: while both rectangles are created starting with their top left vertex, the first goes clockwise (top left, top right, bottom right, bottom left), the last goes counterclockwise instead (top left, bottom left, bottom right, top right);
  3. QGraphicsItem uses the shape() function for collision detection (including mouse hover), which, in the case of QAbstractGraphicsShapeItem subclasses like QGraphicsPolygonItem, returns a complex QPainterPath created started with the item's "path" and adjusted using its pen(), using a QGraphicsPathStroker, that creates a further QPainterPath that has the "contour" of the given path using the provided QPen;

Unfortunately, as powerful and smart QPainterPath is, it's not perfect: Qt developers had to balance between functionality and performance, since QPainterPaths are used a lot in Qt and therefore are required to be extremely light and fast.

The result is that the final shape() of your paths is quite complex. Try to add the following override to your Polygon class:

    def paint(self, qp, opt, widget=None):
        super().paint(qp, opt, widget)
        qp.save()
        qp.setPen(Qt.black)
        qp.drawPath(self.shape())
        qp.restore()

You'll see that the resulting paths of shape() are quite more complex than expected:

Screenshot showing the paths of the above override

Now, the problem raises because QGraphicsScene uses the QPainterPath contains() function to detect whether the mouse cursor is within the graphics item boundaries. With such a complex path, the default ("fast") behavior potentially fails for very big outlines like those set by you.

I won't goo too much deep into mathematical aspects, but consider that collision detection is a well know dilemma and dealing with it means that some pros/cons decision has to be made when dealing with a generic API behavior. QPolygons might be convex or even have intersected lines: for instance, how would you deal with a polygon like this and its lines if they are too thick?

image of intersected polygon shape

Now, assuming that your polygons always have consistent vertexes (meaning that there is absolutely no intersection, including those caused by the pen width), and you are not going to use too many items or shapes that are not that complex, there is a possible work around: provide the shape() on your own.

The trick is to get the default returned shape(), break it in toSubpathPolygons() and iterate through them to check which of them does not contain any of the others.

Unfortunately, there is no certainty about what subpath polygon actually belongs to the boundary of the stroker: it theoretically is the first one, but it may not, so we need to carefully iterate through all of them using a while loop.

Note that, as explained above, the polygon must be closed. To simplify things, I've created a new QPolygonF in the __init__ (mutable objects should never be changed within the initialization of another object), but if you want to do things in the correct way you should add a further implementation that eventually returns a closed polygon and only if required.

class Polygon(QGraphicsPolygonItem):
    def __init__(self, polygon):
        if not polygon.isClosed():
            polygon = QPolygonF(polygon)
            polygon.append(polygon[0])
        super().__init__(polygon)
        self.setBrush(QBrush(QColor(255, 0, 0, 120)))
        self.setPen(QPen(QColor(255, 0, 0), 10))
        self.setAcceptHoverEvents(True)

    def shape(self):
        shape = super().shape().simplified()
        polys = iter(shape.toSubpathPolygons(self.transform()))
        outline = next(polys)
        while True:
            try:
                other = next(polys)
            except StopIteration:
                break
            for p in other:
                # check if all points of the other polygon are *contained*
                # within the current (possible) "outline"
                if outline.containsPoint(p, Qt.WindingFill):
                    # the point is *inside*, meaning that the "other"
                    # polygon is probably an internal intersection
                    break
            else:
                # no intersection found, the "other" polygon is probably the
                # *actual* outline of the QPainterPathStroker
                outline = other
        path = QPainterPath()
        path.addPolygon(outline)
        return path

Since the computation above may be a bit demanding for complex paths, you can consider to construct it only when required and eventually "cache" it as a private instance member, then clear it whenever any change might affect it (for polygon items, that would be the pen and polygon):

class Polygon(QGraphicsPolygonItem):
    _cachedShape = None
    def setPen(self, pen):
        self._cachedShape = None
        super().setPen(pen)

    def setPolygon(self, polygon):
        self._cachedShape = None
        super().setPolygon(polygon)

    def shape(self):
        if not self._cachedShape:
            self._cachedShape = self._createShape()
        return self._cachedShape

    def _createShape(self):
        shape = super().shape().simplified()
        polys = iter(shape.toSubpathPolygons(self.transform()))
        outline = next(polys)
        while True:
            try:
                other = next(polys)
            except StopIteration:
                break
            for p in other:
                # check if all points of the other polygon are *contained*
                # within the current (possible) "outline"
                if outline.containsPoint(p, Qt.WindingFill):
                    # the point is *inside*, meaning that the "other"
                    # polygon is probably an internal intersection
                    break
            else:
                # no intersection found, the "other" polygon is probably the
                # *actual* outline of the QPainterPathStroker
                outline = other
        path = QPainterPath()
        path.addPolygon(outline)
        return path
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • Thank you very much for not only the solution but such a broad explanation. I had no idea that I was not closing the polygons. Also I will check the QPainterPath() and shape() but your answer already gave me a lot of understanding of them. I don't see much of a performance issues when drawing hundreds of polygons. For my needs this is more than enough. Thank you very much again, this is great answer! – Michal Apr 23 '23 at 14:51
  • 1
    @Michal You're welcome! Note that you could still improve performance quite easily: create an instance attribute set to `None`, in `shape` then create the path as above if the attribute is still `None` and then return it, then you can eventually clear it by overriding the `setPen` and `setPolygon` function after calling the base implementation. – musicamante Apr 23 '23 at 16:22
  • Wow that is a massive performance boost. Now I can draw thousands with no performance drop. So am I understanding this correctly? That instance attribute stores cached path and by setting it to None in setPen and setPolygon we are clearing cache? – Michal Apr 23 '23 at 17:45