Skip to content

Commit 3df51f0

Browse files
Add ellipse and elliptical arc support for SolidWorks.
1 parent 20fc93a commit 3df51f0

File tree

2 files changed

+267
-1
lines changed

2 files changed

+267
-1
lines changed

sketch_adapter_solidworks/adapter.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -500,15 +500,23 @@ def export_sketch(self) -> SketchDocument:
500500
doc.add_primitive(prim)
501501
stored_ellipse_ids.add(geom.get('element_id', ''))
502502
# Add all elliptical arc related points
503+
# SolidWorks creates points at center, start, end, and axis endpoints
503504
cx, cy = prim.center.x, prim.center.y
504505
rot = prim.rotation
505506
cos_r, sin_r = math.cos(rot), math.sin(rot)
506507
used_point_coords.add((round(cx, 6), round(cy, 6))) # center
507508
used_point_coords.add((round(prim.start_point.x, 6), round(prim.start_point.y, 6)))
508509
used_point_coords.add((round(prim.end_point.x, 6), round(prim.end_point.y, 6)))
509-
# Major axis endpoint (SolidWorks may create this point)
510+
# Major axis endpoints (SolidWorks may create these)
510511
used_point_coords.add((round(cx + prim.major_radius * cos_r, 6),
511512
round(cy + prim.major_radius * sin_r, 6)))
513+
used_point_coords.add((round(cx - prim.major_radius * cos_r, 6),
514+
round(cy - prim.major_radius * sin_r, 6)))
515+
# Minor axis endpoints (perpendicular to major axis)
516+
used_point_coords.add((round(cx - prim.minor_radius * sin_r, 6),
517+
round(cy + prim.minor_radius * cos_r, 6)))
518+
used_point_coords.add((round(cx + prim.minor_radius * sin_r, 6),
519+
round(cy - prim.minor_radius * cos_r, 6)))
512520

513521
# Build set of ellipse centers for skipping ellipse arc segments
514522
ellipse_centers: set[tuple[float, float]] = set()
@@ -629,6 +637,11 @@ def export_sketch(self) -> SketchDocument:
629637
if point_coords in exported_point_coords:
630638
continue
631639

640+
# Skip points that lie on or inside ellipse/elliptical arc curves
641+
# (SolidWorks creates internal vertex points on these curves)
642+
if self._point_on_ellipse_curve(prim.position.x, prim.position.y):
643+
continue
644+
632645
doc.add_primitive(prim)
633646
self._entity_to_id[id(point)] = prim.id
634647
self._id_to_entity[prim.id] = point
@@ -699,6 +712,55 @@ def _is_dependent_point(self, point: Any) -> bool:
699712
except Exception:
700713
return False
701714

715+
def _point_on_ellipse_curve(self, px: float, py: float, tolerance: float = 0.5) -> bool:
716+
"""Check if a point lies on or near any stored ellipse or elliptical arc curve.
717+
718+
SolidWorks creates internal vertex/control points on ellipse curves that
719+
should not be exported as standalone Point primitives.
720+
721+
Args:
722+
px: Point X coordinate in mm
723+
py: Point Y coordinate in mm
724+
tolerance: Distance tolerance in mm
725+
726+
Returns:
727+
True if the point lies on or inside an ellipse/elliptical arc region
728+
"""
729+
for geom in self._segment_geometry_list:
730+
if geom['type'] not in ('ellipse', 'elliptical_arc'):
731+
continue
732+
733+
cx, cy = geom['center']
734+
major_r = geom['major_radius']
735+
minor_r = geom['minor_radius']
736+
rotation = geom.get('rotation', 0.0)
737+
738+
# Transform point to ellipse-local coordinates
739+
dx = px - cx
740+
dy = py - cy
741+
742+
# Check if point is within the bounding region of the ellipse
743+
dist_from_center = math.sqrt(dx * dx + dy * dy)
744+
if dist_from_center <= major_r + tolerance:
745+
# Rotate to align with ellipse axes
746+
cos_r = math.cos(-rotation)
747+
sin_r = math.sin(-rotation)
748+
local_x = dx * cos_r - dy * sin_r
749+
local_y = dx * sin_r + dy * cos_r
750+
751+
if major_r > 0 and minor_r > 0:
752+
# Check ellipse equation: (x/a)^2 + (y/b)^2 = 1
753+
# Points on or inside the ellipse should be filtered
754+
normalized_dist = (local_x / major_r) ** 2 + (local_y / minor_r) ** 2
755+
756+
# Filter points on the ellipse curve (normalized_dist ≈ 1)
757+
# or inside the ellipse (normalized_dist < 1)
758+
curve_tolerance = tolerance / min(major_r, minor_r)
759+
if normalized_dist < 1.0 + curve_tolerance:
760+
return True
761+
762+
return False
763+
702764
def _is_ellipse_arc_segment(self, segment: Any, ellipse_centers: set[tuple[float, float]]) -> bool:
703765
"""Check if a segment is part of ellipse geometry.
704766

tests/test_solidworks_roundtrip.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2265,3 +2265,207 @@ def test_mixed_constraint_types_export(self, adapter):
22652265

22662266
assert horizontal_line is not None, "Should have a horizontal line"
22672267
assert vertical_line is not None, "Should have a vertical line"
2268+
2269+
2270+
class TestSolidWorksExportRegression:
2271+
"""Regression tests for export edge cases discovered via SketchBridge demo.
2272+
2273+
These tests verify fixes for issues found when exporting the comprehensive
2274+
SketchBridge demo sketch to SolidWorks.
2275+
"""
2276+
2277+
def test_arc_270_degrees_not_circle(self, adapter):
2278+
"""Test that a 270-degree arc is exported as Arc, not Circle.
2279+
2280+
Regression test for: Large arcs (>180°) being incorrectly identified
2281+
as full circles due to arc length comparison tolerance issues.
2282+
"""
2283+
sketch = SketchDocument(name="Arc270Test")
2284+
# 270 degree CCW arc (from right going up and around to bottom)
2285+
# This is similar to the tangent arc in the demo that was incorrectly exported
2286+
sketch.add_primitive(Arc(
2287+
center=Point2D(95, 12.5),
2288+
start_point=Point2D(82, 12.5), # Left of center (180°)
2289+
end_point=Point2D(95, 25.5), # Top of center (90°)
2290+
ccw=True # Going CCW from 180° to 90° = 270° sweep
2291+
))
2292+
2293+
adapter.create_sketch(sketch.name)
2294+
adapter.load_sketch(sketch)
2295+
exported = adapter.export_sketch()
2296+
2297+
assert len(exported.primitives) == 1, "Should have exactly 1 primitive"
2298+
prim = list(exported.primitives.values())[0]
2299+
assert isinstance(prim, Arc), f"Should be Arc, not {type(prim).__name__}"
2300+
assert not isinstance(prim, Circle), "Should not be a Circle"
2301+
2302+
def test_multiple_lines_correct_endpoints(self, adapter):
2303+
"""Test that multiple lines preserve their correct endpoints.
2304+
2305+
Regression test for: Line export matching wrong point pairs when
2306+
multiple lines exist in the sketch, especially with similar lengths.
2307+
"""
2308+
sketch = SketchDocument(name="MultipleLinesTest")
2309+
2310+
# Create lines similar to the demo - rectangle plus angled lines
2311+
# Rectangle
2312+
sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(40, 0)))
2313+
sketch.add_primitive(Line(start=Point2D(40, 0), end=Point2D(40, 25)))
2314+
sketch.add_primitive(Line(start=Point2D(40, 25), end=Point2D(0, 25)))
2315+
sketch.add_primitive(Line(start=Point2D(0, 25), end=Point2D(0, 0)))
2316+
2317+
# Angled line at 30 degrees (similar to demo's line_angled1)
2318+
angle_rad = math.radians(30)
2319+
end_x = 110 + 25 * math.cos(angle_rad)
2320+
end_y = 25 * math.sin(angle_rad)
2321+
sketch.add_primitive(Line(start=Point2D(110, 0), end=Point2D(end_x, end_y)))
2322+
2323+
adapter.create_sketch(sketch.name)
2324+
adapter.load_sketch(sketch)
2325+
exported = adapter.export_sketch()
2326+
2327+
lines = [p for p in exported.primitives.values() if isinstance(p, Line)]
2328+
assert len(lines) == 5, f"Expected 5 lines, got {len(lines)}"
2329+
2330+
# Find the angled line (should have start at 110, 0)
2331+
angled_line = None
2332+
for ln in lines:
2333+
if abs(ln.start.x - 110) < 0.1 and abs(ln.start.y - 0) < 0.1:
2334+
angled_line = ln
2335+
break
2336+
2337+
assert angled_line is not None, "Should find angled line starting at (110, 0)"
2338+
# End point should be near (131.65, 12.5), not (0, 25) or other wrong point
2339+
assert angled_line.end.x > 120, \
2340+
f"Angled line end X should be > 120, got {angled_line.end.x}"
2341+
assert angled_line.end.y > 10, \
2342+
f"Angled line end Y should be > 10, got {angled_line.end.y}"
2343+
2344+
def test_no_degenerate_zero_length_lines(self, adapter):
2345+
"""Test that no zero-length (degenerate) lines are exported.
2346+
2347+
Regression test for: SolidWorks creating internal degenerate segments
2348+
that get incorrectly exported as zero-length lines.
2349+
"""
2350+
sketch = SketchDocument(name="DegenerateLineTest")
2351+
2352+
# Create a simple rectangle - should not produce any degenerate lines
2353+
sketch.add_primitive(Line(start=Point2D(0, 0), end=Point2D(50, 0)))
2354+
sketch.add_primitive(Line(start=Point2D(50, 0), end=Point2D(50, 30)))
2355+
sketch.add_primitive(Line(start=Point2D(50, 30), end=Point2D(0, 30)))
2356+
sketch.add_primitive(Line(start=Point2D(0, 30), end=Point2D(0, 0)))
2357+
2358+
adapter.create_sketch(sketch.name)
2359+
adapter.load_sketch(sketch)
2360+
exported = adapter.export_sketch()
2361+
2362+
lines = [p for p in exported.primitives.values() if isinstance(p, Line)]
2363+
2364+
for ln in lines:
2365+
length = math.sqrt((ln.end.x - ln.start.x)**2 + (ln.end.y - ln.start.y)**2)
2366+
assert length > 0.01, \
2367+
f"Found degenerate line from ({ln.start.x}, {ln.start.y}) to ({ln.end.x}, {ln.end.y})"
2368+
2369+
def test_ellipse_no_extra_standalone_points(self, adapter):
2370+
"""Test that ellipse export doesn't create extra standalone points.
2371+
2372+
Regression test for: SolidWorks internal ellipse vertex points being
2373+
incorrectly exported as standalone Point primitives.
2374+
"""
2375+
sketch = SketchDocument(name="EllipsePointsTest")
2376+
sketch.add_primitive(Ellipse(
2377+
center=Point2D(30, -25),
2378+
major_radius=18,
2379+
minor_radius=10,
2380+
rotation=math.radians(15)
2381+
))
2382+
2383+
adapter.create_sketch(sketch.name)
2384+
adapter.load_sketch(sketch)
2385+
exported = adapter.export_sketch()
2386+
2387+
# Should have only 1 primitive (the ellipse)
2388+
ellipses = [p for p in exported.primitives.values() if isinstance(p, Ellipse)]
2389+
points = [p for p in exported.primitives.values() if isinstance(p, Point)]
2390+
2391+
assert len(ellipses) == 1, f"Expected 1 ellipse, got {len(ellipses)}"
2392+
assert len(points) == 0, \
2393+
f"Expected 0 standalone points, got {len(points)} (ellipse vertex points leaked)"
2394+
2395+
def test_elliptical_arc_no_extra_standalone_points(self, adapter):
2396+
"""Test that elliptical arc export doesn't create extra standalone points.
2397+
2398+
Regression test for: SolidWorks internal elliptical arc vertex points
2399+
being incorrectly exported as standalone Point primitives.
2400+
"""
2401+
sketch = SketchDocument(name="EllipticalArcPointsTest")
2402+
sketch.add_primitive(EllipticalArc(
2403+
center=Point2D(85, -25),
2404+
major_radius=15,
2405+
minor_radius=8,
2406+
rotation=math.radians(-10),
2407+
start_param=math.radians(30),
2408+
end_param=math.radians(240),
2409+
ccw=True
2410+
))
2411+
2412+
adapter.create_sketch(sketch.name)
2413+
adapter.load_sketch(sketch)
2414+
exported = adapter.export_sketch()
2415+
2416+
# Should have only 1 primitive (the elliptical arc)
2417+
arcs = [p for p in exported.primitives.values() if isinstance(p, EllipticalArc)]
2418+
points = [p for p in exported.primitives.values() if isinstance(p, Point)]
2419+
2420+
assert len(arcs) == 1, f"Expected 1 elliptical arc, got {len(arcs)}"
2421+
assert len(points) == 0, \
2422+
f"Expected 0 standalone points, got {len(points)} (arc vertex points leaked)"
2423+
2424+
def test_line_with_negative_coordinates(self, adapter):
2425+
"""Test that lines with negative Y coordinates are correctly exported.
2426+
2427+
Regression test for: Potential issues with negative coordinate handling
2428+
in the line export logic.
2429+
"""
2430+
sketch = SketchDocument(name="NegativeCoordLineTest")
2431+
# Line in negative Y region (like the midpoint_line in demo)
2432+
sketch.add_primitive(Line(start=Point2D(55, -15), end=Point2D(75, -15)))
2433+
2434+
adapter.create_sketch(sketch.name)
2435+
adapter.load_sketch(sketch)
2436+
exported = adapter.export_sketch()
2437+
2438+
lines = [p for p in exported.primitives.values() if isinstance(p, Line)]
2439+
assert len(lines) == 1, f"Expected 1 line, got {len(lines)}"
2440+
2441+
ln = lines[0]
2442+
# Check coordinates are preserved
2443+
assert abs(ln.start.x - 55) < 0.1, f"Start X should be 55, got {ln.start.x}"
2444+
assert abs(ln.start.y - (-15)) < 0.1, f"Start Y should be -15, got {ln.start.y}"
2445+
assert abs(ln.end.x - 75) < 0.1, f"End X should be 75, got {ln.end.x}"
2446+
assert abs(ln.end.y - (-15)) < 0.1, f"End Y should be -15, got {ln.end.y}"
2447+
2448+
def test_multiple_standalone_points_preserved(self, adapter):
2449+
"""Test that multiple standalone points are all preserved.
2450+
2451+
Regression test for: Some standalone points being lost during export
2452+
when multiple points exist in the sketch.
2453+
"""
2454+
sketch = SketchDocument(name="MultiplePointsTest")
2455+
# Three points at different locations (like the demo's symmetric points)
2456+
sketch.add_primitive(Point(position=Point2D(5, -15)))
2457+
sketch.add_primitive(Point(position=Point2D(35, -15)))
2458+
sketch.add_primitive(Point(position=Point2D(65, -15)))
2459+
2460+
adapter.create_sketch(sketch.name)
2461+
adapter.load_sketch(sketch)
2462+
exported = adapter.export_sketch()
2463+
2464+
points = [p for p in exported.primitives.values() if isinstance(p, Point)]
2465+
assert len(points) == 3, f"Expected 3 points, got {len(points)}"
2466+
2467+
# Verify the specific positions are preserved
2468+
point_xs = sorted([p.position.x for p in points])
2469+
assert abs(point_xs[0] - 5) < 0.1, f"First point X should be 5, got {point_xs[0]}"
2470+
assert abs(point_xs[1] - 35) < 0.1, f"Second point X should be 35, got {point_xs[1]}"
2471+
assert abs(point_xs[2] - 65) < 0.1, f"Third point X should be 65, got {point_xs[2]}"

0 commit comments

Comments
 (0)