简体   繁体   中英

How to connect two QGraphicsItem by drawing line between them (using mouse)

I have some custom items in the scene. I would like to allow the user to connect the two items using the mouse. I checked an answer in this question but there wasn't a provision to let users connect the two points. (Also, note that item must be movable)

Here is a demonstration of how I want it to be:

在此处输入图像描述

I want the connection between the two ellipses as shown above

Can I know how this can be done?

For this, you might have to implement your own scene class by inheriting QGraphicsScene and overriding the mouse events.

Here is the code which you may improve:

import sys
from PyQt5 import QtWidgets, QtCore, QtGui


class CustomItem(QtWidgets.QGraphicsItem):
    def __init__(self, pointONLeft=False, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.ellipseOnLeft = pointONLeft
        self.point = None
        self.endPoint =None

        self.isStart = None

        self.line = None

        self.setAcceptHoverEvents(True)
        self.setFlag(self.ItemIsMovable)
        self.setFlag(self.ItemSendsGeometryChanges)

    def addLine(self, line, ispoint):
        if not self.line:
            self.line = line
            self.isStart = ispoint

    def itemChange(self, change, value):

        if change == self.ItemPositionChange and self.scene():
            self.moveLineToCenter(value)

        return super(CustomItem, self).itemChange(change, value)

    def moveLineToCenter(self, newPos): # moves line to center of the ellipse

        if self.line:

            if self.ellipseOnLeft:
                xOffset = QtCore.QRectF(-5, 30, 10, 10).x() + 5
                yOffset = QtCore.QRectF(-5, 30, 10, 10).y() + 5

            else:
                xOffset = QtCore.QRectF(95, 30, 10, 10).x() + 5
                yOffset = QtCore.QRectF(95, 30, 10, 10).y() + 5

            newCenterPos = QtCore.QPointF(newPos.x() + xOffset, newPos.y() + yOffset)

            p1 = newCenterPos if self.isStart else self.line.line().p1()
            p2 =  self.line.line().p2() if self.isStart else newCenterPos

            self.line.setLine(QtCore.QLineF(p1, p2))

    def containsPoint(self, pos):  # checks whether the mouse is inside the ellipse
        x = self.mapToScene(QtCore.QRectF(-5, 30, 10, 10).adjusted(-0.5, 0.5, 0.5, 0.5)).containsPoint(pos, QtCore.Qt.OddEvenFill) or \
            self.mapToScene(QtCore.QRectF(95, 30, 10, 10).adjusted(0.5, 0.5, 0.5, 0.5)).containsPoint(pos,
                                                                                                      QtCore.Qt.OddEvenFill)

        return x

    def boundingRect(self):
        return QtCore.QRectF(-5, 0, 110, 110)

    def paint(self, painter, option, widget):

        pen = QtGui.QPen(QtCore.Qt.red)
        pen.setWidth(2)

        painter.setPen(pen)

        painter.setBrush(QtGui.QBrush(QtGui.QColor(31, 176, 224)))
        painter.drawRoundedRect(QtCore.QRectF(0, 0, 100, 100), 4, 4)

        painter.setBrush(QtGui.QBrush(QtGui.QColor(214, 13, 36)))

        if self.ellipseOnLeft: # draws ellipse on left
            painter.drawEllipse(QtCore.QRectF(-5, 30, 10, 10))

        else: # draws ellipse on right
            painter.drawEllipse(QtCore.QRectF(95, 30, 10, 10))


# ------------------------Scene Class ----------------------------------- #
class Scene(QtWidgets.QGraphicsScene):
    def __init__(self):
        super(Scene, self).__init__()
        self.startPoint = None
        self.endPoint = None

        self.line = None
        self.graphics_line = None

        self.item1 = None
        self.item2 = None

    def mousePressEvent(self, event):
        self.line = None
        self.graphics_line = None

        self.item1 = None
        self.item2 = None

        self.startPoint = None
        self.endPoint = None

        if self.itemAt(event.scenePos(), QtGui.QTransform()) and isinstance(self.itemAt(event.scenePos(),
                                                                            QtGui.QTransform()), CustomItem):

            self.item1 = self.itemAt(event.scenePos(), QtGui.QTransform())
            self.checkPoint1(event.scenePos())

            if self.startPoint:
                self.line = QtCore.QLineF(self.startPoint, self.endPoint)
                self.graphics_line = self.addLine(self.line)

                self.update_path()

        super(Scene, self).mousePressEvent(event)

    def mouseMoveEvent(self, event):

        if event.buttons() & QtCore.Qt.LeftButton and self.startPoint:
            self.endPoint = event.scenePos()
            self.update_path()

        super(Scene, self).mouseMoveEvent(event)

    def filterCollidingItems(self, items):  #  filters out all the colliding items and returns only instances of CustomItem
        return [x for x in items if isinstance(x, CustomItem) and x != self.item1]

    def mouseReleaseEvent(self, event):

        if self.graphics_line:

            self.checkPoint2(event.scenePos())
            self.update_path()

            if self.item2 and not self.item1.line and not self.item2.line:
                self.item1.addLine(self.graphics_line, True)
                self.item2.addLine(self.graphics_line, False)

            else:
                if self.graphics_line:
                    self.removeItem(self.graphics_line)

        super(Scene, self).mouseReleaseEvent(event)

    def checkPoint1(self, pos):

        if self.item1.containsPoint(pos):

            self.item1.setFlag(self.item1.ItemIsMovable, False)
            self.startPoint = self.endPoint = pos

        else:
            self.item1.setFlag(self.item1.ItemIsMovable, True)

    def checkPoint2(self, pos):

        item_lst = self.filterCollidingItems(self.graphics_line.collidingItems())
        contains = False

        if not item_lst:  # checks if there are any items in the list
            return

        for self.item2 in item_lst:
            if self.item2.containsPoint(pos):
                contains = True
                self.endPoint = pos
                break
   
        if not contains:
            self.item2 = None

    def update_path(self):
        if self.startPoint and self.endPoint:
            self.line.setP2(self.endPoint)
            self.graphics_line.setLine(self.line)


def main():
    app = QtWidgets.QApplication(sys.argv)
    scene = Scene()

    item1 = CustomItem(True)
    scene.addItem(item1)

    item2 = CustomItem()
    scene.addItem(item2)

    view = QtWidgets.QGraphicsView(scene)
    view.setViewportUpdateMode(view.FullViewportUpdate)
    view.setMouseTracking(True)

    view.show()

    sys.exit(app.exec_())


if __name__ == '__main__':
    main()

Explanation of the above code:

I make my own custom Item by inheriting the QGraphicsItem. pointONLeft=False is to check which side the ellipse is to be drawn. If pointONLeft=True , then red circle that you see in the question's image will be drawn on the left.

  • The addLine , itemChange and moveLineToCenter methods are taken from here . I suggest you go through that answer before moving on.

  • The containsPoint method inside the CustomItem checks whether the mouse is inside the circle. This method will be accessed from the custom Scene , if the mouse is inside the circle it will disable the movement by using CustomiItem.setFlag(CustomItem.ItemIsMovable, False) .

  • To draw the line I use the QLineF provided by PyQt. If you want to know how to draw a straight line by dragging I suggest you refer this . while the explanation is for qpainterpath same can be applied here.

  • The collidingItems() is a method provided by QGraphicsItem . It returns all the items that are colliding including the line itself. So, I created the filterCollidingItems to filter out only the items that are instances of CustomItem .

(Also, note that collidingItems() returns the colliding items in the reverse order they are inserted i,e if CustomItem1 is inserted first and CustomItem second then if the line collides the second item will be returned first. So if two items are on each other and the line is colliding then the last inserted item will become item2 you can change this by changing the z value )

Readers can add suggestions or queries in the comments. If you have a better answer, feel free to write.

While the solution proposed by JacksonPro is fine, I'd like to provide a slightly different concept that adds some benefits:

  • improved object structure and control;
  • more reliable collision detection;
  • painting is slightly simplified by making it more object-compliant;
  • better readability (mostly by using less variables and functions);
  • clearer connection creation (the line "snaps" to control points);
  • possibility to have control points on both sides (also preventing connections on the same side) and to remove a connection if already exists (by "connecting" again the same points);
  • connections between multiple control points;
  • it's not longer;-)

The idea is to have control points that are actual QGraphicsItem objects (QGraphicsEllipseItem) and children of CustomItem.
This not only simplifies painting, but also improves object collision detection and management: there is no need for a complex function to draw the new line, and creating an ellipse that is drawn around its pos ensures that we already know the targets of the line by getting their scenePos() ; this also makes it much more easy to detect if the mouse cursor is actually inside a control point or not.

Note that for simplification reasons I set some properties as class members. If you want to create subclasses of the item for more advanced or customized controls, those parameters should be created as instance attributes; in that case, you might prefer to inherit from QGraphicsRectItem: even if you'll still need to override the painting in order to draw a rounded rect, it will make it easier to set its properties (pen, brush and rectangle) and even change them during runtime, so that you only need to access those properties within paint() , while also ensuring that updates are correctly called when Qt requires it.

from PyQt5 import QtCore, QtGui, QtWidgets

class Connection(QtWidgets.QGraphicsLineItem):
    def __init__(self, start, p2):
        super().__init__()
        self.start = start
        self.end = None
        self._line = QtCore.QLineF(start.scenePos(), p2)
        self.setLine(self._line)

    def controlPoints(self):
        return self.start, self.end

    def setP2(self, p2):
        self._line.setP2(p2)
        self.setLine(self._line)

    def setStart(self, start):
        self.start = start
        self.updateLine()

    def setEnd(self, end):
        self.end = end
        self.updateLine(end)

    def updateLine(self, source):
        if source == self.start:
            self._line.setP1(source.scenePos())
        else:
            self._line.setP2(source.scenePos())
        self.setLine(self._line)


class ControlPoint(QtWidgets.QGraphicsEllipseItem):
    def __init__(self, parent, onLeft):
        super().__init__(-5, -5, 10, 10, parent)
        self.onLeft = onLeft
        self.lines = []
        # this flag **must** be set after creating self.lines!
        self.setFlags(self.ItemSendsScenePositionChanges)

    def addLine(self, lineItem):
        for existing in self.lines:
            if existing.controlPoints() == lineItem.controlPoints():
                # another line with the same control points already exists
                return False
        self.lines.append(lineItem)
        return True

    def removeLine(self, lineItem):
        for existing in self.lines:
            if existing.controlPoints() == lineItem.controlPoints():
                self.scene().removeItem(existing)
                self.lines.remove(existing)
                return True
        return False

    def itemChange(self, change, value):
        for line in self.lines:
            line.updateLine(self)
        return super().itemChange(change, value)


class CustomItem(QtWidgets.QGraphicsItem):
    pen = QtGui.QPen(QtCore.Qt.red, 2)
    brush = QtGui.QBrush(QtGui.QColor(31, 176, 224))
    controlBrush = QtGui.QBrush(QtGui.QColor(214, 13, 36))
    rect = QtCore.QRectF(0, 0, 100, 100)

    def __init__(self, left=False, right=False, *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.setFlags(self.ItemIsMovable)

        self.controls = []

        for onLeft, create in enumerate((right, left)):
            if create:
                control = ControlPoint(self, onLeft)
                self.controls.append(control)
                control.setPen(self.pen)
                control.setBrush(self.controlBrush)
                if onLeft:
                    control.setX(100)
                control.setY(35)

    def boundingRect(self):
        adjust = self.pen.width() / 2
        return self.rect.adjusted(-adjust, -adjust, adjust, adjust)

    def paint(self, painter, option, widget=None):
        painter.save()
        painter.setPen(self.pen)
        painter.setBrush(self.brush)
        painter.drawRoundedRect(self.rect, 4, 4)
        painter.restore()


class Scene(QtWidgets.QGraphicsScene):
    startItem = newConnection = None
    def controlPointAt(self, pos):
        mask = QtGui.QPainterPath()
        mask.setFillRule(QtCore.Qt.WindingFill)
        for item in self.items(pos):
            if mask.contains(pos):
                # ignore objects hidden by others
                return
            if isinstance(item, ControlPoint):
                return item
            if not isinstance(item, Connection):
                mask.addPath(item.shape().translated(item.scenePos()))

    def mousePressEvent(self, event):
        if event.button() == QtCore.Qt.LeftButton:
            item = self.controlPointAt(event.scenePos())
            if item:
                self.startItem = item
                self.newConnection = Connection(item, event.scenePos())
                self.addItem(self.newConnection)
                return
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.newConnection:
            item = self.controlPointAt(event.scenePos())
            if (item and item != self.startItem and
                self.startItem.onLeft != item.onLeft):
                    p2 = item.scenePos()
            else:
                p2 = event.scenePos()
            self.newConnection.setP2(p2)
            return
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if self.newConnection:
            item = self.controlPointAt(event.scenePos())
            if item and item != self.startItem:
                self.newConnection.setEnd(item)
                if self.startItem.addLine(self.newConnection):
                    item.addLine(self.newConnection)
                else:
                    # delete the connection if it exists; remove the following
                    # line if this feature is not required
                    self.startItem.removeLine(self.newConnection)
                    self.removeItem(self.newConnection)
            else:
                self.removeItem(self.newConnection)
        self.startItem = self.newConnection = None
        super().mouseReleaseEvent(event)


def main():
    import sys
    app = QtWidgets.QApplication(sys.argv)
    scene = Scene()

    scene.addItem(CustomItem(left=True))
    scene.addItem(CustomItem(left=True))

    scene.addItem(CustomItem(right=True))
    scene.addItem(CustomItem(right=True))

    view = QtWidgets.QGraphicsView(scene)
    view.setRenderHints(QtGui.QPainter.Antialiasing)

    view.show()

    sys.exit(app.exec_())


if __name__ == '__main__':
    main()

A small suggestion: I've seen that you have the habit of always creating objects in the paint method, even if those values are normally "hardcoded"; one of the most important aspects of the Graphics View framework is its performance, which can be obviously partially degraded by python, so if you have properties that are constant during runtime (rectangles, pens, brushes) it's usually better to make them more "static", at least as instance attributes, in order to simplify the painting as much as possible.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM