Skip to content

Commit a79cd96

Browse files
chore(geometry): add angle conversion, ellipse eccentricity, polygon geometry, and triangle class (#13447)
* chore(geometry): add angle conversion, ellipse eccentricity, polygon geometry, and triangle class - Added `Angle.to_radians()` and `Angle.from_radians()` for degree-radian conversion. - Improved `Ellipse.perimeter` with Ramanujan’s approximation and added `eccentricity` property. - Enhanced `Polygon` with vertex generation, perimeter, and area computation. - Updated `Rectangle` to construct all four sides and support complete vertex/area logic. - Introduced new `Triangle` class with validation, perimeter, and Heron’s area formula. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 2d1d3d3 commit a79cd96

1 file changed

Lines changed: 203 additions & 8 deletions

File tree

geometry/geometry.py

Lines changed: 203 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,22 @@ def __post_init__(self) -> None:
3333
if not isinstance(self.degrees, (int, float)) or not 0 <= self.degrees <= 360:
3434
raise TypeError("degrees must be a numeric value between 0 and 360.")
3535

36+
def to_radians(self) -> float:
37+
"""
38+
>>> Angle(90).to_radians()
39+
1.5707963267948966
40+
"""
41+
return math.radians(self.degrees)
42+
43+
@classmethod
44+
def from_radians(cls, radians: float) -> Angle:
45+
"""
46+
>>> Angle.from_radians(math.pi / 2)
47+
Angle(degrees=90.0)
48+
"""
49+
degrees = math.degrees(radians) % 360 # Normalize to 0-360
50+
return cls(degrees)
51+
3652

3753
@dataclass
3854
class Side:
@@ -102,10 +118,29 @@ def area(self) -> float:
102118
@property
103119
def perimeter(self) -> float:
104120
"""
105-
>>> Ellipse(5, 10).perimeter
106-
47.12388980384689
121+
>>> round(Ellipse(5, 10).perimeter, 10)
122+
48.4422410807
107123
"""
108-
return math.pi * (self.major_radius + self.minor_radius)
124+
a, b = (
125+
max(self.major_radius, self.minor_radius),
126+
min(self.major_radius, self.minor_radius),
127+
)
128+
h = ((a - b) ** 2) / ((a + b) ** 2)
129+
return math.pi * (a + b) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
130+
131+
@property
132+
def eccentricity(self) -> float:
133+
"""
134+
>>> Ellipse(5, 10).eccentricity
135+
0.8660254037844386
136+
>>> Circle(5).eccentricity
137+
0.0
138+
"""
139+
a, b = (
140+
max(self.major_radius, self.minor_radius),
141+
min(self.major_radius, self.minor_radius),
142+
)
143+
return math.sqrt(1 - (b / a) ** 2)
109144

110145

111146
class Circle(Ellipse):
@@ -193,9 +228,14 @@ class Polygon:
193228

194229
def add_side(self, side: Side) -> Self:
195230
"""
196-
>>> Polygon().add_side(Side(5))
197-
Polygon(sides=[Side(length=5, angle=Angle(degrees=90), next_side=None)])
231+
>>> polygon = Polygon()
232+
>>> _ = polygon.add_side(Side(5, Angle(90)))
233+
>>> _ = polygon.add_side(Side(10, Angle(90)))
234+
>>> polygon.sides[0].next_side == polygon.sides[1]
235+
True
198236
"""
237+
if self.sides:
238+
self.sides[-1].next_side = side
199239
self.sides.append(side)
200240
return self
201241

@@ -222,6 +262,64 @@ def set_side(self, index: int, side: Side) -> Self:
222262
self.sides[index] = side
223263
return self
224264

265+
def get_vertices(self) -> list[tuple[float, float]]:
266+
"""
267+
>>> rect = Rectangle(5, 10)
268+
>>> vertices = rect.get_vertices()
269+
>>> len(vertices)
270+
5
271+
>>> vertices[0]
272+
(0.0, 0.0)
273+
>>> vertices[1]
274+
(5.0, 0.0)
275+
"""
276+
if not self.sides:
277+
return []
278+
vertices = [(0.0, 0.0)]
279+
x, y = 0.0, 0.0
280+
direction = 0.0 # Initial direction in radians
281+
282+
for side in self.sides:
283+
x += side.length * math.cos(direction)
284+
y += side.length * math.sin(direction)
285+
vertices.append((x, y))
286+
# Turn by exterior angle (180 - interior)
287+
turn = math.pi - side.angle.to_radians()
288+
direction += turn
289+
290+
# Check closure (tolerance for float precision)
291+
if (
292+
math.hypot(
293+
vertices[-1][0] - vertices[0][0], vertices[-1][1] - vertices[0][1]
294+
)
295+
> 1e-6
296+
):
297+
raise ValueError("Polygon does not close back to starting point")
298+
return vertices
299+
300+
def perimeter(self) -> float:
301+
"""
302+
>>> Rectangle(5, 10).perimeter()
303+
30
304+
"""
305+
return sum(side.length for side in self.sides)
306+
307+
def area(self) -> float:
308+
"""
309+
>>> Rectangle(5, 10).area()
310+
50
311+
"""
312+
vertices = self.get_vertices()
313+
if len(vertices) < 3:
314+
return 0.0
315+
n = len(vertices)
316+
a = 0.0
317+
for i in range(n):
318+
j = (i + 1) % n
319+
a += vertices[i][0] * vertices[j][1]
320+
a -= vertices[j][0] * vertices[i][1]
321+
return abs(a) / 2.0
322+
225323

226324
class Rectangle(Polygon):
227325
"""
@@ -246,14 +344,21 @@ def __init__(self, short_side_length: float, long_side_length: float) -> None:
246344

247345
def post_init(self) -> None:
248346
"""
249-
>>> Rectangle(5, 10) # doctest: +NORMALIZE_WHITESPACE
250-
Rectangle(sides=[Side(length=5, angle=Angle(degrees=90), next_side=None),
251-
Side(length=10, angle=Angle(degrees=90), next_side=None)])
347+
>>> rect = Rectangle(5, 10)
348+
>>> len(rect.sides)
349+
4
350+
>>> rect.sides[0].length
351+
5
252352
"""
253353
self.short_side = Side(self.short_side_length)
254354
self.long_side = Side(self.long_side_length)
355+
self.short_side_2 = Side(self.short_side_length)
356+
self.long_side_2 = Side(self.long_side_length)
357+
255358
super().add_side(self.short_side)
256359
super().add_side(self.long_side)
360+
super().add_side(self.short_side_2)
361+
super().add_side(self.long_side_2)
257362

258363
def perimeter(self) -> float:
259364
return (self.short_side.length + self.long_side.length) * 2
@@ -284,5 +389,95 @@ def area(self) -> float:
284389
return super().area()
285390

286391

392+
class Triangle(Polygon):
393+
"""
394+
A geometric triangle on a 2D surface.
395+
396+
>>> tri = Triangle(3, 4, 5)
397+
>>> tri.perimeter()
398+
12
399+
>>> tri.area()
400+
6.0
401+
>>> Triangle(1, 2, 10)
402+
Traceback (most recent call last):
403+
...
404+
ValueError: Sides must satisfy triangle inequality
405+
>>> Triangle(3, 4, 5, Angle(90), Angle(90), Angle(90))
406+
Traceback (most recent call last):
407+
...
408+
ValueError: Triangle angles must sum to 180 degrees
409+
"""
410+
411+
def __init__(
412+
self,
413+
side_a: float,
414+
side_b: float,
415+
side_c: float,
416+
angle_a: Angle | None = None,
417+
angle_b: Angle | None = None,
418+
angle_c: Angle | None = None,
419+
) -> None:
420+
super().__init__()
421+
422+
# validate triangle inequality
423+
if not (
424+
side_a + side_b > side_c
425+
and side_a + side_c > side_b
426+
and side_b + side_c > side_a
427+
):
428+
raise ValueError("Sides must satisfy triangle inequality")
429+
430+
self.side_a = side_a
431+
self.side_b = side_b
432+
self.side_c = side_c
433+
434+
# calculate angles using cosines if not provided
435+
if angle_a is None:
436+
cos_a = (side_b**2 + side_c**2 - side_a**2) / (2 * side_b * side_c)
437+
angle_a = Angle.from_radians(math.acos(max(-1, min(1, cos_a))))
438+
439+
if angle_b is None:
440+
cos_b = (side_a**2 + side_c**2 - side_b**2) / (2 * side_a * side_c)
441+
angle_b = Angle.from_radians(math.acos(max(-1, min(1, cos_b))))
442+
443+
if angle_c is None:
444+
cos_c = (side_a**2 + side_b**2 - side_c**2) / (2 * side_a * side_b)
445+
angle_c = Angle.from_radians(math.acos(max(-1, min(1, cos_c))))
446+
447+
# validate angle sum
448+
angle_sum = angle_a.degrees + angle_b.degrees + angle_c.degrees
449+
if abs(angle_sum - 180) > 0.01:
450+
raise ValueError("Triangle angles must sum to 180 degrees")
451+
452+
self.angle_a = angle_a
453+
self.angle_b = angle_b
454+
self.angle_c = angle_c
455+
456+
# add sides with their corresponding angles
457+
self.add_side(Side(side_a, angle_a))
458+
self.add_side(Side(side_b, angle_b))
459+
self.add_side(Side(side_c, angle_c))
460+
461+
def perimeter(self) -> float:
462+
"""
463+
>>> Triangle(3, 4, 5).perimeter()
464+
12
465+
"""
466+
return self.side_a + self.side_b + self.side_c
467+
468+
def area(self) -> float:
469+
"""
470+
Calculate area using Heron's formula.
471+
472+
>>> Triangle(3, 4, 5).area()
473+
6.0
474+
>>> round(Triangle(5, 5, 5).area(), 2)
475+
10.83
476+
"""
477+
s = self.perimeter() / 2 # semi-perimeter
478+
area = math.sqrt(s * (s - self.side_a) * (s - self.side_b) * (s - self.side_c))
479+
return area
480+
481+
287482
if __name__ == "__main__":
288483
__import__("doctest").testmod()

0 commit comments

Comments
 (0)