Skip to content

Commit acb6155

Browse files
itzpr3d4t0rEmc2356andrewhong04ScriptLineStudiosavaxar
authored
Add Circle rotate() / rotate_ip() (#2662)
* Add circle.rotate/ip Co-authored-by: Emc2356 <[email protected]> Co-authored-by: NovialRiptide <[email protected]> Co-authored-by: ScriptLineStudios <[email protected]> Co-authored-by: Avaxar <[email protected]> Co-authored-by: maqa41 <[email protected]> * stubs fix * fixed docs * added missing versionadded tags * Apply suggestions from code review Co-authored-by: Dan Lawrence <[email protected]> --------- Co-authored-by: Emc2356 <[email protected]> Co-authored-by: NovialRiptide <[email protected]> Co-authored-by: ScriptLineStudios <[email protected]> Co-authored-by: Avaxar <[email protected]> Co-authored-by: maqa41 <[email protected]> Co-authored-by: Dan Lawrence <[email protected]>
1 parent fb1b2a1 commit acb6155

File tree

6 files changed

+338
-0
lines changed

6 files changed

+338
-0
lines changed

buildconfig/stubs/pygame/geometry.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ class Circle:
9696
def update(self, x: float, y: float, r: float, /) -> None: ...
9797
@overload
9898
def update(self, center: Coordinate, r: float, /) -> None: ...
99+
@overload
100+
def rotate(self, angle: float, rotation_point: Coordinate, /) -> Circle: ...
101+
@overload
102+
def rotate(self, angle: float, /) -> Circle: ...
103+
@overload
104+
def rotate_ip(self, angle: float, rotation_point: Coordinate, /) -> None: ...
105+
@overload
106+
def rotate_ip(self, angle: float, /) -> None: ...
99107
def as_rect(self) -> Rect: ...
100108
def as_frect(self) -> FRect: ...
101109
def __copy__(self) -> Circle: ...

docs/reST/ref/geometry.rst

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,37 @@
285285

286286
.. ## Circle.update ##
287287
288+
.. method:: rotate
289+
290+
| :sl:`rotates the circle`
291+
| :sg:`rotate(angle, rotation_point=Circle.center, /) -> Circle`
292+
| :sg:`rotate(angle, /) -> Circle`
293+
294+
Returns a new `Circle` that is rotated by the specified angle around a point.
295+
A positive angle rotates the circle clockwise, while a negative angle rotates it counter-clockwise. Angles should be specified in degrees.
296+
The rotation point can be a `tuple`, `list`, or `Vector2`.
297+
If no rotation point is given, the circle will be rotated around its center.
298+
299+
.. versionadded:: 2.5.0
300+
301+
.. ## Circle.rotate ##
302+
303+
.. method:: rotate_ip
304+
305+
| :sl:`rotates the circle in place`
306+
| :sg:`rotate_ip(angle, rotation_point=Circle.center, /) -> None`
307+
| :sg:`rotate_ip(angle, /) -> None`
308+
309+
310+
This method rotates the circle by a specified angle around a point.
311+
A positive angle rotates the circle clockwise, while a negative angle rotates it counter-clockwise. Angles should be specified in degrees.
312+
The rotation point can be a `tuple`, `list`, or `Vector2`.
313+
If no rotation point is given, the circle will be rotated around its center.
314+
315+
.. versionadded:: 2.5.0
316+
317+
.. ## Circle.rotate_ip ##
318+
288319
.. method:: as_rect
289320

290321
| :sl:`returns the smallest pygame.Rect object that contains the circle`

src_c/circle.c

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,99 @@ pg_circle_colliderect(pgCircleObject *self, PyObject *const *args,
388388
return PyBool_FromLong(pgCollision_RectCircle(x, y, w, h, &self->circle));
389389
}
390390

391+
static void
392+
_pg_rotate_circle_helper(pgCircleBase *circle, double angle, double rx,
393+
double ry)
394+
{
395+
if (angle == 0.0 || fmod(angle, 360.0) == 0.0) {
396+
return;
397+
}
398+
399+
double x = circle->x - rx;
400+
double y = circle->y - ry;
401+
402+
const double angle_rad = DEG_TO_RAD(angle);
403+
404+
double cos_theta = cos(angle_rad);
405+
double sin_theta = sin(angle_rad);
406+
407+
circle->x = rx + x * cos_theta - y * sin_theta;
408+
circle->y = ry + x * sin_theta + y * cos_theta;
409+
}
410+
411+
static PyObject *
412+
pg_circle_rotate(pgCircleObject *self, PyObject *const *args, Py_ssize_t nargs)
413+
{
414+
if (!nargs || nargs > 2) {
415+
return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments");
416+
}
417+
418+
pgCircleBase *circle = &self->circle;
419+
double angle, rx, ry;
420+
421+
rx = circle->x;
422+
ry = circle->y;
423+
424+
if (!pg_DoubleFromObj(args[0], &angle)) {
425+
return RAISE(PyExc_TypeError,
426+
"Invalid angle argument, must be numeric");
427+
}
428+
429+
if (nargs != 2) {
430+
return _pg_circle_subtype_new(Py_TYPE(self), circle);
431+
}
432+
433+
if (!pg_TwoDoublesFromObj(args[1], &rx, &ry)) {
434+
return RAISE(PyExc_TypeError,
435+
"Invalid rotation point argument, must be a sequence of "
436+
"2 numbers");
437+
}
438+
439+
PyObject *circle_obj = _pg_circle_subtype_new(Py_TYPE(self), circle);
440+
if (!circle_obj) {
441+
return NULL;
442+
}
443+
444+
_pg_rotate_circle_helper(&pgCircle_AsCircle(circle_obj), angle, rx, ry);
445+
446+
return circle_obj;
447+
}
448+
449+
static PyObject *
450+
pg_circle_rotate_ip(pgCircleObject *self, PyObject *const *args,
451+
Py_ssize_t nargs)
452+
{
453+
if (!nargs || nargs > 2) {
454+
return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments");
455+
}
456+
457+
pgCircleBase *circle = &self->circle;
458+
double angle, rx, ry;
459+
460+
rx = circle->x;
461+
ry = circle->y;
462+
463+
if (!pg_DoubleFromObj(args[0], &angle)) {
464+
return RAISE(PyExc_TypeError,
465+
"Invalid angle argument, must be numeric");
466+
}
467+
468+
if (nargs != 2) {
469+
/* just return None */
470+
Py_RETURN_NONE;
471+
}
472+
473+
if (!pg_TwoDoublesFromObj(args[1], &rx, &ry)) {
474+
return RAISE(PyExc_TypeError,
475+
"Invalid rotation point argument, must be a sequence "
476+
"of 2 numbers");
477+
}
478+
479+
_pg_rotate_circle_helper(circle, angle, rx, ry);
480+
481+
Py_RETURN_NONE;
482+
}
483+
391484
static PyObject *
392485
pg_circle_as_rect(pgCircleObject *self, PyObject *_null)
393486
{
@@ -428,6 +521,10 @@ static struct PyMethodDef pg_circle_methods[] = {
428521
DOC_CIRCLE_ASFRECT},
429522
{"__copy__", (PyCFunction)pg_circle_copy, METH_NOARGS, DOC_CIRCLE_COPY},
430523
{"copy", (PyCFunction)pg_circle_copy, METH_NOARGS, DOC_CIRCLE_COPY},
524+
{"rotate", (PyCFunction)pg_circle_rotate, METH_FASTCALL,
525+
DOC_CIRCLE_ROTATE},
526+
{"rotate_ip", (PyCFunction)pg_circle_rotate_ip, METH_FASTCALL,
527+
DOC_CIRCLE_ROTATEIP},
431528
{NULL, NULL, 0, NULL}};
432529

433530
#define GETTER_SETTER(name) \

src_c/doc/geometry_doc.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
#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"
1616
#define DOC_CIRCLE_COLLIDERECT "colliderect(rect, /) -> bool\ncolliderect((x, y, width, height), /) -> bool\ncolliderect(x, y, width, height, /) -> bool\ncolliderect((x, y), (width, height), /) -> bool\nchecks if a rectangle intersects the circle"
1717
#define DOC_CIRCLE_UPDATE "update((x, y), radius, /) -> None\nupdate(x, y, radius, /) -> None\nupdates the circle position and radius"
18+
#define DOC_CIRCLE_ROTATE "rotate(angle, rotation_point=Circle.center, /) -> Circle\nrotate(angle, /) -> Circle\nrotates the circle"
19+
#define DOC_CIRCLE_ROTATEIP "rotate_ip(angle, rotation_point=Circle.center, /) -> None\nrotate_ip(angle, /) -> None\nrotates the circle in place"
1820
#define DOC_CIRCLE_ASRECT "as_rect() -> Rect\nreturns the smallest pygame.Rect object that contains the circle"
1921
#define DOC_CIRCLE_ASFRECT "as_frect() -> FRect\nreturns the smallest pygame.FRect object that contains the circle"
2022
#define DOC_CIRCLE_COPY "copy() -> Circle\nreturns a copy of the circle"

src_c/geometry.h

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,16 @@ pgCircle_FromObjectFastcall(PyObject *const *args, Py_ssize_t nargs,
4040
#define M_TWOPI 6.28318530717958647692
4141
#endif
4242

43+
/* PI/180 */
44+
#ifndef M_PI_QUO_180
45+
#define M_PI_QUO_180 0.01745329251994329577
46+
#endif
47+
48+
/* Converts degrees to radians */
49+
static inline double
50+
DEG_TO_RAD(double deg)
51+
{
52+
return deg * M_PI_QUO_180;
53+
}
54+
4355
#endif // PYGAME_CE_GEOMETRY_H

test/geometry_test.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@
77
from pygame.geometry import Circle
88

99

10+
def float_range(a, b, step):
11+
result = []
12+
current_value = a
13+
while current_value < b:
14+
result.append(current_value)
15+
current_value += step
16+
return result
17+
18+
1019
class CircleTypeTest(unittest.TestCase):
1120
def testConstruction_invalid_type(self):
1221
"""Checks whether passing wrong types to the constructor
@@ -910,6 +919,185 @@ class CircleSub(Circle):
910919
self.assertEqual(type(c.move_ip(1, 1)), type(None))
911920
self.assertEqual(type(cs.move_ip(1, 1)), type(None))
912921

922+
def test_meth_rotate_ip_invalid_argnum(self):
923+
"""Ensures that the rotate_ip method correctly deals with invalid numbers of arguments."""
924+
c = Circle(0, 0, 1)
925+
926+
with self.assertRaises(TypeError):
927+
c.rotate_ip()
928+
929+
invalid_args = [
930+
(1, (2, 2), 2),
931+
(1, (2, 2), 2, 2),
932+
(1, (2, 2), 2, 2, 2),
933+
(1, (2, 2), 2, 2, 2, 2),
934+
(1, (2, 2), 2, 2, 2, 2, 2),
935+
(1, (2, 2), 2, 2, 2, 2, 2, 2),
936+
]
937+
938+
for args in invalid_args:
939+
with self.assertRaises(TypeError):
940+
c.rotate_ip(*args)
941+
942+
def test_meth_rotate_ip_invalid_argtype(self):
943+
"""Ensures that the rotate_ip method correctly deals with invalid argument types."""
944+
c = Circle(0, 0, 1)
945+
946+
invalid_args = [
947+
("a",), # angle str
948+
(None,), # angle str
949+
((1, 2)), # angle tuple
950+
([1, 2]), # angle list
951+
(1, "a"), # origin str
952+
(1, None), # origin None
953+
(1, True), # origin True
954+
(1, False), # origin False
955+
(1, (1, 2, 3)), # origin tuple
956+
(1, [1, 2, 3]), # origin list
957+
(1, (1, "a")), # origin str
958+
(1, ("a", 1)), # origin str
959+
(1, (1, None)), # origin None
960+
(1, (None, 1)), # origin None
961+
(1, (1, (1, 2))), # origin tuple
962+
(1, (1, [1, 2])), # origin list
963+
]
964+
965+
for value in invalid_args:
966+
with self.assertRaises(TypeError):
967+
c.rotate_ip(*value)
968+
969+
def test_meth_rotate_ip_return(self):
970+
"""Ensures that the rotate_ip method always returns None."""
971+
c = Circle(0, 0, 1)
972+
973+
for angle in float_range(-360, 360, 1):
974+
self.assertIsNone(c.rotate_ip(angle))
975+
self.assertIsInstance(c.rotate_ip(angle), type(None))
976+
977+
def test_meth_rotate_invalid_argnum(self):
978+
"""Ensures that the rotate method correctly deals with invalid numbers of arguments."""
979+
c = Circle(0, 0, 1)
980+
981+
with self.assertRaises(TypeError):
982+
c.rotate()
983+
984+
invalid_args = [
985+
(1, (2, 2), 2),
986+
(1, (2, 2), 2, 2),
987+
(1, (2, 2), 2, 2, 2),
988+
(1, (2, 2), 2, 2, 2, 2),
989+
(1, (2, 2), 2, 2, 2, 2, 2),
990+
(1, (2, 2), 2, 2, 2, 2, 2, 2),
991+
]
992+
993+
for args in invalid_args:
994+
with self.assertRaises(TypeError):
995+
c.rotate(*args)
996+
997+
def test_meth_rotate_invalid_argtype(self):
998+
"""Ensures that the rotate method correctly deals with invalid argument types."""
999+
c = Circle(0, 0, 1)
1000+
1001+
invalid_args = [
1002+
("a",), # angle str
1003+
(None,), # angle str
1004+
((1, 2)), # angle tuple
1005+
([1, 2]), # angle list
1006+
(1, "a"), # origin str
1007+
(1, None), # origin None
1008+
(1, True), # origin True
1009+
(1, False), # origin False
1010+
(1, (1, 2, 3)), # origin tuple
1011+
(1, [1, 2, 3]), # origin list
1012+
(1, (1, "a")), # origin str
1013+
(1, ("a", 1)), # origin str
1014+
(1, (1, None)), # origin None
1015+
(1, (None, 1)), # origin None
1016+
(1, (1, (1, 2))), # origin tuple
1017+
(1, (1, [1, 2])), # origin list
1018+
]
1019+
1020+
for value in invalid_args:
1021+
with self.assertRaises(TypeError):
1022+
c.rotate(*value)
1023+
1024+
def test_meth_rotate_return(self):
1025+
"""Ensures that the rotate method always returns a Circle."""
1026+
c = Circle(0, 0, 1)
1027+
1028+
class CircleSubclass(Circle):
1029+
pass
1030+
1031+
cs = CircleSubclass(0, 0, 1)
1032+
1033+
for angle in float_range(-360, 360, 1):
1034+
self.assertIsInstance(c.rotate(angle), Circle)
1035+
self.assertIsInstance(cs.rotate(angle), CircleSubclass)
1036+
1037+
def test_meth_rotate(self):
1038+
"""Ensures the Circle.rotate() method rotates the Circle correctly."""
1039+
1040+
def rotate_circle(circle: Circle, angle, center):
1041+
def rotate_point(x, y, rang, cx, cy):
1042+
x -= cx
1043+
y -= cy
1044+
x_new = x * math.cos(rang) - y * math.sin(rang)
1045+
y_new = x * math.sin(rang) + y * math.cos(rang)
1046+
return x_new + cx, y_new + cy
1047+
1048+
angle = math.radians(angle)
1049+
cx, cy = center if center is not None else circle.center
1050+
x, y = rotate_point(circle.x, circle.y, angle, cx, cy)
1051+
return Circle(x, y, circle.r)
1052+
1053+
def assert_approx_equal(circle1, circle2, eps=1e-12):
1054+
self.assertAlmostEqual(circle1.x, circle2.x, delta=eps)
1055+
self.assertAlmostEqual(circle1.y, circle2.y, delta=eps)
1056+
self.assertAlmostEqual(circle1.r, circle2.r, delta=eps)
1057+
1058+
c = Circle(0, 0, 1)
1059+
angles = float_range(-360, 360, 0.5)
1060+
centers = [(a, b) for a in range(-10, 10) for b in range(-10, 10)]
1061+
for angle in angles:
1062+
assert_approx_equal(c.rotate(angle), rotate_circle(c, angle, None))
1063+
for center in centers:
1064+
assert_approx_equal(
1065+
c.rotate(angle, center), rotate_circle(c, angle, center)
1066+
)
1067+
1068+
def test_meth_rotate_ip(self):
1069+
"""Ensures the Circle.rotate_ip() method rotates the Circle correctly."""
1070+
1071+
def rotate_circle(circle: Circle, angle, center):
1072+
def rotate_point(x, y, rang, cx, cy):
1073+
x -= cx
1074+
y -= cy
1075+
x_new = x * math.cos(rang) - y * math.sin(rang)
1076+
y_new = x * math.sin(rang) + y * math.cos(rang)
1077+
return x_new + cx, y_new + cy
1078+
1079+
angle = math.radians(angle)
1080+
cx, cy = center if center is not None else circle.center
1081+
x, y = rotate_point(circle.x, circle.y, angle, cx, cy)
1082+
circle.x = x
1083+
circle.y = y
1084+
return circle
1085+
1086+
def assert_approx_equal(circle1, circle2, eps=1e-12):
1087+
self.assertAlmostEqual(circle1.x, circle2.x, delta=eps)
1088+
self.assertAlmostEqual(circle1.y, circle2.y, delta=eps)
1089+
self.assertAlmostEqual(circle1.r, circle2.r, delta=eps)
1090+
1091+
c = Circle(0, 0, 1)
1092+
angles = float_range(-360, 360, 0.5)
1093+
centers = [(a, b) for a in range(-10, 10) for b in range(-10, 10)]
1094+
for angle in angles:
1095+
c.rotate_ip(angle)
1096+
assert_approx_equal(c, rotate_circle(c, angle, None))
1097+
for center in centers:
1098+
c.rotate_ip(angle, center)
1099+
assert_approx_equal(c, rotate_circle(c, angle, center))
1100+
9131101

9141102
if __name__ == "__main__":
9151103
unittest.main()

0 commit comments

Comments
 (0)