From f19c35c83cda0bcb02c1cf7849e64b6c48569801 Mon Sep 17 00:00:00 2001 From: Jojain <50221409+Jojain@users.noreply.github.com> Date: Sun, 26 Sep 2021 12:35:56 +0200 Subject: [PATCH] until extrude/cutblind (#875) * Move dprism to Mixin3D Co-authored-by: AU Co-authored-by: Marcus Boyd --- cadquery/assembly.py | 3 +- cadquery/cq.py | 188 ++++++++++++++++++----- cadquery/cqgi.py | 10 +- cadquery/occ_impl/geom.py | 21 ++- cadquery/occ_impl/importers.py | 6 +- cadquery/occ_impl/shapes.py | 170 ++++++++++++++++----- tests/test_cad_objects.py | 2 +- tests/test_cadquery.py | 269 +++++++++++++++++++++++++++++++++ tests/test_selectors.py | 4 +- 9 files changed, 572 insertions(+), 101 deletions(-) diff --git a/cadquery/assembly.py b/cadquery/assembly.py index 5b677761..ccdd6bbe 100644 --- a/cadquery/assembly.py +++ b/cadquery/assembly.py @@ -168,8 +168,7 @@ class Constraint(object): class Assembly(object): - """Nested assembly of Workplane and Shape objects defining their relative positions. - """ + """Nested assembly of Workplane and Shape objects defining their relative positions.""" loc: Location name: str diff --git a/cadquery/cq.py b/cadquery/cq.py index 8b636917..6fb54c98 100644 --- a/cadquery/cq.py +++ b/cadquery/cq.py @@ -2011,7 +2011,7 @@ class Workplane(object): :param maxDeg: maximum spline degree (default: 3) :param smoothing: optional parameters for the variational smoothing algorithm (default: (1,1,1)) :return: a Workplane object with the current point unchanged - + This method might be unstable and may require tuning of the tol parameter. """ @@ -2978,7 +2978,7 @@ class Workplane(object): def extrude( self: T, - distance: float, + until: Union[float, Literal["next", "last"], Face], combine: bool = True, clean: bool = True, both: bool = False, @@ -2987,8 +2987,12 @@ class Workplane(object): """ Use all un-extruded wires in the parent chain to create a prismatic solid. - :param distance: the distance to extrude, normal to the workplane plane - :type distance: float, negative means opposite the normal direction + :param until: the distance to extrude, normal to the workplane plane + :param until: The distance to extrude, normal to the workplane plane. When a float is + passed, the extrusion extends this far and a negative value is in the opposite direction + to the normal of the plane. The string "next" extrudes until the next face orthogonal to + the wire normal. "last" extrudes to the last face. If a object of type Face is passed then + the extrusion will extend until this face. :param boolean combine: True to combine the resulting solid with parent solids if found. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape :param boolean both: extrude in both directions symmetrically @@ -3000,18 +3004,35 @@ class Workplane(object): The returned object is always a CQ object, and depends on whether combine is True, and whether a context solid is already defined: - * if combine is False, the new value is pushed onto the stack. + * if combine is False, the new value is pushed onto the stack. Note that when extruding + until a specified face, combine can not be False * if combine is true, the value is combined with the context solid if it exists, and the resulting solid becomes the new context solid. - - FutureEnhancement: - Support for non-prismatic extrusion ( IE, sweeping along a profile, not just - perpendicular to the plane extrude to surface. this is quite tricky since the surface - selected may not be planar """ - r = self._extrude( - distance, both=both, taper=taper - ) # returns a Solid (or a compound if there were multiple) + # Handle `until` multiple values + if isinstance(until, str) and until in ("next", "last") and combine: + if until == "next": + faceIndex = 0 + elif until == "last": + faceIndex = -1 + + r = self._extrude(distance=None, both=both, taper=taper, upToFace=faceIndex) + + elif isinstance(until, Face) and combine: + r = self._extrude(None, both=both, taper=taper, upToFace=until) + + elif isinstance(until, (int, float)): + r = self._extrude(until, both=both, taper=taper, upToFace=None) + + elif isinstance(until, (str, Face)) and combine is False: + raise ValueError( + "`combine` can't be set to False when extruding until a face" + ) + + else: + raise ValueError( + f"Do not know how to handle until argument of type {type(until)}" + ) if combine: newS = self._combineWithBase(r) @@ -3355,37 +3376,50 @@ class Workplane(object): def cutBlind( self: T, - distanceToCut: float, + until: Union[float, Literal["next", "last"], Face], clean: bool = True, taper: Optional[float] = None, ) -> T: """ Use all un-extruded wires in the parent chain to create a prismatic cut from existing solid. + You must define either :distance: , :untilNextFace: or :untilLastFace: Similar to extrude, except that a solid in the parent chain is required to remove material from. cutBlind always removes material from a part. - :param distanceToCut: distance to extrude before cutting - :type distanceToCut: float, >0 means in the positive direction of the workplane normal, - <0 means in the negative direction + :param until: The distance to cut to, normal to the workplane plane. When a negative float + is passed the cut extends this far in the opposite direction to the normal of the plane + (i.e in the solid). The string "next" cuts until the next face orthogonal to the wire + normal. "last" cuts to the last face. If a object of type Face is passed then the cut + will extend until this face. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape :param float taper: angle for optional tapered extrusion :raises ValueError: if there is no solid to subtract from in the chain :return: a CQ object with the resulting object selected see :py:meth:`cutThruAll` to cut material from the entire part - - Future Enhancements: - Cut Up to Surface """ - # first, make the object - toCut = self._extrude(distanceToCut, taper=taper) + # Handling of `until` passed values + s: Union[Compound, Solid, Shape] + if isinstance(until, str) and until in ("next", "last"): + if until == "next": + faceIndex = 0 + elif until == "last": + faceIndex = -1 - # now find a solid in the chain - solidRef = self.findSolid() + s = self._extrude(None, taper=taper, upToFace=faceIndex, additive=False) - s = solidRef.cut(toCut) + elif isinstance(until, Face): + s = self._extrude(None, taper=taper, upToFace=until, additive=False) + elif isinstance(until, (int, float)): + toCut = self._extrude(until, taper=taper, upToFace=None, additive=False) + solidRef = self.findSolid() + s = solidRef.cut(toCut) + else: + raise ValueError( + f"Do not know how to handle until argument of type {type(until)}" + ) if clean: s = s.clean() @@ -3441,48 +3475,130 @@ class Workplane(object): return self.newObject([r]) def _extrude( - self, distance: float, both: bool = False, taper: Optional[float] = None + self, + distance: Optional[float] = None, + both: bool = False, + taper: Optional[float] = None, + upToFace: Optional[Union[int, Face]] = None, + additive: bool = True, ) -> Compound: """ Make a prismatic solid from the existing set of pending wires. :param distance: distance to extrude - :param boolean both: extrude in both directions symmetrically + :param boolean both: extrude in both directions symetrically + :param upToFace: if specified extrude up to the :upToFace: face, 0 for the next, -1 for the last + :param additive: specify if extruding or cutting, required param for uptoface algorithm + :return: OCCT solid(s), suitable for boolean operations. This method is a utility method, primarily for plugin and internal use. It is the basis for cutBlind, extrude, cutThruAll, and all similar methods. """ + def getFacesList(eDir, direction, both=False): + """ + Utility function to make the code further below more clean and tidy + Performs some test and raise appropriate error when no Faces are found for extrusion + """ + facesList = self.findSolid().facesIntersectedByLine( + ws[0].Center(), eDir, direction=direction + ) + if len(facesList) == 0 and both: + raise ValueError( + "Couldn't find a face to extrude/cut to for at least one of the two required directions of extrusion/cut." + ) + + if len(facesList) == 0: + # if we don't find faces in the workplane normal direction we try the other + # direction (as the user might have created a workplane with wrong orientation) + facesList = self.findSolid().facesIntersectedByLine( + ws[0].Center(), eDir.multiply(-1.0), direction=direction + ) + if len(facesList) == 0: + raise ValueError( + "Couldn't find a face to extrude/cut to. Check your workplane orientation." + ) + return facesList + # group wires together into faces based on which ones are inside the others # result is a list of lists wireSets = sortWiresByBuildOrder(self.ctx.popPendingWires()) # compute extrusion vector and extrude - eDir = self.plane.zDir.multiply(distance) + if upToFace is not None: + eDir = self.plane.zDir + elif distance is not None: + eDir = self.plane.zDir.multiply(distance) + + if additive: + direction = "AlongAxis" + else: + direction = "Opposite" # one would think that fusing faces into a compound and then extruding would work, - # but it doesn't-- the resulting compound appears to look right, ( right number of faces, etc) + # but it doesnt-- the resulting compound appears to look right, ( right number of faces, etc) # but then cutting it from the main solid fails with BRep_NotDone. # the work around is to extrude each and then join the resulting solids, which seems to work # underlying cad kernel can only handle simple bosses-- we'll aggregate them if there are # multiple sets + thisObj: Union[Solid, Compound] toFuse = [] + taper = 0.0 if taper is None else taper + baseSolid = None - if taper: - for ws in wireSets: - thisObj = Solid.extrudeLinear(ws[0], [], eDir, taper) + for ws in wireSets: + if upToFace is not None: + baseSolid = self.findSolid() if baseSolid is None else thisObj + if isinstance(upToFace, int): + facesList = getFacesList(eDir, direction, both=both) + if ( + baseSolid.isInside(ws[0].Center()) + and additive + and upToFace == 0 + ): + upToFace = 1 # extrude until next face outside the solid + + limitFace = facesList[upToFace] + else: + limitFace = upToFace + + thisObj = Solid.dprism( + baseSolid, + Face.makeFromWires(ws[0]), + ws, + taper=taper, + upToFace=limitFace, + additive=additive, + ) + + if both: + facesList2 = getFacesList(eDir.multiply(-1.0), direction, both=both) + limitFace2 = facesList2[upToFace] + thisObj2 = Solid.dprism( + self.findSolid(), + Face.makeFromWires(ws[0]), + ws, + taper=taper, + upToFace=limitFace2, + additive=additive, + ) + thisObj = Compound.makeCompound([thisObj, thisObj2]) + toFuse = [thisObj] + elif taper != 0.0: + thisObj = Solid.extrudeLinear(ws[0], [], eDir, taper=taper) toFuse.append(thisObj) - else: - for ws in wireSets: - thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir) + else: + thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir, taper=taper) toFuse.append(thisObj) if both: - thisObj = Solid.extrudeLinear(ws[0], ws[1:], eDir.multiply(-1.0)) + thisObj = Solid.extrudeLinear( + ws[0], ws[1:], eDir.multiply(-1.0), taper=taper + ) toFuse.append(thisObj) return Compound.makeCompound(toFuse) diff --git a/cadquery/cqgi.py b/cadquery/cqgi.py index 08eb7853..ad7fa286 100644 --- a/cadquery/cqgi.py +++ b/cadquery/cqgi.py @@ -382,9 +382,9 @@ class NoOutputError(Exception): class ScriptExecutionError(Exception): """ - Represents a script syntax error. - Useful for helping clients pinpoint issues with the script - interactively + Represents a script syntax error. + Useful for helping clients pinpoint issues with the script + interactively """ def __init__(self, line=None, message=None): @@ -448,8 +448,8 @@ class ParameterDescriptionFinder(ast.NodeTransformer): def visit_Call(self, node): """ - Called when we see a function call. Is it describe_parameter? - """ + Called when we see a function call. Is it describe_parameter? + """ try: if node.func.id == "describe_parameter": # looks like we have a call to our function. diff --git a/cadquery/occ_impl/geom.py b/cadquery/occ_impl/geom.py index 6289ad4a..967358ed 100644 --- a/cadquery/occ_impl/geom.py +++ b/cadquery/occ_impl/geom.py @@ -27,16 +27,16 @@ TOL = 1e-2 class Vector(object): """Create a 3-dimensional vector - :param args: a 3D vector, with x-y-z parts. + :param args: a 3D vector, with x-y-z parts. - you can either provide: - * nothing (in which case the null vector is return) - * a gp_Vec - * a vector ( in which case it is copied ) - * a 3-tuple - * a 2-tuple (z assumed to be 0) - * three float values: x, y, and z - * two float values: x,y + you can either provide: + * nothing (in which case the null vector is return) + * a gp_Vec + * a vector ( in which case it is copied ) + * a 3-tuple + * a 2-tuple (z assumed to be 0) + * three float values: x, y, and z + * two float values: x,y """ _wrapped: gp_Vec @@ -347,8 +347,7 @@ class Matrix: return Matrix(self.wrapped.Multiplied(other.wrapped)) def transposed_list(self) -> Sequence[float]: - """Needed by the cqparts gltf exporter - """ + """Needed by the cqparts gltf exporter""" trsf = self.wrapped data = [[trsf.Value(i, j) for j in range(1, 5)] for i in range(1, 4)] + [ diff --git a/cadquery/occ_impl/importers.py b/cadquery/occ_impl/importers.py index c05cc16d..2486c67c 100644 --- a/cadquery/occ_impl/importers.py +++ b/cadquery/occ_impl/importers.py @@ -35,7 +35,7 @@ class UNITS: def importShape(importType, fileName, *args, **kwargs): """ Imports a file based on the type (STEP, STL, etc) - + :param importType: The type of file that we're importing :param fileName: THe name of the file that we're importing """ @@ -53,7 +53,7 @@ def importShape(importType, fileName, *args, **kwargs): def importStep(fileName): """ Accepts a file name and loads the STEP file into a cadquery Workplane - + :param fileName: The path and name of the STEP file to be imported """ @@ -217,7 +217,7 @@ def _dxf_convert(elements, tol): def importDXF(filename, tol=1e-6, exclude=[]): """ Loads a DXF file into a cadquery Workplane. - + :param fileName: The path and name of the DXF file to be imported :param tol: The tolerance used for merging edges into wires (default: 1e-6) :param exclude: a list of layer names not to import (default: []) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 06698e02..5de129f2 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -95,6 +95,7 @@ from OCP.BRepPrimAPI import ( BRepPrimAPI_MakeRevol, BRepPrimAPI_MakeSphere, ) +from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.TopExp import TopExp_Explorer # Toplogy explorer @@ -118,6 +119,7 @@ from OCP.TopoDS import ( from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse # geometry construction from OCP.GCE2d import GCE2d_MakeSegment +from OCP.gce import gce_MakeLin, gce_MakeDir from OCP.GeomAPI import ( GeomAPI_Interpolate, GeomAPI_ProjectPointOnSurf, @@ -1034,6 +1036,85 @@ class Shape(object): return self._bool_op((self,), toIntersect, intersect_op) + def facesIntersectedByLine( + self, + point: VectorLike, + axis: VectorLike, + tol: float = 1e-4, + direction: Optional[Literal["AlongAxis", "Opposite"]] = None, + ): + """ + Computes the intersections between the provided line and the faces of this Shape + + :point: Base point for defining a line + :axis: Axis on which the line rest + :tol: Intersection tolerance + :direction: Valid values : "AlongAxis", "Opposite", if specified will ignore all faces that are not in the specified direction + including the face where the :point: lies if it is the case + + :returns: A list of intersected faces sorted by distance from :point: + """ + + oc_point = ( + gp_Pnt(*point.toTuple()) if isinstance(point, Vector) else gp_Pnt(*point) + ) + oc_axis = ( + gp_Dir(Vector(axis).wrapped) + if not isinstance(axis, Vector) + else gp_Dir(axis.wrapped) + ) + + line = gce_MakeLin(oc_point, oc_axis).Value() + shape = self.wrapped + + intersectMaker = BRepIntCurveSurface_Inter() + intersectMaker.Init(shape, line, tol) + + faces_dist = [] # using a list instead of a dictionary to be able to sort it + while intersectMaker.More(): + interPt = intersectMaker.Pnt() + interDirMk = gce_MakeDir(oc_point, interPt) + + distance = oc_point.SquareDistance(interPt) + + # interDir is not done when `oc_point` and `oc_axis` have the same coord + if interDirMk.IsDone(): + interDir: Any = interDirMk.Value() + else: + interDir = None + + if direction == "AlongAxis": + if ( + interDir is not None + and not interDir.IsOpposite(oc_axis, tol) + and distance > tol + ): + faces_dist.append((intersectMaker.Face(), distance)) + + elif direction == "Opposite": + if ( + interDir is not None + and interDir.IsOpposite(oc_axis, tol) + and distance > tol + ): + faces_dist.append((intersectMaker.Face(), distance)) + + elif direction is None: + faces_dist.append( + (intersectMaker.Face(), abs(distance)) + ) # will sort all intersected faces by distance whatever the direction is + else: + raise ValueError( + "Invalid direction specification.\nValid specification are 'AlongAxis' and 'Opposite'." + ) + + intersectMaker.Next() + + faces_dist.sort(key=lambda x: x[1]) + faces = [face[0] for face in faces_dist] + + return [Face(face) for face in faces] + def split(self, *splitters: "Shape") -> "Shape": """ Split this shape with the positional arguments. @@ -2297,6 +2378,9 @@ class Shell(Shape): return cls(s) +TS = TypeVar("TS", bound=ShapeProtocol) + + class Mixin3D(object): def fillet(self: Any, radius: float, edgeList: Iterable[Edge]) -> Any: """ @@ -2426,6 +2510,51 @@ class Mixin3D(object): return solid_classifier.State() == ta.TopAbs_IN or solid_classifier.IsOnAFace() + def dprism( + self: TS, + basis: Optional[Face], + profiles: List[Wire], + depth: Optional[float] = None, + taper: float = 0, + upToFace: Optional[Face] = None, + thruAll: bool = True, + additive: bool = True, + ) -> TS: + """ + Make a prismatic feature (additive or subtractive) + + :param basis: face to perfrom the operation on + :param profiles: list of profiles + :param depth: depth of the cut or extrusion + :param upToFace: a face to extrude until + :param thruAll: cut thruAll + :param additive: set the kind of operation (additive or subtractive) + :return: a Solid object + """ + + sorted_profiles = sortWiresByBuildOrder(profiles) + shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped + for p in sorted_profiles: + face = Face.makeFromWires(p[0], p[1:]) + feat = BRepFeat_MakeDPrism( + shape, + face.wrapped, + basis.wrapped if basis else TopoDS_Face(), + taper * DEG2RAD, + additive, + False, + ) + if upToFace is not None: + feat.Perform(upToFace.wrapped) + elif thruAll or depth is None: + feat.PerformThruAll() + else: + feat.Perform(depth) + + shape = feat.Shape() + + return self.__class__(shape) + class Solid(Shape, Mixin3D): """ @@ -3015,47 +3144,6 @@ class Solid(Shape, Mixin3D): return cls(builder.Shape()) - def dprism( - self, - basis: Optional[Face], - profiles: List[Wire], - depth: Optional[float] = None, - taper: float = 0, - thruAll: bool = True, - additive: bool = True, - ) -> "Solid": - """ - Make a prismatic feature (additive or subtractive) - - :param basis: face to perform the operation on - :param profiles: list of profiles - :param depth: depth of the cut or extrusion - :param thruAll: cut thruAll - :return: a Solid object - """ - - sorted_profiles = sortWiresByBuildOrder(profiles) - shape: Union[TopoDS_Shape, TopoDS_Solid] = self.wrapped - for p in sorted_profiles: - face = Face.makeFromWires(p[0], p[1:]) - feat = BRepFeat_MakeDPrism( - shape, - face.wrapped, - basis.wrapped if basis else TopoDS_Face(), - taper * DEG2RAD, - additive, - False, - ) - - if thruAll or depth is None: - feat.PerformThruAll() - else: - feat.Perform(depth) - - shape = feat.Shape() - - return Solid(shape) - class CompSolid(Shape, Mixin3D): """ diff --git a/tests/test_cad_objects.py b/tests/test_cad_objects.py index 16692e31..9f551867 100644 --- a/tests/test_cad_objects.py +++ b/tests/test_cad_objects.py @@ -54,7 +54,7 @@ class TestCadObjects(BaseTest): def testVertex(self): """ - Tests basic vertex functions + Tests basic vertex functions """ v = Vertex.makeVertex(1, 1, 1) self.assertEqual(1, v.X) diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index f717ad7f..5c37b3bc 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -3136,6 +3136,275 @@ class TestCadQuery(BaseTest): self.saveModel(result) + def testExtrudeUntilFace(self): + """ + Test untilNextFace and untilLastFace options of Workplane.extrude() + """ + # Basic test to see if it yields same results as regular extrude for similar use case + # Also test if the extrusion worked well by counting the number of faces before and after extrusion + wp_ref = Workplane("XY").box(10, 10, 10).center(20, 0).box(10, 10, 10) + + wp_ref_extrude = wp_ref.faces(">X[1]").workplane().rect(1, 1).extrude(10) + + wp = Workplane("XY").box(10, 10, 10).center(20, 0).box(10, 10, 10) + nb_faces = wp.faces().size() + wp = wp_ref.faces(">X[1]").workplane().rect(1, 1).extrude("next") + + self.assertAlmostEquals(wp_ref_extrude.val().Volume(), wp.val().Volume()) + self.assertTrue(wp.faces().size() - nb_faces == 4) + + # Test tapered option and both option + wp = ( + wp_ref.faces(">X[1]") + .workplane(centerOption="CenterOfMass", offset=5) + .polygon(5, 3) + .extrude("next", both=True) + ) + wp_both_volume = wp.val().Volume() + self.assertTrue(wp.val().isValid()) + + # taper + wp = ( + wp_ref.faces(">X[1]") + .workplane(centerOption="CenterOfMass") + .polygon(5, 3) + .extrude("next", taper=5) + ) + + self.assertTrue(wp.val().Volume() < wp_both_volume) + self.assertTrue(wp.val().isValid()) + + # Test extrude until with more that one wire in context + wp = ( + wp_ref.faces(">X[1]") + .workplane(centerOption="CenterOfMass") + .pushPoints([(0, 0), (3, 3)]) + .rect(2, 3) + .extrude("next") + ) + + self.assertTrue(wp.solids().size() == 1) + self.assertTrue(wp.val().isValid()) + + # Test until last surf + wp_ref = wp_ref.workplane().move(10, 0).box(5, 5, 5) + wp = ( + wp_ref.faces(">X[1]") + .workplane(centerOption="CenterOfMass") + .circle(2) + .extrude("last") + ) + + self.assertTrue(wp.solids().size() == 1) + + with self.assertRaises(ValueError): + Workplane("XY").box(10, 10, 10).center(20, 0).box(10, 10, 10).faces( + ">X[1]" + ).workplane().rect(1, 1).extrude("test") + + # Test extrude until arbitrary face + arbitrary_face = ( + Workplane("XZ", origin=(0, 30, 0)) + .transformed((20, 0, 0)) + .box(10, 10, 10) + .faces("Y") + .workplane() + .circle(2) + .extrude(until=arbitrary_face) + ) + extremity_face_area = wp.faces(">Y").val().Area() + + self.assertAlmostEqual(extremity_face_area, 13.372852288495501, 5) + + # Test that a ValueError is raised if no face can be found to extrude until + with self.assertRaises(ValueError): + wp = ( + Workplane() + .box(5, 5, 5) + .faces(">X") + .workplane(offset=10) + .transformed((90, 0, 0)) + .circle(2) + .extrude(until="next") + ) + + # Test that a ValueError for: + # Extrusion in both direction while having a face to extrude only in one + with self.assertRaises(ValueError): + wp = ( + Workplane() + .box(5, 5, 5) + .faces(">X") + .workplane(offset=10) + .transformed((90, 0, 0)) + .circle(2) + .extrude(until="next", both=True) + ) + + # Test that a ValueError for: + # Extrusion in both direction while having no faces to extrude + with self.assertRaises(ValueError): + wp = Workplane().circle(2).extrude(until="next", both=True) + + # Check that a ValueError is raised if the user want to use `until` with a face and `combine` = False + # This isn't possible as the result of the extrude operation automatically combine the result with the base solid + + with self.assertRaises(ValueError): + wp = ( + Workplane() + .box(5, 5, 5) + .faces(">X") + .workplane(offset=10) + .transformed((90, 0, 0)) + .circle(2) + .extrude(until="next", combine=False) + ) + + # Same as previous test, but use an object of type Face + with self.assertRaises(ValueError): + wp = Workplane().box(5, 5, 5).faces(">X") + face0 = wp.val() + wp = ( + wp.workplane(offset=10) + .transformed((90, 0, 0)) + .circle(2) + .extrude(until=face0, combine=False) + ) + + # Test extrude up to next face when workplane is inside a solid (which should still extrude + # past solid surface and up to next face) + # make an I-beam shape + part = ( + Workplane() + .tag("base") + .box(10, 1, 1, centered=True) + .faces(">Z") + .workplane() + .box(1, 1, 10, centered=(True, True, False)) + .faces(">Z") + .workplane() + .box(10, 1, 1, centered=(True, True, False)) + # make an extrusion that starts inside the existing solid + .workplaneFromTagged("base") + .center(3, 0) + .circle(0.4) + # "next" should extrude to the top of the I-beam, not the bottom (0.5 units away) + .extrude("next") + ) + part_section = part.faces("X[2]") + .workplane(centerOption="CenterOfMass") + .rect(2, 2) + .cutBlind(-10) + ) + wp = ( + wp_ref.faces(">X[2]") + .workplane(centerOption="CenterOfMass") + .rect(2, 2) + .cutBlind("last") + ) + + self.assertAlmostEquals(wp_ref_regular_cut.val().Volume(), wp.val().Volume()) + + wp_last = ( + wp_ref.faces(">X[4]") + .workplane(centerOption="CenterOfMass") + .rect(2, 2) + .cutBlind("last") + ) + wp_next = ( + wp_ref.faces(">X[4]") + .workplane(centerOption="CenterOfMass") + .rect(2, 2) + .cutBlind("next") + ) + + self.assertTrue(wp_last.val().Volume() < wp_next.val().Volume()) + + # multiple wire cuts + + wp = ( + wp_ref.faces(">X[4]") + .workplane(centerOption="CenterOfMass", offset=0) + .rect(2.5, 2.5, forConstruction=True) + .vertices() + .rect(1, 1) + .cutBlind("last") + ) + + self.assertTrue(wp.faces().size() == 50) + + with self.assertRaises(ValueError): + Workplane("XY").box(10, 10, 10).center(20, 0).box(10, 10, 10).faces( + ">X[1]" + ).workplane().rect(1, 1).cutBlind("test") + + # Test extrusion to an arbitrary face + + arbitrary_face = ( + Workplane("XZ", origin=(0, 5, 0)) + .transformed((20, 0, 0)) + .box(10, 10, 10) + .faces("Y") + .workplane() + .circle(2) + .cutBlind(until=arbitrary_face) + ) + inner_face_area = wp.faces("<Z") + .workplane() + .pushPoints(pts) + .box(1, 10, 10) + ) + faces = shape.val().facesIntersectedByLine((0, 0, 7.5), (1, 0, 0)) + mx_face = shape.faces("X").val() + + self.assertTrue(len(faces) == 10) + # extremum faces are last or before last face + self.assertTrue(mx_face in faces[-2:]) + self.assertTrue(px_face in faces[-2:]) + def testExtrude(self): """ Test extrude diff --git a/tests/test_selectors.py b/tests/test_selectors.py index 0efb1868..490ad858 100644 --- a/tests/test_selectors.py +++ b/tests/test_selectors.py @@ -563,7 +563,7 @@ class TestCQSelectors(BaseTest): ((0.4, -0.1, -0.1), (0.6, 0.1, 0.1), (0.5, 0.0, 0.0)), ((-0.1, -0.1, 0.4), (0.1, 0.1, 0.6), (0.0, 0.0, 0.5)), ((0.9, 0.9, 0.4), (1.1, 1.1, 0.6), (1.0, 1.0, 0.5)), - ((0.4, 0.9, 0.9), (0.6, 1.1, 1.1,), (0.5, 1.0, 1.0)), + ((0.4, 0.9, 0.9), (0.6, 1.1, 1.1,), (0.5, 1.0, 1.0),), ] for d in test_data_edges: @@ -801,7 +801,7 @@ class TestCQSelectors(BaseTest): def testLengthNthSelector_UnitEdgeAndWire(self): """ - Checks that key() method of LengthNthSelector + Checks that key() method of LengthNthSelector calculates lengths of unit edge correctly """ unit_edge = Edge.makeLine(Vector(0, 0, 0), Vector(0, 0, 1))