diff --git a/cadquery/cq.py b/cadquery/cq.py index 9659b79a..5031c4a3 100644 --- a/cadquery/cq.py +++ b/cadquery/cq.py @@ -245,7 +245,7 @@ class Workplane(object): # drill a hole in the side c = Workplane().box(1,1,1).faces(">Z").workplane().circle(0.25).cutThruAll() - + # now cut it in half sideways c = c.faces(">Y").workplane(-0.5).split(keepTop=True) """ @@ -327,7 +327,7 @@ class Workplane(object): def size(self) -> int: """ - Return the number of objects currently on the stack + Return the number of objects currently on the stack """ return len(self.objects) @@ -444,7 +444,7 @@ class Workplane(object): * The centerOption paramter sets how the center is defined. Options are 'CenterOfMass', 'CenterOfBoundBox', or 'ProjectedOrigin'. 'CenterOfMass' and 'CenterOfBoundBox' are in relation to the selected - face(s) or vertex (vertices). 'ProjectedOrigin' uses by default the current origin + face(s) or vertex (vertices). 'ProjectedOrigin' uses by default the current origin or the optional origin parameter (if specified) and projects it onto the plane defined by the selected face(s). * The Z direction will be normal to the plane of the face,computed @@ -714,17 +714,17 @@ class Workplane(object): tag: Optional[str] = None, ) -> "Workplane": """ - Filters objects of the selected type with the specified selector,and returns results + Filters objects of the selected type with the specified selector,and returns results - :param objType: the type of object we are searching for - :type objType: string: (Vertex|Edge|Wire|Solid|Shell|Compound|CompSolid) - :param tag: if set, search the tagged CQ object instead of self - :type tag: string - :return: a CQ object with the selected objects on the stack. + :param objType: the type of object we are searching for + :type objType: string: (Vertex|Edge|Wire|Solid|Shell|Compound|CompSolid) + :param tag: if set, search the tagged CQ object instead of self + :type tag: string + :return: a CQ object with the selected objects on the stack. - **Implementation Note**: This is the base implementation of the vertices,edges,faces, - solids,shells, and other similar selector methods. It is a useful extension point for - plugin developers to make other selector methods. + **Implementation Note**: This is the base implementation of the vertices,edges,faces, + solids,shells, and other similar selector methods. It is a useful extension point for + plugin developers to make other selector methods. """ cq_obj = self._getTagged(tag) if tag else self # A single list of all faces from all objects on the stack @@ -1571,7 +1571,7 @@ class Workplane(object): :param float distance: distance of the end of the line from the current point :param float angle: angle of the vector to the end of the line with the x-axis :return: the Workplane object with the current point at the end of the new line - """ + """ x = math.cos(math.radians(angle)) * distance y = math.sin(math.radians(angle)) * distance @@ -1672,6 +1672,9 @@ class Workplane(object): listOfXYTuple: Iterable[VectorLike], tangents: Optional[Sequence[VectorLike]] = None, periodic: bool = False, + parameters: Optional[List[float]] = None, + scale: bool = True, + tolerance: Optional[float] = None, forConstruction: bool = False, includeCurrent: bool = False, makeWire: bool = False, @@ -1681,8 +1684,33 @@ class Workplane(object): :param listOfXYTuple: points to interpolate through :type listOfXYTuple: list of 2-tuple - :param tangents: tuple of Vectors specifying start and finish tangent + :param tangents: vectors specifying the direction of the tangent to the + curve at each of the specified interpolation points. + If only 2 tangents are given, they will be used as the initial and + final tangent. + If some tangents are not specified (i.e., are None), no tangent + constraint will be applied to the corresponding interpolation point. + The spline will be C2 continuous at the interpolation points where + no tangent constraint is specified, and C1 continuous at the points + where a tangent constraint is specified. :param periodic: creation of periodic curves + :param parameters: the value of the parameter at each interpolation point. + (The intepolated curve is represented as a vector-valued function of a + scalar parameter.) + If periodic == True, then len(parameters) must be + len(intepolation points) + 1, otherwise len(parameters) must be equal to + len(interpolation points). + :param scale: whether to scale the specified tangent vectors before + interpolating. + Each tangent is scaled, so it's length is equal to the derivative of + the Lagrange interpolated curve. + I.e., set this to True, if you want to use only the direction of + the tangent vectors specified by ``tangents``, but not their magnitude. + :param tolerance: tolerance of the algorithm (consult OCC documentation) + Used to check that the specified points are not too close to each + other, and that tangent vectors are not too short. (In either case + interpolation may fail.) + Set to None to use the default tolerance. :param includeCurrent: use current point as a starting point of the curve :param makeWire: convert the resulting spline edge to a wire :return: a Workplane object with the current point at the end of the spline @@ -1721,15 +1749,32 @@ class Workplane(object): allPoints = vecs if tangents: - t1, t2 = Vector(tangents[0]), Vector(tangents[1]) - tangents_g: Optional[Tuple[Vector, Vector]] = ( - self.plane.toWorldCoords(t1) - self.plane.origin, - self.plane.toWorldCoords(t2) - self.plane.origin, - ) + tangents_g: Optional[Sequence[Vector]] = [ + self.plane.toWorldCoords(t) - self.plane.origin + if t is not None + else None + for t in tangents + ] else: tangents_g = None - e = Edge.makeSpline(allPoints, tangents=tangents_g, periodic=periodic) + if tolerance is not None: + e = Edge.makeSpline( + allPoints, + tangents=tangents_g, + periodic=periodic, + parameters=parameters, + scale=scale, + tol=tolerance, + ) + else: + e = Edge.makeSpline( + allPoints, + tangents=tangents_g, + periodic=periodic, + parameters=parameters, + scale=scale, + ) if makeWire: rv_w = Wire.assembleEdges([e]) @@ -2937,7 +2982,7 @@ class Workplane(object): """ Unions all of the items on the stack of toUnion with the current solid. If there is no current solid, the items in toUnion are unioned together. - + :param toUnion: :type toUnion: a solid object, or a CQ object having a solid, :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape (default True) @@ -2999,7 +3044,7 @@ class Workplane(object): ) -> "Workplane": """ Cuts the provided solid from the current solid, IE, perform a solid subtraction - + :param toCut: object to cut :type toCut: a solid object, or a CQ object having a solid, :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape @@ -3047,7 +3092,7 @@ class Workplane(object): ) -> "Workplane": """ Intersects the provided solid from the current solid. - + :param toIntersect: object to intersect :type toIntersect: a solid object, or a CQ object having a solid, :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape @@ -3711,7 +3756,7 @@ class Workplane(object): def section(self, height: float = 0.0) -> "Workplane": """ Slices current solid at the given height. - + :param float height: height to slice at (default: 0) :return: a CQ object with the resulting face(s). """ @@ -3732,7 +3777,7 @@ class Workplane(object): def toPending(self) -> "Workplane": """ Adds wires/edges to pendingWires/pendingEdges. - + :return: same CQ object with updated context. """ @@ -3746,10 +3791,10 @@ class Workplane(object): ) -> "Workplane": """ Creates a 2D offset wire. - + :param float d: thickness. Negative thickness denotes offset to inside. :param kind: offset kind. Use "arc" for rounded and "intersection" for sharp edges (default: "arc") - + :return: CQ object with resulting wire(s). """ diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 4e133d06..326fe3ae 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -34,8 +34,18 @@ from OCP.gp import ( gp_Elips, ) -# collection of points (used for spline construction) +# Array of points (used for B-spline construction): from OCP.TColgp import TColgp_HArray1OfPnt + +# Array of vectors (used for B-spline interpolation): +from OCP.TColgp import TColgp_Array1OfVec + +# Array of booleans (used for B-spline interpolation): +from OCP.TColStd import TColStd_HArray1OfBoolean + +# Array of floats (used for B-spline interpolation): +from OCP.TColStd import TColStd_HArray1OfReal + from OCP.BRepAdaptor import ( BRepAdaptor_Curve, BRepAdaptor_CompCurve, @@ -1021,7 +1031,7 @@ class Vertex(Shape): def __init__(self, obj: TopoDS_Shape, forConstruction: bool = False): """ - Create a vertex from a FreeCAD Vertex + Create a vertex from a FreeCAD Vertex """ super(Vertex, self).__init__(obj) @@ -1035,7 +1045,7 @@ class Vertex(Shape): def Center(self) -> Vector: """ - The center of a vertex is itself! + The center of a vertex is itself! """ return Vector(self.toTuple()) @@ -1061,7 +1071,9 @@ class Mixin1DProtocol(ShapeProtocol, Protocol): ... def positionAt( - self, d: float, mode: Literal["length", "parameter"] = "length", + self, + d: float, + mode: Literal["length", "parameter"] = "length", ) -> Vector: ... @@ -1104,7 +1116,7 @@ class Mixin1D(object): def paramAt(self: Mixin1DProtocol, d: float) -> float: """ Compute parameter value at the specified normalized distance. - + :param d: normalized distance [0, 1] :return: parameter value """ @@ -1121,7 +1133,7 @@ class Mixin1D(object): ) -> Vector: """ Compute tangent vector at the specified location. - + :param locationParam: distance or parameter value (default: 0.5) :param mode: position calculation mode (default: parameter) :return: tangent vector @@ -1144,7 +1156,7 @@ class Mixin1D(object): def normal(self: Mixin1DProtocol) -> Vector: """ Calculate the normal Vector. Only possible for planar curves. - + :return: normal vector """ @@ -1321,9 +1333,6 @@ class Edge(Shape, Mixin1D): angle1: float = 360.0, angle2: float = 360, ) -> "Edge": - """ - - """ pnt = Vector(pnt) dir = Vector(dir) @@ -1399,6 +1408,8 @@ class Edge(Shape, Mixin1D): listOfVector: List[Vector], tangents: Optional[Sequence[Vector]] = None, periodic: bool = False, + parameters: Optional[List[float]] = None, + scale: bool = True, tol: float = 1e-6, ) -> "Edge": """ @@ -1407,19 +1418,60 @@ class Edge(Shape, Mixin1D): :param listOfVector: a list of Vectors that represent the points :param tangents: tuple of Vectors specifying start and finish tangent :param periodic: creation of peridic curves + :param parameters: the value of the parameter at each interpolation point. + (The intepolated curve is represented as a vector-valued function of a + scalar parameter.) + If periodic == True, then len(parameters) must be + len(intepolation points) + 1, otherwise len(parameters) must be equal to + len(interpolation points). + :param scale: whether to scale the specified tangent vectors before + interpolating. + Each tangent is scaled, so it's length is equal to the derivative of + the Lagrange interpolated curve. + I.e., set this to True, if you want to use only the direction of + the tangent vectors specified by ``tangents``, but not their magnitude. :param tol: tolerance of the algorithm (consult OCC documentation) + Used to check that the specified points are not too close to each + other, and that tangent vectors are not too short. (In either case + interpolation may fail.) :return: an Edge """ pnts = TColgp_HArray1OfPnt(1, len(listOfVector)) for ix, v in enumerate(listOfVector): pnts.SetValue(ix + 1, v.toPnt()) - spline_builder = GeomAPI_Interpolate(pnts, periodic, tol) + if parameters is None: + spline_builder = GeomAPI_Interpolate(pnts, periodic, tol) + else: + parameters_array = TColStd_HArray1OfReal(1, len(parameters)) + for index, value in enumerate(parameters): + parameters_array.SetValue(index + 1, value) + + spline_builder = GeomAPI_Interpolate(pnts, parameters_array, periodic, tol) + if tangents: - v1, v2 = tangents - spline_builder.Load(v1.wrapped, v2.wrapped) + if len(tangents) == 2 and len(listOfVector) != 2: + # Specify only initial and final tangent: + t1, t2 = tangents + spline_builder.Load(t1.wrapped, t2.wrapped, scale) + else: + assert len(tangents) == len(listOfVector) + + # Specify a tangent for each interpolation point: + tangents_array = TColgp_Array1OfVec(1, len(tangents)) + tangent_enabled_array = TColStd_HArray1OfBoolean(1, len(tangents)) + for index, value in enumerate(tangents): + tangent_enabled_array.SetValue(index + 1, value is not None) + tangent_vec = value if value is not None else Vector() + tangents_array.SetValue(index + 1, tangent_vec.wrapped) + + spline_builder.Load(tangents_array, tangent_enabled_array, scale) spline_builder.Perform() + if not spline_builder.IsDone(): + raise ValueError( + "B-spline interpolation failed! Reason not reported by Open Cascade." + ) spline_geom = spline_builder.Curve() return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge()) @@ -1514,15 +1566,15 @@ class Wire(Shape, Mixin1D): @classmethod def assembleEdges(cls: Type["Wire"], listOfEdges: Iterable[Edge]) -> "Wire": """ - Attempts to build a wire that consists of the edges in the provided list - :param cls: - :param listOfEdges: a list of Edge objects. The edges are not to be consecutive. - :return: a wire with the edges assembled - :BRepBuilderAPI_MakeWire::Error() values - :BRepBuilderAPI_WireDone = 0 - :BRepBuilderAPI_EmptyWire = 1 - :BRepBuilderAPI_DisconnectedWire = 2 - :BRepBuilderAPI_NonManifoldWire = 3 + Attempts to build a wire that consists of the edges in the provided list + :param cls: + :param listOfEdges: a list of Edge objects. The edges are not to be consecutive. + :return: a wire with the edges assembled + :BRepBuilderAPI_MakeWire::Error() values + :BRepBuilderAPI_WireDone = 0 + :BRepBuilderAPI_EmptyWire = 1 + :BRepBuilderAPI_DisconnectedWire = 2 + :BRepBuilderAPI_NonManifoldWire = 3 """ wire_builder = BRepBuilderAPI_MakeWire() @@ -1545,11 +1597,11 @@ class Wire(Shape, Mixin1D): cls: Type["Wire"], radius: float, center: Vector, normal: Vector ) -> "Wire": """ - Makes a Circle centered at the provided point, having normal in the provided direction - :param radius: floating point radius of the circle, must be > 0 - :param center: vector representing the center of the circle - :param normal: vector representing the direction of the plane the circle should lie in - :return: + Makes a Circle centered at the provided point, having normal in the provided direction + :param radius: floating point radius of the circle, must be > 0 + :param center: vector representing the center of the circle + :param normal: vector representing the direction of the plane the circle should lie in + :return: """ circle_edge = Edge.makeCircle(radius, center, normal) @@ -1570,15 +1622,15 @@ class Wire(Shape, Mixin1D): closed: bool = True, ) -> "Wire": """ - Makes an Ellipse centered at the provided point, having normal in the provided direction - :param x_radius: floating point major radius of the ellipse (x-axis), must be > 0 - :param y_radius: floating point minor radius of the ellipse (y-axis), must be > 0 - :param center: vector representing the center of the circle - :param normal: vector representing the direction of the plane the circle should lie in - :param angle1: start angle of arc - :param angle2: end angle of arc - :param rotation_angle: angle to rotate the created ellipse / arc - :return: Wire + Makes an Ellipse centered at the provided point, having normal in the provided direction + :param x_radius: floating point major radius of the ellipse (x-axis), must be > 0 + :param y_radius: floating point minor radius of the ellipse (y-axis), must be > 0 + :param center: vector representing the center of the circle + :param normal: vector representing the direction of the plane the circle should lie in + :param angle1: start angle of arc + :param angle2: end angle of arc + :param rotation_angle: angle to rotate the created ellipse / arc + :return: Wire """ ellipse_edge = Edge.makeEllipse( @@ -1715,11 +1767,11 @@ class Face(Shape): def normalAt(self, locationVector: Optional[Vector] = None) -> Vector: """ - Computes the normal vector at the desired location on the face. + Computes the normal vector at the desired location on the face. - :returns: a vector representing the direction - :param locationVector: the location to compute the normal at. If none, the center of the face is used. - :type locationVector: a vector that lies on the surface. + :returns: a vector representing the direction + :param locationVector: the location to compute the normal at. If none, the center of the face is used. + :type locationVector: a vector that lies on the surface. """ # get the geometry surface = self._geomAdaptor() @@ -1779,7 +1831,7 @@ class Face(Shape): :param points :type points: list of gp_Pnt :param edges - :type edges: list of Edge + :type edges: list of Edge :param continuity=GeomAbs_C0 :type continuity: OCC.Core.GeomAbs continuity condition :param Degree = 3 (OCCT default) @@ -2165,7 +2217,7 @@ class Solid(Shape, Mixin3D): @staticmethod def isSolid(obj: Shape) -> bool: """ - Returns true if the object is a solid, false otherwise + Returns true if the object is a solid, false otherwise """ if hasattr(obj, "ShapeType"): if obj.ShapeType == "Solid" or ( @@ -2274,9 +2326,9 @@ class Solid(Shape, Mixin3D): cls: Type["Solid"], listOfWire: List[Wire], ruled: bool = False ) -> "Solid": """ - makes a loft from a list of wires - The wires will be converted into faces when possible-- it is presumed that nobody ever actually - wants to make an infinitely thin shell for a real FreeCADPart. + makes a loft from a list of wires + The wires will be converted into faces when possible-- it is presumed that nobody ever actually + wants to make an infinitely thin shell for a real FreeCADPart. """ # the True flag requests building a solid instead of a shell. if len(listOfWire) < 2: @@ -2362,28 +2414,32 @@ class Solid(Shape, Mixin3D): angleDegrees: float, ) -> "Solid": """ - Creates a 'twisted prism' by extruding, while simultaneously rotating around the extrusion vector. + Creates a 'twisted prism' by extruding, while simultaneously rotating around the extrusion vector. - Though the signature may appear to be similar enough to extrudeLinear to merit combining them, the - construction methods used here are different enough that they should be separate. + Though the signature may appear to be similar enough to extrudeLinear to merit combining them, the + construction methods used here are different enough that they should be separate. - At a high level, the steps followed are: - (1) accept a set of wires - (2) create another set of wires like this one, but which are transformed and rotated - (3) create a ruledSurface between the sets of wires - (4) create a shell and compute the resulting object + At a high level, the steps followed are: + (1) accept a set of wires + (2) create another set of wires like this one, but which are transformed and rotated + (3) create a ruledSurface between the sets of wires + (4) create a shell and compute the resulting object - :param outerWire: the outermost wire, a cad.Wire - :param innerWires: a list of inner wires, a list of cad.Wire - :param vecCenter: the center point about which to rotate. the axis of rotation is defined by - vecNormal, located at vecCenter. ( a cad.Vector ) - :param vecNormal: a vector along which to extrude the wires ( a cad.Vector ) - :param angleDegrees: the angle to rotate through while extruding - :return: a cad.Solid object + :param outerWire: the outermost wire, a cad.Wire + :param innerWires: a list of inner wires, a list of cad.Wire + :param vecCenter: the center point about which to rotate. the axis of rotation is defined by + vecNormal, located at vecCenter. ( a cad.Vector ) + :param vecNormal: a vector along which to extrude the wires ( a cad.Vector ) + :param angleDegrees: the angle to rotate through while extruding + :return: a cad.Solid object """ # make straight spine straight_spine_e = Edge.makeLine(vecCenter, vecCenter.add(vecNormal)) - straight_spine_w = Wire.combine([straight_spine_e,])[0].wrapped + straight_spine_w = Wire.combine( + [ + straight_spine_e, + ] + )[0].wrapped # make an auxliliary spine pitch = 360.0 / angleDegrees * vecNormal.Length @@ -2418,26 +2474,26 @@ class Solid(Shape, Mixin3D): taper: float = 0, ) -> "Solid": """ - Attempt to extrude the list of wires into a prismatic solid in the provided direction + Attempt to extrude the list of wires into a prismatic solid in the provided direction - :param outerWire: the outermost wire - :param innerWires: a list of inner wires - :param vecNormal: a vector along which to extrude the wires - :param taper: taper angle, default=0 - :return: a Solid object + :param outerWire: the outermost wire + :param innerWires: a list of inner wires + :param vecNormal: a vector along which to extrude the wires + :param taper: taper angle, default=0 + :return: a Solid object - The wires must not intersect + The wires must not intersect - Extruding wires is very non-trivial. Nested wires imply very different geometry, and - there are many geometries that are invalid. In general, the following conditions must be met: + Extruding wires is very non-trivial. Nested wires imply very different geometry, and + there are many geometries that are invalid. In general, the following conditions must be met: - * all wires must be closed - * there cannot be any intersecting or self-intersecting wires - * wires must be listed from outside in - * more than one levels of nesting is not supported reliably + * all wires must be closed + * there cannot be any intersecting or self-intersecting wires + * wires must be listed from outside in + * more than one levels of nesting is not supported reliably - This method will attempt to sort the wires, but there is much work remaining to make this method - reliable. + This method will attempt to sort the wires, but there is much work remaining to make this method + reliable. """ if taper == 0: @@ -2530,7 +2586,11 @@ class Solid(Shape, Mixin3D): def _toWire(p: Union[Edge, Wire]) -> Wire: if isinstance(p, Edge): - rv = Wire.assembleEdges([p,]) + rv = Wire.assembleEdges( + [ + p, + ] + ) else: rv = p @@ -2611,7 +2671,11 @@ class Solid(Shape, Mixin3D): :return: a Solid object """ if isinstance(path, Edge): - w = Wire.assembleEdges([path,]).wrapped + w = Wire.assembleEdges( + [ + path, + ] + ).wrapped else: w = path.wrapped @@ -2768,7 +2832,7 @@ class Compound(Shape, Mixin3D): def __iter__(self) -> Iterator[Shape]: """ - Iterate over subshapes. + Iterate over subshapes. """ @@ -2850,6 +2914,11 @@ def sortWiresByBuildOrder(wireList: List[Wire]) -> List[List[Wire]]: rv = [] for face in faces.Faces(): - rv.append([face.outerWire(),] + face.innerWires()) + rv.append( + [ + face.outerWire(), + ] + + face.innerWires() + ) return rv diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index ad0b103b..a04003a6 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -62,11 +62,11 @@ writeStringToFile(SUMMARY_TEMPLATE, SUMMARY_FILE) class TestCadQuery(BaseTest): def tearDown(self): """ - Update summary with data from this test. - This is a really hackey way of doing it-- we get a startup event from module load, - but there is no way in unittest to get a single shutdown event-- except for stuff in 2.7 and above + Update summary with data from this test. + This is a really hackey way of doing it-- we get a startup event from module load, + but there is no way in unittest to get a single shutdown event-- except for stuff in 2.7 and above - So what we do here is to read the existing file, stick in more content, and leave it + So what we do here is to read the existing file, stick in more content, and leave it """ svgFile = os.path.join(OUTDIR, self._testMethodName + ".svg") @@ -89,8 +89,8 @@ class TestCadQuery(BaseTest): def saveModel(self, shape): """ - shape must be a CQ object - Save models in SVG and STEP format + shape must be a CQ object + Save models in SVG and STEP format """ shape.exportSvg(os.path.join(OUTDIR, self._testMethodName + ".svg")) shape.val().exportStep(os.path.join(OUTDIR, self._testMethodName + ".step")) @@ -155,11 +155,11 @@ class TestCadQuery(BaseTest): def testCylinderPlugin(self): """ - Tests a cylinder plugin. - The plugin creates cylinders of the specified radius and height for each item on the stack + Tests a cylinder plugin. + The plugin creates cylinders of the specified radius and height for each item on the stack - This is a very short plugin that illustrates just about the simplest possible - plugin + This is a very short plugin that illustrates just about the simplest possible + plugin """ def cylinders(self, radius, height): @@ -185,10 +185,10 @@ class TestCadQuery(BaseTest): def testPolygonPlugin(self): """ - Tests a plugin to make regular polygons around points on the stack + Tests a plugin to make regular polygons around points on the stack - Demonstratings using eachpoint to allow working in local coordinates - to create geometry + Demonstratings using eachpoint to allow working in local coordinates + to create geometry """ def rPoly(self, nSides, diameter): @@ -565,12 +565,20 @@ class TestCadQuery(BaseTest): self.assertTrue(path_closed.IsClosed()) # attempt to build a valid face - w = Wire.assembleEdges([path_closed,]) + w = Wire.assembleEdges( + [ + path_closed, + ] + ) f = Face.makeFromWires(w) self.assertTrue(f.isValid()) # attempt to build an invalid face - w = Wire.assembleEdges([path,]) + w = Wire.assembleEdges( + [ + path, + ] + ) f = Face.makeFromWires(w) self.assertFalse(f.isValid()) @@ -590,6 +598,118 @@ class TestCadQuery(BaseTest): path2 = Workplane("XY", (0, 0, 10)).spline(pts, tangents=tangents) self.assertAlmostEqual(path2.val().tangentAt(0).z, 0) + def testSplineWithMultipleTangents(self): + """ + Tests specifying B-spline tangents, besides the start point and end + point tangents. + """ + + points = [(0, 0), (1, 1), (2, 0), (1, -1)] + tangents = [(0, 1), (1, 0), (0, -1), (-1, 0)] + parameters = range(len(points)) + + spline = ( + Workplane("XY") + .spline(points, tangents=tangents, parameters=parameters) + .consolidateWires() + ) + + test_point = spline.edges().val().positionAt(2.5, mode="parameter") + expected_test_point = Vector(1.875, -0.625, 0.0) + + self.assertAlmostEqual((test_point - expected_test_point).Length, 0) + + def testSplineWithSpecifiedAndUnspecifiedTangents(self): + points = [(0, 0), (1, 1), (2, 0), (1, -1)] + tangents = [(0, 1), None, (0, -1), (-1, 0)] + parameters = range(len(points)) + + spline = ( + Workplane("XY") + .spline(points, tangents=tangents, parameters=parameters) + .consolidateWires() + ) + + test_point = spline.edges().val().positionAt(1.5, mode="parameter") + expected_test_point = Vector(1.6875, 0.875, 0.0) + + self.assertAlmostEqual((test_point - expected_test_point).Length, 0) + + def testSplineSpecifyingParameters(self): + points = [(0, 0), (1, 1), (2, 0), (1, -1)] + tangents = [(0, 1), (1, 0), (0, -1), (-1, 0)] + + spline1 = ( + Workplane("XY") + .spline(points, tangents=tangents, parameters=[0, 1, 2, 3]) + .consolidateWires() + ) + # Multiply all parameter values by 10: + spline2 = ( + Workplane("XY") + .spline(points, tangents=tangents, parameters=[0, 10, 20, 30]) + .consolidateWires() + ) + + # Test point equivalence for parameter, and pamater multiplied by 10: + test_point1 = spline1.edges().val().positionAt(1.5, mode="parameter") + test_point2 = spline2.edges().val().positionAt(15, mode="parameter") + expected_test_point = Vector(1.625, 0.625, 0.0) + + self.assertAlmostEqual((test_point1 - test_point2).Length, 0) + self.assertAlmostEqual((test_point1 - expected_test_point).Length, 0) + + def testSplineWithScaleTrue(self): + points = [(0, 0), (1, 1), (2, 0), (1, -1)] + tangents = [(0, 1), (1, 0), (0, -1), (-1, 0)] + parameters = range(len(points)) + + spline = ( + Workplane("XY") + .spline(points, tangents=tangents, parameters=parameters, scale=True) + .consolidateWires() + ) + + test_point = spline.edges().val().positionAt(0.5, mode="parameter") + expected_test_point = Vector(0.375, 0.875, 0.0) + + self.assertAlmostEqual((test_point - expected_test_point).Length, 0) + + def testSplineTangentMagnitudeBelowToleranceThrows(self): + import OCP + + points = [(0, 0), (1, 1), (2, 0), (1, -1)] + # Use a tangent vector with magnitude 0.5: + tangents = [(0, 0.5), (1, 0), (0, -1), (-1, 0)] + parameters = range(len(points)) + + # Set tolerance above the 0.5 length of the tangent vector. This + # should throw an exception: + with raises( + (OCP.Standard.Standard_ConstructionError, OCP.Standard.Standard_Failure) + ): + spline = ( + Workplane("XY") + .spline(points, tangents=tangents, tolerance=1) + .consolidateWires() + ) + + def testSplineWithScaleFalse(self): + points = [(0, 0), (1, 1), (2, 0), (1, -1)] + tangents = [(0, 1), (1, 0), (0, -1), (-1, 0)] + parameters = range(len(points)) + + spline = ( + Workplane("XY") + .spline(points, tangents=tangents, parameters=parameters, scale=False) + .consolidateWires() + ) + + test_point = spline.edges().val().positionAt(0.5, mode="parameter") + expected_test_point = Vector(0.375, 0.625, 0.0) + + self.assertAlmostEqual((test_point - expected_test_point).Length, 0) + def testRotatedEllipse(self): def rotatePoint(x, y, alpha): # rotation matrix @@ -777,7 +897,15 @@ class TestCadQuery(BaseTest): def testMakeEllipse(self): el = Wire.makeEllipse( - 1, 2, Vector(0, 0, 0), Vector(0, 0, 1), Vector(1, 0, 0), 0, 90, 45, True, + 1, + 2, + Vector(0, 0, 0), + Vector(0, 0, 1), + Vector(1, 0, 0), + 0, + 90, + 45, + True, ) self.assertTrue(el.IsClosed()) @@ -1267,7 +1395,7 @@ class TestCadQuery(BaseTest): def testSimpleWorkplane(self): """ - A simple square part with a hole in it + A simple square part with a hole in it """ s = Workplane(Plane.XY()) r = ( @@ -1301,8 +1429,8 @@ class TestCadQuery(BaseTest): def testMultiWireWorkplane(self): """ - A simple square part with a hole in it-- but this time done as a single extrusion - with two wires, as opposed to s cut + A simple square part with a hole in it-- but this time done as a single extrusion + with two wires, as opposed to s cut """ s = Workplane(Plane.XY()) r = s.rect(2.0, 2.0).circle(0.25).extrude(0.5) @@ -1312,8 +1440,8 @@ class TestCadQuery(BaseTest): def testConstructionWire(self): """ - Tests a wire with several holes, that are based on the vertices of a square - also tests using a workplane plane other than XY + Tests a wire with several holes, that are based on the vertices of a square + also tests using a workplane plane other than XY """ s = Workplane(Plane.YZ()) r = ( @@ -1329,7 +1457,7 @@ class TestCadQuery(BaseTest): def testTwoWorkplanes(self): """ - Tests a model that uses more than one workplane + Tests a model that uses more than one workplane """ # base block s = Workplane(Plane.XY()) @@ -1453,7 +1581,7 @@ class TestCadQuery(BaseTest): def testCutThroughAll(self): """ - Tests a model that uses more than one workplane + Tests a model that uses more than one workplane """ # base block s = Workplane(Plane.XY()) @@ -1503,7 +1631,7 @@ class TestCadQuery(BaseTest): def testCutToFaceOffsetNOTIMPLEMENTEDYET(self): """ - Tests cutting up to a given face, or an offset from a face + Tests cutting up to a given face, or an offset from a face """ # base block s = Workplane(Plane.XY()) @@ -1729,7 +1857,7 @@ class TestCadQuery(BaseTest): def testSplineShape(self): """ - Tests making a shape with an edge that is a spline + Tests making a shape with an edge that is a spline """ s = Workplane(Plane.XY()) sPnts = [ @@ -1747,7 +1875,7 @@ class TestCadQuery(BaseTest): def testSimpleMirror(self): """ - Tests a simple mirroring operation + Tests a simple mirroring operation """ s = ( Workplane("XY") @@ -1821,7 +1949,7 @@ class TestCadQuery(BaseTest): def testIbeam(self): """ - Make an ibeam. demonstrates fancy mirroring + Make an ibeam. demonstrates fancy mirroring """ s = Workplane(Plane.XY()) L = 100.0 @@ -1920,7 +2048,7 @@ class TestCadQuery(BaseTest): def testCounterSinks(self): """ - Tests countersinks + Tests countersinks """ s = Workplane(Plane.XY()) result = ( @@ -1993,7 +2121,7 @@ class TestCadQuery(BaseTest): def testSimpleShell(self): """ - Create s simple box + Create s simple box """ s1 = Workplane("XY").box(2, 2, 2).faces("+Z").shell(0.05) self.saveModel(s1) @@ -2013,7 +2141,7 @@ class TestCadQuery(BaseTest): def testClosedShell(self): """ - Create a hollow box + Create a hollow box """ s1 = Workplane("XY").box(2, 2, 2).shell(-0.1) self.assertEqual(12, s1.faces().size()) @@ -2656,32 +2784,32 @@ class TestCadQuery(BaseTest): def testCup(self): """ - UOM = "mm" + UOM = "mm" - # - # PARAMETERS and PRESETS - # These parameters can be manipulated by end users - # - bottomDiameter = FloatParam(min=10.0,presets={'default':50.0,'tumbler':50.0,'shot':35.0,'tea':50.0,'saucer':100.0},group="Basics", desc="Bottom diameter") - topDiameter = FloatParam(min=10.0,presets={'default':85.0,'tumbler':85.0,'shot':50.0,'tea':51.0,'saucer':400.0 },group="Basics", desc="Top diameter") - thickness = FloatParam(min=0.1,presets={'default':2.0,'tumbler':2.0,'shot':2.66,'tea':2.0,'saucer':2.0},group="Basics", desc="Thickness") - height = FloatParam(min=1.0,presets={'default':80.0,'tumbler':80.0,'shot':59.0,'tea':125.0,'saucer':40.0},group="Basics", desc="Overall height") - lipradius = FloatParam(min=1.0,presets={'default':1.0,'tumbler':1.0,'shot':0.8,'tea':1.0,'saucer':1.0},group="Basics", desc="Lip Radius") - bottomThickness = FloatParam(min=1.0,presets={'default':5.0,'tumbler':5.0,'shot':10.0,'tea':10.0,'saucer':5.0},group="Basics", desc="BottomThickness") + # + # PARAMETERS and PRESETS + # These parameters can be manipulated by end users + # + bottomDiameter = FloatParam(min=10.0,presets={'default':50.0,'tumbler':50.0,'shot':35.0,'tea':50.0,'saucer':100.0},group="Basics", desc="Bottom diameter") + topDiameter = FloatParam(min=10.0,presets={'default':85.0,'tumbler':85.0,'shot':50.0,'tea':51.0,'saucer':400.0 },group="Basics", desc="Top diameter") + thickness = FloatParam(min=0.1,presets={'default':2.0,'tumbler':2.0,'shot':2.66,'tea':2.0,'saucer':2.0},group="Basics", desc="Thickness") + height = FloatParam(min=1.0,presets={'default':80.0,'tumbler':80.0,'shot':59.0,'tea':125.0,'saucer':40.0},group="Basics", desc="Overall height") + lipradius = FloatParam(min=1.0,presets={'default':1.0,'tumbler':1.0,'shot':0.8,'tea':1.0,'saucer':1.0},group="Basics", desc="Lip Radius") + bottomThickness = FloatParam(min=1.0,presets={'default':5.0,'tumbler':5.0,'shot':10.0,'tea':10.0,'saucer':5.0},group="Basics", desc="BottomThickness") - # - # Your build method. It must return a solid object - # - def build(): - br = bottomDiameter.value / 2.0 - tr = topDiameter.value / 2.0 - t = thickness.value - s1 = Workplane("XY").circle(br).workplane(offset=height.value).circle(tr).loft() - s2 = Workplane("XY").workplane(offset=bottomThickness.value).circle(br - t ).workplane(offset=height.value - t ).circle(tr - t).loft() + # + # Your build method. It must return a solid object + # + def build(): + br = bottomDiameter.value / 2.0 + tr = topDiameter.value / 2.0 + t = thickness.value + s1 = Workplane("XY").circle(br).workplane(offset=height.value).circle(tr).loft() + s2 = Workplane("XY").workplane(offset=bottomThickness.value).circle(br - t ).workplane(offset=height.value - t ).circle(tr - t).loft() - cup = s1.cut(s2) - cup.faces(">Z").edges().fillet(lipradius.value) - return cup + cup = s1.cut(s2) + cup.faces(">Z").edges().fillet(lipradius.value) + return cup """ # for some reason shell doesnt work on this simple shape. how disappointing! @@ -2703,9 +2831,9 @@ class TestCadQuery(BaseTest): def testEnclosure(self): """ - Builds an electronics enclosure - Original FreeCAD script: 81 source statements ,not including variables - This script: 34 + Builds an electronics enclosure + Original FreeCAD script: 81 source statements ,not including variables + This script: 34 """ # parameter definitions