Skip to content

Commit

Permalink
Line.rotate() / ip() (#213)
Browse files Browse the repository at this point in the history
* base impl and docs

* tests pt. 1

* base impl and docs

* .pyi, docs and tests round 1

* format

* added missing tests

* added missing doc
  • Loading branch information
itzpr3d4t0r authored Oct 19, 2024
1 parent a1e8b3f commit 353a26c
Show file tree
Hide file tree
Showing 5 changed files with 327 additions and 1 deletion.
4 changes: 4 additions & 0 deletions docs/geometry.rst
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,10 @@ other objects.

as_points: returns the line as a list of points.

rotate: Rotates the line by the given amount.

rotate_ip: Rotates the line by the given amount in place.

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

Polygon
Expand Down
30 changes: 29 additions & 1 deletion docs/line.rst
Original file line number Diff line number Diff line change
Expand Up @@ -436,4 +436,32 @@ Line Methods
least 0.


.. ## Line.as_points ##
.. ## Line.as_points ##
.. method:: rotate

| :sl:`rotates the line around its center`
| :sg:`rotate(angle, rotation_point) -> None`
Returns a new `Line` that is rotated by the given angle around the given point.
The angle can be positive or negative. If the angle is positive the line will be
rotated clockwise and if it is negative the line will be rotated counter-clockwise.

The rotation point can be a tuple, list, or Vector2. If no rotation point is
specified the line will be rotated around its center point.

.. ## Line.rotate ##
.. method:: rotate_ip

| :sl:`rotates the line around its center`
| :sg:`rotate_ip(angle, rotation_point) -> None`
Rotates the line by the given angle around the given point.
The angle can be positive or negative. If the angle is positive the line will be
rotated clockwise and if it is negative the line will be rotated counter-clockwise.

The rotation point can be a tuple, list, or Vector2. If no rotation point is
specified the line will be rotated around its center point.

.. ## Line.rotate_ip ##
6 changes: 6 additions & 0 deletions geometry.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,12 @@ class Line(Sequence[float]):
def is_perpendicular(self, line: LineValue) -> bool: ...
def as_points(self, n_points: int) -> List[Tuple[float, float]]: ...
def as_segments(self, n_segments: int) -> List[Line]: ...
def rotate(
self, angle: float, rotation_point: Coordinate = Line.center
) -> Line: ...
def rotate_ip(
self, angle: float, rotation_point: Coordinate = Line.center
) -> None: ...

class Circle:
x: float
Expand Down
97 changes: 97 additions & 0 deletions src_c/line.c
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,101 @@ pg_line_as_circle(pgLineObject *self, PyObject *_null)
return (PyObject *)circle_obj;
}

static void
_pg_rotate_line_helper(pgLineBase *line, double angle, double rx, double ry)
{
if (angle == 0.0 || fmod(angle, 360.0) == 0.0) {
return;
}

double angle_rad = DEG_TO_RAD(angle);

double x1 = line->x1, y1 = line->y1;
double x2 = line->x2, y2 = line->y2;

double cos_a = cos(angle_rad);
double sin_a = sin(angle_rad);

x1 -= rx;
y1 -= ry;
x2 -= rx;
y2 -= ry;

double x1_new = x1 * cos_a - y1 * sin_a;
double y1_new = x1 * sin_a + y1 * cos_a;
double x2_new = x2 * cos_a - y2 * sin_a;
double y2_new = x2 * sin_a + y2 * cos_a;

line->x1 = x1_new + rx;
line->y1 = y1_new + ry;

line->x2 = x2_new + rx;
line->y2 = y2_new + ry;
}

static PyObject *
pg_line_rotate(pgLineObject *self, PyObject *const *args, Py_ssize_t nargs)
{
if (!nargs || nargs > 2) {
return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments");
}

pgLineBase *line = &self->line;
double angle, rx, ry;

rx = (line->x1 + line->x2) / 2;
ry = (line->y1 + line->y2) / 2;

if (!pg_DoubleFromObj(args[0], &angle)) {
return RAISE(PyExc_TypeError,
"Invalid angle argument, must be numeric");
}

if (nargs == 2 && !pg_TwoDoublesFromObj(args[1], &rx, &ry)) {
return RAISE(PyExc_TypeError,
"Invalid rotation_point argument, must be a sequence of "
"two numbers");
}

PyObject *line_obj;
if (!(line_obj = pgLine_New(line))) {
return NULL;
}

_pg_rotate_line_helper(&pgLine_AsLine(line_obj), angle, rx, ry);

return line_obj;
}

static PyObject *
pg_line_rotate_ip(pgLineObject *self, PyObject *const *args, Py_ssize_t nargs)
{
if (!nargs || nargs > 2) {
return RAISE(PyExc_TypeError, "rotate requires 1 or 2 arguments");
}

pgLineBase *line = &self->line;
double angle, rx, ry;

rx = (line->x1 + line->x2) / 2;
ry = (line->y1 + line->y2) / 2;

if (!pg_DoubleFromObj(args[0], &angle)) {
return RAISE(PyExc_TypeError,
"Invalid angle argument, must be numeric");
}

if (nargs == 2 && !pg_TwoDoublesFromObj(args[1], &rx, &ry)) {
return RAISE(PyExc_TypeError,
"Invalid rotation_point argument, must be a sequence of "
"two numbers");
}

_pg_rotate_line_helper(line, angle, rx, ry);

Py_RETURN_NONE;
}

static struct PyMethodDef pg_line_methods[] = {
{"__copy__", (PyCFunction)pg_line_copy, METH_NOARGS, NULL},
{"copy", (PyCFunction)pg_line_copy, METH_NOARGS, NULL},
Expand All @@ -770,6 +865,8 @@ static struct PyMethodDef pg_line_methods[] = {
{"scale", (PyCFunction)pg_line_scale, METH_FASTCALL, NULL},
{"scale_ip", (PyCFunction)pg_line_scale_ip, METH_FASTCALL, NULL},
{"as_circle", (PyCFunction)pg_line_as_circle, METH_NOARGS, NULL},
{"rotate", (PyCFunction)pg_line_rotate, METH_FASTCALL, NULL},
{"rotate_ip", (PyCFunction)pg_line_rotate_ip, METH_FASTCALL, NULL},
{NULL, NULL, 0, NULL}};

/* sequence functions */
Expand Down
191 changes: 191 additions & 0 deletions test/test_line.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
import unittest
from math import sqrt

Expand All @@ -20,6 +21,15 @@ def get_points_between(line, n_pts):
return [(line.xa + i * dx, line.ya + i * dy) for i in range(n_pts + 2)]


def float_range(a, b, step):
result = []
current_value = a
while current_value < b:
result.append(current_value)
current_value += step
return result


class LineTypeTest(unittest.TestCase):
class ClassWithLineAttrib:
def __init__(self, line):
Expand Down Expand Up @@ -1381,6 +1391,187 @@ def test_meth_as_segments_argvalue(self):
with self.assertRaises(ValueError):
l.as_segments(value)

def test_meth_rotate_ip_invalid_argnum(self):
"""Ensures that the rotate_ip method correctly deals with invalid numbers of arguments."""
l = Line(0, 0, 1, 1)

with self.assertRaises(TypeError):
l.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):
l.rotate_ip(*args)

def test_meth_rotate_ip_invalid_argtype(self):
"""Ensures that the rotate_ip method correctly deals with invalid argument types."""
l = Line(0, 0, 1, 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):
l.rotate_ip(*value)

def test_meth_rotate_ip_return(self):
"""Ensures that the rotate_ip method always returns None."""
l = Line(0, 0, 1, 1)

for angle in float_range(-360, 360, 1):
self.assertIsNone(l.rotate_ip(angle))
self.assertIsInstance(l.rotate_ip(angle), type(None))

def test_meth_rotate_invalid_argnum(self):
"""Ensures that the rotate method correctly deals with invalid numbers of arguments."""
l = Line(0, 0, 1, 1)

with self.assertRaises(TypeError):
l.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):
l.rotate(*args)

def test_meth_rotate_invalid_argtype(self):
"""Ensures that the rotate method correctly deals with invalid argument types."""
l = Line(0, 0, 1, 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):
l.rotate(*value)

def test_meth_rotate_return(self):
"""Ensures that the rotate method always returns a Line."""
l = Line(0, 0, 1, 1)

for angle in float_range(-360, 360, 1):
self.assertIsInstance(l.rotate(angle), Line)

def test_meth_rotate(self):
"""Ensures the Line.rotate() method rotates the line correctly."""

def rotate_line(line: Line, 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)
x1, y1 = line.a
x2, y2 = line.b
cx, cy = center if center is not None else line.center
x1, y1 = rotate_point(x1, y1, angle, cx, cy)
x2, y2 = rotate_point(x2, y2, angle, cx, cy)
return Line(x1, y1, x2, y2)

def assert_approx_equal(line1, line2, eps=1e-12):
self.assertAlmostEqual(line1.x1, line2.x1, delta=eps)
self.assertAlmostEqual(line1.y1, line2.y1, delta=eps)
self.assertAlmostEqual(line1.x2, line2.x2, delta=eps)
self.assertAlmostEqual(line1.y2, line2.y2, delta=eps)

l = Line(0, 0, 1, 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(l.rotate(angle), rotate_line(l, angle, None))
for center in centers:
assert_approx_equal(
l.rotate(angle, center), rotate_line(l, angle, center)
)

def test_meth_rotate_ip(self):
"""Ensures the Line.rotate_ip() method rotates the line correctly."""

def rotate_line(line: Line, 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)
x1, y1 = line.a
x2, y2 = line.b
cx, cy = center if center is not None else line.center
x1, y1 = rotate_point(x1, y1, angle, cx, cy)
x2, y2 = rotate_point(x2, y2, angle, cx, cy)
return Line(x1, y1, x2, y2)

def assert_approx_equal(line1, line2, eps=1e-12):
self.assertAlmostEqual(line1.x1, line2.x1, delta=eps)
self.assertAlmostEqual(line1.y1, line2.y1, delta=eps)
self.assertAlmostEqual(line1.x2, line2.x2, delta=eps)
self.assertAlmostEqual(line1.y2, line2.y2, delta=eps)

l = Line(0, 0, 1, 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:
new_l = l.copy()
new_l.rotate_ip(angle)
assert_approx_equal(new_l, rotate_line(l, angle, None))
for center in centers:
new_l = l.copy()
new_l.rotate_ip(angle, center)
assert_approx_equal(new_l, rotate_line(l, angle, center))


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

0 comments on commit 353a26c

Please sign in to comment.