Skip to content

Commit 1aee139

Browse files
Added ellipse and ellipse arc support to core code.
1 parent cb0d9d3 commit 1aee139

6 files changed

Lines changed: 708 additions & 6 deletions

File tree

sketch_canonical/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
88
Example usage:
99
from sketch_canonical import (
10-
SketchDocument, Point2D, Line, Arc, Circle, Spline,
10+
SketchDocument, Point2D, Line, Arc, Circle, Ellipse, EllipticalArc, Spline,
1111
Coincident, Tangent, Horizontal, Radius,
1212
PointRef, PointType,
1313
validate_sketch, sketch_to_json, sketch_from_json
@@ -96,6 +96,8 @@
9696
from .primitives import (
9797
Arc,
9898
Circle,
99+
Ellipse,
100+
EllipticalArc,
99101
Line,
100102
Point,
101103
SketchPrimitive,
@@ -155,6 +157,8 @@
155157
"Line",
156158
"Arc",
157159
"Circle",
160+
"Ellipse",
161+
"EllipticalArc",
158162
"Point",
159163
"Spline",
160164

sketch_canonical/document.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,16 @@
44
from enum import Enum
55

66
from .constraints import SketchConstraint
7-
from .primitives import Arc, Circle, Line, Point, SketchPrimitive, Spline
7+
from .primitives import (
8+
Arc,
9+
Circle,
10+
Ellipse,
11+
EllipticalArc,
12+
Line,
13+
Point,
14+
SketchPrimitive,
15+
Spline,
16+
)
817
from .types import ElementPrefix, Point2D, PointRef, PointType
918

1019

@@ -44,7 +53,9 @@ class SketchDocument:
4453
ElementPrefix.ARC: 0,
4554
ElementPrefix.CIRCLE: 0,
4655
ElementPrefix.POINT: 0,
47-
ElementPrefix.SPLINE: 0
56+
ElementPrefix.SPLINE: 0,
57+
ElementPrefix.ELLIPSE: 0,
58+
ElementPrefix.ELLIPTICAL_ARC: 0,
4859
})
4960

5061
def _get_prefix_for_type(self, primitive_type: type[SketchPrimitive]) -> str:
@@ -54,7 +65,9 @@ def _get_prefix_for_type(self, primitive_type: type[SketchPrimitive]) -> str:
5465
Arc: ElementPrefix.ARC,
5566
Circle: ElementPrefix.CIRCLE,
5667
Point: ElementPrefix.POINT,
57-
Spline: ElementPrefix.SPLINE
68+
Spline: ElementPrefix.SPLINE,
69+
Ellipse: ElementPrefix.ELLIPSE,
70+
EllipticalArc: ElementPrefix.ELLIPTICAL_ARC,
5871
}
5972
prefix = prefix_map.get(primitive_type)
6073
if prefix is None:
@@ -203,6 +216,14 @@ def get_splines(self) -> list[Spline]:
203216
"""Get all splines in the sketch."""
204217
return [p for p in self.primitives.values() if isinstance(p, Spline)]
205218

219+
def get_ellipses(self) -> list[Ellipse]:
220+
"""Get all ellipses in the sketch."""
221+
return [p for p in self.primitives.values() if isinstance(p, Ellipse)]
222+
223+
def get_elliptical_arcs(self) -> list[EllipticalArc]:
224+
"""Get all elliptical arcs in the sketch."""
225+
return [p for p in self.primitives.values() if isinstance(p, EllipticalArc)]
226+
206227
def get_construction_geometry(self) -> list[SketchPrimitive]:
207228
"""Get all construction (reference) geometry."""
208229
return [p for p in self.primitives.values() if p.construction]
@@ -220,7 +241,9 @@ def clear(self) -> None:
220241
ElementPrefix.ARC: 0,
221242
ElementPrefix.CIRCLE: 0,
222243
ElementPrefix.POINT: 0,
223-
ElementPrefix.SPLINE: 0
244+
ElementPrefix.SPLINE: 0,
245+
ElementPrefix.ELLIPSE: 0,
246+
ElementPrefix.ELLIPTICAL_ARC: 0,
224247
}
225248
self.solver_status = SolverStatus.DIRTY
226249
self.degrees_of_freedom = -1
@@ -279,6 +302,15 @@ def _describe_primitive(self, p: SketchPrimitive) -> str:
279302
elif isinstance(p, Spline):
280303
periodic = "periodic" if p.periodic else "open"
281304
return f"{p.id}: Spline degree={p.degree} points={len(p.control_points)} {periodic}{const_marker}"
305+
elif isinstance(p, Ellipse):
306+
import math
307+
rot_deg = math.degrees(p.rotation)
308+
return f"{p.id}: Ellipse center=({p.center.x:.2f},{p.center.y:.2f}) a={p.major_radius:.2f} b={p.minor_radius:.2f} rot={rot_deg:.1f}°{const_marker}"
309+
elif isinstance(p, EllipticalArc):
310+
import math
311+
direction = "CCW" if p.ccw else "CW"
312+
rot_deg = math.degrees(p.rotation)
313+
return f"{p.id}: EllipticalArc center=({p.center.x:.2f},{p.center.y:.2f}) a={p.major_radius:.2f} b={p.minor_radius:.2f} rot={rot_deg:.1f}° {direction}{const_marker}"
282314
else:
283315
return f"{p.id}: {type(p).__name__}{const_marker}"
284316

sketch_canonical/primitives.py

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
)

sketch_canonical/serialization.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from .constraints import ConstraintStatus, ConstraintType, SketchConstraint
77
from .document import SketchDocument, SolverStatus
8-
from .primitives import Arc, Circle, Line, Point, SketchPrimitive, Spline
8+
from .primitives import Arc, Circle, Ellipse, EllipticalArc, Line, Point, SketchPrimitive, Spline
99
from .types import Point2D, PointRef, PointType
1010

1111

@@ -134,6 +134,23 @@ def primitive_to_dict(p: SketchPrimitive) -> dict:
134134
})
135135
if p.weights is not None:
136136
base["weights"] = p.weights
137+
elif isinstance(p, Ellipse):
138+
base.update({
139+
"center": [p.center.x, p.center.y],
140+
"major_radius": p.major_radius,
141+
"minor_radius": p.minor_radius,
142+
"rotation": p.rotation,
143+
})
144+
elif isinstance(p, EllipticalArc):
145+
base.update({
146+
"center": [p.center.x, p.center.y],
147+
"major_radius": p.major_radius,
148+
"minor_radius": p.minor_radius,
149+
"rotation": p.rotation,
150+
"start_param": p.start_param,
151+
"end_param": p.end_param,
152+
"ccw": p.ccw,
153+
})
137154

138155
return base
139156

@@ -209,6 +226,33 @@ def dict_to_primitive(data: dict) -> SketchPrimitive:
209226
periodic=data.get("periodic", False),
210227
is_fit_spline=data.get("is_fit_spline", False)
211228
)
229+
elif prim_type == "ellipse":
230+
center = _parse_point(data.get("center", [0, 0]))
231+
prim = Ellipse(
232+
id=id_val,
233+
construction=construction,
234+
source=source,
235+
confidence=confidence,
236+
center=center,
237+
major_radius=data.get("major_radius", 1.0),
238+
minor_radius=data.get("minor_radius", 0.5),
239+
rotation=data.get("rotation", 0.0),
240+
)
241+
elif prim_type == "ellipticalarc":
242+
center = _parse_point(data.get("center", [0, 0]))
243+
prim = EllipticalArc(
244+
id=id_val,
245+
construction=construction,
246+
source=source,
247+
confidence=confidence,
248+
center=center,
249+
major_radius=data.get("major_radius", 1.0),
250+
minor_radius=data.get("minor_radius", 0.5),
251+
rotation=data.get("rotation", 0.0),
252+
start_param=data.get("start_param", 0.0),
253+
end_param=data.get("end_param", 1.5707963267948966), # pi/2
254+
ccw=data.get("ccw", True),
255+
)
212256
else:
213257
raise ValueError(f"Unknown primitive type: {prim_type}")
214258

0 commit comments

Comments
 (0)