pythonpyqt5qgraphicssceneqgraphicsitemqpainterpath

How to create an own shape for a QGraphicItem in PyQt5


I am new to PyQt5 and I am programming a small Logo-Editor for generating simple Logos. I use PyQt5 Version: 5.15.7 in Python 3.10 all together in PyCharm PyCharm 2022.1.3 (Community Edition) on Windows 11.

I am using the QGraphicsScene to draw all my lines and then I can customize length, color, zValue, etc of the created logo. I use MouseEvents to click on one QGraphicsItem and so I am able to change the color and zValue. That's just for the introduction.

The Problem I have is on creating a diagonal line. Then the resulting default boundingRect() of the QGraphicsItem is much too big, and this makes problems when I have several lines on my Scene, which I would like to select with the mouse. Then the clicking on one Item results in the selection of a nearby diagonal line item.

Here is a screenshot of what I mean: Diagonal line with selection box (black), the red line shows the boundingRect or shape I would like to use)

I made a small QtApp to demonstrate my problem:

from PyQt5.QtWidgets import (
    QApplication,
    QWidget,
    QGraphicsView,
    QGraphicsScene,
    QGraphicsSceneMouseEvent,
    QGraphicsItem,
    QTextEdit,
    )
from PyQt5.QtGui import QPolygonF, QPen, QTransform
from PyQt5.QtCore import Qt, QPointF, QLineF
import sys


# Own QGraphicsScene Subclass with some drawings and points as example
class MyScene( QGraphicsScene ):
    def __init__( self ):
        super().__init__( -300, -300, 600, 600 )
        
        # Set QPen for drawings
        self.my_pen = QPen( Qt.darkBlue )
        self.my_pen.setWidthF( 15 )
        self.my_pen.setCapStyle( Qt.RoundCap )
        
        # Set Start- & End-Points for my line
        self.start = QPointF( 0, 0 )
        self.end = QPointF( 200, 200 )
        
        # Draw a line (boundingRect is created automatically)
        self.lin = self.addLine( QLineF( self.start, self.end ), self.my_pen )
        self.lin.setFlags( QGraphicsItem.ItemIsSelectable )
        
        # Change Pen-Setttings for new Item
        self.my_pen.setWidthF( 2 )
        self.my_pen.setColor( Qt.darkRed )
        self.my_pen.setStyle( Qt.DotLine )
        
        # Draw polygon, which I would like to apply on my line as a bounding rect
        self.poly = self.addPolygon(
                QPolygonF(
                        [
                                QPointF( 20, -30 ),
                                QPointF( -30, 20 ),
                                QPointF( 180, 230 ),
                                QPointF( 230, 180 ),
                                ] ),
                self.my_pen
                )
    
    # Reimplementing the mousePressEvent for catching some information
    def mousePressEvent( self, sceneEvent: QGraphicsSceneMouseEvent ):
        # Get position and item at the position of the event
        
        #### EDIT after Comments from musicamente
        #### FIRST pass the event to the original implementation of the mousePressEvent
        super().mousePressEvent( sceneEvent )
        #### and THEN get the position of the item at the event-scenePosition
        pp = sceneEvent.scenePos()
        
        current_item = self.itemAt( pp, QTransform() )
        # So if there is an item at the clicked position, then write some information
        if current_item is not None:
            text = f"scenePos() = {pp} \n"\
                   f"screenPos() = {sceneEvent.screenPos()}\n"\
                   f"current_item.boundingRect() = "\
                   f"{current_item.boundingRect()}\n"\
                   f"current_item.shape() = {current_item.shape()}\n"\
                   f"current_item.shape().boundingRect() = "\
                   f"{current_item.shape().boundingRect()}\n"\
                   f"current_item.shape().controlPointRect() = "\
                   f"{current_item.shape().controlPointRect()}\n"\
                   f""
            my_gui.my_textedit.setText( text )
            current_item.mousePressEvent( sceneEvent )


# The Class/Widget for placing the view and a QTextEdit
class MyGui( QWidget ):
    def __init__( self ):
        super().__init__()
        self.setGeometry( 50, 50, 800, 800 )
        self.my_scene = MyScene()
        self.my_view = QGraphicsView( self )
        self.my_view.setScene( self.my_scene )
        
        # QTextEdit for displaying some stuff for debuggin
        self.my_textedit = QTextEdit( self )
        self.my_textedit.setGeometry( 0, 610, 600, 150 )


# Starting the App
my_app = QApplication( sys.argv )
my_gui = MyGui()
my_gui.show()

sys.exit( my_app.exec() )

In the textEdit I see roughly, what I have to do:

current_item.shape() = <PyQt5.QtGui.QPainterPath object at 0x00000213C8DD9A10>
current_item.shape().boundingRect() = PyQt5.QtCore.QRectF(-30.707106781186553, -30.999999999983665, 261.70710678117024, 261.70710678117024)
current_item.shape().controlPointRect() = PyQt5.QtCore.QRectF(-30.707106781186553, -31.09763107291604, 261.8047378541026, 261.8047378541026)

I found some questions here addressing the same problem, but they are in C++

I understand that I have to reimplement the shape() of my line... BUT I don't have a clue how to do this in my example...

Can somebody give me a hint, how to solve this?

I haven't found some information on the internet regarding problems like this. If you know any Websites with tutorials on these topics, I would be very pleased to know them. Or a book dealing with Graphics in PyQt5 would be also great.

Thanks in advance :-)


Solution

  • I found a solution for my problem...Or better I tried out something and I realized that my original problem is not a problem, but a feature lol

    My original problem was that I cannot select the GraphicsItem I would like to select, because there are more than one Item at the scenePosition, or they are intersecting, which is the same when you look on a certain scenePostion. It's like a stack of paper...if you want the 3rd piece of paper from the top, then you have to chose the 3rd paper of the top...using itemAt(scenePosition, ...) is like saying "Give me a paper from the paper stack on the table!"...so you get the topmost piece of paper. Therefore I decided to take items(scenePos) of the QGraphicsScene and to loop through the list of the items at the scenePos and to pick the ones I need, and/or let the user decide.

    But I added some Code to my original script. Now I can see the actual shape of a GraphicsItem. At least for the "standard"-ones, like a line, circle, polygon. And what I saw is that the shapes are VERY precise: The black outline shows the shape of the drawn polygon

    And I also tried to implement an own method for the shape of a GraphicsItem. But I am pretty sure that it's not that easy... I use my_GraphicsItem.shape = my_shape() Here's the Code:

    
    # coding=utf-8
    
    # File 4 stackoverflow question about the usage of PyQt5.QtWidgets.QGraphicsItem.shape()
    
    from PyQt5.QtWidgets import (
        QApplication,
        QWidget,
        QGraphicsView,
        QGraphicsScene,
        QGraphicsSceneMouseEvent,
        QGraphicsItem,
        QTextEdit,
        QGraphicsLineItem,
        QStyleOptionGraphicsItem, QGraphicsEllipseItem, QGraphicsTextItem, QGraphicsRectItem
        )
    from PyQt5.QtGui import (
        QPolygonF, QPen, QTransform, QPainter,
        QPainterPath, QPixmap, QBrush, QColor, QFont, QIcon)
    from PyQt5.QtCore import Qt, QPointF, QLineF, QRectF
    import sys
    
    ppath = None
    
    # Own QGraphicsScene Subclass with some drawings and points as example
    class MyScene(QGraphicsScene):
        def __init__(self):
            super().__init__(-300, -300, 600, 600)
            self.ppath = None
            # Set QPen for drawings
            self.my_pen = QPen(Qt.darkRed)
            self.my_pen.setWidthF(15)
            self.my_pen.setCapStyle( Qt.RoundCap )
    
            # Set QBrush for drawings
            self.my_brush = QBrush( QColor( Qt.darkRed ),
                                    Qt.BrushStyle( Qt.SolidPattern ) )
    
    
            
            # Draw a circle ( shape is created by my method.
            #               The created shape is senseless for a circle,
            #               and I guess, that this is NOT the right way to do it...)
            self.circle = self.addEllipse(
                    QRectF( QPointF( -200, 100 ), QPointF( -100, 50 )),
                    self.my_pen,
                    self.my_brush
                    )
            self.circle.setData(0, "Circle")
            self.circle.setFlags(QGraphicsItem.ItemIsMovable)
            self.circle.shape = self.my_shape() # This ca
            
            
            # Set Start- & End-Points for my line
            self.start = QPointF(-250, -250)
            self.end = QPointF(-100, -100)
            
            # Draw a line (shape is created automatically)
            self.my_pen.setColor(Qt.green)
            self.my_pen.setWidth(25)
            
            self.lin = QGraphicsLineItem(QLineF(self.start, self.end))
            self.lin.setPen(self.my_pen)
            self.lin.setFlags(QGraphicsItem.ItemIsMovable)
            self.lin.setData( 0, "Line" )
            self.addItem(self.lin)
            
            # Change Pen-Settings
            self.my_pen.setWidthF(5)
            self.my_pen.setColor(Qt.black)
            self.my_pen.setStyle(Qt.SolidLine)
            
            # Draw polygon
            self.my_brush.setColor(Qt.darkBlue)
            self.poly = self.addPolygon(
                    QPolygonF(
                            [ QPointF( 10, -30 ),
                              QPointF( -30, 20 ),
                              QPointF( 180, 230 ),
                              QPointF( 230, 180 ),
                              QPointF( 270, 270 ),
                              QPointF( 240, 290 )
                              ] ),
                    self.my_pen,
                    self.my_brush
                    )
            self.poly.setFlags( QGraphicsItem.ItemIsMovable)
            self.poly.setData( 0, "Polygon" )
        
        # Reimplementing the mousePressEvent for catching some information
        def mousePressEvent(self, sceneEvent: 'QGraphicsSceneMouseEvent'):
            if sceneEvent.button() == Qt.LeftButton:
                text: str = ""
                # Get position and item at the position of the event
                pp = sceneEvent.scenePos()
                current_item = self.itemAt( pp, QTransform() )
                # So if there is an item at the clicked position, then write some information
                if current_item is not None:
                    if current_item.data(0) != "Circle":
                        text = f"ItemData = {current_item.data( 0 )}\n" \
                               f"scenePos() = {pp} \n" \
                               f"screenPos() = {sceneEvent.screenPos()}\n" \
                               f"current_item.boundingRect() = " \
                               f"{current_item.boundingRect()}\n" \
                               f"current_item.shape() = {current_item.shape()}\n" \
                               f"current_item.shape().toFillPolygon() = " \
                               f"{current_item.shape().toFillPolygon()}\n"
                        self.my_pen.setColor(Qt.black)
                        self.my_pen.setWidth(2)
                        if self.ppath is not None:
                            self.removeItem(self.ppath)
                        self.ppath = self.addPolygon(current_item.shape().toFillPolygon(), self.my_pen)
               
                        # took this from here:
                        # https://doc.qt.io/qtforpython-5/PySide2/QtGui/QPainterPath.html#PySide2.QtGui.PySide2.QtGui.QPainterPath.toFillPolygon
                    else:
                        text = f"My Circle shape is not callable....however...\n" \
                            f"ItemData = {current_item.data( 0 )}\n" \
                               f"scenePos() = {pp} \n" \
                               f"screenPos() = {sceneEvent.screenPos()}\n" \
                               f"current_item.boundingRect() = " \
                               f"{current_item.boundingRect()}\n"
                else:
                    text = f"scenePos = {pp}  But here is nothing"
                my_gui.my_textedit.setText( text )
            super().mousePressEvent( sceneEvent )
            # @ musicamente: As in this example I have only 2 Items which could be detected by
            # sceneEvent.scenePos() I call the super().mousePressEvent( sceneEvent ) at the end, so that I can move around
            # my items after all the stuff done above in the code.
            
        # This is surely NOT the right way...however
        def my_shape( self):
            my_painter_path = QPainterPath()
            my_painter_path.addPolygon(QPolygonF( [
                                    QPointF( 20, -30 ),
                                    QPointF( -30, 20 ),
                                    QPointF( 180, 230 ),
                                    QPointF( 230, 180 ),
                                    ] ))
            return my_painter_path
        #
    
            
    # The Class/Widget for placing the view for placing
    class MyGui(QWidget):
        def __init__(self):
            super().__init__()
            self.setGeometry(50, 50, 800, 800)
            self.my_scene = MyScene()
            self.my_view = QGraphicsView(self)
            self.my_view.setScene(self.my_scene)
            
            # QTextEdit for displaying some stuff for debugging
            self.my_textedit = QTextEdit(self)
            self.my_textedit.setGeometry(0, 610, 600, 150)
    
    
    
    # Starting the App
    my_app = QApplication(sys.argv)
    my_gui = MyGui()
    my_gui.show()
    
    sys.exit(my_app.exec())
    
    
    

    I hope this might be helpful to somebody, except of me ;-)