diff --git a/cadquery/fig.py b/cadquery/fig.py index d5b67f9b5..b450dfa36 100644 --- a/cadquery/fig.py +++ b/cadquery/fig.py @@ -23,6 +23,7 @@ vtkRenderWindow, vtkRenderWindowInteractor, vtkProp3D, + vtkMapper, ) @@ -102,6 +103,11 @@ def __init__(self, port: int = 18081): orient_widget.EnabledOn() orient_widget.InteractiveOff() + # rendering related settings + vtkMapper.SetResolveCoincidentTopologyToPolygonOffset() + vtkMapper.SetResolveCoincidentTopologyPolygonOffsetParameters(1, 0) + vtkMapper.SetResolveCoincidentTopologyLineOffsetParameters(-1, 0) + self.axes = axes self.orient_widget = orient_widget self.win = win diff --git a/cadquery/func.py b/cadquery/func.py index ef65d674d..df9a975b2 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -47,10 +47,83 @@ offset2D, sweep, loft, + hollow, check, closest, setThreads, project, faceOn, isSubshape, + prism, + hollow, + offset2D, + fillet2D, + chamfer2D, + draft, ) + +__all__ = [ + "Vector", + "Plane", + "Location", + "Shape", + "Vertex", + "Edge", + "Wire", + "Face", + "Shell", + "Solid", + "CompSolid", + "Compound", + "edgeOn", + "wireOn", + "wire", + "face", + "shell", + "solid", + "compound", + "vertex", + "segment", + "polyline", + "polygon", + "rect", + "spline", + "circle", + "ellipse", + "plane", + "box", + "cylinder", + "sphere", + "torus", + "cone", + "text", + "fuse", + "cut", + "intersect", + "imprint", + "split", + "fill", + "clean", + "cap", + "fillet", + "chamfer", + "extrude", + "revolve", + "offset", + "offset2D", + "sweep", + "loft", + "hollow", + "check", + "closest", + "setThreads", + "project", + "faceOn", + "isSubshape", + "prism", + "hollow", + "offset2D", + "chamfer2D", + "fillet2D", + "draft", +] diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 38b641372..35aa2d3bd 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -179,6 +179,7 @@ from OCP.BRepLib import BRepLib, BRepLib_FindSurface from OCP.BRepOffsetAPI import ( + BRepOffsetAPI_DraftAngle, BRepOffsetAPI_ThruSections, BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, @@ -238,7 +239,7 @@ from OCP.NCollection import NCollection_Utf8String -from OCP.BRepFeat import BRepFeat_MakeDPrism +from OCP.BRepFeat import BRepFeat_MakeDPrism, BRepFeat_MakePrism from OCP.BRepClass3d import BRepClass3d_SolidClassifier, BRepClass3d @@ -1959,6 +1960,36 @@ def reverse(self) -> "Shape": return self.cast(self.wrapped.Reversed()) + def __and__(self, other: "Shape") -> "Compound": + """ + Set intersection for combining selection results. + """ + + LHS = set(self) if isinstance(self, Compound) else {self} + RHS = set(other) if isinstance(other, Compound) else {other} + + return compound(*(LHS & RHS)) + + def __or__(self, other: "Shape") -> "Compound": + """ + Set sum for combining selection results. + """ + + LHS = set(self) if isinstance(self, Compound) else {self} + RHS = set(other) if isinstance(other, Compound) else {other} + + return compound(*(LHS | RHS)) + + def __mod__(self, other: "Shape") -> "Compound": + """ + Set difference for combining selection results. + """ + + LHS = set(self) if isinstance(self, Compound) else {self} + RHS = set(other) if isinstance(other, Compound) else {other} + + return compound(*(LHS - RHS)) + class ShapeProtocol(Protocol): @property @@ -5022,7 +5053,7 @@ def edgesToWires(edges: Iterable[Edge], tol: float = 1e-6) -> List[Wire]: return [Wire(el) for el in wires_out] -#%% utilities +# %% utilities def _get(s: Shape, ts: Union[Shapes, Tuple[Shapes, ...]]) -> Iterable[Shape]: @@ -5134,6 +5165,26 @@ def _get_edges(*shapes: Shape) -> Iterable[Shape]: raise ValueError(f"Required type(s): Edge, Wire; encountered {t}") +def _get_faces(*shapes: Shape) -> Iterable[Face]: + """ + Get faces or faces from wires or edges. + """ + + for s in shapes: + t = s.ShapeType() + + if t == "Face": + yield s.face() + elif t == "Edge": + yield face(s) + elif t == "Wire": + yield face(s) + elif t == "Compound": + yield from _get_faces(*s) + else: + raise ValueError(f"Required type(s): Edge, Wire, Face; encountered {t}") + + def _get_wire_lists(s: Sequence[Shape]) -> List[List[Union[Wire, Vertex]]]: """ Get lists of wires for sweeping or lofting. @@ -5474,7 +5525,7 @@ def _adaptor_curve_to_edge(crv: Adaptor3d_Curve, p1: float, p2: float) -> TopoDS return bldr.Edge() -#%% alternative constructors +# %% alternative constructors ShapeHistory = Dict[Union[Shape, str], Shape] @@ -5813,7 +5864,7 @@ def compound(s: Sequence[Shape] | Generator[Shape, None, None]) -> Compound: return compound(*s) -#%% primitives +# %% primitives @multimethod @@ -6189,7 +6240,7 @@ def text( return _normalize(compound(rv)) -#%% ops +# %% ops def _bool_op( @@ -6589,9 +6640,63 @@ def offset2D( return _compound_or_shape(bldr.Shape()) +def chamfer2D(s: Shape, verts: Shape, d: float): + """ + Apply a 2D chamfer to a planar face. + """ + + f = _get_one(s, "Face") + + bldr = BRepFilletAPI_MakeFillet2d(tcast(TopoDS_Face, f.wrapped)) + edge_map = s._entitiesFrom("Vertex", "Edge") + + for v in verts.vertices(): + edges = edge_map[v] + if len(edges) < 2: + raise ValueError("Cannot chamfer at this location") + + e1, e2 = edges + + bldr.AddChamfer( + tcast(TopoDS_Edge, e1.wrapped), tcast(TopoDS_Edge, e2.wrapped), d, d + ) + + bldr.Build() + + return _compound_or_shape(bldr.Shape()) + + +def fillet2D(s: Shape, verts: Shape, r: float): + """ + Apply a 2D fillet to a planar face. + """ + + f = _get_one(s, "Face") + + bldr = BRepFilletAPI_MakeFillet2d(tcast(TopoDS_Face, f.wrapped)) + + for v in verts.vertices(): + bldr.AddFillet(tcast(TopoDS_Vertex, v.wrapped), r) + + bldr.Build() + + return _compound_or_shape(bldr.Shape()) + + +_trans_mode_dict = { + "transformed": BRepBuilderAPI_Transformed, + "round": BRepBuilderAPI_RoundCorner, + "right": BRepBuilderAPI_RightCorner, +} + + @multimethod def sweep( - s: Shape, path: Shape, aux: Optional[Shape] = None, cap: bool = False + s: Shape, + path: Shape, + aux: Optional[Shape] = None, + cap: bool = False, + transition: Literal["transformed", "round", "right"] = "transformed", ) -> Shape: """ Sweep edge, wire or face along a path. For faces cap has no effect. @@ -6610,6 +6715,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to get faces @@ -6645,7 +6752,11 @@ def _make_builder(): @multimethod def sweep( - s: Sequence[Shape], path: Shape, aux: Optional[Shape] = None, cap: bool = False + s: Sequence[Shape], + path: Shape, + aux: Optional[Shape] = None, + cap: bool = False, + transition: Literal["transformed", "round", "right"] = "transformed", ) -> Shape: """ Sweep edges, wires or faces along a path, multiple sections are supported. @@ -6665,6 +6776,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to construct sweeps using faces @@ -6878,7 +6991,211 @@ def project( return _normalize(compound(results)) -#%% diagnostics +_offset_kind_dict = { + "arc": GeomAbs_JoinType.GeomAbs_Arc, + "intersection": GeomAbs_JoinType.GeomAbs_Intersection, +} + + +@multidispatch +def hollow( + s: Shape, + faces: Optional[Shape], + t: float, + tol: float = 1e-3, + kind: Literal["arc", "intersection"] = "intersection", +): + """ + Make a hollow solid by removing faces and applying thickness t. + """ + + bldr = BRepOffsetAPI_MakeThickSolid() + _faces = ( + _shapes_to_toptools_list(faces.Faces()) if faces else TopTools_ListOfShape() + ) + + bldr.MakeThickSolidByJoin( + s.solid().wrapped, + _faces, + t, + tol, + Intersection=True, + Join=_offset_kind_dict[kind], + ) + bldr.Build() + + rv = _compound_or_shape(bldr.Shape()) + + # if no faces provided a watertight solid will be constructed + if faces is None: + sh1 = rv.shell().wrapped + sh2 = s.shell().wrapped + + # sh1 can be outer or inner shell depending on the thickness sign + if t > 0: + sol = BRepBuilderAPI_MakeSolid(sh1, sh2) + else: + sol = BRepBuilderAPI_MakeSolid(sh2, sh1) + + # fix needed for the orientations + rv = _compound_or_shape(sol.Shape()).fix() + + return rv + + +@multidispatch +def hollow( + s: Shape, + t: float, + tol: float = 1e-3, + kind: Literal["arc", "intersection"] = "intersection", +) -> Solid: + + return hollow(s, None, t, tol, kind) + + +@multidispatch +def prism( + ctx: Shape, + base: Optional[Shape], + faces: Shape, + t: Optional[Real | Shape | tuple[Shape, Shape]], + angle: Real = 0.0, + additive: bool = True, +) -> Shape: + """ + Build a drafted prismatic feature that can be additive or subtractive. + """ + + s_tmp = ctx.wrapped + + for f in _get_faces(faces): + bldr: BRepFeat_MakePrism | BRepFeat_MakeDPrism + # if taper is requested, use the dprism builder + if angle != 0: + bldr = BRepFeat_MakeDPrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + radians(angle), + additive, + False, + ) + # otherwise use the prism builder to get cleaner topologies + else: + bldr = BRepFeat_MakePrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + f.normalAt().toDir(), + additive, + False, + ) + + # dispatch on thickens type + if isinstance(t, Shape): + bldr.Perform(t.face().wrapped) + elif isinstance(t, tuple): + bldr.Perform(t[0].face().wrapped, t[1].face().wrapped) + elif t is None: + bldr.PerformThruAll() + else: + bldr.Perform(t) + + s_tmp = bldr.Shape() + + return _compound_or_shape(s_tmp) + + +@multidispatch +def prism( + ctx: Shape, + base: Optional[Shape], + faces: Shape, + t: Optional[Real | Shape | tuple[Shape, Shape]], + dir: VectorLike, + additive: bool = True, +) -> Shape: + """ + Build a (potentially tilted) prismatic feature that can be additive or subtractive. + """ + + s_tmp = ctx.wrapped + + for f in _get_faces(faces): + bldr = BRepFeat_MakePrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + Vector(dir).toDir(), + additive, + False, + ) + + # dispatch on thickens type + if isinstance(t, Shape): + bldr.Perform(t.face().wrapped) + elif isinstance(t, tuple): + bldr.Perform(t[0].face().wrapped, t[1].face().wrapped) + elif t is None: + bldr.PerformThruAll() + else: + bldr.Perform(t) + + s_tmp = bldr.Shape() + + return _compound_or_shape(s_tmp) + + +@multidispatch +def draft(ctx: Shape, base: Shape, faces: Shape, angle: Real,) -> Shape: + """ + Add a draft angle to the specified faces. + """ + + base_face = base.face() + n_dir = base_face.normalAt().toDir() + base_pln = base_face.toPln() + + bldr = BRepOffsetAPI_DraftAngle(ctx.wrapped) + + for f in _get_faces(faces): + bldr.Add(f.wrapped, n_dir, radians(angle), base_pln) + + if not bldr.AddDone(): + raise ValueError(f"Face {f} cannot be used in a draft operation.") + + bldr.Build() + + return _compound_or_shape(bldr.Shape()) + + +@multidispatch +def draft( + ctx: Shape, base: Shape, faces: Shape, dir: VectorLike, angle: Real, +) -> Shape: + """ + Add a draft angle to the specified faces. + """ + + base_face = base.face() + n_dir = Vector(dir).toDir() + base_pln = base_face.toPln() + + bldr = BRepOffsetAPI_DraftAngle(ctx.wrapped) + + for f in _get_faces(faces): + bldr.Add(f.wrapped, n_dir, radians(angle), base_pln) + + if not bldr.AddDone(): + raise ValueError(f"Face {f} cannot be used in a draft operation.") + + bldr.Build() + + return _compound_or_shape(bldr.Shape()) + + +# %% diagnostics def check( @@ -6933,7 +7250,7 @@ def isSubshape(s1: Shape, s2: Shape) -> bool: return shape_map.Contains(s1.wrapped) -#%% properties +# %% properties def closest(s1: Shape, s2: Shape) -> Tuple[Vector, Vector]: diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index d63711419..0388766b3 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -47,6 +47,11 @@ edgeOn, faceOn, offset2D, + prism, + hollow, + chamfer2D, + fillet2D, + draft, ) from cadquery.occ_impl.shapes import ( @@ -57,6 +62,7 @@ _get_edges, _adaptor_curve_to_edge, _shape_to_faces_shells, + _get_faces, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -72,6 +78,12 @@ def tmpdir(tmp_path_factory): return tmp_path_factory.mktemp("free_functions") +@pytest.fixture +def box_shape(): + + return box(1, 1, 1) + + # %% test utils @@ -121,6 +133,13 @@ def test_utils(): with raises(ValueError): list(_get_edges(fill(circle(1)))) + r5 = _get_faces(plane(1, 1), rect(1, 1), circle(1.0), compound(circle(1.0))) + + assert len(list(r5)) == 4 + + with raises(ValueError): + list(_get_faces(vertex(0, 0, 0))) + def test_adaptor_curve_to_edge(): @@ -718,6 +737,172 @@ def test_moved(): # %% ops + + +def test_hollow(box_shape): + + res1 = hollow(box_shape, -0.1) + res2 = hollow(box_shape, 0.1) + + assert res1.isValid() + assert res1.faces().size() == 2 * box_shape.faces().size() + + assert res2.isValid() + assert res2.faces().size() == 2 * box_shape.faces().size() + + +def test_hollow_open(box_shape): + + # offset inwards + res1 = hollow(box_shape, box_shape.faces(">Z"), -0.1) + + # offset outwards + res2 = hollow(box_shape, box_shape.faces(">Z"), 0.1) + + assert res1.isValid() + assert res1.faces().size() == 6 + 5 + + assert res2.isValid() + assert res2.faces().size() == 6 + 5 + + +def test_prism(box_shape): + + ftop = box_shape.faces(">Z") + c = circle(0.2).moved(ftop) + + # additive prism + res1 = prism(box_shape, ftop, c, 0.1, (0, 0, 1)) + + assert res1.isValid() + assert res1.Volume() > box_shape.Volume() + assert res1.faces().size() == 6 + 2 + + # subtractive prism + res2 = prism(box_shape, ftop, c, -0.1, (0, 0, 1), False) + + assert res2.isValid() + assert res2.Volume() < box_shape.Volume() + assert res2.faces().size() == 6 + 2 + + # subtractive prism with tilt + res3 = prism(box_shape, None, c, box_shape.face("Z").innerWires()) == 1 + assert len(res4.face(">X").Center(), + ftop.Center() + Vector(0, 0, 1), + ) + ) + + res5 = prism( + box_shape, + None, + tri, + (box_shape.face("Y").extend(10)), + ) + + assert res5.isValid() + assert res5.faces("|Z").size() == 1 + + # additive prism from/to face using different overload + res6 = prism( + box_shape, + None, + tri, + (box_shape.face("Y").extend(10)), + (0, 1, 0), + ) + + assert res6.isValid() + assert res6.faces("|Z").size() == 1 + + +def test_prism_taper(box_shape): + + ftop = box_shape.faces(">Z") + c = circle(0.2).moved(ftop) + + # additive prism + res1 = prism(box_shape, ftop, c, 0.1) + + assert res1.isValid() + assert res1.Volume() > box_shape.Volume() + assert res1.faces().size() == 6 + 2 + + # additive prism with a taper + res2 = prism(box_shape, ftop, c, 0.1, 15) + + assert res2.isValid() + assert res2.faces().size() == 6 + 4 # NB: side face is split into 3 + assert res2.wire(">Z").Length() < c.Length() + + # subtractive prism + res3 = prism(box_shape / c, ftop, c, box_shape.face("Z")), + 5, + False, + ) + + assert res5.isValid() + assert res5.faces().size() == 6 + 2 * 3 + + +def test_draft(box_shape): + + fbot = box_shape.face("Z").Area() > fbot.Area() + + # direction specified explicitely + res2 = draft(box_shape, fbot, fside, (0, 0, 1), 5) + assert res2.face(">Z").Area() < fbot.Area() + + # raise on unsupported face type + s = extrude(face(ellipse(2, 1)), (0, 0, 1)) + + with raises(ValueError): + draft(s, s.face(">Z[-2]"), 5) + + with raises(ValueError): + draft(s, s.face(">Z[-2]"), (0, 0, 1), 5) + + def test_clean(): b1 = box(1, 1, 1) @@ -857,6 +1042,27 @@ def test_offset2D(): assert r3.edge().Length() == approx(seg.Length()) +def test_fillet2D(): + + f = plane(1, 1) + + res = fillet2D(f, f.vertices(), 0.1) + + assert res.isValid() + assert res.edges().size() == 8 + assert res.edges("%CIRCLE").size() == 4 + + +def test_chamfer2D(): + + f = plane(1, 1) + + res = chamfer2D(f, f.vertices(), 0.1) + + assert res.isValid() + assert res.edges().size() == 8 + + def test_sweep(): w1 = rect(1, 1) diff --git a/tests/test_shapes.py b/tests/test_shapes.py index 4fae3f107..bb34fdce5 100644 --- a/tests/test_shapes.py +++ b/tests/test_shapes.py @@ -468,3 +468,10 @@ def test_siblings(simple_box): assert level_1.size() + level_2.size() + level_3.size() == level_123.size() assert set(level_1) | set(level_2) | set(level_3) == set(level_123) + + +def test_set_ops(simple_box): + + assert (simple_box.faces(">Z") | simple_box.faces("Z") & simple_box.faces("