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..384bdc90a 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -47,10 +47,81 @@ offset2D, sweep, loft, + hollow, check, closest, setThreads, project, faceOn, isSubshape, + prism, + hollow, + offset2D, + fillet2D, + chamfer2D, ) + +__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", +] diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 38b641372..0f583981b 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -238,7 +238,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 @@ -5022,7 +5022,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 +5134,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 +5494,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 +5833,7 @@ def compound(s: Sequence[Shape] | Generator[Shape, None, None]) -> Compound: return compound(*s) -#%% primitives +# %% primitives @multimethod @@ -6189,7 +6209,7 @@ def text( return _normalize(compound(rv)) -#%% ops +# %% ops def _bool_op( @@ -6589,9 +6609,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 +6684,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to get faces @@ -6645,7 +6721,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 +6745,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to construct sweeps using faces @@ -6878,7 +6960,158 @@ 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], + 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): + # 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 thickess type + if isinstance(t, Shape): + bldr.Perform(t.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], + 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 thickess type + if isinstance(t, Shape): + bldr.Perform(t.face().wrapped) + elif t is None: + bldr.PerformThruAll() + else: + bldr.Perform(t) + + s_tmp = bldr.Shape() + + return _compound_or_shape(s_tmp) + + +# %% diagnostics def check( @@ -6933,7 +7166,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..48b584f29 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -47,6 +47,10 @@ edgeOn, faceOn, offset2D, + prism, + hollow, + chamfer2D, + fillet2D, ) from cadquery.occ_impl.shapes import ( @@ -57,6 +61,7 @@ _get_edges, _adaptor_curve_to_edge, _shape_to_faces_shells, + _get_faces, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -72,6 +77,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 +132,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 +736,105 @@ 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("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("