@@ -368,3 +368,221 @@ def create_uniform_bspline(cls, control_points: list[Point2D], degree: int = 3,
368368 knots = knots ,
369369 construction = construction
370370 )
371+
372+
373+ @dataclass
374+ class Ellipse (SketchPrimitive ):
375+ """
376+ Full ellipse defined by center, semi-major/minor radii, and rotation.
377+
378+ The ellipse is parameterized as:
379+ x = center.x + major_radius * cos(t) * cos(rotation) - minor_radius * sin(t) * sin(rotation)
380+ y = center.y + major_radius * cos(t) * sin(rotation) + minor_radius * sin(t) * cos(rotation)
381+
382+ where t is the parametric angle in [0, 2*pi).
383+
384+ Attributes:
385+ center: Center point of the ellipse
386+ major_radius: Semi-major axis length (must be >= minor_radius)
387+ minor_radius: Semi-minor axis length
388+ rotation: Angle of major axis from positive X-axis, in radians (default 0)
389+
390+ Valid point types: CENTER only
391+ """
392+ center : Point2D = field (default_factory = lambda : Point2D (0 , 0 ))
393+ major_radius : float = 1.0
394+ minor_radius : float = 0.5
395+ rotation : float = 0.0 # Radians, angle of major axis from X-axis
396+
397+ @property
398+ def eccentricity (self ) -> float :
399+ """Calculate ellipse eccentricity (0 = circle, approaching 1 = very elongated)."""
400+ if self .major_radius == 0 :
401+ return 0.0
402+ return math .sqrt (1 - (self .minor_radius / self .major_radius ) ** 2 )
403+
404+ @property
405+ def focal_distance (self ) -> float :
406+ """Distance from center to each focus."""
407+ return math .sqrt (self .major_radius ** 2 - self .minor_radius ** 2 )
408+
409+ @property
410+ def focus1 (self ) -> Point2D :
411+ """First focus point (along positive major axis direction)."""
412+ c = self .focal_distance
413+ return Point2D (
414+ self .center .x + c * math .cos (self .rotation ),
415+ self .center .y + c * math .sin (self .rotation )
416+ )
417+
418+ @property
419+ def focus2 (self ) -> Point2D :
420+ """Second focus point (along negative major axis direction)."""
421+ c = self .focal_distance
422+ return Point2D (
423+ self .center .x - c * math .cos (self .rotation ),
424+ self .center .y - c * math .sin (self .rotation )
425+ )
426+
427+ @property
428+ def area (self ) -> float :
429+ """Calculate ellipse area."""
430+ return math .pi * self .major_radius * self .minor_radius
431+
432+ @property
433+ def circumference (self ) -> float :
434+ """
435+ Approximate ellipse circumference using Ramanujan's approximation.
436+
437+ This is accurate to within 0.01% for most ellipses.
438+ """
439+ a , b = self .major_radius , self .minor_radius
440+ h = ((a - b ) ** 2 ) / ((a + b ) ** 2 )
441+ return math .pi * (a + b ) * (1 + (3 * h ) / (10 + math .sqrt (4 - 3 * h )))
442+
443+ def point_at_parameter (self , t : float ) -> Point2D :
444+ """
445+ Get point on ellipse at parametric angle t (radians).
446+
447+ The parametric angle t is NOT the geometric angle from the center.
448+ At t=0, the point is at the positive major axis endpoint.
449+ At t=pi/2, the point is at the positive minor axis endpoint.
450+ """
451+ cos_r = math .cos (self .rotation )
452+ sin_r = math .sin (self .rotation )
453+ cos_t = math .cos (t )
454+ sin_t = math .sin (t )
455+
456+ x = self .center .x + self .major_radius * cos_t * cos_r - self .minor_radius * sin_t * sin_r
457+ y = self .center .y + self .major_radius * cos_t * sin_r + self .minor_radius * sin_t * cos_r
458+ return Point2D (x , y )
459+
460+ def get_point (self , point_type : PointType ) -> Point2D :
461+ match point_type :
462+ case PointType .CENTER :
463+ return self .center
464+ case _:
465+ raise ValueError (f"Invalid point type { point_type } for Ellipse" )
466+
467+ def get_valid_point_types (self ) -> list [PointType ]:
468+ return [PointType .CENTER ]
469+
470+
471+ @dataclass
472+ class EllipticalArc (SketchPrimitive ):
473+ """
474+ Elliptical arc defined by center, radii, rotation, and angular extent.
475+
476+ The arc is a portion of an ellipse, parameterized by start and end angles.
477+ These are parametric angles (not geometric angles from center).
478+
479+ The parametric equation is:
480+ x = center.x + major_radius * cos(t) * cos(rotation) - minor_radius * sin(t) * sin(rotation)
481+ y = center.y + major_radius * cos(t) * sin(rotation) + minor_radius * sin(t) * cos(rotation)
482+
483+ Attributes:
484+ center: Center point of the ellipse
485+ major_radius: Semi-major axis length (must be >= minor_radius)
486+ minor_radius: Semi-minor axis length
487+ rotation: Angle of major axis from positive X-axis, in radians
488+ start_param: Parametric angle at arc start, in radians
489+ end_param: Parametric angle at arc end, in radians
490+ ccw: If True, arc goes counter-clockwise from start to end
491+
492+ Valid point types: START, END, CENTER, MIDPOINT
493+ """
494+ center : Point2D = field (default_factory = lambda : Point2D (0 , 0 ))
495+ major_radius : float = 1.0
496+ minor_radius : float = 0.5
497+ rotation : float = 0.0
498+ start_param : float = 0.0 # Parametric angle at start (radians)
499+ end_param : float = math .pi / 2 # Parametric angle at end (radians)
500+ ccw : bool = True
501+
502+ def _normalize_angle (self , angle : float ) -> float :
503+ """Normalize angle to [0, 2*pi)."""
504+ while angle < 0 :
505+ angle += 2 * math .pi
506+ while angle >= 2 * math .pi :
507+ angle -= 2 * math .pi
508+ return angle
509+
510+ @property
511+ def sweep_param (self ) -> float :
512+ """
513+ Signed sweep in parametric angle (positive = CCW).
514+
515+ Returns the parametric angle traversed from start to end.
516+ """
517+ delta = self .end_param - self .start_param
518+ if self .ccw :
519+ # CCW: want positive sweep
520+ while delta <= 0 :
521+ delta += 2 * math .pi
522+ else :
523+ # CW: want negative sweep
524+ while delta >= 0 :
525+ delta -= 2 * math .pi
526+ return delta
527+
528+ @property
529+ def mid_param (self ) -> float :
530+ """Parametric angle at arc midpoint."""
531+ return self .start_param + self .sweep_param / 2
532+
533+ def point_at_parameter (self , t : float ) -> Point2D :
534+ """
535+ Get point on ellipse at parametric angle t (radians).
536+ """
537+ cos_r = math .cos (self .rotation )
538+ sin_r = math .sin (self .rotation )
539+ cos_t = math .cos (t )
540+ sin_t = math .sin (t )
541+
542+ x = self .center .x + self .major_radius * cos_t * cos_r - self .minor_radius * sin_t * sin_r
543+ y = self .center .y + self .major_radius * cos_t * sin_r + self .minor_radius * sin_t * cos_r
544+ return Point2D (x , y )
545+
546+ @property
547+ def start_point (self ) -> Point2D :
548+ """Point at the start of the arc."""
549+ return self .point_at_parameter (self .start_param )
550+
551+ @property
552+ def end_point (self ) -> Point2D :
553+ """Point at the end of the arc."""
554+ return self .point_at_parameter (self .end_param )
555+
556+ @property
557+ def midpoint (self ) -> Point2D :
558+ """Point at the middle of the arc."""
559+ return self .point_at_parameter (self .mid_param )
560+
561+ def get_point (self , point_type : PointType ) -> Point2D :
562+ match point_type :
563+ case PointType .START :
564+ return self .start_point
565+ case PointType .END :
566+ return self .end_point
567+ case PointType .CENTER :
568+ return self .center
569+ case PointType .MIDPOINT :
570+ return self .midpoint
571+ case _:
572+ raise ValueError (f"Invalid point type { point_type } for EllipticalArc" )
573+
574+ def get_valid_point_types (self ) -> list [PointType ]:
575+ return [PointType .START , PointType .END , PointType .CENTER , PointType .MIDPOINT ]
576+
577+ def to_full_ellipse (self ) -> Ellipse :
578+ """Convert to the full ellipse this arc is part of."""
579+ return Ellipse (
580+ id = self .id ,
581+ construction = self .construction ,
582+ source = self .source ,
583+ confidence = self .confidence ,
584+ center = self .center ,
585+ major_radius = self .major_radius ,
586+ minor_radius = self .minor_radius ,
587+ rotation = self .rotation
588+ )
0 commit comments