diff --git a/cadquery/cq.py b/cadquery/cq.py index 14de1a64e..cb10c0f40 100644 --- a/cadquery/cq.py +++ b/cadquery/cq.py @@ -37,6 +37,7 @@ from typing_extensions import Literal from inspect import Parameter, Signature + from .occ_impl.geom import Vector, Plane, Location from .occ_impl.shapes import ( Shape, @@ -50,7 +51,7 @@ from .occ_impl.exporters.svg import getSVG, exportSVG -from .utils import deprecate +from .utils import deprecate, deprecate_kwarg_name from .selectors import ( Selector, @@ -61,6 +62,7 @@ CQObject = Union[Vector, Location, Shape, Sketch] VectorLike = Union[Tuple[float, float], Tuple[float, float, float], Vector] +CombineMode = Union[bool, Literal["cut", "a", "s"]] # a : additive, s: subtractive T = TypeVar("T", bound="Workplane") """A type variable used to make the return type of a method the same as the @@ -2391,6 +2393,8 @@ def each( self: T, callback: Callable[[CQObject], Shape], useLocalCoordinates: bool = False, + combine: CombineMode = True, + clean: bool = True, ) -> T: """ Runs the provided function on each value in the stack, and collects the return values into @@ -2401,6 +2405,9 @@ def each( :param callBackFunction: the function to call for each item on the current stack. :param useLocalCoordinates: should values be converted from local coordinates first? :type useLocalCoordinates: boolean + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. + :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape + The callback function must accept one argument, which is the item on the stack, and return one object, which is collected. If the function returns None, nothing is added to the stack. @@ -2436,15 +2443,16 @@ def each( if isinstance(r, Wire): if not r.forConstruction: self._addPendingWire(r) - results.append(r) - return self.newObject(results) + return self._combineWithBase(results, combine, clean) def eachpoint( self: T, callback: Callable[[Location], Shape], useLocalCoordinates: bool = False, + combine: CombineMode = False, + clean: bool = True, ) -> T: """ Same as each(), except each item on the stack is converted into a point before it @@ -2454,6 +2462,9 @@ def eachpoint( :param useLocalCoordinates: should points be in local or global coordinates :type useLocalCoordinates: boolean + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. + :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape + The resulting object has a point on the stack for each object on the original stack. Vertices and points remain a point. Faces, Wires, Solids, Edges, and Shells are converted @@ -2489,7 +2500,7 @@ def eachpoint( if isinstance(r, Wire) and not r.forConstruction: self._addPendingWire(r) - return self.newObject(res) + return self._combineWithBase(res, combine, clean) def rect( self: T, @@ -2948,7 +2959,7 @@ def twistExtrude( self: T, distance: float, angleDegrees: float, - combine: bool = True, + combine: CombineMode = True, clean: bool = True, ) -> T: """ @@ -2965,7 +2976,7 @@ def twistExtrude( :param distance: the distance to extrude normal to the workplane :param angle: angle (in degrees) to rotate through the extrusion - :param boolean combine: True to combine the resulting solid with parent solids if found. + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape :return: a CQ object with the resulting solid selected. """ @@ -2990,18 +3001,12 @@ def twistExtrude( r = Compound.makeCompound(shapes).fuse() - if combine: - newS = self._combineWithBase(r) - else: - newS = self.newObject([r]) - if clean: - newS = newS.clean() - return newS + return self._combineWithBase(r, combine, clean) def extrude( self: T, until: Union[float, Literal["next", "last"], Face], - combine: bool = True, + combine: CombineMode = True, clean: bool = True, both: bool = False, taper: Optional[float] = None, @@ -3009,20 +3014,17 @@ def extrude( """ Use all un-extruded wires in the parent chain to create a prismatic solid. - :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. (Cannot be set to False when `until` is not set as a float) + the extrusion will extend until this face. **Note that the Workplane must contain a Solid for extruding to a given face.** + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape :param boolean both: extrude in both directions symmetrically :param float taper: angle for optional tapered extrusion :return: a CQ object with the resulting solid selected. - extrude always *adds* material to a part. - The returned object is always a CQ object, and depends on whether combine is True, and whether a context solid is already defined: @@ -3031,14 +3033,19 @@ def extrude( * if combine is true, the value is combined with the context solid if it exists, and the resulting solid becomes the new context solid. """ + + # If subtractive mode is requested, use cutBlind + if combine in ("cut", "s"): + return self.cutBlind(until, clean, taper) + # Handle `until` multiple values - if isinstance(until, str) and until in ("next", "last") and combine: + elif until in ("next", "last") and combine in (True, "a"): if until == "next": faceIndex = 0 elif until == "last": faceIndex = -1 - r = self._extrude(distance=None, both=both, taper=taper, upToFace=faceIndex) + r = self._extrude(None, both=both, taper=taper, upToFace=faceIndex) elif isinstance(until, Face) and combine: r = self._extrude(None, both=both, taper=taper, upToFace=until) @@ -3056,20 +3063,14 @@ def extrude( f"Do not know how to handle until argument of type {type(until)}" ) - if combine: - newS = self._combineWithBase(r) - else: - newS = self.newObject([r]) - if clean: - newS = newS.clean() - return newS + return self._combineWithBase(r, combine, clean) def revolve( self: T, angleDegrees: float = 360.0, axisStart: Optional[VectorLike] = None, axisEnd: Optional[VectorLike] = None, - combine: bool = True, + combine: CombineMode = True, clean: bool = True, ) -> T: """ @@ -3081,8 +3082,7 @@ def revolve( :type axisStart: tuple, a two tuple :param axisEnd: the end point of the axis of rotation :type axisEnd: tuple, a two tuple - :param combine: True to combine the resulting solid with parent solids if found. - :type combine: boolean, combine with parent solid + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape :return: a CQ object with the resulting solid selected. @@ -3126,13 +3126,8 @@ def revolve( # returns a Solid (or a compound if there were multiple) r = self._revolve(angleDegrees, axisStart, axisEnd) - if combine: - newS = self._combineWithBase(r) - else: - newS = self.newObject([r]) - if clean: - newS = newS.clean() - return newS + + return self._combineWithBase(r, combine, clean) def sweep( self: T, @@ -3141,7 +3136,7 @@ def sweep( sweepAlongWires: Optional[bool] = None, makeSolid: bool = True, isFrenet: bool = False, - combine: bool = True, + combine: CombineMode = True, clean: bool = True, transition: Literal["right", "round", "transformed"] = "right", normal: Optional[VectorLike] = None, @@ -3152,7 +3147,7 @@ def sweep( :param path: A wire along which the pending wires will be swept :param boolean multiSection: False to create multiple swept from wires on the chain along path. True to create only one solid swept along path with shape following the list of wires on the chain - :param boolean combine: True to combine the resulting solid with parent solids if found. + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape :param transition: handling of profile orientation at C1 path discontinuities. Possible values are {'transformed','round', 'right'} (default: 'right'). :param normal: optional fixed normal for extrusion @@ -3181,18 +3176,48 @@ def sweep( auxSpine, ) # returns a Solid (or a compound if there were multiple) - newS: T - if combine: - newS = self._combineWithBase(r) + return self._combineWithBase(r, combine, clean) + + def _combineWithBase( + self: T, + obj: Union[Shape, Iterable[Shape]], + mode: CombineMode = True, + clean: bool = False, + ) -> T: + """ + Combines the provided object with the base solid, if one can be found. + :param obj: The object to be combined with the context solid + :param mode: The mode to combine with the base solid (True, False, "cut", "a" or "s") + :return: a new object that represents the result of combining the base object with obj, + or obj if one could not be found + """ + + if mode: + # since we are going to do something convert the iterable if needed + if not isinstance(obj, Shape): + obj = Compound.makeCompound(obj) + + # dispatch on the mode + if mode in ("cut", "s"): + newS = self._cutFromBase(obj) + elif mode in (True, "a"): + newS = self._fuseWithBase(obj) + else: - newS = self.newObject([r]) + # do not combine branch + newS = self.newObject(obj if not isinstance(obj, Shape) else [obj]) + if clean: - newS = newS.clean() + # NB: not calling self.clean() to not pollute the parents + newS.objects = [ + obj.clean() if isinstance(obj, Shape) else obj for obj in newS.objects + ] + return newS - def _combineWithBase(self: T, obj: Shape) -> T: + def _fuseWithBase(self: T, obj: Shape) -> T: """ - Combines the provided object with the base solid, if one can be found. + Fuse the provided object with the base solid, if one can be found. :param obj: :return: a new object that represents the result of combining the base object with obj, or obj if one could not be found @@ -3205,7 +3230,6 @@ def _combineWithBase(self: T, obj: Shape) -> T: r = baseSolid.fuse(obj) elif isinstance(obj, Compound): r = obj.fuse() - return self.newObject([r]) def _cutFromBase(self: T, obj: Shape) -> T: @@ -3215,9 +3239,8 @@ def _cutFromBase(self: T, obj: Shape) -> T: :return: a new object that represents the result of combining the base object with obj, or obj if one could not be found """ - baseSolid = self._findType( - (Solid, Compound), searchStack=True, searchParents=True - ) + baseSolid = self._findType((Solid, Compound), True, True) + r = obj if baseSolid is not None: r = baseSolid.cut(obj) @@ -3490,11 +3513,17 @@ def cutThruAll(self: T, clean: bool = True, taper: float = 0) -> T: return self.newObject([s]) def loft( - self: T, filled: bool = True, ruled: bool = False, combine: bool = True + self: T, ruled: bool = False, combine: CombineMode = True, clean: bool = True ) -> T: """ Make a lofted solid, through the set of wires. - :return: a CQ object containing the created loft + + :param boolean ruled: When set to `True` connects each section linearly and without continuity + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. + :param boolean clean: call :py:meth:`clean` afterwards to have a clean shape + + :return: a Workplane object containing the created loft + """ if self.ctx.pendingWires: @@ -3507,14 +3536,9 @@ def loft( r: Shape = Solid.makeLoft(wiresToLoft, ruled) - if combine: - parentSolid = self._findType( - (Solid, Compound), searchStack=False, searchParents=True - ) - if parentSolid is not None: - r = parentSolid.fuse(r) + newS = self._combineWithBase(r, combine, clean) - return self.newObject([r]) + return newS def _getFaces(self) -> List[Face]: """ @@ -4115,13 +4139,14 @@ def clean(self: T) -> T: return self.newObject(cleanObjects) + @deprecate_kwarg_name("cut", "combine='cut'") def text( self: T, txt: str, fontsize: float, distance: float, cut: bool = True, - combine: bool = False, + combine: CombineMode = False, clean: bool = True, font: str = "Arial", fontPath: Optional[str] = None, @@ -4137,7 +4162,7 @@ def text( :param distance: the distance to extrude or cut, normal to the workplane plane :type distance: float, negative means opposite the normal direction :param cut: True to cut the resulting solid from the parent solids if found - :param combine: True to combine the resulting solid with parent solids if found + :param combine: True or "a" to combine the resulting solid with parent solids if found, "cut" or "s" to remove the resulting solid from the parent solids if found. False to keep the resulting solid separated from the parent solids. :param clean: call :py:meth:`clean` afterwards to have a clean shape :param font: font name :param fontPath: path to font file @@ -4183,14 +4208,9 @@ def text( ) if cut: - newS = self._cutFromBase(r) - elif combine: - newS = self._combineWithBase(r) - else: - newS = self.newObject([r]) - if clean: - newS = newS.clean() - return newS + combine = "cut" + + return self._combineWithBase(r, combine, clean) def section(self: T, height: float = 0.0) -> T: """ diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index dc9281bce..e415fa34a 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -966,10 +966,12 @@ def moved(self: T, loc: Location) -> T: return r def __hash__(self) -> int: + return self.hashCode() def __eq__(self, other) -> bool: - return self.isSame(other) + + return self.isSame(other) if isinstance(other, Shape) else False def _bool_op( self, diff --git a/cadquery/utils.py b/cadquery/utils.py index 09a85e0db..87e4144f6 100644 --- a/cadquery/utils.py +++ b/cadquery/utils.py @@ -47,3 +47,26 @@ def __call__(self, *args, **kwargs): return super().__call__(*args, **kwargs) except DispatchError: return next(iter(self.values()))(*args, **kwargs) + + +class deprecate_kwarg_name: + def __init__(self, name, new_name): + + self.name = name + self.new_name = new_name + + def __call__(self, f): + @wraps(f) + def wrapped(*args, **kwargs): + + f_sig_params = signature(f).parameters + + if f_sig_params[self.name]: + warn( + f"Kwarg <{self.name}> will be removed. Plase use <{self.new_name}>", + FutureWarning, + ) + + return f(*args, **kwargs) + + return wrapped diff --git a/changes.md b/changes.md index 2947ec663..5ba13d789 100644 --- a/changes.md +++ b/changes.md @@ -6,8 +6,10 @@ master ### Breaking changes * Renamed the argument for `Workplane.extrude` from `distance` to `until` and `Workplane.cutBlind`'s `distanceToCut` also to `until`. This is only a breaking change if you use the named parameters, i.e. `extrude(distance = 10.0)` or `cutBlind(distanceToCut)` instead of using positional them as positional parameters. * Fixed a bug in `Mixin1DProtocol.tangentAt()` where `paramAt()` was being called twice. This should only break scripts that relied on the incorrect behavior. [#641](https://github.com/CadQuery/cadquery/pull/641) + * `each` and `eachpoint` accept now `combine` argument defaulted to `True` [#954](https://github.com/CadQuery/cadquery/pull/954). This only affects use cases involving solids. ### Other changes + * Combine option can be set to "cut" (or "s") resulting in a subtractive operation [#954](https://github.com/CadQuery/cadquery/pull/954) * Documentation updates [#648](https://github.com/CadQuery/cadquery/pull/648) [#654](https://github.com/CadQuery/cadquery/pull/654) [#656](https://github.com/CadQuery/cadquery/pull/656) [#659](https://github.com/CadQuery/cadquery/pull/659) [#668](https://github.com/CadQuery/cadquery/pull/668) [#689](https://github.com/CadQuery/cadquery/pull/689) [#695](https://github.com/CadQuery/cadquery/pull/695) [#699](https://github.com/CadQuery/cadquery/pull/699) [#711](https://github.com/CadQuery/cadquery/pull/711) [#727](https://github.com/CadQuery/cadquery/pull/727) [#733](https://github.com/CadQuery/cadquery/pull/733) [#734](https://github.com/CadQuery/cadquery/pull/734) [#737](https://github.com/CadQuery/cadquery/pull/737) [#738](https://github.com/CadQuery/cadquery/pull/738) [#748](https://github.com/CadQuery/cadquery/pull/748) [#757](https://github.com/CadQuery/cadquery/pull/757) [#774](https://github.com/CadQuery/cadquery/pull/774) [#775](https://github.com/CadQuery/cadquery/pull/775) [#805](https://github.com/CadQuery/cadquery/pull/805) [#813](https://github.com/CadQuery/cadquery/pull/813) [#837](https://github.com/CadQuery/cadquery/pull/837) [#839](https://github.com/CadQuery/cadquery/pull/839) [#843](https://github.com/CadQuery/cadquery/pull/843) [#845](https://github.com/CadQuery/cadquery/pull/845) [#846](https://github.com/CadQuery/cadquery/pull/846) [#847](https://github.com/CadQuery/cadquery/pull/847) [#848](https://github.com/CadQuery/cadquery/pull/848) [#852](https://github.com/CadQuery/cadquery/pull/852) [#863](https://github.com/CadQuery/cadquery/pull/863) [#866](https://github.com/CadQuery/cadquery/pull/866) [#867](https://github.com/CadQuery/cadquery/pull/867) [#887](https://github.com/CadQuery/cadquery/pull/887) [#908](https://github.com/CadQuery/cadquery/pull/908) [#910](https://github.com/CadQuery/cadquery/pull/910) [#912] (https://github.com/CadQuery/cadquery/pull/912) [#921](https://github.com/CadQuery/cadquery/pull/921) * Added better documentation on the internals of CadQuery [#821](https://github.com/CadQuery/cadquery/pull/821) * Added documentation for assembly constraints [#850](https://github.com/CadQuery/cadquery/pull/850) diff --git a/tests/test_cadquery.py b/tests/test_cadquery.py index c366ff017..84420ac16 100644 --- a/tests/test_cadquery.py +++ b/tests/test_cadquery.py @@ -467,6 +467,32 @@ def testLoft(self): # the resulting loft had a split on the side, not sure why really, i expected only 3 faces self.assertEqual(7, s.faces().size()) + # test loft with combine="cut" + box = Workplane().box(10, 10, 10) + cut = ( + box.faces(">Z") + .workplane() + .circle(2) + .workplane(invert=True, offset=12) + .rect(3, 2) + .loft(combine="cut") + ) + + self.assertGreater(box.val().Volume(), cut.val().Volume()) + + # test loft with combine=True + box = Workplane().box(10, 10, 10) + add = ( + box.faces(">Z") + .workplane() + .circle(2) + .workplane(offset=12) + .rect(3, 2) + .loft(combine=True) + ) + + self.assertGreater(add.val().Volume(), box.val().Volume()) + def testLoftRaisesValueError(self): s0 = Workplane().hLine(1) # no wires with raises(ValueError): @@ -616,6 +642,17 @@ def testRevolveCone(self): self.assertEqual(2, result.vertices().size()) self.assertEqual(2, result.edges().size()) + def testRevolveCut(self): + box = Workplane().box(10, 10, 10) + cut = ( + box.transformed((90, 0, 0)) + .move(5, 0) + .rect(3, 4, centered=False) + .revolve(360, (0, 0, 0), (0, 1, 0), combine="cut") + ) + + self.assertGreater(box.val().Volume(), cut.val().Volume()) + def testRevolveErrors(self): """ Test that revolve raises errors when used incorrectly. @@ -1131,6 +1168,28 @@ def testSweep(self): .sweep(path, auxSpine=Workplane().box(1, 1, 1)) ) + # test sweep with combine="cut" + box = Workplane().box(10, 10, 10, centered=False) + path = Workplane("YZ").lineTo(10, 10) + cut = ( + box.vertices(">Z and >X and >Y") + .workplane(centerOption="CenterOfMass") + .circle(1.5) + .sweep(path, combine="cut") + ) + self.assertGreater(box.val().Volume(), cut.val().Volume()) + + # test sweep with combine = True + box = Workplane().box(10, 10, 10, centered=False) + path = Workplane("YZ").lineTo(10, 10) + add = ( + box.vertices(">Z and >X and >Y") + .workplane(centerOption="CenterOfMass") + .circle(1.5) + .sweep(path, combine=True) + ) + self.assertGreater(add.val().Volume(), box.val().Volume()) + def testMultisectionSweep(self): """ Tests the operation of sweeping along a list of wire(s) along a path @@ -1255,6 +1314,19 @@ def testTwistExtrude(self): self.assertEqual(6, r.faces().size()) + def testTwistExtrudeCombineCut(self): + """ + Tests extrusion while twisting through an angle, removing the solid from the base solid + """ + box = Workplane().box(10, 10, 10) + cut = ( + box.faces(">Z") + .workplane(invert=True) + .rect(1.5, 5) + .twistExtrude(10, 90, combine="cut") + ) + self.assertGreater(box.val().Volume(), cut.val().Volume()) + def testTwistExtrudeCombine(self): """ Tests extrusion while twisting through an angle, combining with other solids. @@ -3452,6 +3524,11 @@ def testExtrude(self): with self.assertRaises(ValueError): Workplane().rect(2, 2).rect(1, 1).extrude(2, taper=4) + # Test extrude with combine="cut" + box = Workplane().box(5, 5, 5) + r = box.faces(">Z").workplane(invert=True).circle(0.5).extrude(4, combine="cut") + self.assertGreater(box.val().Volume(), r.val().Volume()) + def testTaperedExtrudeCutBlind(self): h = 1.0 @@ -5021,6 +5098,16 @@ def testEachpoint(self): for v in r1.vals(): self.assertTupleAlmostEquals(v.Center().toTuple(), (0, 0, 0), 6) + # test eachpoint with combine = True + box = Workplane().box(2, 1, 1).val() + ref = Workplane().box(5, 5, 5) + r = ref.vertices().eachpoint(lambda loc: box.moved(loc), combine=True) + self.assertGreater(r.val().Volume(), ref.val().Volume()) + + # test eachpoint with combine = "cut" + r = ref.vertices().eachpoint(lambda loc: box.moved(loc), combine="cut") + self.assertGreater(ref.val().Volume(), r.val().Volume()) + def testSketch(self): r1 = ( @@ -5113,6 +5200,24 @@ def circumradius(n, a): vs[3].toTuple(), approx((a, -a * math.tan(math.radians(45)), 0)) ) + def test_combineWithBase(self): + # Test the helper mehod _combinewith + + box = Workplane().box(10, 10, 10) + sphere = box.faces(">Z").sphere(2) + new_box = box._combineWithBase(sphere.val()) + + self.assertGreater(new_box.val().Volume(), box.val().Volume()) + + def test_cutFromBase(self): + # Test the helper method _cutFromBase + + box = Workplane().box(10, 10, 10) + sphere = Workplane().sphere(2) + hoolow_box = box._cutFromBase(sphere.val()) + + self.assertGreater(box.val().Volume(), hoolow_box.val().Volume()) + def test_MergeTags(self): a = Workplane().box(1, 1, 1)