diff --git a/buildconfig/stubs/pygame/geometry.pyi b/buildconfig/stubs/pygame/geometry.pyi index c82da74b2c..14ab6563b0 100644 --- a/buildconfig/stubs/pygame/geometry.pyi +++ b/buildconfig/stubs/pygame/geometry.pyi @@ -4,6 +4,7 @@ from typing import ( Callable, Protocol, Tuple, + List, ) from pygame import Rect, FRect @@ -20,6 +21,7 @@ class _HasCirclettribute(Protocol): _CircleValue = Union[_CanBeCircle, _HasCirclettribute] _CanBeCollided = Union[Circle, Rect, FRect, Coordinate, Vector2] +_CanBeIntersected = Union[Circle] class Circle: @property @@ -93,6 +95,7 @@ class Circle: @overload def colliderect(self, topleft: Coordinate, size: Coordinate, /) -> bool: ... def collideswith(self, other: _CanBeCollided, /) -> bool: ... + def intersect(self, other: _CanBeIntersected, /) -> List[Tuple[float, float]]: ... def contains(self, shape: _CanBeCollided) -> bool: ... @overload def update(self, circle: _CircleValue, /) -> None: ... diff --git a/docs/reST/ref/geometry.rst b/docs/reST/ref/geometry.rst index dc16f6dec3..57ad92f423 100644 --- a/docs/reST/ref/geometry.rst +++ b/docs/reST/ref/geometry.rst @@ -279,6 +279,24 @@ .. ## Circle.move_ip ## + .. method:: intersect + + | :sl:`finds intersections between the circle and a shape` + | :sg:`intersect(circle, /) -> list` + + Finds and returns a list of intersection points between the circle and another shape. + The other shape must be a `Circle` object. + If the circle does not intersect or has infinite intersections, an empty list is returned. + + .. note:: + The shape argument must be an instance of the `Circle` class. + Passing a tuple or list of coordinates representing the shape is not supported, + as the type of shape cannot be determined from coordinates alone. + + .. versionadded:: 2.5.2 + + .. ## Circle.intersect ## + .. method:: update | :sl:`updates the circle position and radius` diff --git a/src_c/circle.c b/src_c/circle.c index beaee31741..31afb1fb2c 100644 --- a/src_c/circle.c +++ b/src_c/circle.c @@ -425,6 +425,28 @@ pg_circle_contains(pgCircleObject *self, PyObject *arg) return PyBool_FromLong(result); } +static PyObject * +pg_circle_intersect(pgCircleObject *self, PyObject *arg) +{ + pgCircleBase *scirc = &self->circle; + + /* max number of intersections when supporting: Circle (2), */ + double intersections[4]; + int num = 0; + + if (pgCircle_Check(arg)) { + pgCircleBase *other = &pgCircle_AsCircle(arg); + num = pgIntersection_CircleCircle(scirc, other, intersections); + } + else { + PyErr_Format(PyExc_TypeError, "Argument must be a CircleType, got %s", + Py_TYPE(arg)->tp_name); + return NULL; + } + + return pg_PointList_FromArrayDouble(intersections, num * 2); +} + static struct PyMethodDef pg_circle_methods[] = { {"collidepoint", (PyCFunction)pg_circle_collidepoint, METH_FASTCALL, DOC_CIRCLE_COLLIDEPOINT}, @@ -450,6 +472,8 @@ static struct PyMethodDef pg_circle_methods[] = { {"rotate_ip", (PyCFunction)pg_circle_rotate_ip, METH_FASTCALL, DOC_CIRCLE_ROTATEIP}, {"contains", (PyCFunction)pg_circle_contains, METH_O, DOC_CIRCLE_CONTAINS}, + {"intersect", (PyCFunction)pg_circle_intersect, METH_O, + DOC_CIRCLE_INTERSECT}, {NULL, NULL, 0, NULL}}; #define GETTER_SETTER(name) \ @@ -643,14 +667,6 @@ pg_circle_setdiameter(pgCircleObject *self, PyObject *value, void *closure) return 0; } -static int -double_compare(double a, double b) -{ - /* Uses both a fixed epsilon and an adaptive epsilon */ - const double e = 1e-6; - return fabs(a - b) < e || fabs(a - b) <= e * MAX(fabs(a), fabs(b)); -} - static PyObject * pg_circle_richcompare(PyObject *self, PyObject *other, int op) { diff --git a/src_c/doc/geometry_doc.h b/src_c/doc/geometry_doc.h index e179cbf874..89a7ef6f3d 100644 --- a/src_c/doc/geometry_doc.h +++ b/src_c/doc/geometry_doc.h @@ -16,6 +16,7 @@ #define DOC_CIRCLE_CONTAINS "contains(circle, /) -> bool\ncontains(rect, /) -> bool\ncontains((x, y), /) -> bool\ncontains(vector2, /) -> bool\ntests if a shape or point is inside the circle" #define DOC_CIRCLE_MOVE "move((x, y), /) -> Circle\nmove(x, y, /) -> Circle\nmove(vector2, /) -> Circle\nmoves the circle by a given amount" #define DOC_CIRCLE_MOVEIP "move_ip((x, y), /) -> None\nmove_ip(x, y, /) -> None\nmove_ip(vector2, /) -> None\nmoves the circle by a given amount, in place" +#define DOC_CIRCLE_INTERSECT "intersect(circle, /) -> list\nfinds intersections between the circle and a shape" #define DOC_CIRCLE_UPDATE "update((x, y), radius, /) -> None\nupdate(x, y, radius, /) -> None\nupdate(vector2, radius, /) -> None\nupdates the circle position and radius" #define DOC_CIRCLE_ROTATE "rotate(angle, rotation_point=Circle.center, /) -> Circle\nrotate(angle, /) -> Circle\nrotates the circle" #define DOC_CIRCLE_ROTATEIP "rotate_ip(angle, rotation_point=Circle.center, /) -> None\nrotate_ip(angle, /) -> None\nrotates the circle in place" diff --git a/src_c/geometry_common.c b/src_c/geometry_common.c index 57f4b1db37..07d84f0843 100644 --- a/src_c/geometry_common.c +++ b/src_c/geometry_common.c @@ -146,3 +146,11 @@ pgCircle_FromObjectFastcall(PyObject *const *args, Py_ssize_t nargs, return 0; } } + +static inline int +double_compare(double a, double b) +{ + /* Uses both a fixed epsilon and an adaptive epsilon */ + const double e = 1e-6; + return fabs(a - b) < e || fabs(a - b) <= e * MAX(fabs(a), fabs(b)); +} diff --git a/src_c/geometry_common.h b/src_c/geometry_common.h index 66628de282..115f248768 100644 --- a/src_c/geometry_common.h +++ b/src_c/geometry_common.h @@ -13,6 +13,9 @@ int pgCircle_FromObjectFastcall(PyObject *const *args, Py_ssize_t nargs, pgCircleBase *out); +static inline int +double_compare(double a, double b); + /* === Collision Functions === */ static inline int @@ -49,4 +52,49 @@ pgCollision_RectCircle(double rx, double ry, double rw, double rh, return pgCollision_CirclePoint(circle, test_x, test_y); } +static inline int +pgIntersection_CircleCircle(pgCircleBase *A, pgCircleBase *B, + double *intersections) +{ + double dx = B->x - A->x; + double dy = B->y - A->y; + double d2 = dx * dx + dy * dy; + double r_sum = A->r + B->r; + double r_diff = A->r - B->r; + double r_sum2 = r_sum * r_sum; + double r_diff2 = r_diff * r_diff; + + if (d2 > r_sum2 || d2 < r_diff2) { + return 0; + } + + if (double_compare(d2, 0) && double_compare(A->r, B->r)) { + return 0; + } + + double d = sqrt(d2); + double a = (d2 + A->r * A->r - B->r * B->r) / (2 * d); + double h = sqrt(A->r * A->r - a * a); + + double xm = A->x + a * (dx / d); + double ym = A->y + a * (dy / d); + + double xs1 = xm + h * (dy / d); + double ys1 = ym - h * (dx / d); + double xs2 = xm - h * (dy / d); + double ys2 = ym + h * (dx / d); + + if (double_compare(d2, r_sum2) || double_compare(d2, r_diff2)) { + intersections[0] = xs1; + intersections[1] = ys1; + return 1; + } + + intersections[0] = xs1; + intersections[1] = ys1; + intersections[2] = xs2; + intersections[3] = ys2; + return 2; +} + #endif // PYGAME_CE_GEOMETRY_COMMON_H diff --git a/src_c/include/_pygame.h b/src_c/include/_pygame.h index e1510b7756..5ff4882dfb 100644 --- a/src_c/include/_pygame.h +++ b/src_c/include/_pygame.h @@ -659,3 +659,32 @@ pg_tuple_couple_from_values_double(double val1, double val2) return tuple; } + +static PG_INLINE PyObject * +pg_PointList_FromArrayDouble(double const *array, int arr_length) +{ + if (arr_length % 2) { + return RAISE(PyExc_ValueError, "array length must be even"); + } + + int num_points = arr_length / 2; + PyObject *sequence = PyList_New(num_points); + if (!sequence) { + return NULL; + } + + int i; + PyObject *point = NULL; + for (i = 0; i < num_points; i++) { + point = + pg_tuple_couple_from_values_double(array[i * 2], array[i * 2 + 1]); + if (!point) { + Py_DECREF(sequence); + return NULL; + } + PyList_SET_ITEM(sequence, i, point); + point = NULL; + } + + return sequence; +} diff --git a/test/geometry_test.py b/test/geometry_test.py index f819a7a5f2..e95d43ba46 100644 --- a/test/geometry_test.py +++ b/test/geometry_test.py @@ -1295,6 +1295,60 @@ def test_contains_rect_frect(self): # on the edge self.assertTrue(c.contains(fr_edge)) + def test_intersect_argtype(self): + """Tests if the function correctly handles incorrect types as parameters""" + + invalid_types = (None, "1", (1,), 1, (1, 2, 3), True, False) + + c = Circle(10, 10, 4) + + for value in invalid_types: + with self.assertRaises(TypeError): + c.intersect(value) + + def test_intersect_argnum(self): + """Tests if the function correctly handles incorrect number of parameters""" + c = Circle(10, 10, 4) + + circles = [(Circle(10, 10, 4) for _ in range(100))] + for size in range(len(circles)): + with self.assertRaises(TypeError): + c.intersect(*circles[:size]) + + def test_intersect_return_type(self): + """Tests if the function returns the correct type""" + c = Circle(10, 10, 4) + + objects = [ + Circle(10, 10, 4), + Circle(10, 10, 400), + Circle(10, 10, 1), + Circle(15, 10, 10), + ] + + for object in objects: + self.assertIsInstance(c.intersect(object), list) + + def test_intersect(self): + # Circle + c = Circle(10, 10, 4) + c2 = Circle(10, 10, 2) + c3 = Circle(100, 100, 1) + c3_1 = Circle(10, 10, 400) + c4 = Circle(16, 10, 7) + c5 = Circle(18, 10, 4) + + for circle in [c, c2, c3, c3_1]: + self.assertEqual(c.intersect(circle), []) + + # intersecting circle + self.assertEqual( + [(10.25, 6.007820144332172), (10.25, 13.992179855667828)], c.intersect(c4) + ) + + # touching + self.assertEqual([(14.0, 10.0)], c.intersect(c5)) + if __name__ == "__main__": unittest.main()