Skip to content

Implemented the Line.project #3402

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

Open
wants to merge 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
22ccf84
implemented the Line.project method with all the tests and docs
XFajk Apr 14, 2025
e2001dd
added the doc string to the method in C
XFajk Apr 14, 2025
193071a
optimized the Line.project method made it METH_FASTCALL and removed s…
XFajk Apr 15, 2025
ea76d39
made the docs more clear, improved the images
XFajk Apr 15, 2025
44ab4fc
Update docs/reST/ref/geometry.rst
XFajk Apr 16, 2025
cd9c648
Update docs/reST/ref/geometry.rst
XFajk Apr 16, 2025
896f7b2
Update docs/reST/ref/geometry.rst
XFajk Apr 16, 2025
ed8af87
added "/" to the stub file to indicate that the first arg is position…
XFajk Apr 15, 2025
bfe2698
added one more test case fixed the stub
XFajk Apr 16, 2025
a291329
made a small change to the stub
XFajk Apr 16, 2025
317719e
fixed a bug in the Line.project method
XFajk Apr 16, 2025
e6c1f8c
remade the method to be VARARGS insted of FASTCALL
XFajk Apr 29, 2025
383741f
added proper error handling and and reflected that in the docs and tests
XFajk Apr 29, 2025
f7178f6
removed one mistake made
XFajk Apr 29, 2025
e89deaa
Update line.c
XFajk Apr 29, 2025
d06b383
Update line.c
XFajk Apr 29, 2025
81dc704
Delete .clangd unimportant file
XFajk Jun 19, 2025
70dc960
Update buildconfig/stubs/pygame/geometry.pyi
XFajk Jun 20, 2025
82c43c9
Update geometry.rst
XFajk Jun 20, 2025
32ae369
Update src_c/line.c
XFajk Jun 20, 2025
1a8d9f6
Update src_c/line.c
XFajk Jun 20, 2025
2401414
Update src_c/line.c
XFajk Jun 20, 2025
4194dce
Fix typo in variable name for squared line length in _line_project_he…
XFajk Jun 20, 2025
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
2 changes: 2 additions & 0 deletions .clangd
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
CompileFlags:
CompilationDatabase: ./.mesonpy-build/
1 change: 1 addition & 0 deletions buildconfig/stubs/pygame/geometry.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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]: ...
Binary file added docs/reST/ref/code_examples/project.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reST/ref/code_examples/project_clamp.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 27 additions & 0 deletions docs/reST/ref/geometry.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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: This method has to have some length or the clamp parameter must be true for it to work and not throw a `ValueError`

.. versionadded:: 2.5.4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would need to be updated to 2.5.6 after we have consensus on this PR


.. ## Line.project ##
1 change: 1 addition & 0 deletions src_c/doc/geometry_doc.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"
82 changes: 82 additions & 0 deletions src_c/line.c
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,86 @@ 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 squred_line_length =
line_vector[0] * line_vector[0] + line_vector[1] * line_vector[1];

if (squred_line_length == 0.0 && clamp) {
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]);
}
else if (squred_line_length == 0.0) {
return RAISE(PyExc_ValueError,
"The Line has to have some length or this method has to "
"be clamped to work");
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API discussion point: is the current implementation better, or would it be better if the clamp and no clamp behaviour was identical when squared_line_length == 0.0? It seems like that essentially becomes "project a point onto a point" where there should be no notion of clamping because there are no two distinct points.

Me personally I don't have much of an opinion either way but I'd like to get other reviewers say on this front, for now I am not asking you to change anything here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe it should not error when the line has no length in any case. actually, it makes less sense to error when it is not clamped. there is one good available spot to project the point, which is the only one. The clamped-0-length behavior is the one that actually has an "exception" since the method doesn't know in what direction to project, resulting in the only valid spot.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No one would want their code to error because while the mouse was dragging or while the objects overlapped, the line got a length of zero. If you really do care about it working differently in that case, checking it before hand is straight forward.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that these edge case errors should be avoided when possible.

For the 0-length non-clamped projection, the method could possibly either return the line point or the argument point. (Clamped returns the line point.) I think returning the line point is accurate though.


// 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]) /
(line_vector[0] * line_vector[0] + line_vector[1] * line_vector[1]);

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] >
line_vector[0] * line_vector[0] +
line_vector[1] * line_vector[1]) {
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},
Expand All @@ -231,6 +311,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 *
Expand Down
34 changes: 34 additions & 0 deletions test/geometry_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2201,6 +2201,40 @@ 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)

# testing if the method fails when it should
with self.assertRaises(ValueError):
bad_line.project(test_bad_line_point)

def test__str__(self):
"""Checks whether the __str__ method works correctly."""
l_str = "Line((10.1, 10.2), (4.3, 56.4))"
Expand Down
Loading