Skip to content

Commit

Permalink
Feat: Project line onto plane (#300)
Browse files Browse the repository at this point in the history
  • Loading branch information
CristianoPizzamiglio committed Apr 4, 2022
1 parent 95ca2d4 commit 3b3a1da
Show file tree
Hide file tree
Showing 11 changed files with 162 additions and 26 deletions.
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.1.0
hooks:
- id: check-ast
- id: check-builtin-literals
Expand All @@ -10,17 +10,17 @@ repos:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/reorder_python_imports
rev: v2.6.0
rev: v3.0.1
hooks:
- id: reorder-python-imports
args: [--application-directories, ".:src"]
- repo: https://github.com/psf/black
rev: 21.8b0
rev: 22.3.0
hooks:
- id: black
args: [-S, -l 120]
- repo: https://github.com/asottile/add-trailing-comma
rev: v2.1.0
rev: v2.2.2
hooks:
- id: add-trailing-comma
args: [--py36-plus]
Expand All @@ -44,6 +44,6 @@ repos:
args: [--convention=numpy, "--add-ignore=D104,D105"]
exclude: tests|examples|conf.py
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v2.4.0
rev: v2.6.2
hooks:
- id: prettier
1 change: 1 addition & 0 deletions docs/source/api_reference/skspatial.objects.Plane.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Methods
~skspatial.objects.Plane.intersect_line
~skspatial.objects.Plane.intersect_plane
~skspatial.objects.Plane.plot_3d
~skspatial.objects.Plane.project_line
~skspatial.objects.Plane.project_point
~skspatial.objects.Plane.project_vector
~skspatial.objects.Plane.side_point
Expand Down
22 changes: 22 additions & 0 deletions examples/projection/plot_line_plane.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""
Line-Plane Projection
=======================
Project a line onto a plane.
"""
from skspatial.objects import Line
from skspatial.objects import Plane
from skspatial.plotting import plot_3d

plane = Plane([0, 1, 0], [0, 1, 0])
line = Line([0, -1, 0], [1, -2, 0])

line_projected = plane.project_line(line)


plot_3d(
plane.plotter(lims_x=(-5, 5), lims_y=(-5, 5), alpha=0.3),
line.plotter(t_1=-2, t_2=2, color='k'),
line_projected.plotter(t_1=-2, t_2=4, color='r'),
)
2 changes: 1 addition & 1 deletion src/skspatial/_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ def _solve_quadratic(a: float, b: float, c: float, n_digits: Optional[int] = Non
if a == 0:
raise ValueError("The coefficient `a` must be non-zero.")

discriminant = b ** 2 - 4 * a * c
discriminant = b**2 - 4 * a * c

if discriminant < 0:
raise ValueError("The discriminant must not be negative.")
Expand Down
8 changes: 4 additions & 4 deletions src/skspatial/objects/circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ def area(self) -> float:
12.57
"""
return np.pi * self.radius ** 2
return np.pi * self.radius**2

def intersect_line(self, line: Line) -> Tuple[Point, Point]:
"""
Expand Down Expand Up @@ -213,9 +213,9 @@ def intersect_line(self, line: Line) -> Tuple[Point, Point]:
d_y = y_2 - y_1

# Pre-compute variables common to x and y equations.
d_r_squared = d_x ** 2 + d_y ** 2
d_r_squared = d_x**2 + d_y**2
determinant = x_1 * y_2 - x_2 * y_1
discriminant = self.radius ** 2 * d_r_squared - determinant ** 2
discriminant = self.radius**2 * d_r_squared - determinant**2

if discriminant < 0:
raise ValueError("The line does not intersect the circle.")
Expand Down Expand Up @@ -292,7 +292,7 @@ def best_fit(cls, points: array_like) -> Circle:

n = points.shape[0]
A = np.hstack((2 * points, np.ones((n, 1))))
b = (points ** 2).sum(axis=1)
b = (points**2).sum(axis=1)
c = np.linalg.lstsq(A, b, rcond=None)[0]

center = c[:2]
Expand Down
6 changes: 3 additions & 3 deletions src/skspatial/objects/cylinder.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ def surface_area(self) -> np.float64:
50.265
"""
return self.lateral_surface_area() + 2 * np.pi * self.radius ** 2
return self.lateral_surface_area() + 2 * np.pi * self.radius**2

def volume(self) -> np.float64:
r"""
Expand Down Expand Up @@ -257,7 +257,7 @@ def volume(self) -> np.float64:
6.28319
"""
return np.pi * self.radius ** 2 * self.length()
return np.pi * self.radius**2 * self.length()

def is_point_within(self, point: array_like) -> bool:
"""
Expand Down Expand Up @@ -540,7 +540,7 @@ def _intersect_line_with_infinite_cylinder(

a = (v_l - v_l.dot(v_c) * v_c).norm() ** 2
b = 2 * (v_l - v_l.dot(v_c) * v_c).dot(delta_p - delta_p.dot(v_c) * v_c)
c = (delta_p - delta_p.dot(v_c) * v_c).norm() ** 2 - r ** 2
c = (delta_p - delta_p.dot(v_c) * v_c).norm() ** 2 - r**2

try:
X = _solve_quadratic(a, b, c, n_digits=n_digits)
Expand Down
50 changes: 50 additions & 0 deletions src/skspatial/objects/plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,56 @@ def project_vector(self, vector: array_like) -> Vector:

return Vector.from_points(self.point, point_on_plane)

def project_line(self, line: Line, **kwargs: float) -> Line:
"""
Project a line onto the plane.
This method can also handle the case where the line is parallel to the plane.
Parameters
----------
line : Line
Input line.
kwargs : dict, optional
Additional keywords passed to :meth:`Vector.is_perpendicular`,
which is used to check if the line is parallel to the plane
(i.e., the line direction is perpendicular to the plane normal).
Returns
-------
Line
Projection of the line onto the plane.
Examples
--------
>>> from skspatial.objects import Line, Plane
>>> plane = Plane([0, 0, 0], [0, 0, 1])
>>> line = Line([0, 0, 0], [1, 1, 1])
>>> plane.project_line(line)
Line(point=Point([0., 0., 0.]), direction=Vector([1., 1., 0.]))
The line is parallel to the plane.
>>> line = Line([0, 0, 5], [1, 0, 0])
>>> plane.project_line(line)
Line(point=Point([0., 0., 0.]), direction=Vector([1, 0, 0]))
"""
if self.normal.is_parallel(line.vector, **kwargs):
raise ValueError("The line and plane must not be perpendicular.")

point_projected = self.project_point(line.point)

if self.normal.is_perpendicular(line.vector, **kwargs):
return Line(point_projected, line.vector)

vector_projected = self.project_vector(line.vector)

return Line(point_projected, vector_projected)

def distance_point_signed(self, point: array_like) -> np.float64:
"""
Return the signed distance from a point to the plane.
Expand Down
8 changes: 4 additions & 4 deletions src/skspatial/objects/sphere.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def surface_area(self) -> float:
50.27
"""
return 4 * np.pi * self.radius ** 2
return 4 * np.pi * self.radius**2

@np_float
def volume(self) -> float:
Expand All @@ -131,7 +131,7 @@ def volume(self) -> float:
33.51
"""
return 4 / 3 * np.pi * self.radius ** 3
return 4 / 3 * np.pi * self.radius**3

def intersect_line(self, line: Line) -> Tuple[Point, Point]:
"""
Expand Down Expand Up @@ -172,7 +172,7 @@ def intersect_line(self, line: Line) -> Tuple[Point, Point]:

dot = vector_unit.dot(vector_to_line)

discriminant = dot ** 2 - (vector_to_line.norm() ** 2 - self.radius ** 2)
discriminant = dot**2 - (vector_to_line.norm() ** 2 - self.radius**2)

if discriminant < 0:
raise ValueError("The line does not intersect the sphere.")
Expand Down Expand Up @@ -235,7 +235,7 @@ def best_fit(cls, points: array_like) -> Sphere:

n = points.shape[0]
A = np.hstack((2 * points, np.ones((n, 1))))
b = (points ** 2).sum(axis=1)
b = (points**2).sum(axis=1)

c, _, _, _ = np.linalg.lstsq(A, b, rcond=None)

Expand Down
8 changes: 4 additions & 4 deletions src/skspatial/objects/triangle.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,13 +407,13 @@ def angle(self, vertex: str) -> float:
a, b, c = self.multiple('length', 'abc')

if vertex == 'A':
arg = (b ** 2 + c ** 2 - a ** 2) / (2 * b * c)
arg = (b**2 + c**2 - a**2) / (2 * b * c)

elif vertex == 'B':
arg = (a ** 2 + c ** 2 - b ** 2) / (2 * a * c)
arg = (a**2 + c**2 - b**2) / (2 * a * c)

elif vertex == 'C':
arg = (a ** 2 + b ** 2 - c ** 2) / (2 * a * b)
arg = (a**2 + b**2 - c**2) / (2 * a * b)

else:
raise ValueError("The vertex must be 'A', 'B', or 'C'.")
Expand Down Expand Up @@ -625,7 +625,7 @@ def is_right(self, **kwargs: float) -> bool:
"""
a, b, c = sorted(self.multiple('length', 'abc'))

return math.isclose(a ** 2 + b ** 2, c ** 2, **kwargs)
return math.isclose(a**2 + b**2, c**2, **kwargs)

def plot_2d(self, ax_2d: Axes, part: str = 'points', **kwargs: str) -> None:
"""
Expand Down
68 changes: 68 additions & 0 deletions tests/unit/objects/test_plane.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,74 @@ def test_project_vector(plane, vector, vector_expected):
assert vector_projected.is_close(vector_expected)


@pytest.mark.parametrize(
("plane", "line", "line_expected"),
[
(
Plane([0, 0, 0], [0, 0, 1]),
Line([0, 0, 0], [1, 0, 0]),
Line([0, 0, 0], [1, 0, 0]),
),
(
Plane([0, 0, 0], [0, 0, 1]),
Line([0, 0, 5], [1, 0, 0]),
Line([0, 0, 0], [1, 0, 0]),
),
(
Plane([0, 0, 0], [0, 0, 1]),
Line([2, 3, -5], [1, 0, 0]),
Line([2, 3, 0], [1, 0, 0]),
),
(
Plane([0, 0, 0], [1, 0, 0]),
Line([1, 0, 0], [0, 1, 0]),
Line([0, 0, 0], [0, 1, 0]),
),
(
Plane([0, 0, 0], [0, -1, 1]),
Line([0, 0, 0], [0, 1, 0]),
Line([0, 0, 0], [0, 0.5, 0.5]),
),
(
Plane([0, 1, 0], [0, 1, 0]),
Line([0, -1, 0], [1, -2, 0]),
Line([0, 1, 0], [1, 0, 0]),
),
],
)
def test_project_line(plane, line, line_expected):

line_projected = plane.project_line(line)

assert line_projected.point.is_close(line_expected.point)
assert line_projected.vector.is_close(line_expected.vector)


@pytest.mark.parametrize(
("plane", "line"),
[
(
Plane([0, 0, 0], [0, 0, 1]),
Line([0, 0, 0], [0, 0, 1]),
),
(
Plane([0, 0, 5], [-1, 0, 0]),
Line([0, 0, 0], [5, 0, 0]),
),
(
Plane([1, 2, 3], [1, 2, 4]),
Line([4, 5, 6], [-2, -4, -8]),
),
],
)
def test_project_line_failure(plane, line):

message_expected = "The line and plane must not be perpendicular."

with pytest.raises(ValueError, match=message_expected):
plane.project_line(line)


@pytest.mark.parametrize(
("point", "plane", "dist_signed_expected"),
[
Expand Down
5 changes: 0 additions & 5 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,6 @@ python =
[testenv]
allowlist_externals = poetry

[testenv:pre-commit]
commands =
poetry install -E pre_commit
poetry run pre-commit run --all-files

[testenv:types]
commands =
pip install pytype==2021.7.19
Expand Down

0 comments on commit 3b3a1da

Please sign in to comment.