From 4b90fc907fa9c883d2335b1dd235780f4acbd6d7 Mon Sep 17 00:00:00 2001 From: kimstik Date: Mon, 14 Sep 2026 13:13:53 +0200 Subject: [PATCH 1/3] Skip entities that lie inside or on a circle _pt_arc takes sqrt(l**2 - r**2), which has no real value when the point is inside the circle and degenerates to the point itself when it is on it: a line end inside a circle, or a circle inside a bigger one, raised ValueError; a line end on the circle produced an empty segment. Such an entity cannot touch the hull beyond the circle, so it is not a candidate - report it the way get_angle already reports an entity against itself. Partially addresses #1190 and #1224. --- cadquery/hull.py | 35 ++++++++++++++++++++++++----------- tests/test_hull.py | 15 +++++++++++++++ 2 files changed, 39 insertions(+), 11 deletions(-) diff --git a/cadquery/hull.py b/cadquery/hull.py index 2ea396642..1ccbc536f 100644 --- a/cadquery/hull.py +++ b/cadquery/hull.py @@ -192,6 +192,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 +205,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 +315,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) - else: - return pt_arc(current, e) - else: - if isinstance(e, Point): - return arc_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 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( diff --git a/tests/test_hull.py b/tests/test_hull.py index aecdb5511..8a183548c 100644 --- a/tests/test_hull.py +++ b/tests/test_hull.py @@ -119,3 +119,18 @@ 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)) From 62ca594a273fa712d618280816a7dbec09b9182a Mon Sep 17 00:00:00 2001 From: kimstik Date: Mon, 14 Sep 2026 14:07:56 +0200 Subject: [PATCH 2/3] Return the start circle when nothing else can be reached A circle with every other entity inside it is its own hull, but the march raised "Hull could not be closed". The lone-circle shortcut was one instance of this and is folded in. --- cadquery/hull.py | 11 ++++++----- tests/test_hull.py | 6 ++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cadquery/hull.py b/cadquery/hull.py index 1ccbc536f..b447ad6d9 100644 --- a/cadquery/hull.py +++ b/cadquery/hull.py @@ -399,11 +399,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) @@ -431,6 +426,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 8a183548c..421cfeb17 100644 --- a/tests/test_hull.py +++ b/tests/test_hull.py @@ -134,3 +134,9 @@ 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) From c45ce4f07a7bdb6cd172d9a6ea520c1a2f15dc17 Mon Sep 17 00:00:00 2001 From: kimstik Date: Mon, 14 Sep 2026 19:00:53 +0200 Subject: [PATCH 3/3] Treat edges of one circle as one entity Two arcs on the same circle became two entities: arc_arc divided by the distance between their centres, and since the march closes on identity it could come back to the other one and fail to close. The merged bounds only steer select_lowest_arc; every tangent takes the full circle. Fixes #1888. Partially addresses #943 and #1224. --- cadquery/hull.py | 23 +++++++++-------------- tests/test_hull.py | 32 ++++++++++++++++++++++++-------- 2 files changed, 33 insertions(+), 22 deletions(-) diff --git a/cadquery/hull.py b/cadquery/hull.py index b447ad6d9..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]: diff --git a/tests/test_hull.py b/tests/test_hull.py index 421cfeb17..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(): @@ -140,3 +136,23 @@ 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)