Skip to content

Commit

Permalink
Feat: Add Circle.from_points method to define a circle from three p…
Browse files Browse the repository at this point in the history
…oints (#332)
  • Loading branch information
ajhynes7 committed Jan 27, 2023
1 parent c357f67 commit 798b6d6
Show file tree
Hide file tree
Showing 3 changed files with 128 additions and 5 deletions.
1 change: 1 addition & 0 deletions docs/source/api_reference/skspatial.objects.Circle.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Methods
~skspatial.objects.Circle.area
~skspatial.objects.Circle.best_fit
~skspatial.objects.Circle.circumference
~skspatial.objects.Circle.from_points
~skspatial.objects.Circle.intersect_circle
~skspatial.objects.Circle.intersect_line
~skspatial.objects.Circle.plot_2d
84 changes: 84 additions & 0 deletions src/skspatial/objects/circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,90 @@ def __init__(self, point: array_like, radius: float):
if self.point.dimension != 2:
raise ValueError("The point must be 2D.")

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

@np_float
def circumference(self) -> float:
r"""
Expand Down
48 changes: 43 additions & 5 deletions tests/unit/objects/test_circle.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
from skspatial.objects import Line
from skspatial.objects import Points

POINT_MUST_BE_2D = "The point must be 2D."
RADIUS_MUST_BE_POSITIVE = "The radius must be positive."

POINTS_MUST_BE_2D = "The points must be 2D."
POINTS_MUST_NOT_BE_COLLINEAR = "The points must not be collinear."

CIRCLE_CENTRES_ARE_COINCIDENT = "The centres of the circles are coincident."
CIRCLES_ARE_SEPARATE = "The circles do not intersect. These circles are separate."
CIRCLE_CONTAINED_IN_OTHER = "The circles do not intersect. One circle is contained within the other."
Expand All @@ -15,11 +21,11 @@
@pytest.mark.parametrize(
("point", "radius", "message_expected"),
[
([0, 0, 0], 1, "The point must be 2D"),
([1, 2, 3], 1, "The point must be 2D"),
([0, 0], 0, "The radius must be positive"),
([0, 0], -1, "The radius must be positive"),
([0, 0], -5, "The radius must be positive"),
([0, 0, 0], 1, POINT_MUST_BE_2D),
([1, 2, 3], 1, POINT_MUST_BE_2D),
([0, 0], 0, RADIUS_MUST_BE_POSITIVE),
([0, 0], -1, RADIUS_MUST_BE_POSITIVE),
([0, 0], -5, RADIUS_MUST_BE_POSITIVE),
],
)
def test_failure(point, radius, message_expected):
Expand All @@ -28,6 +34,38 @@ def test_failure(point, radius, message_expected):
Circle(point, radius)


@pytest.mark.parametrize(
("point_a", "point_b", "point_c", "circle_expected"),
[
([0, -1], [1, 0], [0, 1], Circle([0, 0], 1)),
([0, -2], [2, 0], [0, 2], Circle([0, 0], 2)),
([1, -1], [2, 0], [1, 1], Circle([1, 0], 1)),
],
)
def test_from_points(point_a, point_b, point_c, circle_expected):

circle = Circle.from_points(point_a, point_b, point_c)

assert circle.point.is_close(circle_expected.point)
assert math.isclose(circle.radius, circle_expected.radius)


@pytest.mark.parametrize(
("point_a", "point_b", "point_c", "message_expected"),
[
([1, 0, 0], [1, 0], [1, 0], POINTS_MUST_BE_2D),
([1, 0], [1, 0, 0], [1, 0], POINTS_MUST_BE_2D),
([1, 0], [0, 0], [1, 0, 1], POINTS_MUST_BE_2D),
([0, 0], [0, 0], [0, 0], POINTS_MUST_NOT_BE_COLLINEAR),
([0, 0], [1, 1], [2, 2], POINTS_MUST_NOT_BE_COLLINEAR),
],
)
def test_from_points_failure(point_a, point_b, point_c, message_expected):

with pytest.raises(ValueError, match=message_expected):
Circle.from_points(point_a, point_b, point_c)


@pytest.mark.parametrize(
("radius", "circumference_expected", "area_expected"),
[
Expand Down

0 comments on commit 798b6d6

Please sign in to comment.