@@ -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
3854class 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
111144class 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
226322class 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+
287480if __name__ == "__main__" :
288481 __import__ ("doctest" ).testmod ()
0 commit comments