Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Circle.rotate/ip #232

Merged
merged 1 commit into from
Jan 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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()