"""Module for the Circle class."""
from __future__ import annotations
import math
from typing import Tuple
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.axes import Axes
from skspatial._functions import np_float
from skspatial.objects._base_sphere import _BaseSphere
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 Circle(_BaseSphere):
"""
A circle in 2D space.
The circle is defined by a 2D point and a radius.
Parameters
----------
point : (2,) array_like
Center of the circle.
radius : {int, float}
Radius of the circle/
Attributes
----------
point : (2,) Point
Center of the circle.
radius : {int, float}
Radius of the circle.
dimension : int
Dimension of the circle.
Raises
------
ValueError
If the radius is not positive.
If the point is not 2D.
Examples
--------
>>> from skspatial.objects import Circle
>>> circle = Circle([2, 5], 3)
>>> circle
Circle(point=Point([2, 5]), radius=3)
>>> circle.dimension
2
>>> circle.area().round(2)
28.27
>>> Circle([0, 0, 0], 1)
Traceback (most recent call last):
...
ValueError: The point must be 2D.
>>> Circle([0, 0], 0)
Traceback (most recent call last):
...
ValueError: The radius must be positive.
"""
def __init__(self, point: array_like, radius: float):
super().__init__(point, radius)
if self.point.dimension != 2:
raise ValueError("The point must be 2D.")
[docs] @classmethod
def from_points(cls, point_a: array_like, point_b: array_like, point_c: array_like, **kwargs) -> Circle:
"""
Instantiate a circle from three points.
Parameters
----------
point_a, point_b, point_c: array_like
Three points defining the circle. The points must be 2D.
kwargs: dict, optional
Additional keywords passed to :meth:`Points.are_collinear`.
Returns
-------
Circle
Circle containing the three input points.
Raises
------
ValueError
If the points are not 2D.
If the points are collinear.
Examples
--------
>>> from skspatial.objects import Circle
>>> Circle.from_points([-1, 0], [0, 1], [1, 0])
Circle(point=Point([-0., 0.]), radius=1.0)
>>> Circle.from_points([1, 0, 0], [0, 1], [1, 0])
Traceback (most recent call last):
...
ValueError: The points must be 2D.
>>> Circle.from_points([0, 0], [1, 1], [2, 2])
Traceback (most recent call last):
...
ValueError: The points must not be collinear.
"""
def _minor(array, i: int, j: int):
subarray = array[
np.array(list(range(i)) + list(range(i + 1, array.shape[0])))[:, np.newaxis],
np.array(list(range(j)) + list(range(j + 1, array.shape[1]))),
]
return np.linalg.det(subarray)
point_a = Point(point_a)
point_b = Point(point_b)
point_c = Point(point_c)
if any(point.dimension != 2 for point in [point_a, point_b, point_c]):
raise ValueError("The points must be 2D.")
if Points([point_a, point_b, point_c]).are_collinear(**kwargs):
raise ValueError("The points must not be collinear.")
x_a, y_a = point_a
x_b, y_b = point_b
x_c, y_c = point_c
matrix = np.array(
[
[0, 0, 0, 1],
[x_a**2 + y_a**2, x_a, y_a, 1],
[x_b**2 + y_b**2, x_b, y_b, 1],
[x_c**2 + y_c**2, x_c, y_c, 1],
],
)
M_00 = _minor(matrix, 0, 0)
M_01 = _minor(matrix, 0, 1)
M_02 = _minor(matrix, 0, 2)
M_03 = _minor(matrix, 0, 3)
x = 0.5 * M_01 / M_00
y = -0.5 * M_02 / M_00
radius = math.sqrt(x**2 + y**2 + M_03 / M_00)
return cls([x, y], radius)
[docs] @np_float
def circumference(self) -> float:
r"""
Return the circumference of the circle.
The circumference :math:`C` of a circle with radius :math:`r` is
.. math:: C = 2 \pi r
Returns
-------
np.float64
Circumference of the circle.
Examples
--------
>>> from skspatial.objects import Circle
>>> Circle([0, 0], 1).area().round(2)
3.14
>>> Circle([0, 0], 2).area().round(2)
12.57
"""
return 2 * np.pi * self.radius
[docs] @np_float
def area(self) -> float:
r"""
Return the area of the circle.
The area :math:`A` of a circle with radius :math:`r` is
.. math:: A = \pi r ^ 2
Returns
-------
np.float64
Area of the circle.
Examples
--------
>>> from skspatial.objects import Circle
>>> Circle([0, 0], 1).area().round(2)
3.14
>>> Circle([0, 0], 2).area().round(2)
12.57
"""
return np.pi * self.radius**2
[docs] def intersect_circle(self, other: Circle) -> Tuple[Point, Point]:
"""
Intersect the circle with another circle.
A circle intersects a circle at two points.
Parameters
----------
other : Circle
Other circle.
Returns
-------
point_a, point_b : Point
The two points of intersection.
Raises
------
ValueError
If the centres of the circles are coincident.
If the circles are separate.
If one circle is contained within the other.
References
----------
http://paulbourke.net/geometry/circlesphere/
Examples
--------
>>> from skspatial.objects import Circle
>>> circle_a = Circle([0, 0], 1)
>>> circle_b = Circle([2, 0], 1)
>>> circle_a.intersect_circle(circle_b)
(Point([1., 0.]), Point([1., 0.]))
>>> circle_a.intersect_circle(Circle([0, 0], 2))
Traceback (most recent call last):
...
ValueError: The centres of the circles are coincident.
>>> circle_a.intersect_circle(Circle([3, 0], 1))
Traceback (most recent call last):
...
ValueError: The circles do not intersect. These circles are separate.
>>> Circle([0, 0], 3).intersect_circle(Circle([1, 0], 1))
Traceback (most recent call last):
...
ValueError: The circles do not intersect. One circle is contained within the other.
"""
d = self.point.distance_point(other.point)
if d == 0:
raise ValueError("The centres of the circles are coincident.")
if d > self.radius + other.radius:
raise ValueError("The circles do not intersect. These circles are separate.")
if d < abs(self.radius - other.radius):
raise ValueError("The circles do not intersect. One circle is contained within the other.")
a = (self.radius**2 - other.radius**2 + d**2) / (2 * d)
h = math.sqrt(self.radius**2 - a**2)
point_middle = self.point + a * Vector.from_points(self.point, other.point) / d
pm = np.array([1, -1])
X = point_middle[0] + pm * h * (self.point[1] - other.point[1]) / d
Y = point_middle[1] - pm * h * (self.point[0] - other.point[0]) / d
point_a = Point([X[0], Y[0]])
point_b = Point([X[1], Y[1]])
return point_a, point_b
[docs] def intersect_line(self, line: Line) -> Tuple[Point, Point]:
"""
Intersect the circle with a line.
A line intersects a circle at two points.
Parameters
----------
line : Line
Input line.
Returns
-------
point_a, point_b : Point
The two points of intersection.
Raises
------
ValueError
If the line does not intersect the circle.
References
----------
http://mathworld.wolfram.com/Circle-LineIntersection.html
Examples
--------
>>> from skspatial.objects import Circle, Line
>>> circle = Circle([0, 0], 1)
>>> circle.intersect_line(Line(point=[0, 0], direction=[1, 0]))
(Point([-1., 0.]), Point([1., 0.]))
>>> point_a, point_b = circle.intersect_line(Line(point=[0, 0], direction=[1, 1]))
>>> point_a.round(3)
Point([-0.707, -0.707])
>>> point_b.round(3)
Point([0.707, 0.707])
>>> circle.intersect_line(Line(point=[1, 2], direction=[1, 1]))
(Point([-1., 0.]), Point([0., 1.]))
If the line is tangent to the circle, the two intersection points are the same.
>>> circle.intersect_line(Line(point=[1, 0], direction=[0, 1]))
(Point([1., 0.]), Point([1., 0.]))
The circle does not have to be centered on the origin.
>>> point_a, point_b = Circle([2, 3], 5).intersect_line(Line([1, 1], [2, 3]))
>>> point_a.round(3)
Point([-0.538, -1.308])
>>> point_b.round(3)
Point([5., 7.])
>>> circle.intersect_line(Line(point=[5, 0], direction=[1, 1]))
Traceback (most recent call last):
...
ValueError: The line does not intersect the circle.
"""
# Two points on the line.
point_1 = line.point
point_2 = point_1 + line.direction.unit()
# Translate the points on the line to mimic the circle being centered on the origin.
point_translated_1 = point_1 - self.point
point_translated_2 = point_2 - self.point
x_1, y_1 = point_translated_1
x_2, y_2 = point_translated_2
d_x = x_2 - x_1
d_y = y_2 - y_1
# Pre-compute variables common to x and y equations.
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
if discriminant < 0:
raise ValueError("The line does not intersect the circle.")
root = math.sqrt(discriminant)
mp = np.array([-1, 1]) # Array to compute minus/plus.
sign = -1 if d_y < 0 else 1
coords_x = (determinant * d_y + mp * sign * d_x * root) / d_r_squared
coords_y = (-determinant * d_x + mp * abs(d_y) * root) / d_r_squared
point_translated_a = Point([coords_x[0], coords_y[0]])
point_translated_b = Point([coords_x[1], coords_y[1]])
# Translate the intersection points back from origin circle to real circle.
point_a = point_translated_a + self.point
point_b = point_translated_b + self.point
return point_a, point_b
[docs] @classmethod
def best_fit(cls, points: array_like) -> Circle:
"""
Return the circle of best fit for a set of 2D points.
Parameters
----------
points : array_like
Input 2D points.
Returns
-------
Circle
The circle of best fit.
Raises
------
ValueError
If the points are not 2D.
If there are fewer than three points.
If the points are collinear.
References
----------
https://meshlogic.github.io/posts/jupyter/curve-fitting/fitting-a-circle-to-cluster-of-3d-points/
Examples
--------
>>> import numpy as np
>>> from skspatial.objects import Circle
>>> points = [[1, 1], [2, 2], [3, 1]]
>>> circle = Circle.best_fit(points)
>>> circle.point
Point([2., 1.])
>>> np.round(circle.radius, 2)
1.0
"""
points = Points(points)
if points.dimension != 2:
raise ValueError("The points must be 2D.")
if points.shape[0] < 3:
raise ValueError("There must be at least 3 points.")
if points.affine_rank() != 2:
raise ValueError("The points must not be collinear.")
n = points.shape[0]
A = np.hstack((2 * points, np.ones((n, 1))))
b = (points**2).sum(axis=1)
c = np.linalg.lstsq(A, b, rcond=None)[0]
center = c[:2]
radius = np.sqrt(c[2] + c[0] ** 2 + c[1] ** 2)
return cls(center, radius)
[docs] def plot_2d(self, ax_2d: Axes, **kwargs) -> None:
"""
Plot the circle in 2D.
Parameters
----------
ax_2d : Axes
Instance of :class:`~matplotlib.axes.Axes`.
kwargs : dict, optional
Additional keywords passed to :class:`matplotlib.patches.Circle`.
Examples
--------
.. plot::
:include-source:
>>> import matplotlib.pyplot as plt
>>> from skspatial.objects import Circle
>>> circle = Circle([-2, 3], 3)
>>> fig, ax = plt.subplots()
>>> circle.plot_2d(ax, fill=False)
>>> circle.point.plot_2d(ax)
>>> limits = plt.axis([-10, 10, -10, 10])
"""
circle = plt.Circle(self.point, self.radius, **kwargs)
ax_2d.add_artist(circle)