Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions cadquery/func.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
spline,
circle,
ellipse,
arc,
bezier,
helix,
plane,
box,
cylinder,
Expand Down Expand Up @@ -92,6 +95,9 @@
"spline",
"circle",
"ellipse",
"arc",
"bezier",
"helix",
"plane",
"box",
"cylinder",
Expand Down
132 changes: 123 additions & 9 deletions cadquery/occ_impl/shapes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
"""
Expand Down
33 changes: 33 additions & 0 deletions doc/free-func.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
------------------

Expand Down
155 changes: 154 additions & 1 deletion tests/test_free_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
rect,
circle,
ellipse,
arc,
bezier,
helix,
plane,
box,
cylinder,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
Loading