From 2a747ac53aafc13742a676f4bce0a1a8ff70e154 Mon Sep 17 00:00:00 2001 From: kimstik Date: Tue, 8 Sep 2026 18:06:05 +0200 Subject: [PATCH 1/2] Fix hull failure with collinear circle centres A circle tangent to the hull without contributing to it enters as a zero-span arc, which makeCircle turns into a full circle, so the wire cannot close. Skip such arcs. Intermittent because convert_and_validate returns list(set(arcs)) and Arc is unhashable, so the traversal order follows id(). --- cadquery/hull.py | 10 +++++++--- tests/test_hull.py | 24 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/cadquery/hull.py b/cadquery/hull.py index a10131b10..93648dc63 100644 --- a/cadquery/hull.py +++ b/cadquery/hull.py @@ -18,6 +18,9 @@ Entity = Union["Arc", "Point"] Hull = List[Union["Arc", "Point", "Segment"]] +# minimum arc span; below this makeCircle would return a full circle +TOL = 1e-9 + class Point: @@ -348,9 +351,10 @@ def finalize_hull(hull: Hull) -> Wire: a1 = degrees(atan2p(el_p.b.x - el.c.x, el_p.b.y - el.c.y)) a2 = degrees(atan2p(el_n.a.x - el.c.x, el_n.a.y - el.c.y)) - rv.append( - Edge.makeCircle(el.r, Vector(el.c.x, el.c.y), angle1=a1, angle2=a2) - ) + if abs(a2 - a1) > TOL: + rv.append( + Edge.makeCircle(el.r, Vector(el.c.x, el.c.y), angle1=a1, angle2=a2) + ) el1 = hull[1] if isinstance(el, Segment) and isinstance(el_n, Arc) and isinstance(el1, Segment): diff --git a/tests/test_hull.py b/tests/test_hull.py index 976783ea8..a7fb33bf7 100644 --- a/tests/test_hull.py +++ b/tests/test_hull.py @@ -1,3 +1,5 @@ +from math import pi + import pytest import cadquery as cq @@ -30,3 +32,25 @@ def test_validation(): e1 = cq.Edge.makeEllipse(2, 1) c1 = cq.Edge.makeCircle(0.5, (-1.5, 0.5, 0)) hull.find_hull([c1, e1]) + + +def test_collinear(): + + r = 2.5 + spacing = 8.0 + + # collinear centres: the tangent line touches every circle in between + for n in (3, 4, 5): + + expected = spacing * (n - 1) * 2 * r + pi * r ** 2 + + # arc order follows id(), so repeat to hit the failing orders + for _ in range(20): + + edges = [cq.Edge.makeCircle(r, (i * spacing, 0, 0)) for i in range(n)] + + h = hull.find_hull(edges) + + assert h.IsClosed() + assert h.isValid() + assert cq.Face.makeFromWires(h).Area() == pytest.approx(expected) From 534bcec2081e6c14a8df45078917a5c9030b2777 Mon Sep 17 00:00:00 2001 From: kimstik Date: Wed, 9 Sep 2026 11:07:33 +0200 Subject: [PATCH 2/2] Make Arc hashable so the traversal order is reproducible convert_and_validate returns list(set(arcs)); without __hash__ the set was keyed on id(), so the order varied between runs. Keying on the arc definition makes it stable, and identical arcs now collapse instead of reaching arc_arc with a zero distance between centres. Both __eq__ compare the type first, otherwise == is not commutative between the two classes, and arc == None would raise instead of returning False. Strict rather than isinstance: a subclass adding fields would inherit a hash built from the base fields only. test_collinear permutes its input instead of repeating it - with a stable order the repeats would all take the same path. --- cadquery/hull.py | 12 +++++++++++- tests/test_hull.py | 25 +++++++++++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/cadquery/hull.py b/cadquery/hull.py index 93648dc63..88f40d6c8 100644 --- a/cadquery/hull.py +++ b/cadquery/hull.py @@ -42,7 +42,7 @@ def __hash__(self): def __eq__(self, other): - return (self.x, self.y) == (other.x, other.y) + return type(self) == type(other) and (self.x, self.y) == (other.x, other.y) class Segment: @@ -77,6 +77,16 @@ def __init__(self, c: Point, r: float, a1: float, a2: float): self.e = Point(r * cos(a2), 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): diff --git a/tests/test_hull.py b/tests/test_hull.py index a7fb33bf7..6b83e2476 100644 --- a/tests/test_hull.py +++ b/tests/test_hull.py @@ -1,3 +1,4 @@ +from itertools import permutations from math import pi import pytest @@ -39,18 +40,34 @@ def test_collinear(): r = 2.5 spacing = 8.0 - # collinear centres: the tangent line touches every circle in between + # collinear centres let an inner circle enter the hull as a zero span arc; + # only some traversal orders reach it, so permute the input for n in (3, 4, 5): expected = spacing * (n - 1) * 2 * r + pi * r ** 2 - # arc order follows id(), so repeat to hit the failing orders - for _ in range(20): + for order in permutations(range(n)): - edges = [cq.Edge.makeCircle(r, (i * spacing, 0, 0)) for i in range(n)] + edges = [cq.Edge.makeCircle(r, (i * spacing, 0, 0)) for i in order] h = hull.find_hull(edges) assert h.IsClosed() assert h.isValid() assert cq.Face.makeFromWires(h).Area() == pytest.approx(expected) + + +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 a != hull.Arc(hull.Point(0.0, 0.0), 2.0, 0.0, 2 * pi) + assert a != p + assert p != a + assert a != None