Skip to content

Commit 8fedaa3

Browse files
committed
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.
1 parent 788d95b commit 8fedaa3

File tree

1 file changed

+201
-8
lines changed

1 file changed

+201
-8
lines changed

geometry/geometry.py

Lines changed: 201 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,27 @@ 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 = max(self.major_radius, self.minor_radius), min(
125+
self.major_radius, self.minor_radius
126+
)
127+
h = ((a - b) ** 2) / ((a + b) ** 2)
128+
return math.pi * (a + b) * (1 + 3 * h / (10 + math.sqrt(4 - 3 * h)))
129+
130+
@property
131+
def eccentricity(self) -> float:
132+
"""
133+
>>> Ellipse(5, 10).eccentricity
134+
0.8660254037844386
135+
>>> Circle(5).eccentricity
136+
0.0
137+
"""
138+
a, b = max(self.major_radius, self.minor_radius), min(
139+
self.major_radius, self.minor_radius
140+
)
141+
return math.sqrt(1 - (b / a) ** 2)
109142

110143

111144
class Circle(Ellipse):
@@ -193,9 +226,14 @@ class Polygon:
193226

194227
def add_side(self, side: Side) -> Self:
195228
"""
196-
>>> Polygon().add_side(Side(5))
197-
Polygon(sides=[Side(length=5, angle=Angle(degrees=90), next_side=None)])
229+
>>> polygon = Polygon()
230+
>>> _ = polygon.add_side(Side(5, Angle(90)))
231+
>>> _ = polygon.add_side(Side(10, Angle(90)))
232+
>>> polygon.sides[0].next_side == polygon.sides[1]
233+
True
198234
"""
235+
if self.sides:
236+
self.sides[-1].next_side = side
199237
self.sides.append(side)
200238
return self
201239

@@ -222,6 +260,64 @@ def set_side(self, index: int, side: Side) -> Self:
222260
self.sides[index] = side
223261
return self
224262

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

226322
class Rectangle(Polygon):
227323
"""
@@ -246,14 +342,21 @@ def __init__(self, short_side_length: float, long_side_length: float) -> None:
246342

247343
def post_init(self) -> None:
248344
"""
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)])
345+
>>> rect = Rectangle(5, 10)
346+
>>> len(rect.sides)
347+
4
348+
>>> rect.sides[0].length
349+
5
252350
"""
253351
self.short_side = Side(self.short_side_length)
254352
self.long_side = Side(self.long_side_length)
353+
self.short_side_2 = Side(self.short_side_length)
354+
self.long_side_2 = Side(self.long_side_length)
355+
255356
super().add_side(self.short_side)
256357
super().add_side(self.long_side)
358+
super().add_side(self.short_side_2)
359+
super().add_side(self.long_side_2)
257360

258361
def perimeter(self) -> float:
259362
return (self.short_side.length + self.long_side.length) * 2
@@ -284,5 +387,95 @@ def area(self) -> float:
284387
return super().area()
285388

286389

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

0 commit comments

Comments
 (0)