diff --git a/cadquery/cq.py b/cadquery/cq.py index 4fee00e6..c7de5511 100644 --- a/cadquery/cq.py +++ b/cadquery/cq.py @@ -1160,6 +1160,28 @@ class Workplane(CQ): else: return p + def _findFromEdge(self, useLocalCoords=False): + """ + Finds the previous edge for an operation that needs it, similar to + method _findFromPoint. Examples include tangentArcEndpoint. + + :param useLocalCoords: selects whether the point is returned + in local coordinates or global coordinates. + :return: an Edge + """ + obj = self.objects[-1] + + if not isinstance(obj, Edge): + raise RuntimeError( + "Previous Edge requested, but the previous object was of " + + f"type {type(obj)}, not an Edge." + ) + + if useLocalCoords: + obj = self.plane.toLocalCoords(obj) + + return obj + def rarray(self, xSpacing, ySpacing, xCount, yCount, center=True): """ Creates an array of points and pushes them onto the stack. @@ -1660,6 +1682,37 @@ class Workplane(CQ): else: return self.sagittaArc(endPoint, -sag, forConstruction) + def tangentArcEndpoint(self, endpoint, forConstruction=False, relative=True): + """ + Draw an arc as a tangent from the end of the current edge to endpoint. + + :param endpoint: point for the arc to end at + :type endpoint: 2-tuple or Vector + :param relative: True if endpoint is specified relative to the current point, False if endpoint is in workplane coordinates + :type relative: Bool + :return: a Workplane object with an arc on the stack + + Requires the the current first object on the stack is an Edge, as would + be the case after a lineTo operation or similar. + """ + + if not isinstance(endpoint, Vector): + endpoint = Vector(endpoint) + if relative: + endpoint = endpoint + self._findFromPoint(useLocalCoords=True) + endpoint = self.plane.toWorldCoords(endpoint) + + previousEdge = self._findFromEdge() + + arc = Edge.makeTangentArc( + previousEdge.endPoint(), previousEdge.tangentAt(1), endpoint + ) + + if not forConstruction: + self._addPendingEdge(arc) + + return self.newObject([arc]) + def rotateAndCopy(self, matrix): """ Makes a copy of all edges on the stack, rotates them according to the @@ -2094,11 +2147,13 @@ class Workplane(CQ): :return: a CQ object with a completed wire on the stack, if possible. - After 2-d drafting with lineTo,threePointArc, and polyline, it is necessary - to convert the edges produced by these into one or more wires. + After 2-d drafting with methods such as lineTo, threePointArc, + tangentArcEndpoint and polyline, it is necessary to convert the edges + produced by these into one or more wires. - When a set of edges is closed, cadQuery assumes it is safe to build the group of edges - into a wire. This example builds a simple triangular prism:: + When a set of edges is closed, cadQuery assumes it is safe to build + the group of edges into a wire. This example builds a simple triangular + prism:: s = Workplane().lineTo(1,0).lineTo(1,1).close().extrude(0.2) """ diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 72c4e7b9..02ed4190 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -790,6 +790,21 @@ class Edge(Shape, Mixin1D): return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) + @classmethod + def makeTangentArc(cls, v1, v2, v3): + """ + Makes a tangent arc from point v1, in the direction of v2 and ends at + v3. + :param cls: + :param v1: start vector + :param v2: tangent vector + :param v3: end vector + :return: an edge + """ + circle_geom = GC_MakeArcOfCircle(v1.toPnt(), v2._wrapped, v3.toPnt()).Value() + + return cls(BRepBuilderAPI_MakeEdge(circle_geom).Edge()) + @classmethod def makeLine(cls, v1, v2): """ diff --git a/doc/apireference.rst b/doc/apireference.rst index e83e7b93..84152d48 100644 --- a/doc/apireference.rst +++ b/doc/apireference.rst @@ -54,6 +54,7 @@ All 2-d operations require a **Workplane** object to be created. Workplane.threePointArc Workplane.sagittaArc Workplane.radiusArc + Workplane.tangentArcEndpoint Workplane.rotateAndCopy Workplane.mirrorY Workplane.mirrorX diff --git a/tests/test_cad_objects.py b/tests/test_cad_objects.py index c556678a..b43c59de 100644 --- a/tests/test_cad_objects.py +++ b/tests/test_cad_objects.py @@ -82,6 +82,24 @@ class TestCadObjects(BaseTest): (-10.0, 0.0, 0.0), halfCircleEdge.endPoint().toTuple(), 3 ) + def testEdgeWrapperMakeTangentArc(self): + tangent_arc = Edge.makeTangentArc( + Vector(1, 1), # starts at 1, 1 + Vector(0, 1), # tangent at start of arc is in the +y direction + Vector(2, 1), # arc curves 180 degrees and ends at 2, 1 + ) + self.assertTupleAlmostEquals((1, 1, 0), tangent_arc.startPoint().toTuple(), 3) + self.assertTupleAlmostEquals((2, 1, 0), tangent_arc.endPoint().toTuple(), 3) + self.assertTupleAlmostEquals( + (0, 1, 0), tangent_arc.tangentAt(locationParam=0).toTuple(), 3 + ) + self.assertTupleAlmostEquals( + (1, 0, 0), tangent_arc.tangentAt(locationParam=0.5).toTuple(), 3 + ) + self.assertTupleAlmostEquals( + (0, -1, 0), tangent_arc.tangentAt(locationParam=1).toTuple(), 3 + ) + def testFaceWrapperMakePlane(self): mplane = Face.makePlane(10, 10) diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index f50f8982..c39ba951 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -3053,3 +3053,88 @@ class TestCadQuery(BaseTest): plate_4 = Workplane("XY").interpPlate(edge_wire, surface_points, thickness) self.assertTrue(plate_4.val().isValid()) self.assertAlmostEqual(plate_4.val().Volume(), 7.760559490, 3) + + def testTangentArcToPoint(self): + + # create a simple shape with tangents of straight edges and see if it has the correct area + s0 = ( + Workplane("XY") + .hLine(1) + .tangentArcEndpoint((1, 1), relative=False) + .hLineTo(0) + .tangentArcEndpoint((0, 0), relative=False) + .close() + .extrude(1) + ) + area0 = s0.faces(">Z").val().Area() + self.assertAlmostEqual(area0, (1 + math.pi * 0.5 ** 2), 4) + + # test relative coords + s1 = ( + Workplane("XY") + .hLine(1) + .tangentArcEndpoint((0, 1), relative=True) + .hLineTo(0) + .tangentArcEndpoint((0, -1), relative=True) + .close() + .extrude(1) + ) + self.assertTupleAlmostEquals( + s1.val().Center().toTuple(), s0.val().Center().toTuple(), 4 + ) + self.assertAlmostEqual(s1.val().Volume(), s0.val().Volume(), 4) + + # consecutive tangent arcs + s1 = ( + Workplane("XY") + .vLine(2) + .tangentArcEndpoint((1, 0)) + .tangentArcEndpoint((1, 0)) + .tangentArcEndpoint((1, 0)) + .vLine(-2) + .close() + .extrude(1) + ) + self.assertAlmostEqual( + s1.faces(">Z").val().Area(), 2 * 3 + 0.5 * math.pi * 0.5 ** 2, 4 + ) + + # tangentArc on the end of a spline + # spline will be a simple arc of a circle, then finished off with a + # tangentArcEndpoint + angles = [idx * 1.5 * math.pi / 10 for idx in range(10)] + pts = [(math.sin(a), math.cos(a)) for a in angles] + s2 = ( + Workplane("XY") + .spline(pts) + .tangentArcEndpoint((0, 1), relative=False) + .close() + .extrude(1) + ) + # volume should almost be pi, but not accurately because we need to + # start with a spline + self.assertAlmostEqual(s2.val().Volume(), math.pi, 1) + # assert local coords are mapped to global correctly + arc0 = ( + Workplane("XZ", origin=(1, 1, 1)).hLine(1).tangentArcEndpoint((1, 1)).val() + ) + self.assertTupleAlmostEquals(arc0.endPoint().toTuple(), (3, 1, 2), 4) + + def test_findFromEdge(self): + part = Workplane("XY", origin=(1, 1, 1)).hLine(1) + found_edge = part._findFromEdge(useLocalCoords=False) + self.assertTupleAlmostEquals(found_edge.startPoint().toTuple(), (1, 1, 1), 3) + self.assertTupleAlmostEquals(found_edge.Center().toTuple(), (1.5, 1, 1), 3) + self.assertTupleAlmostEquals(found_edge.endPoint().toTuple(), (2, 1, 1), 3) + found_edge = part._findFromEdge(useLocalCoords=True) + self.assertTupleAlmostEquals(found_edge.endPoint().toTuple(), (1, 0, 0), 3) + # check _findFromEdge can find a spline + pts = [(0, 0), (0, 1), (1, 2), (2, 4)] + spline0 = Workplane("XZ").spline(pts)._findFromEdge() + self.assertTupleAlmostEquals((2, 0, 4), spline0.endPoint().toTuple(), 3) + # check method fails if no edge is present + part2 = Workplane("XY").box(1, 1, 1) + with self.assertRaises(RuntimeError): + part2._findFromEdge() + with self.assertRaises(RuntimeError): + part2._findFromEdge(useLocalCoords=True)