Search code examples
pythonpyqtpyqt5qgraphicssceneqpainterpath

Create closed path with moveable nodes in QGraphicScene


I'm trying to extend the great implementation from this answer so the path will be created from the user's clicks inside the graphic-scene and close the path using a double-click.

This works well, but the only issue I couldn't solve yet is the changing of the first node in the path. Obviously I wish it will update the last subpath attached to it through the itemChanged method, but couldn't make it happen.

Any suggestions on how to approach this?

I tried the following variation but it didn't affect the last subpath:

def itemChange(self, change, value):
    if change == QGraphicsItem.ItemPositionChange:
        self.path.updateElement(self.index, value.toPoint())
        if self.index == 0:
            last_element_idx = self.path.path.elementCount()
            self.path.updateElement(last_element_idx, value.toPoint())
    return QGraphicsEllipseItem.itemChange(self, change, value)

The full code for my experiment:

from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QPointF, Qt
from PyQt5.QtGui import QPen, QPainterPath, QPainter, QPolygonF
from PyQt5.QtWidgets import QGraphicsEllipseItem, QGraphicsItem, QGraphicsPathItem, QApplication, QGraphicsScene, \
    QGraphicsView, QFrame, QMainWindow

rad = 3

class Node(QGraphicsEllipseItem):
    def __init__(self, path, index):
        super(Node, self).__init__(-rad, -rad, 2*rad, 2*rad)
        self.rad = rad
        self.path = path
        self.index = index
        self.setZValue(1)
        self.setFlag(QGraphicsItem.ItemIsMovable)
        self.setFlag(QGraphicsItem.ItemSendsGeometryChanges)
        self.setBrush(Qt.green)

    def itemChange(self, change, value):
        if change == QGraphicsItem.ItemPositionChange:
            self.path.updateElement(self.index, value.toPoint())
            if self.index == 0:
                last_element_idx = self.path.path.elementCount()
                self.path.updateElement(last_element_idx, value.toPoint())
        return QGraphicsEllipseItem.itemChange(self, change, value)

class Path(QGraphicsPathItem):
    def __init__(self, pos, scene):
        self.path = QPainterPath()
        self.path.moveTo(*pos)
        super(Path, self).__init__(self.path)
        self.scene = scene
        self.pos = pos
        self.setPen(QPen(Qt.red, 1.75))
        self.scene.addItem(self)

    def addElement(self, pos):
        self.path.lineTo(QPointF(*pos))
        self.setPath(self.path)
        self.scene.update()

    def closePath(self):
        self.path.closeSubpath()
        n = self.path.elementCount()
        for i in range(n-1):
            node = Node(self, i)
            elem = self.path.elementAt(i)
            node.setPos(elem.x, elem.y)
            self.scene.addItem(node)
        self.setPath(self.path)
        self.scene.update()

    def updateElement(self, index, pos):
        self.path.setElementPositionAt(index, pos.x(), pos.y())
        self.setPath(self.path)
        self.scene.update()

class GraphicViewer(QGraphicsView):
    def __init__(self, parent, img=None, masks=None):
        super(GraphicViewer, self).__init__(parent)
        self.img = img
        self._scene = QGraphicsScene(self)
        self._scene.setSceneRect(0, 0, 1920, 900)
        self.setScene(self._scene)
        self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
        self.setResizeAnchor(QGraphicsView.AnchorUnderMouse)
        self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
        self.setBackgroundBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0)))
        self.setFrameShape(QFrame.NoFrame)
        self.setRenderHint(QPainter.Antialiasing)
        self.resize(1920, 900)
        self.editable_path = None

    def mousePressEvent(self, event: QtGui.QMouseEvent):
        modifiers = QApplication.keyboardModifiers()
        if modifiers == QtCore.Qt.AltModifier:
            self.pos = (event.x(), event.y())
            if self.editable_path is None:
                self.editable_path = Path(self.pos, self._scene)
            else:
                self.editable_path.addElement(self.pos)
        else:
            super(GraphicViewer, self).mousePressEvent(event)

    def mouseDoubleClickEvent(self, event: QtGui.QMouseEvent):
        if self.editable_path is not None:
            self.editable_path.closePath()
            self.editable_path = None

if __name__ == "__main__":

    app = QApplication([])
    MainWindow = QMainWindow()
    view = GraphicViewer(MainWindow)
    MainWindow.resize(1920, 900)
    MainWindow.show()
    app.exec_()

Solution

  • You almost had it, but your itemChange method has an off-by-one error.

    I would re-write your example to look like this:

    class Node(QGraphicsEllipseItem):
        ...
        def itemChange(self, change, value):
            if change == QGraphicsItem.ItemPositionChange:
                self.path.updateElement(self.index, value.toPoint())
            return QGraphicsEllipseItem.itemChange(self, change, value)
    
    class Path(QGraphicsPathItem):
        ...    
        def updateElement(self, index, pos):
            self.path.setElementPositionAt(index, pos.x(), pos.y())
            if index == 0:
                self.path.setElementPositionAt(
                    self.path.elementCount() - 1, pos.x(), pos.y())
            self.setPath(self.path)
            self.scene.update()