Skip to content

Commit eff0b26

Browse files
authored
Merge pull request #232 from itzpr3d4t0r/circle-rotate
Add Circle.rotate/ip
2 parents 56ab50c + ce7958d commit eff0b26

File tree

5 files changed

+316
-0
lines changed

5 files changed

+316
-0
lines changed

docs/circle.rst

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,29 @@ Circle Methods
326326

327327
.. ## Circle.contains ##
328328
329+
.. method:: rotate
330+
| :sl:`rotates the circle`
331+
| :sg:`rotate(angle, rotation_point=Circle.center) -> None`
332+
333+
Returns a new `Circle` that is rotated by the specified angle around a point.
334+
A positive angle rotates the circle clockwise, while a negative angle rotates it counter-clockwise.
335+
The rotation point can be a `tuple`, `list`, or `Vector2`.
336+
If no rotation point is given, the circle will be rotated around its center.
337+
338+
.. ## Circle.rotate ##
339+
340+
.. method:: rotate_ip
341+
| :sl:`rotates the circle in place`
342+
| :sg:`rotate_ip(angle, rotation_point=Circle.center) -> None`
343+
344+
This method rotates the circle by a specified angle around a point.
345+
A positive angle rotates the circle clockwise, while a negative angle rotates it counter-clockwise.
346+
The rotation point can be a `tuple`, `list`, or `Vector2`.
347+
348+
If no rotation point is given, the circle will be rotated around its center.
349+
350+
.. ## Circle.rotate_ip ##
351+
329352
.. method:: copy
330353

331354
| :sl:`returns a copy of the circle`

docs/geometry.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ performing transformations and checking for collisions with other objects.
6060

6161
contains: Checks if the circle fully contains the given object.
6262

63+
rotate: Rotates the circle by the given amount.
64+
65+
rotate_ip: Rotates the circle by the given amount in place.
66+
6367
as_rect: Returns the smallest rectangle that contains the circle.
6468

6569
Additionally to these, the circle shape can also be used as a collider for the ``geometry.raycast`` function.

geometry.pyi

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,12 @@ class Circle:
200200
) -> bool: ...
201201
@overload
202202
def collidepolygon(self, *coords, only_edges: bool = False) -> bool: ...
203+
def rotate(
204+
self, angle: float, rotation_point: Coordinate = Circle.center
205+
) -> Circle: ...
206+
def rotate_ip(
207+
self, angle: float, rotation_point: Coordinate = Circle.center
208+
) -> None: ...
203209

204210
class Polygon:
205211
vertices: List[Coordinate]

src_c/circle.c

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,99 @@ pg_circle_collidepolygon(pgCircleObject *self, PyObject *const *args,
498498
return PyBool_FromLong(result);
499499
}
500500

501+
static void
502+
_pg_rotate_circle_helper(pgCircleBase *circle, double angle, double rx,
503+
double ry)
504+
{
505+
if (angle == 0.0 || fmod(angle, 360.0) == 0.0) {
506+
return;
507+
}
508+
509+
double x = circle->x - rx;
510+
double y = circle->y - ry;
511+
512+
const double angle_rad = DEG_TO_RAD(angle);
513+
514+
double cos_theta = cos(angle_rad);
515+
double sin_theta = sin(angle_rad);
516+
517+
circle->x = rx + x * cos_theta - y * sin_theta;
518+
circle->y = ry + x * sin_theta + y * cos_theta;
519+
}
520+
521+
static PyObject *
522+
pg_circle_rotate(pgCircleObject *self, PyObject *const *args, Py_ssize_t nargs)
523+
{
524+
if (!nargs || nargs > 2) {
525+
return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments");
526+
}
527+
528+
pgCircleBase *circle = &self->circle;
529+
double angle, rx, ry;
530+
531+
rx = circle->x;
532+
ry = circle->y;
533+
534+
if (!pg_DoubleFromObj(args[0], &angle)) {
535+
return RAISE(PyExc_TypeError,
536+
"Invalid angle argument, must be numeric");
537+
}
538+
539+
if (nargs != 2) {
540+
return _pg_circle_subtype_new(Py_TYPE(self), circle);
541+
}
542+
543+
if (!pg_TwoDoublesFromObj(args[1], &rx, &ry)) {
544+
return RAISE(PyExc_TypeError,
545+
"Invalid rotation point argument, must be a sequence of "
546+
"2 numbers");
547+
}
548+
549+
PyObject *circle_obj = _pg_circle_subtype_new(Py_TYPE(self), circle);
550+
if (!circle_obj) {
551+
return NULL;
552+
}
553+
554+
_pg_rotate_circle_helper(&pgCircle_AsCircle(circle_obj), angle, rx, ry);
555+
556+
return circle_obj;
557+
}
558+
559+
static PyObject *
560+
pg_circle_rotate_ip(pgCircleObject *self, PyObject *const *args,
561+
Py_ssize_t nargs)
562+
{
563+
if (!nargs || nargs > 2) {
564+
return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments");
565+
}
566+
567+
pgCircleBase *circle = &self->circle;
568+
double angle, rx, ry;
569+
570+
rx = circle->x;
571+
ry = circle->y;
572+
573+
if (!pg_DoubleFromObj(args[0], &angle)) {
574+
return RAISE(PyExc_TypeError,
575+
"Invalid angle argument, must be numeric");
576+
}
577+
578+
if (nargs != 2) {
579+
/* just return None */
580+
Py_RETURN_NONE;
581+
}
582+
583+
if (!pg_TwoDoublesFromObj(args[1], &rx, &ry)) {
584+
return RAISE(PyExc_TypeError,
585+
"Invalid rotation point argument, must be a sequence "
586+
"of 2 numbers");
587+
}
588+
589+
_pg_rotate_circle_helper(circle, angle, rx, ry);
590+
591+
Py_RETURN_NONE;
592+
}
593+
501594
static struct PyMethodDef pg_circle_methods[] = {
502595
{"collidecircle", (PyCFunction)pg_circle_collidecircle, METH_FASTCALL,
503596
NULL},
@@ -514,6 +607,8 @@ static struct PyMethodDef pg_circle_methods[] = {
514607
{"contains", (PyCFunction)pg_circle_contains, METH_O, NULL},
515608
{"__copy__", (PyCFunction)pg_circle_copy, METH_NOARGS, NULL},
516609
{"copy", (PyCFunction)pg_circle_copy, METH_NOARGS, NULL},
610+
{"rotate", (PyCFunction)pg_circle_rotate, METH_FASTCALL, NULL},
611+
{"rotate_ip", (PyCFunction)pg_circle_rotate_ip, METH_FASTCALL, NULL},
517612
{NULL, NULL, 0, NULL}};
518613

519614
/* numeric functions */

test/test_circle.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,15 @@
1212
E_F = "Expected False, "
1313

1414

15+
def float_range(a, b, step):
16+
result = []
17+
current_value = a
18+
while current_value < b:
19+
result.append(current_value)
20+
current_value += step
21+
return result
22+
23+
1524
class CircleTypeTest(unittest.TestCase):
1625
def testConstruction_invalid_type(self):
1726
"""Checks whether passing wrong types to the constructor
@@ -1186,6 +1195,185 @@ def test_collidepolygon_no_invalidation(self):
11861195
self.assertEqual(poly.centerx, poly_copy.centerx)
11871196
self.assertEqual(poly.centery, poly_copy.centery)
11881197

1198+
def test_meth_rotate_ip_invalid_argnum(self):
1199+
"""Ensures that the rotate_ip method correctly deals with invalid numbers of arguments."""
1200+
c = Circle(0, 0, 1)
1201+
1202+
with self.assertRaises(TypeError):
1203+
c.rotate_ip()
1204+
1205+
invalid_args = [
1206+
(1, (2, 2), 2),
1207+
(1, (2, 2), 2, 2),
1208+
(1, (2, 2), 2, 2, 2),
1209+
(1, (2, 2), 2, 2, 2, 2),
1210+
(1, (2, 2), 2, 2, 2, 2, 2),
1211+
(1, (2, 2), 2, 2, 2, 2, 2, 2),
1212+
]
1213+
1214+
for args in invalid_args:
1215+
with self.assertRaises(TypeError):
1216+
c.rotate_ip(*args)
1217+
1218+
def test_meth_rotate_ip_invalid_argtype(self):
1219+
"""Ensures that the rotate_ip method correctly deals with invalid argument types."""
1220+
c = Circle(0, 0, 1)
1221+
1222+
invalid_args = [
1223+
("a",), # angle str
1224+
(None,), # angle str
1225+
((1, 2)), # angle tuple
1226+
([1, 2]), # angle list
1227+
(1, "a"), # origin str
1228+
(1, None), # origin None
1229+
(1, True), # origin True
1230+
(1, False), # origin False
1231+
(1, (1, 2, 3)), # origin tuple
1232+
(1, [1, 2, 3]), # origin list
1233+
(1, (1, "a")), # origin str
1234+
(1, ("a", 1)), # origin str
1235+
(1, (1, None)), # origin None
1236+
(1, (None, 1)), # origin None
1237+
(1, (1, (1, 2))), # origin tuple
1238+
(1, (1, [1, 2])), # origin list
1239+
]
1240+
1241+
for value in invalid_args:
1242+
with self.assertRaises(TypeError):
1243+
c.rotate_ip(*value)
1244+
1245+
def test_meth_rotate_ip_return(self):
1246+
"""Ensures that the rotate_ip method always returns None."""
1247+
c = Circle(0, 0, 1)
1248+
1249+
for angle in float_range(-360, 360, 1):
1250+
self.assertIsNone(c.rotate_ip(angle))
1251+
self.assertIsInstance(c.rotate_ip(angle), type(None))
1252+
1253+
def test_meth_rotate_invalid_argnum(self):
1254+
"""Ensures that the rotate method correctly deals with invalid numbers of arguments."""
1255+
c = Circle(0, 0, 1)
1256+
1257+
with self.assertRaises(TypeError):
1258+
c.rotate()
1259+
1260+
invalid_args = [
1261+
(1, (2, 2), 2),
1262+
(1, (2, 2), 2, 2),
1263+
(1, (2, 2), 2, 2, 2),
1264+
(1, (2, 2), 2, 2, 2, 2),
1265+
(1, (2, 2), 2, 2, 2, 2, 2),
1266+
(1, (2, 2), 2, 2, 2, 2, 2, 2),
1267+
]
1268+
1269+
for args in invalid_args:
1270+
with self.assertRaises(TypeError):
1271+
c.rotate(*args)
1272+
1273+
def test_meth_rotate_invalid_argtype(self):
1274+
"""Ensures that the rotate method correctly deals with invalid argument types."""
1275+
c = Circle(0, 0, 1)
1276+
1277+
invalid_args = [
1278+
("a",), # angle str
1279+
(None,), # angle str
1280+
((1, 2)), # angle tuple
1281+
([1, 2]), # angle list
1282+
(1, "a"), # origin str
1283+
(1, None), # origin None
1284+
(1, True), # origin True
1285+
(1, False), # origin False
1286+
(1, (1, 2, 3)), # origin tuple
1287+
(1, [1, 2, 3]), # origin list
1288+
(1, (1, "a")), # origin str
1289+
(1, ("a", 1)), # origin str
1290+
(1, (1, None)), # origin None
1291+
(1, (None, 1)), # origin None
1292+
(1, (1, (1, 2))), # origin tuple
1293+
(1, (1, [1, 2])), # origin list
1294+
]
1295+
1296+
for value in invalid_args:
1297+
with self.assertRaises(TypeError):
1298+
c.rotate(*value)
1299+
1300+
def test_meth_rotate_return(self):
1301+
"""Ensures that the rotate method always returns a Line."""
1302+
c = Circle(0, 0, 1)
1303+
1304+
class CircleSubclass(Circle):
1305+
pass
1306+
1307+
cs = CircleSubclass(0, 0, 1)
1308+
1309+
for angle in float_range(-360, 360, 1):
1310+
self.assertIsInstance(c.rotate(angle), Circle)
1311+
self.assertIsInstance(cs.rotate(angle), CircleSubclass)
1312+
1313+
def test_meth_rotate(self):
1314+
"""Ensures the Circle.rotate() method rotates the Circle correctly."""
1315+
1316+
def rotate_circle(circle: Circle, angle, center):
1317+
def rotate_point(x, y, rang, cx, cy):
1318+
x -= cx
1319+
y -= cy
1320+
x_new = x * math.cos(rang) - y * math.sin(rang)
1321+
y_new = x * math.sin(rang) + y * math.cos(rang)
1322+
return x_new + cx, y_new + cy
1323+
1324+
angle = math.radians(angle)
1325+
cx, cy = center if center is not None else circle.center
1326+
x, y = rotate_point(circle.x, circle.y, angle, cx, cy)
1327+
return Circle(x, y, circle.r)
1328+
1329+
def assert_approx_equal(circle1, circle2, eps=1e-12):
1330+
self.assertAlmostEqual(circle1.x, circle2.x, delta=eps)
1331+
self.assertAlmostEqual(circle1.y, circle2.y, delta=eps)
1332+
self.assertAlmostEqual(circle1.r, circle2.r, delta=eps)
1333+
1334+
c = Circle(0, 0, 1)
1335+
angles = float_range(-360, 360, 0.5)
1336+
centers = [(a, b) for a in range(-10, 10) for b in range(-10, 10)]
1337+
for angle in angles:
1338+
assert_approx_equal(c.rotate(angle), rotate_circle(c, angle, None))
1339+
for center in centers:
1340+
assert_approx_equal(
1341+
c.rotate(angle, center), rotate_circle(c, angle, center)
1342+
)
1343+
1344+
def test_meth_rotate_ip(self):
1345+
"""Ensures the Circle.rotate_ip() method rotates the Circle correctly."""
1346+
1347+
def rotate_circle(circle: Circle, angle, center):
1348+
def rotate_point(x, y, rang, cx, cy):
1349+
x -= cx
1350+
y -= cy
1351+
x_new = x * math.cos(rang) - y * math.sin(rang)
1352+
y_new = x * math.sin(rang) + y * math.cos(rang)
1353+
return x_new + cx, y_new + cy
1354+
1355+
angle = math.radians(angle)
1356+
cx, cy = center if center is not None else circle.center
1357+
x, y = rotate_point(circle.x, circle.y, angle, cx, cy)
1358+
circle.x = x
1359+
circle.y = y
1360+
return circle
1361+
1362+
def assert_approx_equal(circle1, circle2, eps=1e-12):
1363+
self.assertAlmostEqual(circle1.x, circle2.x, delta=eps)
1364+
self.assertAlmostEqual(circle1.y, circle2.y, delta=eps)
1365+
self.assertAlmostEqual(circle1.r, circle2.r, delta=eps)
1366+
1367+
c = Circle(0, 0, 1)
1368+
angles = float_range(-360, 360, 0.5)
1369+
centers = [(a, b) for a in range(-10, 10) for b in range(-10, 10)]
1370+
for angle in angles:
1371+
c.rotate_ip(angle)
1372+
assert_approx_equal(c, rotate_circle(c, angle, None))
1373+
for center in centers:
1374+
c.rotate_ip(angle, center)
1375+
assert_approx_equal(c, rotate_circle(c, angle, center))
1376+
11891377

11901378
if __name__ == "__main__":
11911379
unittest.main()

0 commit comments

Comments
 (0)