如何通过在它们之间画线来连接两个 QGraphicsItem(使用鼠标)

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

我在场景中有一些自定义物品。我想允许用户使用鼠标连接这两个项目。我检查了 问题中的答案,但没有规定让用户连接两点。 (另请注意,物品必须是可移动的)

这是我想要的演示:

我想要两个椭圆之间的连接如上图

我能知道怎么做吗?

为此,您可能必须通过继承 QGraphicsScene 并覆盖鼠标事件来实现自己的场景 class。

这是您可以改进的代码:

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()

上面代码的解释:

我通过继承QGraphicsItem 制作了自己的自定义Item。 pointONLeft=False是检查椭圆要画在哪一边。如果 pointONLeft=True,那么您在问题图像中看到的红色圆圈将绘制在左侧。

  • addLineitemChangemoveLineToCenter方法取自。我建议您在继续之前仔细阅读该答案。

  • CustomItem里面的containsPoint方法检查鼠标是否在圆圈内。此方法将从自定义 Scene 访问,如果鼠标在圆圈内,它将通过使用 CustomiItem.setFlag(CustomItem.ItemIsMovable, False).

    禁用移动
  • 为了画线,我使用 PyQt 提供的 QLineF。如果你想知道如何通过拖动画一条直线,我建议你参考。虽然解释是针对 qpainterpath 的,但同样适用于此处。

  • collidingItems()QGraphicsItem提供的方法。它 returns 所有碰撞的项目,包括线本身。因此,我创建了 filterCollidingItems 以仅过滤掉属于 CustomItem.

    实例的项目

(另外,请注意 collidingItems() returns 以相反的顺序插入冲突项,即如果首先插入 CustomItem1,然后插入 CustomItem,那么如果该行发生碰撞,第二个项目将首先返回。因此,如果两个项目彼此重叠并且行发生碰撞,那么最后插入的项目将变为 item2 您可以通过更改 z value)

来更改此设置

读者可以在评论中提出建议或疑问。如果你有更好的答案,欢迎留言。

虽然解决方案 很好,但我想提供一个略有不同的概念来增加一些好处:

  • 改进了对象结构和控制;
  • 更可靠的碰撞检测;
  • 通过使其更符合对象,绘画略有简化;
  • 更好的可读性(主要是通过使用更少的变量和函数);
  • 更清晰的连接创建(线“捕捉”到控制点);
  • 可以在两侧都有控制点(也可以防止同一侧的连接)并删除已经存在的连接(通过再次“连接”相同的点);
  • 多个控制点之间的连接;
  • 它不再是 ;-)

想法是控制点是 actual QGraphicsItem 对象 (QGraphicsEllipseItem) 和 CustomItem 的 children
这不仅简化了绘画,还改进了对象碰撞检测和管理:不需要复杂的函数来绘制新线,并创建一个绘制在 aroundpos 通过获取目标 scenePos() 确保我们已经知道该行的目标;这也使得检测鼠标光标是否确实在控制点内变得更加容易。

请注意,出于简化原因,我将一些属性设置为 class 成员。如果要为更高级或自定义的控件创建项目的子class,则应将这些参数创建为实例属性;在这种情况下,您可能更愿意继承自 QGraphicsRectItem:即使您仍然需要重写绘画以绘制圆角矩形,它也可以更轻松地设置其属性(钢笔、画笔和矩形)甚至更改它们在运行时运行,因此您只需要在 paint() 中访问这些属性,同时还确保在 Qt 需要时正确调用更新。

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()

一个小建议:我发现您有总是在 paint 方法中创建对象的习惯,即使这些值通常是“硬编码”的; Graphics View 框架最重要的方面之一是它的性能,python 显然会部分降低性能,因此如果您有在运行时保持不变的属性(矩形、钢笔、画笔),通常最好使它们更“静态”,至少作为实例属性,以尽可能简化绘画。