@@ -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,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
111146class 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
226324class 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+
287482if __name__ == "__main__" :
288483 __import__ ("doctest" ).testmod ()
0 commit comments