diff --git a/cadquery/hull.py b/cadquery/hull.py index 2ea396642..9d3e6787c 100644 --- a/cadquery/hull.py +++ b/cadquery/hull.py @@ -1,4 +1,4 @@ -from typing import List, Tuple, Union, Iterable, Set +from typing import Dict, List, Tuple, Union, Iterable, Set from math import pi, sin, cos, atan2, sqrt, inf, degrees from numpy import lexsort, argmin, argmax @@ -77,16 +77,6 @@ def __init__(self, c: Point, r: float, a1: float, a2: float): self.e = Point(c.x + r * cos(a2), c.y + r * sin(a2)) self.ac = 2 * pi - (a1 - a2) - def __hash__(self): - - return hash((self.c, self.r, self.a1, self.a2)) - - def __eq__(self, other): - - return type(self) == type(other) and ( - (self.c, self.r, self.a1, self.a2) == (other.c, other.r, other.a1, other.a2) - ) - def atan2p(x, y): @@ -100,7 +90,7 @@ def atan2p(x, y): def convert_and_validate(edges: Iterable[Edge]) -> Tuple[List[Arc], List[Point]]: - arcs: Set[Arc] = set() + arcs: Dict[Tuple[Point, float], Arc] = {} points: Set[Point] = set() for e in edges: @@ -116,13 +106,18 @@ def convert_and_validate(edges: Iterable[Edge]) -> Tuple[List[Arc], List[Point]] c = e.arcCenter() r = e.radius() a1, a2 = e._bounds() + p = Point(c.x, c.y) + + if (p, r) in arcs: + a = arcs[p, r] + a1, a2 = min(a.a1, a1), max(a.a2, a2) - arcs.add(Arc(Point(c.x, c.y), r, a1, a2)) + arcs[p, r] = Arc(p, r, a1, a2) else: raise ValueError("Unsupported geometry {gt}") - return list(arcs), list(points) + return list(arcs.values()), list(points) def select_lowest_point(points: Points) -> Tuple[Point, int]: @@ -192,6 +187,10 @@ def pt_pt(p1: Point, p2: Point) -> Tuple[float, Segment]: return angle, Segment(p1, p2) +class NoTangent(Exception): + pass + + def _pt_arc(p: Point, a: Arc) -> Tuple[float, float, float, float]: x, y = p.x, p.y @@ -201,6 +200,9 @@ def _pt_arc(p: Point, a: Arc) -> Tuple[float, float, float, float]: dx, dy = x - xc, y - yc l = sqrt(dx ** 2 + dy ** 2) + if l <= r: + raise NoTangent + x1 = r ** 2 / l ** 2 * dx - r / l ** 2 * sqrt(l ** 2 - r ** 2) * dy + xc y1 = r ** 2 / l ** 2 * dy + r / l ** 2 * sqrt(l ** 2 - r ** 2) * dx + yc x2 = r ** 2 / l ** 2 * dx + r / l ** 2 * sqrt(l ** 2 - r ** 2) * dy + xc @@ -308,21 +310,27 @@ def arc_arc(a1: Arc, a2: Arc) -> Tuple[float, Segment]: return angles[ix], segments[ix] +NO_TANGENT = inf, Segment(Point(inf, inf), Point(inf, inf)) + + def get_angle(current: Entity, e: Entity) -> Tuple[float, Segment]: if current is e: - return inf, Segment(Point(inf, inf), Point(inf, inf)) - - if isinstance(current, Point): - if isinstance(e, Point): - return pt_pt(current, e) + return NO_TANGENT + + try: + if isinstance(current, Point): + if isinstance(e, Point): + return pt_pt(current, e) + else: + return pt_arc(current, e) else: - return pt_arc(current, e) - else: - if isinstance(e, Point): - return arc_pt(current, e) - else: - return arc_arc(current, e) + if isinstance(e, Point): + return arc_pt(current, e) + else: + return arc_arc(current, e) + except NoTangent: + return NO_TANGENT def update_hull( @@ -386,11 +394,6 @@ def find_hull(edges: Iterable[Edge]) -> Wire: # split into arcs and points arcs, points = convert_and_validate(edges) - # a lone circle is its own hull - if len(arcs) == 1 and not points: - a = arcs[0] - return Wire.assembleEdges([Edge.makeCircle(a.r, Vector(a.c.x, a.c.y))]) - # select the starting element start = select_lowest(arcs, points) rv.append(start) @@ -418,6 +421,12 @@ def find_hull(edges: Iterable[Edge]) -> Wire: next_ix = int(argmin(angles)) if angles[next_ix] == inf: + # nothing reaches the largest circle: everything else is inside it + if len(rv) == 1 and start is max(arcs, key=lambda a: a.r, default=None): + return Wire.assembleEdges( + [Edge.makeCircle(start.r, Vector(start.c.x, start.c.y))] + ) + raise ValueError("Hull could not be closed") current_e, current_angle, finished = update_hull( diff --git a/tests/test_hull.py b/tests/test_hull.py index aecdb5511..350f9f1ba 100644 --- a/tests/test_hull.py +++ b/tests/test_hull.py @@ -63,18 +63,14 @@ def test_collinear(): def test_eq(): - a = hull.Arc(hull.Point(0.0, 0.0), 1.0, 0.0, 2 * pi) - b = hull.Arc(hull.Point(0.0, 0.0), 1.0, 0.0, 2 * pi) p = hull.Point(0.0, 0.0) - assert a == b - assert hash(a) == hash(b) assert p == hull.Point(0.0, 0.0) + assert hash(p) == hash(hull.Point(0.0, 0.0)) - assert a != hull.Arc(hull.Point(0.0, 0.0), 2.0, 0.0, 2 * pi) - assert a != p - assert p != a - assert a != None + assert p != hull.Point(1.0, 0.0) + assert p != hull.Arc(p, 1.0, 0.0, 2 * pi) + assert p != None def test_lines_only(): @@ -119,3 +115,44 @@ def test_arc_endpoints(): assert (a.s.x, a.s.y) == pytest.approx((11.0, 20.0)) assert (a.e.x, a.e.y) == pytest.approx((9.0, 20.0)) + + +@pytest.mark.parametrize( + "inner", + [ + cq.Edge.makeCircle(5.0, (2, 0, 0)), + cq.Edge.makeLine(cq.Vector(-3, 0), cq.Vector(3, 0)), + cq.Edge.makeLine(cq.Vector(0, 20), cq.Vector(0, 10)), + ], + ids=["circle", "line", "line from the circle"], +) +def test_geometry_inside_circle(inner): + outer = [cq.Edge.makeCircle(20.0, (0, 0, 0)), cq.Edge.makeCircle(20.0, (60, 5, 0))] + + assert area(outer + [inner]) == pytest.approx(area(outer)) + + +def test_circle_with_nested_only(): + edges = [cq.Edge.makeCircle(20.0, (0, 0, 0)), cq.Edge.makeCircle(5.0, (2, 0, 0))] + + assert area(edges) == pytest.approx(400 * pi) + + +def test_coincident_arcs(): + # the start is the circle's bottom only if the halves are read as one circle + halves = [ + cq.Edge.makeCircle(5.0, (0, 0, 0), angle1=0, angle2=180), + cq.Edge.makeCircle(5.0, (0, 0, 0), angle1=180, angle2=360), + ] + segment = cq.Edge.makeLine(cq.Vector(-2, -3), cq.Vector(2, -3)) + + for order in permutations(halves): + assert area(list(order) + [segment]) == pytest.approx(25 * pi) + + +def test_intersecting_circles(): + # a fuse splits each circle at the seam and at the intersections + edges = cq.Sketch().push([(-19, 0), (19, 0)]).circle(35).reset()._faces.Edges() + + assert len(edges) == 6 + assert area(edges) == pytest.approx(38 * 70 + pi * 35 ** 2)