"""Module for the Triangle class."""
import math
from itertools import combinations
from typing import Sequence
import numpy as np
from matplotlib.axes import Axes
from mpl_toolkits.mplot3d import Axes3D
from skspatial._functions import np_float
from skspatial.objects._base_spatial import _BaseSpatial
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 Triangle(_BaseSpatial):
"""
A triangle represented by three points in space.
Parameters
----------
point_a, point_b, point_c : array_like
The three vertices of the triangle.
Attributes
----------
point_a, point_b, point_c : Point
The three vertices of the triangle.
dimension : int
Dimension of the triangle.
Raises
------
ValueError
If the points do not have the same dimension.
If the points are collinear.
Examples
--------
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> triangle
Triangle(point_a=Point([0, 0]), point_b=Point([1, 0]), point_c=Point([0, 1]))
>>> triangle.dimension
2
>>> Triangle([0, 0, 0], [1, 0], [0, 1])
Traceback (most recent call last):
...
ValueError: The points must have the same dimension.
>>> Triangle([0, 0], [0, 1], [0, 2])
Traceback (most recent call last):
...
ValueError: The points must not be collinear.
"""
def __init__(self, point_a: array_like, point_b: array_like, point_c: array_like):
self.point_a = Point(point_a)
self.point_b = Point(point_b)
self.point_c = Point(point_c)
if not (self.point_a.dimension == self.point_b.dimension == self.point_c.dimension):
raise ValueError("The points must have the same dimension.")
if Points([self.point_a, self.point_b, self.point_c]).are_collinear():
raise ValueError("The points must not be collinear.")
self.dimension = self.point_a.dimension
def __repr__(self) -> str:
repr_a = np.array_repr(self.point_a)
repr_b = np.array_repr(self.point_b)
repr_c = np.array_repr(self.point_c)
return f"Triangle(point_a={repr_a}, point_b={repr_b}, point_c={repr_c})"
[docs] def multiple(self, name_method: str, inputs: Sequence) -> tuple:
"""
Return multiple properties of the triangle.
Parameters
----------
name_method : str
Name of the triangle method.
inputs : Sequence
Sequence of different inputs to the method.
Returns
-------
tuple
Multiple outputs from the triangle method.
Examples
--------
>>> from math import degrees
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [0, 1], [1, 0])
>>> lengths = triangle.multiple('length', 'abc')
>>> [round(x, 3) for x in lengths]
[np.float64(1.414), np.float64(1.0), np.float64(1.0)]
>>> angles = triangle.multiple('angle', 'ABC')
>>> [round(degrees(x), 3) for x in angles]
[90.0, 45.0, 45.0]
"""
method = getattr(self, name_method)
return tuple(method(x) for x in inputs)
[docs] def normal(self) -> Vector:
r"""
Return a vector normal to the triangle.
The normal vector is calculated as
.. math::
v_{AB} \times v_{AC}
where :math:`v_{AB}` is the vector from vertex A to vertex B.
Returns
-------
Vector
Normal vector.
Examples
--------
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [1, 0], [0, 1]).normal()
Vector([0, 0, 1])
The normal vector is not necessarily a unit vector.
>>> Triangle([0, 0], [2, 0], [0, 2]).normal()
Vector([0, 0, 4])
The direction of the normal vector is dependent on the order of the vertices.
>>> Triangle([0, 0], [0, 1], [1, 0]).normal()
Vector([ 0, 0, -1])
"""
vector_ab = Vector.from_points(self.point_a, self.point_b)
vector_ac = Vector.from_points(self.point_a, self.point_c)
return vector_ab.cross(vector_ac)
[docs] def area(self) -> np.float64:
"""
Return the area of the triangle.
The points are the vertices of the triangle. They must be 3D or less.
Returns
-------
np.float64
The area of the triangle.
References
----------
http://mathworld.wolfram.com/TriangleArea.html
Examples
--------
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [0, 1], [1, 0]).area()
np.float64(0.5)
>>> Triangle([0, 0], [0, 2], [1, 1]).area()
np.float64(1.0)
>>> Triangle([3, -5, 1], [5, 2, 1], [9, 4, 2]).area().round(2)
np.float64(12.54)
"""
return 0.5 * self.normal().norm()
[docs] def perimeter(self) -> np.float64:
"""
Return the perimeter of the triangle.
Returns
-------
np.float64
The perimeter of the triangle.
Examples
--------
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [0, 1], [1, 0]).perimeter().round(3)
np.float64(3.414)
>>> Triangle([0, 1], [1, 1], [2, 0]).perimeter().round(3)
np.float64(4.65)
"""
result = np.sum(self.multiple('length', 'abc'))
return np.float64(result)
[docs] def point(self, vertex: str) -> Point:
"""
Return a point (vertex) of the triangle.
Parameters
----------
vertex: str
'A', 'B', or 'C'.
Returns
-------
Point
A vertex of the triangle.
Raises
------
ValueError
If the vertex is not 'A', 'B', or 'C'.
Examples
--------
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> triangle.point('A')
Point([0, 0])
>>> triangle.point('B')
Point([1, 0])
>>> triangle.point('C')
Point([0, 1])
>>> triangle.point('D')
Traceback (most recent call last):
...
ValueError: The vertex must be 'A', 'B', or 'C'.
"""
if vertex == 'A':
return self.point_a
if vertex == 'B':
return self.point_b
if vertex == 'C':
return self.point_c
raise ValueError("The vertex must be 'A', 'B', or 'C'.")
[docs] def line(self, side: str) -> Line:
"""
Return the line along a side of the triangle.
Parameters
----------
side: str
'a', 'b', or 'c'.
Side 'a' is the side across from vertex 'A'.
Returns
-------
Line
Line along the side.
Raises
------
ValueError
If the side is not 'a', 'b', or 'c'.
Examples
--------
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> triangle.line('a')
Line(point=Point([1, 0]), direction=Vector([-1, 1]))
>>> triangle.line('b')
Line(point=Point([0, 1]), direction=Vector([ 0, -1]))
>>> triangle.line('c')
Line(point=Point([0, 0]), direction=Vector([1, 0]))
>>> triangle.line('d')
Traceback (most recent call last):
...
ValueError: The side must be 'a', 'b', or 'c'.
"""
if side == 'a':
point_1, point_2 = self.point_b, self.point_c
elif side == 'b':
point_1, point_2 = self.point_c, self.point_a
elif side == 'c':
point_1, point_2 = self.point_a, self.point_b
else:
raise ValueError("The side must be 'a', 'b', or 'c'.")
return Line.from_points(point_1, point_2)
[docs] def length(self, side: str) -> np.float64:
"""
Return a side length of the triangle.
Side 'a' is the side across from vertex 'A'.
The side length is the distance between vertices 'B' and 'C'.
Parameters
----------
side: str
'a', 'b', or 'c'.
Returns
-------
np.float64
Side length.
Raises
------
ValueError
If the side is not 'a', 'b', or 'c'.
Examples
--------
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> triangle.length('a').round(3)
np.float64(1.414)
>>> triangle.length('b')
np.float64(1.0)
>>> triangle.length('c')
np.float64(1.0)
>>> triangle.length('d')
Traceback (most recent call last):
...
ValueError: The side must be 'a', 'b', or 'c'.
"""
return self.line(side).direction.norm()
[docs] @np_float
def angle(self, vertex: str) -> float:
"""
Return an interior angle of the triangle.
The angle is in radians.
Parameters
----------
vertex: str
'A', 'B', or 'C'.
Returns
-------
np.float64
Interior angle.
Raises
------
ValueError
If the vertex is not 'A', 'B', or 'C'.
Examples
--------
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> triangle.angle('A').round(3)
np.float64(1.571)
>>> triangle.angle('B').round(3)
np.float64(0.785)
>>> triangle.angle('C').round(3)
np.float64(0.785)
>>> triangle.angle('D')
Traceback (most recent call last):
...
ValueError: The vertex must be 'A', 'B', or 'C'.
"""
a, b, c = self.multiple('length', 'abc')
if vertex == 'A':
arg = (b**2 + c**2 - a**2) / (2 * b * c)
elif vertex == 'B':
arg = (a**2 + c**2 - b**2) / (2 * a * c)
elif vertex == 'C':
arg = (a**2 + b**2 - c**2) / (2 * a * b)
else:
raise ValueError("The vertex must be 'A', 'B', or 'C'.")
return math.acos(arg)
[docs] def centroid(self) -> Point:
"""
Return the centroid of the triangle.
Returns
-------
Point
Centroid of the triangle.
Examples
--------
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [0, 1], [1, 0]).centroid().round(3)
Point([0.333, 0.333])
>>> Triangle([0, 0, 0], [1, 2, 3], [4, 5, 6]).centroid().round(3)
Point([1.667, 2.333, 3. ])
"""
return Points([self.point_a, self.point_b, self.point_c]).centroid()
[docs] def altitude(self, vertex: str) -> Line:
"""
Return the line of an altitude of the triangle.
An altitude is a line segment through a vertex and perpendicular to the opposite side.
Parameters
----------
vertex: str
'A', 'B', or 'C'.
Returns
-------
Line
Altitude line.
Raises
------
ValueError
If the vertex is not 'A', 'B', or 'C'.
Examples
--------
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 1], [2, 0])
>>> triangle.altitude('A')
Line(point=Point([0, 0]), direction=Vector([1., 1.]))
>>> triangle.altitude('B')
Line(point=Point([1, 1]), direction=Vector([ 0., -1.]))
>>> triangle.altitude('C')
Line(point=Point([2, 0]), direction=Vector([-1., 1.]))
>>> triangle.altitude('D')
Traceback (most recent call last):
...
ValueError: The vertex must be 'A', 'B', or 'C'.
"""
if vertex == 'A':
point_proj = self.line('a').project_point(self.point_a)
return Line.from_points(self.point_a, point_proj)
if vertex == 'B':
point_proj = self.line('b').project_point(self.point_b)
return Line.from_points(self.point_b, point_proj)
if vertex == 'C':
point_proj = self.line('c').project_point(self.point_c)
return Line.from_points(self.point_c, point_proj)
raise ValueError("The vertex must be 'A', 'B', or 'C'.")
[docs] def orthocenter(self, **kwargs) -> Point:
"""
Return the orthocenter of the triangle.
The orthocenter is the intersection point of the three altitudes.
Parameters
----------
kwargs : dict, optional
Additional keywords passed to :meth:`Line.intersect_line`.
Returns
-------
Point
Orthocenter of the triangle.
Examples
--------
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [0, 1], [1, 0]).orthocenter()
Point([0., 0.])
>>> Triangle([0, 0], [1, 2], [2, 0]).orthocenter()
Point([1. , 0.5])
"""
line_alt_a = self.altitude('A')
line_alt_b = self.altitude('B')
return line_alt_a.intersect_line(line_alt_b, **kwargs)
[docs] def classify(self, **kwargs: float) -> str:
"""
Classify the triangle as equilateral, isosceles, or scalene.
Parameters
----------
kwargs : dict, optional
Additional keywords passed to :func:`math.isclose`.
Returns
-------
str
'equilateral', 'isosceles', or 'scalene'.
Examples
--------
>>> import math
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [1, 0], [1, 1]).classify()
'isosceles'
>>> Triangle([0, 0], [1, 0], [0, 1]).classify()
'isosceles'
>>> Triangle([0, 0], [1, 0], [0, 2]).classify()
'scalene'
>>> Triangle([0, 0], [1, 0], [0.5, math.sin(math.pi / 3)]).classify()
'equilateral'
This triangle is approximately equilateral.
>>> triangle = Triangle([0, 0], [1, 0], [0.5, 0.866])
>>> triangle.classify()
'isosceles'
>>> triangle.classify(rel_tol=1e-3)
'equilateral'
"""
lengths = self.multiple('length', 'abc')
pairs = combinations(lengths, 2)
n_pairs_close = sum(math.isclose(a, b, **kwargs) for a, b in pairs)
if n_pairs_close == 3:
return 'equilateral'
if n_pairs_close == 1:
return 'isosceles'
return 'scalene'
[docs] def is_right(self, **kwargs: float) -> bool:
"""
Check if the triangle is a right triangle.
A right triangle with sides :math:`abc`, satisfies the Pythagorean theorem
.. math::
a^2 + b^2 = c^2
Parameters
----------
kwargs : dict, optional
Additional keywords passed to :func:`math.isclose`.
Returns
-------
bool
True if the triangle is a right triangle; false otherwise.
Examples
--------
>>> from skspatial.objects import Triangle
>>> Triangle([0, 0], [0, 1], [1, 0]).is_right()
True
>>> Triangle([0, 0], [0, 1], [1, 1]).is_right()
True
>>> Triangle([0, 0], [1, 2], [2, 0]).is_right()
False
This triangle is approximately a right triangle.
>>> triangle = Triangle([0, 0], [100, 0], [101, 100])
>>> triangle.is_right()
False
>>> triangle.is_right(rel_tol=1e-2)
True
"""
a, b, c = sorted(self.multiple('length', 'abc'))
return math.isclose(a**2 + b**2, c**2, **kwargs)
[docs] def plot_2d(self, ax_2d: Axes, part: str = 'points', **kwargs: str) -> None:
"""
Plot a triangle in 2D.
Parameters
----------
ax_2d : Axes
Instance of :class:`~matplotlib.axes.Axes`.
part : str, optional
Part of the triangle to plot.
Either 'points' or 'lines' (default 'points').
kwargs : dict, optional
Additional keywords passed to :meth:`~skspatial.objects.point.plot_2d` or
:meth:`~skspatial.objects.line.plot_2d`.
Examples
--------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> _, ax = plt.subplots()
>>> triangle.plot_2d(ax, part='points', s=100, zorder=3)
>>> triangle.plot_2d(ax, part='lines', c='k')
"""
if part == 'points':
for point in self.multiple('point', 'ABC'):
point.plot_2d(ax_2d, **kwargs)
elif part == 'lines':
for line in self.multiple('line', 'abc'):
line.plot_2d(ax_2d, **kwargs)
[docs] def plot_3d(self, ax_3d: Axes3D, part: str = 'points', **kwargs: str) -> None:
"""
Plot a triangle in 3D.
Parameters
----------
ax_3d : Axes3D
Instance of :class:`~mpl_toolkits.mplot3d.axes3d.Axes3D`.
part : str, optional
Part of the triangle to plot.
Either 'points' or 'lines' (default 'points').
kwargs : dict, optional
Additional keywords passed to :meth:`~skspatial.objects.Point.plot_3d` or
:meth:`~skspatial.objects.Line.plot_3d`.
Examples
--------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from skspatial.objects import Triangle
>>> triangle = Triangle([0, 0], [1, 0], [0, 1])
>>> _, ax = plt.subplots()
>>> triangle.plot_2d(ax, part='points', s=100, zorder=3)
>>> triangle.plot_2d(ax, part='lines', c='k')
"""
if part == 'points':
for point in self.multiple('point', 'ABC'):
point.plot_3d(ax_3d, **kwargs)
elif part == 'lines':
for line in self.multiple('line', 'abc'):
line.plot_3d(ax_3d, **kwargs)