diff --git a/buildconfig/stubs/pygame/math.pyi b/buildconfig/stubs/pygame/math.pyi index 4526960b5a..97fb7155dd 100644 --- a/buildconfig/stubs/pygame/math.pyi +++ b/buildconfig/stubs/pygame/math.pyi @@ -221,6 +221,10 @@ class Vector2(_GenericVector): xy: Vector2 yx: Vector2 yy: Vector2 + @property + def angle(self) -> float: ... + @property + def angle_rad(self) -> float: ... @overload def __init__( self: _TVec, diff --git a/docs/reST/ref/math.rst b/docs/reST/ref/math.rst index e359a4b899..0580c5ee81 100644 --- a/docs/reST/ref/math.rst +++ b/docs/reST/ref/math.rst @@ -616,6 +616,23 @@ Multiple coordinates can be set using slices or swizzling find that either the margin is too large or too small, in which case changing ``epsilon`` slightly might help you out. + .. attribute:: angle + + | :sl:`Gives the angle of the vector in degrees, relative to the X-axis, normalized to the interval [-180, 180].` + + Read-only attribute representing the angle of the vector in degrees relative to the X-axis. This angle is normalized to + the interval [-180, 180]. + + Usage: Accessing `angle` provides the current angle of the vector in degrees within the predefined range of [-180, 180]. + + .. attribute:: angle_rad + + | :sl:`Gives the angle of the vector in radians, relative to the X-axis, normalized to the interval [-π, π].` + + Read-only attribute representing the angle of the vector in radians relative to the X-axis. This value is equivalent + to the `angle` attribute converted to radians and is normalized to the interval [-π, π]. + + Usage: Accessing `angle_rad` provides the current angle of the vector in radians within the predefined range of [-π, π]. .. ## pygame.math.Vector2 ## diff --git a/src_c/doc/math_doc.h b/src_c/doc/math_doc.h index e002a0c672..548465487d 100644 --- a/src_c/doc/math_doc.h +++ b/src_c/doc/math_doc.h @@ -40,6 +40,8 @@ #define DOC_MATH_VECTOR2_CLAMPMAGNITUDEIP "clamp_magnitude_ip(max_length, /) -> None\nclamp_magnitude_ip(min_length, max_length, /) -> None\nClamps the vector's magnitude between max_length and min_length" #define DOC_MATH_VECTOR2_UPDATE "update() -> None\nupdate(int) -> None\nupdate(float) -> None\nupdate(Vector2) -> None\nupdate(x, y) -> None\nupdate((x, y)) -> None\nSets the coordinates of the vector." #define DOC_MATH_VECTOR2_EPSILON "Determines the tolerance of vector calculations." +#define DOC_MATH_VECTOR2_ANGLE "Gives the angle of the vector in degrees, relative to the X-axis, normalized to the interval [-180, 180]." +#define DOC_MATH_VECTOR2_ANGLERAD "Gives the angle of the vector in radians, relative to the X-axis, normalized to the interval [-π, π]." #define DOC_MATH_VECTOR3 "Vector3() -> Vector3(0, 0, 0)\nVector3(int) -> Vector3\nVector3(float) -> Vector3\nVector3(Vector3) -> Vector3\nVector3(x, y, z) -> Vector3\nVector3((x, y, z)) -> Vector3\na 3-Dimensional Vector" #define DOC_MATH_VECTOR3_DOT "dot(Vector3, /) -> float\ncalculates the dot- or scalar-product with the other vector" #define DOC_MATH_VECTOR3_CROSS "cross(Vector3, /) -> Vector3\ncalculates the cross- or vector-product" diff --git a/src_c/math.c b/src_c/math.c index 24c04365ca..10238b6b6b 100644 --- a/src_c/math.c +++ b/src_c/math.c @@ -51,6 +51,9 @@ #define TWO_PI (2. * M_PI) +#define RAD_TO_DEG (180.0 / M_PI) +#define DEG_TO_RAD (M_PI / 180.0) + #ifndef M_PI_2 #define M_PI_2 (M_PI / 2.0) #endif /* M_PI_2 */ @@ -142,6 +145,8 @@ _vector_coords_from_string(PyObject *str, char **delimiter, double *coords, static void _vector_move_towards_helper(Py_ssize_t dim, double *origin_coords, double *target_coords, double max_distance); +static double +_pg_atan2(double y, double x); /* generic vector functions */ static PyObject * @@ -202,6 +207,10 @@ vector_sety(pgVector *self, PyObject *value, void *closure); static int vector_setz(pgVector *self, PyObject *value, void *closure); static PyObject * +vector_get_angle(pgVector *self, void *closure); +static PyObject * +vector_get_angle_rad(pgVector *self, void *closure); +static PyObject * vector_richcompare(PyObject *o1, PyObject *o2, int op); static PyObject * vector_length(pgVector *self, PyObject *args); @@ -631,6 +640,40 @@ vector_dealloc(pgVector *self) Py_TYPE(self)->tp_free((PyObject *)self); } +/* + *Returns rhe arctangent of the quotient y / x, in radians, considering the + *following special cases: atan2((anything), NaN ) is NaN; atan2(NAN , + *(anything) ) is NaN; atan2(+-0, +(anything but NaN)) is +-0 ; atan2(+-0, + *-(anything but NaN)) is +-pi ; atan2(+-(anything but 0 and NaN), 0) is + *+-pi/2; atan2(+-(anything but INF and NaN), +INF) is +-0 ; atan2(+-(anything + *but INF and NaN), -INF) is +-pi; atan2(+-INF,+INF ) is +-pi/4 ; + * atan2(+-INF,-INF ) is +-3pi/4; + * atan2(+-INF, (anything but,0,NaN, and INF)) is +-pi/2; + * + */ +static double +_pg_atan2(double y, double x) +{ + if (Py_IS_NAN(x) || Py_IS_NAN(y)) { + return Py_NAN; + } + + if (Py_IS_INFINITY(y)) { + if (Py_IS_INFINITY(x)) { + return copysign((copysign(1., x) == 1.) ? 0.25 * Py_MATH_PI + : 0.75 * Py_MATH_PI, + y); + } + return copysign(0.5 * Py_MATH_PI, y); + } + + if (Py_IS_INFINITY(x) || y == 0.) { + return copysign((copysign(1., x) == 1.) ? 0. : Py_MATH_PI, y); + } + + return atan2(y, x); +} + /********************************************** * Generic vector PyNumber emulation routines **********************************************/ @@ -1269,6 +1312,23 @@ vector_setz(pgVector *self, PyObject *value, void *closure) return vector_set_component(self, value, 2); } +static PyObject * +vector_get_angle_rad(pgVector *self, void *closure) +{ + double angle_rad = _pg_atan2(self->coords[1], self->coords[0]); + + return PyFloat_FromDouble(angle_rad); +} + +static PyObject * +vector_get_angle(pgVector *self, void *closure) +{ + double angle_rad = _pg_atan2(self->coords[1], self->coords[0]); + double angle_deg = angle_rad * RAD_TO_DEG; + + return PyFloat_FromDouble(angle_deg); +} + static PyObject * vector_richcompare(PyObject *o1, PyObject *o2, int op) { @@ -2585,6 +2645,9 @@ static PyMethodDef vector2_methods[] = { static PyGetSetDef vector2_getsets[] = { {"x", (getter)vector_getx, (setter)vector_setx, NULL, NULL}, {"y", (getter)vector_gety, (setter)vector_sety, NULL, NULL}, + {"angle", (getter)vector_get_angle, NULL, DOC_MATH_VECTOR2_ANGLE, NULL}, + {"angle_rad", (getter)vector_get_angle_rad, NULL, + DOC_MATH_VECTOR2_ANGLERAD, NULL}, {NULL, 0, NULL, NULL, NULL} /* Sentinel */ }; diff --git a/test/math_test.py b/test/math_test.py index d8690ff502..e99f3aa25f 100644 --- a/test/math_test.py +++ b/test/math_test.py @@ -1363,6 +1363,104 @@ def test_del_y(self): exception = ctx.exception self.assertEqual(str(exception), "Cannot delete the y attribute") + def test_angle_rad_property(self): + v0 = Vector2(1, 0) + self.assertEqual(v0.angle_rad, 0.0) + + v1 = Vector2(0, 1) + self.assertEqual(v1.angle_rad, math.pi / 2) + + v2 = Vector2(-1, 0) + self.assertEqual(v2.angle_rad, math.pi) + + v3 = Vector2(0, -1) + self.assertEqual(v3.angle_rad, -math.pi / 2) + + v4 = Vector2(1, 1) + self.assertEqual(v4.angle_rad, math.pi / 4) + + v5 = Vector2(-1, 1) + self.assertEqual(v5.angle_rad, 3 * math.pi / 4) + + v6 = Vector2(-1, -1) + self.assertEqual(v6.angle_rad, -3 * math.pi / 4) + + v7 = Vector2(1, -1) + self.assertEqual(v7.angle_rad, -math.pi / 4) + + v8 = Vector2(float('inf'), float('inf')) + self.assertEqual(v8.angle_rad, math.pi / 4) + + v9 = Vector2(float('-inf'), float('inf')) + self.assertEqual(v9.angle_rad, 3 * math.pi / 4) + + v10 = Vector2(float('-inf'), float('-inf')) + self.assertEqual(v10.angle_rad, -3 * math.pi / 4) + + v11 = Vector2(float('inf'), float('-inf')) + self.assertEqual(v11.angle_rad, -math.pi / 4) + + v12 = Vector2(0, 0) + self.assertEqual(v12.angle_rad, 0.0) + + v13 = Vector2(float('nan'), 1) + self.assertTrue(math.isnan(v13.angle_rad)) + + v14 = Vector2(1, float('nan')) + self.assertTrue(math.isnan(v14.angle_rad)) + + v15 = Vector2(float('nan'), float('nan')) + self.assertTrue(math.isnan(v15.angle_rad)) + + def test_angle_property(self): + v0 = pygame.math.Vector2(1, 0) + self.assertEqual(v0.angle, 0.0) + + v1 = pygame.math.Vector2(0, 1) + self.assertEqual(v1.angle, 90.0) + + v2 = pygame.math.Vector2(-1, 0) + self.assertEqual(v2.angle, 180.0) + + v3 = pygame.math.Vector2(0, -1) + self.assertEqual(v3.angle, -90.0) + + v4 = pygame.math.Vector2(1, 1) + self.assertEqual(v4.angle, 45.0) + + v5 = pygame.math.Vector2(-1, 1) + self.assertEqual(v5.angle, 135.0) + + v6 = pygame.math.Vector2(-1, -1) + self.assertEqual(v6.angle, -135.0) + + v7 = pygame.math.Vector2(1, -1) + self.assertEqual(v7.angle, -45.0) + + v8 = pygame.math.Vector2(float('inf'), float('inf')) + self.assertEqual(v8.angle, 45.0) + + v9 = pygame.math.Vector2(float('-inf'), float('inf')) + self.assertEqual(v9.angle, 135.0) + + v10 = pygame.math.Vector2(float('-inf'), float('-inf')) + self.assertEqual(v10.angle, -135.0) + + v11 = pygame.math.Vector2(float('inf'), float('-inf')) + self.assertEqual(v11.angle, -45.0) + + v12 = pygame.math.Vector2(0, 0) + self.assertEqual(v12.angle, 0.0) + + v13 = pygame.math.Vector2(float('nan'), 1) + self.assertTrue(math.isnan(v13.angle)) + + v14 = pygame.math.Vector2(1, float('nan')) + self.assertTrue(math.isnan(v14.angle)) + + v15 = pygame.math.Vector2(float('nan'), float('nan')) + self.assertTrue(math.isnan(v15.angle)) + class Vector3TypeTest(unittest.TestCase): def setUp(self):