1

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()
alec
  • 5,799
  • 1
  • 7
  • 20
drivereye
  • 33
  • 5
  • Avoding collision of shapes (especially with multiple/different shapes) is not an easy task. You have to find an algorithm on your own that suits your needs, based on lots of aspects, *including* the previous position of the item(s). There are lots of studies on the matter, and there's no easy/absolute solution. Consider the basic example of two rectangles that are possible colliding items, and the moving one that cannot fit the space within them: what should be the behavior of that movement? At which point the moving item should stop or "jump"? And what about collision with moving items? – musicamante Nov 19 '21 at 03:05
  • Ellipses make that even harder, as you have to consider collision based on trigonometric functions, and that's just assuming that you're dealing with "regular" circles and their axis are orthogonal. What about a rotated ellipse? See the Wikipedia article about [Collision detection](//en.wikipedia.org/wiki/Collision_detection). – musicamante Nov 19 '21 at 03:08
  • Thank you for explaining I see it is a hard problem. I am trying to take it step by step dealing only with circles and to handle 2 collisions also – drivereye Nov 19 '21 at 04:41

1 Answers1

2

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))
alec
  • 5,799
  • 1
  • 7
  • 20
  • The problem still remains: what about *unknown* number of possibly colliding items? – musicamante Nov 19 '21 at 04:19
  • @musicamante The question is only asking how to "extend the code for len(colliding)==1 to work for 2 collision items". – alec Nov 19 '21 at 04:21
  • You fixed my issue but I dont want the circle to jump to the other intersection point. Say, if yellow circle is on the right side of the 2 greens and you move the mouse to the left, it should not jump to the left side because the green ones are blocking it. – drivereye Nov 19 '21 at 04:42
  • @drivereye See revised – alec Nov 19 '21 at 05:09
  • Thank you so much for real. I will try with more circles too. Can it also work with any number of collision items? – drivereye Nov 19 '21 at 06:01
  • No. If you collide with two items before hitting a 3rd it might handle that by default, but otherwise it's tailored to your example for two circles – alec Nov 19 '21 at 06:02
  • Ok I see.. it prevents the overlap a lot of the time but the items feel snapped in a place when 4 or more clustered together. It wish the QGraphicscene had an option for this to prevent all collisions. Do you think it can be done? – drivereye Nov 19 '21 at 06:04
  • @drivereye It's not going to be perfect, especially as mouse events can jump long distances if moved fast enough. Maybe it can be done, but as musicamante described it's a very complex problem and at this point, and based on the behavior you described, I would use a physics engine. – alec Nov 19 '21 at 06:12
  • OK. Can you show me an example what you mean or where to start? Thanks again – drivereye Nov 19 '21 at 18:47
  • @drivereye See [this](https://stackoverflow.com/q/70074528/9435771) – alec Nov 23 '21 at 03:28