diff --git a/buildconfig/stubs/pygame/geometry.pyi b/buildconfig/stubs/pygame/geometry.pyi index 43724816ec..a9995cd795 100644 --- a/buildconfig/stubs/pygame/geometry.pyi +++ b/buildconfig/stubs/pygame/geometry.pyi @@ -197,3 +197,4 @@ class Line: def scale_ip(self, factor_and_origin: Point, /) -> None: ... def flip_ab(self) -> Line: ... def flip_ab_ip(self) -> None: ... + def project(self, point: Point, clamp: bool = False) -> tuple[float, float]: ... diff --git a/docs/reST/ref/code_examples/project.png b/docs/reST/ref/code_examples/project.png new file mode 100644 index 0000000000..3177bf05f6 Binary files /dev/null and b/docs/reST/ref/code_examples/project.png differ diff --git a/docs/reST/ref/code_examples/project_clamp.png b/docs/reST/ref/code_examples/project_clamp.png new file mode 100644 index 0000000000..e3447bc994 Binary files /dev/null and b/docs/reST/ref/code_examples/project_clamp.png differ diff --git a/docs/reST/ref/geometry.rst b/docs/reST/ref/geometry.rst index 12ffc5ec9f..53b713fe3a 100644 --- a/docs/reST/ref/geometry.rst +++ b/docs/reST/ref/geometry.rst @@ -740,3 +740,30 @@ .. versionadded:: 2.5.3 .. ## Line.flip_ab_ip ## + + .. method:: project + + | :sl:`projects the line onto the given line` + | :sg:`project(point: tuple[float, float], clamp=False) -> tuple[float, float]` + + This method takes in a point and one boolean keyword argument clamp. It outputs an orthogonally projected point onto the line. + If clamp is `True` it makes sure that the outputted point will be on the line segment (which might not be orthogonal), and if it is `False` (the default) then any point on the infinitely extended line may be outputted. + This method can be used to find the closest point on a line to the given point. The output is the unique point on the line or line segment that is the smallest distance away from the given point. + + + .. figure:: code_examples/project.png + :alt: project method image + + Example of how it projects the point onto the line. The red point is the point we want to project and the blue point is what you would get as a result. + + + .. figure:: code_examples/project_clamp.png + :alt: project clamp argument image + + Example of what the clamp argument changes. If it is `True`, the point is bounded between the line segment ends. + + WARNING: If the line has no length (i.e. the start and end points are the same) then the returned point of this function will be the same point as both ends of the line. + + .. versionadded:: 2.5.6 + + .. ## Line.project ## diff --git a/src_c/doc/geometry_doc.h b/src_c/doc/geometry_doc.h index 7873df226f..6c061840d3 100644 --- a/src_c/doc/geometry_doc.h +++ b/src_c/doc/geometry_doc.h @@ -45,3 +45,4 @@ #define DOC_LINE_SCALEIP "scale_ip(factor, origin) -> None\nscale_ip(factor_and_origin) -> None\nscales the line by the given factor from the given origin in place" #define DOC_LINE_FLIPAB "flip_ab() -> Line\nflips the line a and b points" #define DOC_LINE_FLIPABIP "flip_ab_ip() -> None\nflips the line a and b points, in place" +#define DOC_LINE_PROJECT "project(point: tuple[float, float], clamp=False) -> tuple[float, float]\nprojects the line onto the given line" diff --git a/src_c/line.c b/src_c/line.c index ad25ed4866..56a3e321fd 100644 --- a/src_c/line.c +++ b/src_c/line.c @@ -219,6 +219,80 @@ pg_line_scale_ip(pgLineObject *self, PyObject *const *args, Py_ssize_t nargs) Py_RETURN_NONE; } +static PyObject * +_line_project_helper(pgLineBase *line, double *point, int clamp) +{ + // this is a vector that goes from one point of the line to another + double line_vector[2] = {line->bx - line->ax, line->by - line->ay}; + double squared_line_length = + line_vector[0] * line_vector[0] + line_vector[1] * line_vector[1]; + + if (squared_line_length == 0.0) { + double projected_point[2]; + projected_point[0] = line->ax; + projected_point[1] = line->ay; + return pg_tuple_couple_from_values_double(projected_point[0], + projected_point[1]); + } + + // this is a vector that goes from the start of the line to the point we + // are projecting onto the line + double vector_from_line_start_to_point[2] = {point[0] - line->ax, + point[1] - line->ay}; + + double dot_product = + (vector_from_line_start_to_point[0] * line_vector[0] + + vector_from_line_start_to_point[1] * line_vector[1]) / + squared_line_length; + + double projection[2] = {dot_product * line_vector[0], + dot_product * line_vector[1]}; + + if (clamp) { + if (dot_product < 0) { + projection[0] = 0; + projection[1] = 0; + } + else if (projection[0] * projection[0] + + projection[1] * projection[1] > + squared_line_length) { + projection[0] = line_vector[0]; + projection[1] = line_vector[1]; + } + } + + double projected_point[2] = {line->ax + projection[0], + line->ay + projection[1]}; + + return pg_tuple_couple_from_values_double(projected_point[0], + projected_point[1]); +} + +static PyObject * +pg_line_project(pgLineObject *self, PyObject *args, PyObject *kwnames) +{ + double point[2] = {0.f, 0.f}; + int clamp = 0; + + PyObject *point_obj = NULL; + + static char *kwlist[] = {"point", "clamp", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwnames, "O|p:project", kwlist, + &point_obj, &clamp)) { + return RAISE( + PyExc_TypeError, + "project requires a sequence(point) and an optional clamp flag"); + } + + if (!pg_TwoDoublesFromObj(point_obj, &point[0], &point[1])) { + return RAISE(PyExc_TypeError, + "project requires a sequence of two numbers"); + } + + return _line_project_helper(&pgLine_AsLine(self), point, clamp); +} + static struct PyMethodDef pg_line_methods[] = { {"__copy__", (PyCFunction)pg_line_copy, METH_NOARGS, DOC_LINE_COPY}, {"copy", (PyCFunction)pg_line_copy, METH_NOARGS, DOC_LINE_COPY}, @@ -231,6 +305,8 @@ static struct PyMethodDef pg_line_methods[] = { {"scale", (PyCFunction)pg_line_scale, METH_FASTCALL, DOC_LINE_SCALE}, {"scale_ip", (PyCFunction)pg_line_scale_ip, METH_FASTCALL, DOC_LINE_SCALEIP}, + {"project", (PyCFunction)pg_line_project, METH_VARARGS | METH_KEYWORDS, + DOC_LINE_PROJECT}, {NULL, NULL, 0, NULL}}; static PyObject * diff --git a/test/geometry_test.py b/test/geometry_test.py index c4d48e0d6a..606fc088f4 100644 --- a/test/geometry_test.py +++ b/test/geometry_test.py @@ -2201,6 +2201,39 @@ def test_meth_update(self): with self.assertRaises(TypeError): line.update(1, 2, 3) + def test_meth_project(self): + line = Line(0, 0, 100, 100) + test_point1 = (25, 75) + test_clamp_point1 = (100, 300) + test_clamp_point2 = (-50, -150) + test_clamp_point3 = (-200, -200) + + bad_line = Line(0, 0, 0, 0) + test_bad_line_point = (10, 10) + + projected_point = line.project(test_point1) + self.assertEqual(math.ceil(projected_point[0]), 50) + self.assertEqual(math.ceil(projected_point[1]), 50) + + projected_point = line.project(test_clamp_point1, clamp=True) + self.assertEqual(math.ceil(projected_point[0]), 100) + self.assertEqual(math.ceil(projected_point[1]), 100) + + projected_point = line.project(test_clamp_point2, clamp=True) + self.assertEqual(math.ceil(projected_point[0]), 0) + self.assertEqual(math.ceil(projected_point[1]), 0) + + projected_point = line.project(test_clamp_point3, clamp=True) + self.assertEqual(math.ceil(projected_point[0]), 0) + self.assertEqual(math.ceil(projected_point[1]), 0) + + projected_point = bad_line.project(test_bad_line_point, clamp=True) + self.assertEqual(math.ceil(projected_point[0]), 0) + self.assertEqual(math.ceil(projected_point[1]), 0) + + projected_point = bad_line.project(test_bad_line_point) + self.assertEqual(math.ceil(projected_point[0]), 0) + def test__str__(self): """Checks whether the __str__ method works correctly.""" l_str = "Line((10.1, 10.2), (4.3, 56.4))"