diff --git a/cadquery/func.py b/cadquery/func.py index a16e2a51d..60f498ee1 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -24,6 +24,9 @@ spline, circle, ellipse, + arc, + bezier, + helix, plane, box, cylinder, @@ -92,6 +95,9 @@ "spline", "circle", "ellipse", + "arc", + "bezier", + "helix", "plane", "box", "cylinder", diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 40dd9dccf..aba05a8ab 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6799,20 +6799,38 @@ def spline( return _shape(BRepBuilderAPI_MakeEdge(builder.Curve()).Edge(), Edge) -def circle(r: float) -> Edge: +def circle(r: float, start: Real = 0, end: Real = 360) -> Edge: """ - Construct a circle in the XY plane, centered at the origin. + Construct a circle or circular arc in the XY plane, centered at the origin. + + The arc goes counterclockwise from ``start`` to ``end``. Angles are in + degrees, measured from the X axis. :param r: Radius (unlike most solid primitives, which take a diameter). - :return: Closed circular edge. Use :func:`face` to get a disk. + :param start: Start angle. + :param end: End angle. Must satisfy ``start < end <= start + 360``. + :return: Circular edge, closed if ``end - start == 360``. Use :func:`face` + to get a disk. + :raises ValueError: If ``r`` is not positive or the angles are invalid. """ - return _shape( - BRepBuilderAPI_MakeEdge( - gp_Circ(gp_Ax2(Vector().toPnt(), Vector(0, 0, 1).toDir()), r) - ).Edge(), - Edge, - ) + if r <= 0: + raise ValueError(f"circle(): radius must be positive, got {r}") + + if not start < end <= start + 360: + raise ValueError( + "circle(): angles must satisfy start < end <= start + 360, " + f"got start={start}, end={end}" + ) + + circ = gp_Circ(gp_Ax2(Vector().toPnt(), Vector(0, 0, 1).toDir()), r) + + if end - start == 360: + builder = BRepBuilderAPI_MakeEdge(circ) + else: + builder = BRepBuilderAPI_MakeEdge(circ, radians(start), radians(end)) + + return _shape(builder.Edge(), Edge) def ellipse(r1: float, r2: float) -> Edge: @@ -6832,6 +6850,102 @@ def ellipse(r1: float, r2: float) -> Edge: ) +def arc( + p1: VectorLike, + p2: VectorLike, + p3: VectorLike | None = None, + *, + tangent: VectorLike | None = None, +) -> Edge: + """ + Construct a circular arc from three points, or from two points and a tangent. + + - ``arc(p1, p2, p3)``: from ``p1`` through ``p2`` to ``p3``. + - ``arc(p1, p2, tangent=t)``: from ``p1`` to ``p2``, with tangent ``t`` at ``p1``. + + For an arc defined by radius and angles, use :func:`circle`. + + :param p1: Start point. + :param p2: Point on the arc if ``p3`` is given, end point otherwise. + :param p3: End point. + :param tangent: Tangent direction at ``p1``. + :return: Circular edge. + :raises TypeError: If not exactly one of ``p3`` and ``tangent`` is given. + :raises ValueError: If the points are coincident or collinear, or the + tangent is zero or parallel to the line ``p1``-``p2``. + """ + + if (p3 is None) == (tangent is None): + raise TypeError( + "arc() takes either a third point, arc(p1, p2, p3), " + "or a tangent, arc(p1, p2, tangent=t), but not both" + ) + + v1, v2 = Vector(p1), Vector(p2) + + if (v2 - v1).Length <= TOLERANCE: + raise ValueError("arc(): p1 and p2 must be distinct") + + if p3 is not None: + v3 = Vector(p3) + + if (v3 - v1).Length <= TOLERANCE or (v3 - v2).Length <= TOLERANCE: + raise ValueError("arc(): the three points must be distinct") + + if (v2 - v1).normalized().cross((v3 - v1).normalized()).Length <= TOLERANCE: + raise ValueError("arc(): the three points must not be collinear") + + return Edge.makeThreePointArc(v1, v2, v3) + + assert tangent is not None + vt = Vector(tangent) + + if vt.Length <= TOLERANCE: + raise ValueError("arc(): tangent must be non-zero") + + if vt.normalized().cross((v2 - v1).normalized()).Length <= TOLERANCE: + raise ValueError("arc(): tangent must not be parallel to the line p1-p2") + + return Edge.makeTangentArc(v1, vt, v2) + + +def bezier(*pts: VectorLike) -> Edge: + """ + Construct a Bézier curve. + + :param pts: Points defining the curve. The curve passes through the first + and the last point; the inner points are control points. + :return: Bézier edge of degree ``len(pts) - 1``. + :raises ValueError: If fewer than 2 points are given. + """ + + if len(pts) < 2: + raise ValueError(f"bezier(): at least 2 points are required, got {len(pts)}") + + return Edge.makeBezier([Vector(p) for p in pts]) + + +def helix(pitch: Real, height: Real, radius: Real, lefthand: bool = False) -> Edge: + """ + Construct a cylindrical helix around the Z axis. + + The helix starts on the positive X axis at Z=0 and rises by ``height``. + + :param pitch: Height of one full turn. + :param height: Total height. + :param radius: Radius (unlike most solid primitives, which take a diameter). + :param lefthand: Wind clockwise (seen from +Z) instead of counterclockwise. + :return: Helical edge. + :raises ValueError: If ``pitch``, ``height`` or ``radius`` is not positive. + """ + + for name, val in (("pitch", pitch), ("height", height), ("radius", radius)): + if val <= 0: + raise ValueError(f"helix(): {name} must be positive, got {val}") + + return Wire.makeHelix(pitch, height, radius, lefthand=lefthand).Edges()[0] + + @multimethod def plane(w: Real, l: Real) -> Face: """ diff --git a/doc/free-func.rst b/doc/free-func.rst index 7a4d9c86c..69efb05ac 100644 --- a/doc/free-func.rst +++ b/doc/free-func.rst @@ -110,6 +110,39 @@ Various 1D, 2D and 3D primitives are supported. result = compound(e, c.move(2), f.move(4), b.move(6)) +Curves +------ + +1D primitives include segments, polylines, circles, ellipses, splines, Bézier curves and helices. +Circular arcs can be defined by three points, by two points and the tangent at the first point, +or by passing start and end angles to ``circle`` (in degrees, measured counterclockwise from the X axis). + +.. cadquery:: + + from cadquery.func import * + + pl = polyline((0,0), (0.5,1), (1,0)) + + el = ellipse(0.75, 0.5) + + sp = spline((0,0), (0.5,1), (1,-1), (1.5,0)) # through the points + + bz = bezier((0,0), (0.5,1), (1,-1), (1.5,0)) # inner points are control points + + h = helix(pitch=0.25, height=1, radius=0.5) + + a1 = arc((0,0), (0.5,0.5), (1,0)) # through three points + + a2 = arc((0,0), (1,0), tangent=(0,1)) # tangent at the first point + + a3 = circle(0.5, 0, 270) # from 0° to 270° + + result = compound( + pl, el.move(2.25), sp.move(3.5), bz.move(5.5), h.move(8), + a1.move(y=-2.5), a2.move(2, -2.5), a3.move(4.5, -2.5), + ) + + Boolean operations ------------------ diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 3802aefd8..c6d2a82b3 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -6,6 +6,9 @@ rect, circle, ellipse, + arc, + bezier, + helix, plane, box, cylinder, @@ -78,7 +81,7 @@ import pytest from pytest import approx, raises, fixture from contextlib import chdir -from math import pi +from math import pi, sqrt @pytest.fixture(scope="function") @@ -450,6 +453,156 @@ def test_ellipse(): assert face(s).Area() == approx(6 * pi) +def test_arc(): + + # three points + s1 = arc((1, 0), (0, 1), (-1, 0)) + + assert s1.isValid() + assert s1.radius() == approx(1) + assert s1.Length() == approx(pi) + assert s1.positionAt(0.5).toTuple() == approx((0, 1, 0)) + + # start, end and start tangent + s2 = arc((1, 0), (-1, 0), tangent=(0, 1)) + + assert s2.isValid() + assert s2.radius() == approx(1) + assert s2.Length() == approx(pi) + assert s2.tangentAt(0).toTuple() == approx((0, 1, 0)) + + # keywords work + s3 = arc(p1=(1, 0), p2=(0, 1), p3=(-1, 0)) + + assert s3.Length() == approx(pi) + + # combine with other free functions + f = face(wire(arc((1, 0), (0, 1), (-1, 0)), segment((-1, 0), (1, 0)))) + + assert f.isValid() + assert f.Area() == approx(pi / 2) + + +@pytest.mark.parametrize( + "args, kwargs, err, msg", + [ + (((0, 0), (1, 0)), {}, TypeError, "either a third point"), + (((0, 0), (1, 0), (1, 1)), {"tangent": (0, 1)}, TypeError, "not both"), + (((0, 0), (0, 0), (1, 1)), {}, ValueError, "distinct"), + (((0, 0), (1, 1), (0, 0)), {}, ValueError, "distinct"), + (((0, 0), (1, 0), (2, 0)), {}, ValueError, "collinear"), + (((0, 0), (1, 0)), {"tangent": (0, 0)}, ValueError, "non-zero"), + (((0, 0), (1, 0)), {"tangent": (1, 0)}, ValueError, "parallel"), + (((0, 0), (1, 0)), {"tangent": (-1, 0)}, ValueError, "parallel"), + ], +) +def test_arc_invalid(args, kwargs, err, msg): + + with raises(err, match=msg): + arc(*args, **kwargs) + + +def test_circle_arc(): + + s1 = circle(2, 90, 180) + + assert s1.isValid() + assert not s1.IsClosed() + assert s1.radius() == approx(2) + assert s1.arcCenter().toTuple() == approx((0, 0, 0)) + assert s1.Length() == approx(pi) + assert s1.startPoint().toTuple() == approx((0, 2, 0)) + assert s1.endPoint().toTuple() == approx((-2, 0, 0)) + + # crossing the X axis + s2 = circle(1, start=-90, end=90) + + assert s2.startPoint().toTuple() == approx((0, -1, 0)) + assert s2.endPoint().toTuple() == approx((0, 1, 0)) + assert s2.positionAt(0.5).toTuple() == approx((1, 0, 0)) + + # a full turn starting at an arbitrary angle is closed + s3 = circle(1, 45, 405) + + assert s3.IsClosed() + assert s3.Length() == approx(2 * pi) + + +@pytest.mark.parametrize( + "args, msg", + [ + ((0,), "radius"), + ((-1,), "radius"), + ((1, 90, 90), "angles"), + ((1, 90, 0), "angles"), + ((1, 0, 450), "angles"), + ], +) +def test_circle_invalid(args, msg): + + with raises(ValueError, match=msg): + circle(*args) + + +def test_bezier(): + + s = bezier((0, 0), (1, 1), (2, 0)) + + assert s.isValid() + assert s.geomType() == "BEZIER" + assert s.startPoint().toTuple() == approx((0, 0, 0)) + assert s.endPoint().toTuple() == approx((2, 0, 0)) + assert s.positionAt(0.5, mode="parameter").toTuple() == approx((1, 0.5, 0)) + assert s.tangentAt(0).toTuple() == approx(Vector(1, 1, 0).normalized().toTuple()) + + with raises(ValueError, match="at least 2 points"): + bezier((0, 0)) + + +def test_helix(): + + s = helix(1, 3, 2) + + assert s.isValid() + assert s.startPoint().toTuple() == approx((2, 0, 0)) + assert s.endPoint().toTuple() == approx((2, 0, 3), abs=1e-6) + assert s.Length() == approx(3 * sqrt((4 * pi) ** 2 + 1), rel=1e-4) + + bb = s.BoundingBox() + + assert bb.zmin == approx(0, abs=1e-6) + assert bb.zmax == approx(3, abs=1e-6) + assert bb.xlen == approx(4, rel=1e-3) + + # handedness + s_right = helix(1, 0.25, 1) + s_left = helix(1, 0.25, 1, lefthand=True) + + assert s_right.endPoint().y > 0 + assert s_left.endPoint().y < 0 + + # keywords + s_kw = helix(pitch=1, height=3, radius=2) + + assert s_kw.Length() == approx(s.Length()) + + +@pytest.mark.parametrize( + "args, msg", + [ + ((0, 1, 1), "pitch"), + ((-1, 1, 1), "pitch"), + ((1, 0, 1), "height"), + ((1, -1, 1), "height"), + ((1, 1, 0), "radius"), + ], +) +def test_helix_invalid(args, msg): + + with raises(ValueError, match=msg): + helix(*args) + + def test_plane(): s = plane(1, 2)