# Source code for skspatial.objects.circle

"""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.

Attributes
----------
point : (2,) Point
Center 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.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):

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.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)

[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

"""

[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.")

raise ValueError("The circles do not intersect. These circles are separate.")

raise ValueError("The circles do not intersect. One circle is contained within the other.")

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 sphere 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.])

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)

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

"""