0

I wrote some code to show a circle and a rectangle randomly on the screen with PyQt6. and I want to detect if these two objects have a collision then I make them red otherwise I make them green.

But how should I detect whether there is a collision or not?

here is my code

from random import randint
from sys import argv
from PyQt6.QtCore import QRect, QTimer, Qt, QMimeData
from PyQt6.QtGui import QColor, QKeyEvent, QMouseEvent, QPainter, QPen, QPaintEvent, QBrush, QDrag
from PyQt6.QtWidgets import QApplication, QVBoxLayout, QMainWindow, QPushButton

class Window(QMainWindow):
    def __init__(self) -> None:
        super().__init__()
        screenWidth = 1920
        screenHeight = 1080
        self.isRunning = True
        self.windowWidth = 1200
        self.windowHeight = 800
        self.clockCounterVariable = 0
        self.milSec = 0
        self.seconds = 0
        self.minutes = 0
        self.hours = 0
        self.setWindowTitle("Smart rockets")
        self.setGeometry((screenWidth - self.windowWidth) // 2, (screenHeight - self.windowHeight) // 2, self.windowWidth, self.windowHeight)
        self.setLayout(QVBoxLayout())
        self.setStyleSheet("background-color:rgb(20, 20, 20);font-size:20px;")
        self.clock = QTimer(self)
        self.clock.timeout.connect(self.clockCounter)
        self.clock.start(10)
        button = QPushButton("Refresh", self)
        button.setGeometry(20,self.windowHeight - 60,self.windowWidth - 40,40)
        button.setStyleSheet("background-color:rgb(80, 80, 80);font-size:20px;")
        button.setCheckable(True)
        button.clicked.connect(self.refreshRectAndCircle)
        rectangleWidth = randint(50, 500)
        rectangleHeight = randint(50, 500)
        self.rectangle = QRect(randint(0, self.windowWidth - rectangleWidth), randint(0, self.windowHeight - rectangleHeight - 80), rectangleWidth, rectangleHeight)
        circleRadius = randint(50, 200)
        self.circle = QRect(randint(0, self.windowWidth - circleRadius), randint(0, self.windowHeight - circleRadius - 80), circleRadius, circleRadius)
        self.show()

    def dragEnterEvent(self, event) -> super:
        event.accept()

    def keyPressEvent(self, event: QKeyEvent) -> super:
        key = QKeyEvent.key(event)
        if key == 112 or key == 80: # P/p
            if self.isRunning:
                self.clock.stop()
                print("pause process")
                self.isRunning = False
            else:
                print("continue process")
                self.isRunning = True
                self.clock.start(10)
        elif (key == 115) or (key == 83): # S/s
            self.closeWindow()
        return super().keyPressEvent(event)

    def mousePressEvent(self, event: QMouseEvent) -> super:
        if event.buttons() == Qt.MouseButton.LeftButton:
            if self.isRunning:
                self.clock.stop()
                print("pause process")
                self.isRunning = False
            else:
                print("continue process")
                self.isRunning = True
                self.clock.start(10)
        return super().mousePressEvent(event)

    def clockCounter(self) -> None:
        self.clockCounterVariable += 1
        self.update()

    def paintEvent(self, a0: QPaintEvent) -> super:
        painter = QPainter()
        self.milSec = self.clockCounterVariable
        self.seconds, self.milSec = divmod(self.milSec, 100)
        self.minutes, self.seconds = divmod(self.seconds, 60)
        self.hours, self.minutes = divmod(self.minutes, 60)
        painter.begin(self)
        painter.setPen(QPen(QColor(255, 128, 20),  1, Qt.PenStyle.SolidLine))
        painter.drawText(QRect(35, 30, 400, 30), Qt.AlignmentFlag.AlignLeft, "{:02d} : {:02d} : {:02d} : {:02d}".format(self.hours, self.minutes, self.seconds, self.milSec))
        if self.collided():
            painter.setPen(QPen(QColor(255, 20, 20),  0, Qt.PenStyle.SolidLine))
            painter.setBrush(QBrush(QColor(128, 20, 20), Qt.BrushStyle.SolidPattern))
        else:
            painter.setPen(QPen(QColor(20, 255, 20),  0, Qt.PenStyle.SolidLine))
            painter.setBrush(QBrush(QColor(20, 128, 20), Qt.BrushStyle.SolidPattern))
        painter.drawRect(self.rectangle)
        painter.drawEllipse(self.circle)
        painter.end()
        return super().paintEvent(a0)
    
    def refreshRectAndCircle(self) -> None:
        rectangleWidth = randint(50, 500)
        rectangleHeight = randint(50, 500)
        self.rectangle = QRect(randint(0, self.windowWidth - rectangleWidth), randint(0, self.windowHeight - rectangleHeight - 80), rectangleWidth, rectangleHeight)
        circleRadius = randint(50, 200)
        self.circle = QRect(randint(0, self.windowWidth - circleRadius), randint(0, self.windowHeight - circleRadius - 80), circleRadius, circleRadius)
        self.update()

    def collided(self) -> bool:
        # return True if collided and return False if not collided
        circle = self.circle
        rect = self.rectangle

if __name__ == "__main__":
    App = QApplication(argv)
    window = Window()
    App.exec()

how should I detect whether there is a collision between the circle and the rectangle or not?

DSA5252
  • 35
  • 6

1 Answers1

3

While you can achieve this with math functions, luckily Qt provides some useful functions that can make this much easier.

You can achieve this with three steps - or even just one (see the last section).

Check the center of the circle

If the center of the circle is within the boundaries of the rectangle, you can always assume that they collide. You're using a QRect, which is a rectangle that is always aligned to the axis, making things much easier.

Mathematically speaking you just need to ensure that the X of the center is between the smallest and biggest X of the left and right vertical lines of the rectangle, then the same for the Y.

Qt allows us to check if QRect.contains() the QRect.center() of the circle.

    def collided(self) -> bool:
        center = self.circle.center()
        if self.rectangle.contains(center):
            return True

Check the vertexes of the rectangle

If the length between the center of the circle and any of the vertexes of the rectangle is smaller than the radius, you can be sure that they are within the circle area.

Using the basic Pythagorean equation, you can know the hypotenuse created between the center and each of the vertexes of the rectangle, and if the hypotenuse is smaller than the radius, it means that they are within the circle.

With Qt we can use QLineF with the center and the vertexes (topLeft(), topRight(), bottomRight() and bottomLeft()), whenever any of the lengths is smaller than the radius, it means that the vertex is within the circle. Using QPolygonF we can easily iterate through all vertexes in a for loop.

        # ...
        center = QPointF(center)
        radius = self.circle.width() / 2
        corners = QPolygonF(QRectF(self.rectangle))[:4]
        for corner in corners:
            if QLineF(center, corner).length() < radius:
                return True

Check the closest side of the rectangle

It is possible that the circle only collides with a side of the rectangle: the center of the circle is outside of the rectangle, and none of the vertexes are within the circle.

Consider this case:

External collision

In this situations, the collision always happens whenever the perpendicular line of the closest side of the rectangle is smaller than the radius:

External collision with reference lines

Using math, we'll need to get the line perpendicular to the closest side, going toward the center of the circle, computing the angle between the side and the lines connecting the center with each vertex (shown in orange above), then with the help of some trigonometry, get the cathetus of one of the triangles (shown in red): if the length of that line is smaller than the radius, the shapes collide.

Luckily again, Qt can help us. We can get the two closest points using the lines created in the section "Check the vertexes of the rectangle" above, get the side of those points and compute a perpendicular angle that will be used to create a "diameter": starting from the center, we create two lines with opposite angles and the radius with the fromPolar(), then create the actual diameter with the external points of those lines. Finally, we check if that diameter intersects() with the side.

And this is the final function:

    def collided(self) -> bool:
        center = self.circle.center()
        if self.rectangle.contains(center):
            return True

        # use floating point based coordinates
        center = QPointF(center)
        radius = self.circle.width() / 2
        corners = QPolygonF(QRectF(self.rectangle))[:4]

        lines = []
        for corner in corners:
            line = QLineF(center, corner)
            if line.length() < radius:
                return True
            lines.append(line)

        # sort lines by their lengths
        lines.sort(key=lambda l: l.length())
        # create the side of the closest points
        segment = QLineF(lines[0].p2(), lines[1].p2())
        # the perpendicular angle, intersecting with the center of the circle
        perpAngle = (segment.angle() + 90) % 360

        # the ends of the "diameter" per pendicular to the side
        d1 = QLineF.fromPolar(radius, perpAngle).translated(center)
        d2 = QLineF.fromPolar(radius, perpAngle + 180).translated(center)
        # the actual diameter line
        diameterLine = QLineF(d1.p2(), d2.p2())
        # get the intersection type
        intersection = diameterLine.intersects(segment, QPointF())
        return intersection == QLineF.BoundedIntersection

Further considerations

  • when dealing with geometric shapes, you should consider using QPainterPath which actually makes the above extremely simpler:
    def collided(self) -> bool:
        circlePath = QPainterPath()
        circlePath.addEllipse(QRectF(self.circle))
        return circlePath.intersects(QRectF(self.rectangle))
  • Qt has a powerful (yet complex) Graphics View Framework that makes graphics and user interaction much more intuitive and effective; while the QPainter API is certainly easier for simpler cases, it may result in cumbersome (and difficult to debug) code as soon as your program requirements grow in complexity;

  • QMainWindow has its own, private and inaccessible layout manager, you cannot call setLayout() on it; use setCentralWidget() and set a layout to that widget eventually;

  • never use generic stylesheet properties for parent widgets (as you did for the main window) because it may result in awkward drawing of complex widgets like scroll areas; always use selector types for windows and containers instead;

  • unless you actually need to paint on the QMainWindow contents (which is a rare occurrence), you should always implement the paintEvent() on its central widget instead; otherwise, if you don't need QMainWindow features (menubar, statusbar, dock widgets and toolbars), just use a QWidget;

  • QTimer is not reliable for precise time measurement: if any function called while it's running requires more time than the timeout interval, the connected function will always be called afterwards; use QElapsedTimer instead;

  • in paintEvent() just use painter = QPainter(self), remove painter.begin(self) (it's implicit using the above) and painter.end() (unnecessary, since it's automatically destroyed when the function returns);

  • don't create unnecessary instance attributes (self.milSec, self.seconds, etc) that will be almost certainly overwritten sooner or later, and that you're not using elsewhere; the paint event must always return as soon as possible and must be always optimized as much as possible;

musicamante
  • 41,230
  • 6
  • 33
  • 58