Search code examples
pythonpyqt5qgraphicsscene

Extend graphics item movement around other items without overlap


I have a graphics scene with QGraphicsEllipseitem circles that are movable. I am trying to prevent the circles from overlapping by having the one that am dragging to move around the other circles that it collides into. So far it works for 1 collision item to set the minumum distance.

I am trying to extend the code for len(colliding)==1 to work for 2 collision items so what I tried is to apply the code to each of the colliding items. I apply the one with the more overlap first and then the second one.

When the collision items are the same size it is partly working, because it moves around without overlap, but it "glitches" around a lot so I know its not perfect. But when they are a different size then it doesnt work at all and still overlap. I don't know how to fix it.

class Circleitem(QGraphicsEllipseItem):

    def __init__(self, size, brush):
        super().__init__()
        radius = size / -2
        self.setRect(radius, radius, size, size)
        self.setBrush(brush)
        self.setFlag(self.ItemIsMovable)
        self.setFlag(self.ItemIsSelectable)

    def paint(self, painter, option, a):
        option.state = QStyle.State_None
        return super(Circleitem, self).paint(painter,option)

    def mouseMoveEvent(self, event):
        super().mouseMoveEvent(event)
        colliding = self.collidingItems()
        
        if len(colliding)==1:
            item = colliding[0]
            line = QLineF(item.pos(), self.pos() + QPoint(self.pos() == item.pos(), 0))
            min_distance = (self.rect().width() + item.rect().width()) / 2
            if line.length() < min_distance:
                line.setLength(min_distance)
                self.setPos(line.p2())

        elif len(colliding)==2:
            item0 = colliding[0]
            item1 = colliding[1]
            line0 = QLineF(item0.pos(), self.pos())
            line1 = QLineF(item1.pos(), self.pos())
            
            if line0.length() < line1.length():
                
                mindist = (self.rect().width() + item0.rect().width()) / 2
                if line0.length() < mindist:
                    line0.setLength(mindist)
                    self.setPos(line0.p2())

                second = item1
            else:
                mindist = (self.rect().width() + item1.rect().width()) / 2
            
                if line1.length() < mindist:
                    line1.setLength(mindist)
                    self.setPos(line1.p2())

                second = item0

            
            line = QLineF(second.pos(), self.pos())
            min_distance = (self.rect().width() + second.rect().width()) / 2
            if line.length() < min_distance:
                line.setLength(min_distance)
                self.setPos(line.p2())
            

    
class MainWindow(QMainWindow):

    def __init__(self):
        super().__init__()
        self.gscene = QGraphicsScene(0, 0, 1000, 1000)
        gview = QGraphicsView(self.gscene)
        self.setCentralWidget(gview)
        self.circle1 = Circleitem (123, brush=QColor(255,255,0))
        self.circle2 =Circleitem(80, brush=QColor(0,255,0))
        self.circle3 =Circleitem(80, brush=QColor(0,255,0))
        self.gscene.addItem(self.circle1)
        self.gscene.addItem(self.circle2)
        self.gscene.addItem(self.circle3)
        self.circle1.setPos(500, 500)
        self.circle2.setPos(300, 300)
        self.circle3.setPos(300, 400)
        self.show()



app = QApplication([])
win = MainWindow()
app.exec()

Solution

  • You can extend this for two colliding items by getting the point where the moving circle is tangent to both colliding circles.

    • Assume your circle R has radius r and collides with two other circles. The two other circles have centers A, B and radii a, b (shown in black).

    • The minimum distance there can be between circles R and A is the sum of their radii (r + a), so circle R is tangent to A when its center is any point along the circle given at center A and radius r + a (shown in gray).

    • Similarly, R is tangent to B at any point along the circle given at center B and radius r + b.

    • In order to respect both distances, you are looking for a point that lies on both gray circles, i.e. the intersection points I and J.

    enter image description here

    Therefore, circle R will be tangent to both circles A and B if its center lies at either point I or J. (Choose the one closer to your current position). Explanation of the formula is in Intersection of two circles on this page.

    class Circleitem(QGraphicsEllipseItem):
    
        def __init__(self, size, brush):
            super().__init__()
            radius = size / -2
            self.setRect(radius, radius, size, size)
            self.setBrush(brush)
            self.setFlag(self.ItemIsMovable)
            self.setFlag(self.ItemIsSelectable)
            self.last = Info()
    
        def paint(self, painter, option, a):
            option.state = QStyle.State_None
            return super(Circleitem, self).paint(painter,option)
    
        def mouseMoveEvent(self, event):
            super().mouseMoveEvent(event)
            if len(self.last.items) == 2 and self.last.intersects(self.pos()):
                return self.setPos(self.last.pos)
            
            colliding = self.collidingItems()
            if len(colliding) == 1:
                item = colliding[0]
                line = QLineF(item.pos(), self.pos() + QPoint(item.pos() == self.pos(), 0))
                min_distance = (self.rect().width() + item.rect().width()) / 2
                if line.length() < min_distance:
                    line.setLength(min_distance)
                    self.setPos(line.p2())
                    
                    colliding = self.collidingItems()
                    if len(colliding) >= 2:
                        i, j = self.tangentPositions(*colliding[:2])
                        self.setPos(self.closest(self.pos(), i, j))
                                            
            elif len(colliding) >= 2:
                i, j = self.tangentPositions(*colliding[:2])
                self.setPos(self.closest(self.pos(), i, j))
    
            self.last.update(colliding[:2], self.pos())
    
        def closest(self, pos, i, j):
            return i if QLineF(pos, i).length() < QLineF(pos, j).length() else j
    
        def tangentPositions(self, A, B):
            r = self.rect().width() / 2
            rA = r + A.rect().width() / 2
            rB = r + B.rect().width() / 2
            A = A.pos(); B = B.pos()
            d = QLineF(A, B).length()
            
            cd = (rA ** 2 - rB ** 2 + d ** 2) / (2 * d)         # chord distance
            h = abs(rA ** 2 - cd ** 2) ** 0.5 *(-1*(cd>rA)|1)   # half chord length
            mid = A + cd * (B - A) / d                          # chord midpoint
            dx = h * (B - A).y() / d
            dy = h * (B - A).x() / d
            return mid + QPointF(dx, -dy), mid + QPointF(-dx, dy)
    
    
    class Info:
        
        def __init__(self):
            self.update([], None)
            
        def update(self, items, pos):
            self.items = [x.pos() for x in items]
            self.pos = pos
    
        def intersects(self, point):
            return (QLineF(self.pos, point).intersect( # Qt 5.14+ use intersects()
                QLineF(*self.items), QPoint()) == QLineF.BoundedIntersection or
                    QPolygonF([self.pos, *self.items]).containsPoint(point, 0))