diff --git a/docs/circle.rst b/docs/circle.rst index d980971b..ab561983 100644 --- a/docs/circle.rst +++ b/docs/circle.rst @@ -326,6 +326,29 @@ Circle Methods .. ## Circle.contains ## + .. method:: rotate + | :sl:`rotates the circle` + | :sg:`rotate(angle, rotation_point=Circle.center) -> None` + + Returns a new `Circle` that is rotated by the specified angle around a point. + A positive angle rotates the circle clockwise, while a negative angle rotates it counter-clockwise. + The rotation point can be a `tuple`, `list`, or `Vector2`. + If no rotation point is given, the circle will be rotated around its center. + + .. ## Circle.rotate ## + + .. method:: rotate_ip + | :sl:`rotates the circle in place` + | :sg:`rotate_ip(angle, rotation_point=Circle.center) -> None` + + This method rotates the circle by a specified angle around a point. + A positive angle rotates the circle clockwise, while a negative angle rotates it counter-clockwise. + The rotation point can be a `tuple`, `list`, or `Vector2`. + + If no rotation point is given, the circle will be rotated around its center. + + .. ## Circle.rotate_ip ## + .. method:: copy | :sl:`returns a copy of the circle` diff --git a/docs/geometry.rst b/docs/geometry.rst index 528f7bcb..de01e5d2 100644 --- a/docs/geometry.rst +++ b/docs/geometry.rst @@ -60,6 +60,10 @@ performing transformations and checking for collisions with other objects. contains: Checks if the circle fully contains the given object. + rotate: Rotates the circle by the given amount. + + rotate_ip: Rotates the circle by the given amount in place. + as_rect: Returns the smallest rectangle that contains the circle. Additionally to these, the circle shape can also be used as a collider for the ``geometry.raycast`` function. diff --git a/geometry.pyi b/geometry.pyi index 02eca2d7..86c7d8ed 100644 --- a/geometry.pyi +++ b/geometry.pyi @@ -200,6 +200,12 @@ class Circle: ) -> bool: ... @overload def collidepolygon(self, *coords, only_edges: bool = False) -> bool: ... + def rotate( + self, angle: float, rotation_point: Coordinate = Circle.center + ) -> Circle: ... + def rotate_ip( + self, angle: float, rotation_point: Coordinate = Circle.center + ) -> None: ... class Polygon: vertices: List[Coordinate] diff --git a/src_c/circle.c b/src_c/circle.c index c94a3085..7b93772d 100644 --- a/src_c/circle.c +++ b/src_c/circle.c @@ -498,6 +498,99 @@ pg_circle_collidepolygon(pgCircleObject *self, PyObject *const *args, return PyBool_FromLong(result); } +static void +_pg_rotate_circle_helper(pgCircleBase *circle, double angle, double rx, + double ry) +{ + if (angle == 0.0 || fmod(angle, 360.0) == 0.0) { + return; + } + + double x = circle->x - rx; + double y = circle->y - ry; + + const double angle_rad = DEG_TO_RAD(angle); + + double cos_theta = cos(angle_rad); + double sin_theta = sin(angle_rad); + + circle->x = rx + x * cos_theta - y * sin_theta; + circle->y = ry + x * sin_theta + y * cos_theta; +} + +static PyObject * +pg_circle_rotate(pgCircleObject *self, PyObject *const *args, Py_ssize_t nargs) +{ + if (!nargs || nargs > 2) { + return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments"); + } + + pgCircleBase *circle = &self->circle; + double angle, rx, ry; + + rx = circle->x; + ry = circle->y; + + if (!pg_DoubleFromObj(args[0], &angle)) { + return RAISE(PyExc_TypeError, + "Invalid angle argument, must be numeric"); + } + + if (nargs != 2) { + return _pg_circle_subtype_new(Py_TYPE(self), circle); + } + + if (!pg_TwoDoublesFromObj(args[1], &rx, &ry)) { + return RAISE(PyExc_TypeError, + "Invalid rotation point argument, must be a sequence of " + "2 numbers"); + } + + PyObject *circle_obj = _pg_circle_subtype_new(Py_TYPE(self), circle); + if (!circle_obj) { + return NULL; + } + + _pg_rotate_circle_helper(&pgCircle_AsCircle(circle_obj), angle, rx, ry); + + return circle_obj; +} + +static PyObject * +pg_circle_rotate_ip(pgCircleObject *self, PyObject *const *args, + Py_ssize_t nargs) +{ + if (!nargs || nargs > 2) { + return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments"); + } + + pgCircleBase *circle = &self->circle; + double angle, rx, ry; + + rx = circle->x; + ry = circle->y; + + if (!pg_DoubleFromObj(args[0], &angle)) { + return RAISE(PyExc_TypeError, + "Invalid angle argument, must be numeric"); + } + + if (nargs != 2) { + /* just return None */ + Py_RETURN_NONE; + } + + if (!pg_TwoDoublesFromObj(args[1], &rx, &ry)) { + return RAISE(PyExc_TypeError, + "Invalid rotation point argument, must be a sequence " + "of 2 numbers"); + } + + _pg_rotate_circle_helper(circle, angle, rx, ry); + + Py_RETURN_NONE; +} + static struct PyMethodDef pg_circle_methods[] = { {"collidecircle", (PyCFunction)pg_circle_collidecircle, METH_FASTCALL, NULL}, @@ -514,6 +607,8 @@ static struct PyMethodDef pg_circle_methods[] = { {"contains", (PyCFunction)pg_circle_contains, METH_O, NULL}, {"__copy__", (PyCFunction)pg_circle_copy, METH_NOARGS, NULL}, {"copy", (PyCFunction)pg_circle_copy, METH_NOARGS, NULL}, + {"rotate", (PyCFunction)pg_circle_rotate, METH_FASTCALL, NULL}, + {"rotate_ip", (PyCFunction)pg_circle_rotate_ip, METH_FASTCALL, NULL}, {NULL, NULL, 0, NULL}}; /* numeric functions */ diff --git a/test/test_circle.py b/test/test_circle.py index 4d011430..5d494ed9 100644 --- a/test/test_circle.py +++ b/test/test_circle.py @@ -12,6 +12,15 @@ E_F = "Expected False, " +def float_range(a, b, step): + result = [] + current_value = a + while current_value < b: + result.append(current_value) + current_value += step + return result + + class CircleTypeTest(unittest.TestCase): def testConstruction_invalid_type(self): """Checks whether passing wrong types to the constructor @@ -1186,6 +1195,185 @@ def test_collidepolygon_no_invalidation(self): self.assertEqual(poly.centerx, poly_copy.centerx) self.assertEqual(poly.centery, poly_copy.centery) + def test_meth_rotate_ip_invalid_argnum(self): + """Ensures that the rotate_ip method correctly deals with invalid numbers of arguments.""" + c = Circle(0, 0, 1) + + with self.assertRaises(TypeError): + c.rotate_ip() + + invalid_args = [ + (1, (2, 2), 2), + (1, (2, 2), 2, 2), + (1, (2, 2), 2, 2, 2), + (1, (2, 2), 2, 2, 2, 2), + (1, (2, 2), 2, 2, 2, 2, 2), + (1, (2, 2), 2, 2, 2, 2, 2, 2), + ] + + for args in invalid_args: + with self.assertRaises(TypeError): + c.rotate_ip(*args) + + def test_meth_rotate_ip_invalid_argtype(self): + """Ensures that the rotate_ip method correctly deals with invalid argument types.""" + c = Circle(0, 0, 1) + + invalid_args = [ + ("a",), # angle str + (None,), # angle str + ((1, 2)), # angle tuple + ([1, 2]), # angle list + (1, "a"), # origin str + (1, None), # origin None + (1, True), # origin True + (1, False), # origin False + (1, (1, 2, 3)), # origin tuple + (1, [1, 2, 3]), # origin list + (1, (1, "a")), # origin str + (1, ("a", 1)), # origin str + (1, (1, None)), # origin None + (1, (None, 1)), # origin None + (1, (1, (1, 2))), # origin tuple + (1, (1, [1, 2])), # origin list + ] + + for value in invalid_args: + with self.assertRaises(TypeError): + c.rotate_ip(*value) + + def test_meth_rotate_ip_return(self): + """Ensures that the rotate_ip method always returns None.""" + c = Circle(0, 0, 1) + + for angle in float_range(-360, 360, 1): + self.assertIsNone(c.rotate_ip(angle)) + self.assertIsInstance(c.rotate_ip(angle), type(None)) + + def test_meth_rotate_invalid_argnum(self): + """Ensures that the rotate method correctly deals with invalid numbers of arguments.""" + c = Circle(0, 0, 1) + + with self.assertRaises(TypeError): + c.rotate() + + invalid_args = [ + (1, (2, 2), 2), + (1, (2, 2), 2, 2), + (1, (2, 2), 2, 2, 2), + (1, (2, 2), 2, 2, 2, 2), + (1, (2, 2), 2, 2, 2, 2, 2), + (1, (2, 2), 2, 2, 2, 2, 2, 2), + ] + + for args in invalid_args: + with self.assertRaises(TypeError): + c.rotate(*args) + + def test_meth_rotate_invalid_argtype(self): + """Ensures that the rotate method correctly deals with invalid argument types.""" + c = Circle(0, 0, 1) + + invalid_args = [ + ("a",), # angle str + (None,), # angle str + ((1, 2)), # angle tuple + ([1, 2]), # angle list + (1, "a"), # origin str + (1, None), # origin None + (1, True), # origin True + (1, False), # origin False + (1, (1, 2, 3)), # origin tuple + (1, [1, 2, 3]), # origin list + (1, (1, "a")), # origin str + (1, ("a", 1)), # origin str + (1, (1, None)), # origin None + (1, (None, 1)), # origin None + (1, (1, (1, 2))), # origin tuple + (1, (1, [1, 2])), # origin list + ] + + for value in invalid_args: + with self.assertRaises(TypeError): + c.rotate(*value) + + def test_meth_rotate_return(self): + """Ensures that the rotate method always returns a Line.""" + c = Circle(0, 0, 1) + + class CircleSubclass(Circle): + pass + + cs = CircleSubclass(0, 0, 1) + + for angle in float_range(-360, 360, 1): + self.assertIsInstance(c.rotate(angle), Circle) + self.assertIsInstance(cs.rotate(angle), CircleSubclass) + + def test_meth_rotate(self): + """Ensures the Circle.rotate() method rotates the Circle correctly.""" + + def rotate_circle(circle: Circle, angle, center): + def rotate_point(x, y, rang, cx, cy): + x -= cx + y -= cy + x_new = x * math.cos(rang) - y * math.sin(rang) + y_new = x * math.sin(rang) + y * math.cos(rang) + return x_new + cx, y_new + cy + + angle = math.radians(angle) + cx, cy = center if center is not None else circle.center + x, y = rotate_point(circle.x, circle.y, angle, cx, cy) + return Circle(x, y, circle.r) + + def assert_approx_equal(circle1, circle2, eps=1e-12): + self.assertAlmostEqual(circle1.x, circle2.x, delta=eps) + self.assertAlmostEqual(circle1.y, circle2.y, delta=eps) + self.assertAlmostEqual(circle1.r, circle2.r, delta=eps) + + c = Circle(0, 0, 1) + angles = float_range(-360, 360, 0.5) + centers = [(a, b) for a in range(-10, 10) for b in range(-10, 10)] + for angle in angles: + assert_approx_equal(c.rotate(angle), rotate_circle(c, angle, None)) + for center in centers: + assert_approx_equal( + c.rotate(angle, center), rotate_circle(c, angle, center) + ) + + def test_meth_rotate_ip(self): + """Ensures the Circle.rotate_ip() method rotates the Circle correctly.""" + + def rotate_circle(circle: Circle, angle, center): + def rotate_point(x, y, rang, cx, cy): + x -= cx + y -= cy + x_new = x * math.cos(rang) - y * math.sin(rang) + y_new = x * math.sin(rang) + y * math.cos(rang) + return x_new + cx, y_new + cy + + angle = math.radians(angle) + cx, cy = center if center is not None else circle.center + x, y = rotate_point(circle.x, circle.y, angle, cx, cy) + circle.x = x + circle.y = y + return circle + + def assert_approx_equal(circle1, circle2, eps=1e-12): + self.assertAlmostEqual(circle1.x, circle2.x, delta=eps) + self.assertAlmostEqual(circle1.y, circle2.y, delta=eps) + self.assertAlmostEqual(circle1.r, circle2.r, delta=eps) + + c = Circle(0, 0, 1) + angles = float_range(-360, 360, 0.5) + centers = [(a, b) for a in range(-10, 10) for b in range(-10, 10)] + for angle in angles: + c.rotate_ip(angle) + assert_approx_equal(c, rotate_circle(c, angle, None)) + for center in centers: + c.rotate_ip(angle, center) + assert_approx_equal(c, rotate_circle(c, angle, center)) + if __name__ == "__main__": unittest.main()