# Source code for skspatial.objects.plane

```"""Module for the Plane class."""
from __future__ import annotations

from typing import Optional
from typing import Tuple

import numpy as np
from mpl_toolkits.mplot3d import Axes3D

from skspatial.objects._base_line_plane import _BaseLinePlane
from skspatial.objects._mixins import _ToPointsMixin
from skspatial.objects.line import Line
from skspatial.objects.point import Point
from skspatial.objects.points import Points
from skspatial.objects.vector import Vector
from skspatial.typing import array_like

class Plane(_BaseLinePlane, _ToPointsMixin):
"""
A plane in space.

The plane is defined by a point and a normal vector.

Parameters
----------
point : array_like
Point on the plane.
direction : array_like
Normal vector of the plane.
kwargs : dict, optional
This method is used to ensure that the normal vector is not the zero vector.

Attributes
----------
point : Point
Point on the plane.
normal : Vector
Unit normal vector.
vector : Vector
Same as the normal.
dimension : int
Dimension of the plane.

Raises
------
ValueError
If the point and vector have different dimensions.
If the vector is all zeros.

Examples
--------
>>> from skspatial.objects import Plane

>>> plane = Plane(point=[0, 0, 0], normal=[0, 0, 5])

>>> plane
Plane(point=Point([0, 0, 0]), normal=Vector([0, 0, 5]))

>>> plane.normal
Vector([0, 0, 5])

The normal can also be accessed with the ``vector`` attribute.

>>> plane.vector
Vector([0, 0, 5])

The plane dimension is the dimension of the point and vector.

>>> plane.dimension
3

>>> Plane([0, 0], [1, 0, 0])
Traceback (most recent call last):
...
ValueError: The point and vector must have the same dimension.

>>> Plane([1, 1], [0, 0])
Traceback (most recent call last):
...
ValueError: The vector must not be the zero vector.

"""

def __init__(self, point: array_like, normal: array_like):

super().__init__(point, normal)
self.normal = self.vector

[docs]    @classmethod
def from_vectors(cls, point: array_like, vector_a: array_like, vector_b: array_like, **kwargs) -> Plane:
"""
Instantiate a plane from a point and two vectors.

The two vectors span the plane.

Parameters
----------
point : array_like
Point on the plane.
vector_a, vector_b : array_like
Input vectors.
kwargs : dict, optional

Returns
-------
Plane
Plane containing input point and spanned by the two input vectors.

Raises
------
ValueError
If the vectors are parallel.

Examples
--------
>>> from skspatial.objects import Plane

>>> Plane.from_vectors([0, 0], [1, 0], [0, 1])
Plane(point=Point([0, 0, 0]), normal=Vector([0, 0, 1]))

>>> Plane.from_vectors([0, 0], [1, 0], [2, 0])
Traceback (most recent call last):
...
ValueError: The vectors must not be parallel.

"""
vector_a = Vector(vector_a)

if vector_a.is_parallel(vector_b, **kwargs):
raise ValueError("The vectors must not be parallel.")

# The cross product returns a 3D vector.
vector_normal = vector_a.cross(vector_b)

# Convert the point to 3D so that it matches the vector dimension.
point = Point(point).set_dimension(3)

return cls(point, vector_normal)

[docs]    @classmethod
def from_points(cls, point_a: array_like, point_b: array_like, point_c: array_like, **kwargs) -> Plane:
"""
Instantiate a plane from three points.

The three points lie on the plane.

Parameters
----------
point_a, point_b, point_c: array_like
Three points defining the plane.
kwargs: dict, optional

Returns
-------
Plane
Plane containing the three input points.

Raises
------
ValueError
If the points are collinear.

Examples
--------
>>> from skspatial.objects import Plane

>>> Plane.from_points([0, 0], [1, 0], [3, 3])
Plane(point=Point([0, 0, 0]), normal=Vector([0, 0, 3]))

The order of the points affects the direction of the normal vector.

>>> Plane.from_points([0, 0], [3, 3], [1, 0])
Plane(point=Point([0, 0, 0]), normal=Vector([ 0,  0, -3]))

>>> Plane.from_points([0, 0], [0, 1], [0, 3])
Traceback (most recent call last):
...
ValueError: The points must not be collinear.

"""
if Points([point_a, point_b, point_c]).are_collinear(**kwargs):
raise ValueError("The points must not be collinear.")

vector_ab = Vector.from_points(point_a, point_b)
vector_ac = Vector.from_points(point_a, point_c)

return Plane.from_vectors(point_a, vector_ab, vector_ac)

[docs]    def cartesian(self) -> Tuple[np.number, np.number, np.number, np.number]:
"""
Return the coefficients of the Cartesian equation of the plane.

The equation has the form ax + by + cz + d = 0.

Returns
-------
tuple
Coefficients a, b, c, d.

Raises
------
ValueError
If the plane dimension is higher than 3.

Examples
--------
>>> from skspatial.objects import Plane

>>> Plane(point=[1, 1], normal=[1, 0]).cartesian()
(1, 0, 0, -1)

>>> Plane(point=[1, 2, 0], normal=[0, 0, 1]).cartesian()
(0, 0, 1, 0)

>>> Plane(point=[1, 2, 8], normal=[0, 0, 5]).cartesian()
(0, 0, 5, -40)

>>> Plane(point=[4, 9, -1], normal=[10, 2, 4]).cartesian()
(10, 2, 4, -54)

>>> Plane([0, 0, 0, 0], [1, 0, 0, 0]).cartesian()
Traceback (most recent call last):
...
ValueError: The plane dimension must be <= 3.

"""
if self.dimension > 3:
raise ValueError("The plane dimension must be <= 3.")

# The normal must be 3D to extract the coefficients.
a, b, c = self.normal.set_dimension(3)

d = -self.normal.dot(self.point)

return a, b, c, d

[docs]    def project_point(self, point: array_like) -> Point:
"""
Project a point onto the plane.

Parameters
----------
point : array_like
Input point.

Returns
-------
Point
Projection of the point onto the plane.

Examples
--------
>>> from skspatial.objects import Plane

>>> plane = Plane(point=[0, 0, 0], normal=[0, 0, 2])

>>> plane.project_point([10, 2, 5])
Point([10.,  2.,  0.])

>>> plane.project_point([5, 9, -3])
Point([5., 9., 0.])

"""
# Vector from the point in space to the point on the plane.
vector_to_plane = Vector.from_points(point, self.point)

# Perpendicular vector from the point in space to the plane.
vector_projected = self.normal.project_vector(vector_to_plane)

return Point(point) + vector_projected

[docs]    def project_vector(self, vector: array_like) -> Vector:
"""
Project a vector onto the plane.

Parameters
----------
vector : array_like
Input vector.

Returns
-------
Vector
Projection of the vector onto the plane.

Examples
--------
>>> from skspatial.objects import Plane

>>> plane = Plane([0, 4, 0], [0, 1, 1])

>>> plane.project_vector([2, 4, 8])
Vector([ 2., -2.,  2.])

"""
point_in_space = self.point + vector
point_on_plane = self.project_point(point_in_space)

return Vector.from_points(self.point, point_on_plane)

[docs]    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
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)

[docs]    def distance_point_signed(self, point: array_like) -> np.float64:
"""
Return the signed distance from a point to the plane.

Parameters
----------
point : array_like
Input point.

Returns
-------
np.float64
Signed distance from the point to the plane.

References
----------
http://mathworld.wolfram.com/Point-PlaneDistance.html

Examples
--------
>>> from skspatial.objects import Plane

>>> plane = Plane([0, 0, 0], [0, 0, 1])

>>> plane.distance_point_signed([5, 2, 0])
0.0

>>> plane.distance_point_signed([5, 2, 1])
1.0

>>> plane.distance_point([5, 2, -4])
4.0
>>> plane.distance_point_signed([5, 2, -4])
-4.0

"""
vector_to_point = Vector.from_points(self.point, point)

return self.normal.scalar_projection(vector_to_point)

def distance_point(self, point: array_like) -> np.float64:
"""
Return the distance from a point to the plane.

Parameters
----------
point : array_like
Input point.

Returns
-------
np.float64
Distance from the point to the plane.

References
----------
http://mathworld.wolfram.com/Point-PlaneDistance.html

Examples
--------
>>> from skspatial.objects import Plane

>>> plane = Plane([0, 0, 0], [0, 0, 1])

>>> plane.distance_point([5, 2, 0])
0.0

>>> plane.distance_point([5, 2, 1])
1.0

>>> plane.distance_point([5, 2, -4])
4.0

"""
return abs(self.distance_point_signed(point))

[docs]    def side_point(self, point: array_like) -> int:
"""
Find the side of the plane where a point lies.

Parameters
----------
point : array_like
Input point.

Returns
-------
int
-1 if the point is behind the plane.
0 if the point is on the plane.
1 if the point is in front of the plane.

Examples
--------
>>> from skspatial.objects import Plane
>>> plane = Plane([0, 0, 0], [0, 0, 1])

The point is in on the plane.

>>> plane.side_point([2, 5, 0])
0

The point is in front of the plane.

>>> plane.side_point([1, -5, 6])
1

The point is behind the plane.

>>> plane.side_point([5, 8, -4])
-1

Higher dimensions are supported.

>>> plane = Plane([0, 0, 0, 0], [0, 1, 0, 1])
>>> plane.side_point([0, -10, 4, 1])
-1

"""
return int(np.sign(self.distance_point_signed(point)))

[docs]    def intersect_line(self, line: Line, **kwargs) -> Point:
"""
Intersect the plane with a line.

The line and plane must not be parallel.

Parameters
----------
line : Line
Input line.
kwargs : dict, optional

Returns
-------
Point
The point of intersection.

Raises
------
ValueError
If the line and plane are parallel.

References
----------
http://geomalgorithms.com/a05-_intersect-1.html

Examples
--------
>>> from skspatial.objects import Line, Plane

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

>>> plane.intersect_line(line)
Point([0., 0., 0.])

>>> plane = Plane([2, -53, -7], [0, 0, 1])
>>> plane.intersect_line(line)
Point([ 0.,  0., -7.])

>>> line = Line([0, 1, 0], [1, 0, 0])
>>> plane.intersect_line(line)
Traceback (most recent call last):
...
ValueError: The line and plane must not be parallel.

"""
if self.normal.is_perpendicular(line.direction, **kwargs):
raise ValueError("The line and plane must not be parallel.")

vector_plane_line = Vector.from_points(self.point, line.point)

num = -self.normal.dot(vector_plane_line)
denom = self.normal.dot(line.direction)

# Vector along the line to the intersection point.
vector_line_scaled = num / denom * line.direction

return line.point + vector_line_scaled

[docs]    def intersect_plane(self, other: Plane, **kwargs) -> Line:
"""
Intersect the plane with another.

The planes must not be parallel.

Parameters
----------
other : Plane
Other plane.
kwargs : dict, optional

Returns
-------
Line
The line of intersection.

Raises
------
ValueError
If the planes are parallel.

References
----------
http://tbirdal.blogspot.com/2016/10/a-better-approach-to-plane-intersection.html

Examples
--------
>>> from skspatial.objects import Plane

>>> plane_a = Plane([0, 0, 0], [0, 0, 1])
>>> plane_b = Plane([0, 0, 0], [1, 0, 0])

>>> plane_a.intersect_plane(plane_b)
Line(point=Point([0., 0., 0.]), direction=Vector([0, 1, 0]))

>>> plane_b = Plane([5, 16, -94], [1, 0, 0])
>>> plane_a.intersect_plane(plane_b)
Line(point=Point([5., 0., 0.]), direction=Vector([0, 1, 0]))

>>> plane_b = Plane([0, 0, 1], [1, 0, 1])
>>> plane_a.intersect_plane(plane_b)
Line(point=Point([1., 0., 0.]), direction=Vector([0, 1, 0]))

>>> plane_b = Plane([0, 0, 5], [0, 0, -8])
>>> plane_a.intersect_plane(plane_b)
Traceback (most recent call last):
...
ValueError: The planes must not be parallel.

"""
if self.normal.is_parallel(other.normal, **kwargs):
raise ValueError("The planes must not be parallel.")

array_normals_stacked = np.vstack((self.normal, other.normal))

# Construct a matrix for a linear system.
array_00 = 2 * np.eye(3)
array_01 = array_normals_stacked.T
array_10 = array_normals_stacked
array_11 = np.zeros((2, 2))
matrix = np.block([[array_00, array_01], [array_10, array_11]])

dot_a = np.dot(self.point, self.normal)
dot_b = np.dot(other.point, other.normal)
array_y = np.array([0, 0, 0, dot_a, dot_b])

# Solve the linear system.
solution = np.linalg.solve(matrix, array_y)

point_line = Point(solution[:3])
direction_line = self.normal.cross(other.normal)

return Line(point_line, direction_line)

[docs]    @classmethod
def best_fit(cls, points: array_like, tol: Optional[float] = None, **kwargs) -> Plane:
"""
Return the plane of best fit for a set of 3D points.

Parameters
----------
points : array_like
Input 3D points.
tol : float | None, optional
Keyword passed to :meth:`Points.are_collinear` (default None).
kwargs : dict, optional

Returns
-------
Plane
The plane of best fit.

Raises
------
ValueError
If the points are collinear or are not 3D.

References
----------
Using SVD for some fitting problems
Inge Söderkvist
Algorithm 3.1
https://www.ltu.se/cms_fs/1.51590!/svd-fitting.pdf

Examples
--------
>>> from skspatial.objects import Plane

>>> points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]]
>>> plane = Plane.best_fit(points)

The point on the plane is the centroid of the points.

>>> plane.point
Point([0.25, 0.25, 0.25])

The plane normal is a unit vector.

>>> plane.normal.round(3)
Vector([-0.577, -0.577, -0.577])

>>> points = [[0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0]]

>>> Plane.best_fit(points)
Plane(point=Point([0.5, 0.5, 0. ]), normal=Vector([0., 0., 1.]))

>>> Plane.best_fit(points, full_matrices=False)
Plane(point=Point([0.5, 0.5, 0. ]), normal=Vector([0., 0., 1.]))

"""
points = Points(points)

if points.dimension != 3:
raise ValueError("The points must be 3D.")

if points.are_collinear(tol=tol):
raise ValueError("The points must not be collinear.")

points_centered, centroid = points.mean_center(return_centroid=True)

u, _, _ = np.linalg.svd(points_centered.T, **kwargs)
normal = Vector(u[:, 2])

return cls(centroid, normal)

[docs]    def to_mesh(
self,
lims_x: array_like = (-1, 1),
lims_y: array_like = (-1, 1),
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
"""
Return coordinate matrices for the 3D surface of the plane.

Parameters
----------
lims_x, lims_y : (2,) tuple
x and y limits of the plane.
Tuple of form (min, max). The default is (-1, 1).
The point on the plane is used as the origin.

Returns
-------
X, Y, Z: ndarray
Coordinate matrices.

Examples
--------
>>> from skspatial.objects import Plane

>>> X, Y, Z = Plane([0, 0, 0], [0, 0, 1]).to_mesh()

>>> X
array([[-1,  1],
[-1,  1]])

>>> Y
array([[-1, -1],
[ 1,  1]])

>>> Z
array([[0., 0.],
[0., 0.]])

"""
a, b, c, d = self.cartesian()
x_center, y_center = self.point[:2]

values_x = x_center + lims_x
values_y = y_center + lims_y

X, Y = np.meshgrid(values_x, values_y)

if c != 0:
Z = -(a * X + b * Y + d) / c

elif b != 0:
Z = -(a * X + c * Y + d) / b
X, Y, Z = X, Z, Y

else:
Z = -(b * X + c * Y + d) / a
X, Y, Z = Z, X, Y

return X, Y, Z

[docs]    def plot_3d(self, ax_3d: Axes3D, lims_x: array_like = (-1, 1), lims_y: array_like = (-1, 1), **kwargs) -> None:
"""
Plot a 3D plane.

Parameters
----------
ax_3d : Axes3D
Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`.
lims_x, lims_y : (2,) tuple
x and y limits of the plane.
Tuple of form (min, max). The default is (-1, 1).
The point on the plane is used as the origin.
kwargs : dict, optional

Examples
--------
.. plot::
:include-source:

>>> import matplotlib.pyplot as plt
>>> from mpl_toolkits.mplot3d import Axes3D

>>> from skspatial.objects import Plane

>>> fig = plt.figure()