Skip to content

Commit

Permalink
Merge pull request #232 from itzpr3d4t0r/circle-rotate
Browse files Browse the repository at this point in the history
Add Circle.rotate/ip
  • Loading branch information
itzpr3d4t0r authored Jan 7, 2024
2 parents 56ab50c + ce7958d commit eff0b26
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 0 deletions.
23 changes: 23 additions & 0 deletions docs/circle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 4 additions & 0 deletions docs/geometry.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions geometry.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
95 changes: 95 additions & 0 deletions src_c/circle.c
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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 */
Expand Down
188 changes: 188 additions & 0 deletions test/test_circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

0 comments on commit eff0b26

Please sign in to comment.