From 4c8cc9a27fe7a2f14276a74ae7b3d5fde5d0274f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 22 May 2026 19:41:47 +0200 Subject: [PATCH 1/6] More free functions --- cadquery/func.py | 69 ++++++++++ cadquery/occ_impl/shapes.py | 237 +++++++++++++++++++++++++++++++++-- tests/test_free_functions.py | 116 +++++++++++++++++ 3 files changed, 413 insertions(+), 9 deletions(-) diff --git a/cadquery/func.py b/cadquery/func.py index ef65d674d..79ea83a45 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -47,10 +47,79 @@ offset2D, sweep, loft, + hollow, check, closest, setThreads, project, faceOn, isSubshape, + prism, + hollow, + offset2D, + 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", +] diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 38b641372..6bd91c9e7 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,27 @@ 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": + for el in s: + yield from _get_faces(el) + 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 +5495,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 +5834,7 @@ def compound(s: Sequence[Shape] | Generator[Shape, None, None]) -> Compound: return compound(*s) -#%% primitives +# %% primitives @multimethod @@ -6189,7 +6210,7 @@ def text( return _normalize(compound(rv)) -#%% ops +# %% ops def _bool_op( @@ -6589,9 +6610,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 +6685,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to get faces @@ -6645,7 +6722,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 +6746,8 @@ def _make_builder(): else: rv.SetMode(False) + rv.SetTransitionMode(_trans_mode_dict[transition]) + return rv # try to construct sweeps using faces @@ -6878,7 +6961,143 @@ 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"] = "arc", +): + """ + 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"] = "arc", +) -> 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): + bldr = BRepFeat_MakeDPrism( + s_tmp, + f.wrapped, + base.face().wrapped if base else TopoDS_Face(), + radians(angle), + 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 +7152,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..6aaba6be8 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -47,6 +47,8 @@ edgeOn, faceOn, offset2D, + prism, + hollow, ) from cadquery.occ_impl.shapes import ( @@ -57,6 +59,8 @@ _get_edges, _adaptor_curve_to_edge, _shape_to_faces_shells, + chamfer2D, + fillet2D, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -72,6 +76,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 @@ -718,6 +728,91 @@ 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() == 20 + 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() == 5 + 5 + 1 + + assert res2.isValid() + assert res2.faces().size() == 12 + 5 + 5 + 1 + + +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, -0.1, (0, 1, 1), False) + + assert res3.isValid() + assert res3.Volume() < box_shape.Volume() + assert res3.faces().size() == 6 + 2 + + # subtractive prism without base through all + res4 = prism(box_shape, None, c, None, (0, 0, 1), False) + + assert res4.isValid() + assert res4.Volume() < box_shape.Volume() + assert res4.faces().size() == 6 + 1 + assert len(res4.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 + 4 + + # additive prism with a taper + res2 = prism(box_shape, ftop, c, 0.1, 15) + + assert res2.isValid() + assert res2.faces().size() == 6 + 4 + assert res2.wire(">Z").Length() < c.Length() + + def test_clean(): b1 = box(1, 1, 1) @@ -857,6 +952,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) From 7098f9239ce83baf9085748d88d1c4794396ba91 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 23 May 2026 00:02:44 +0200 Subject: [PATCH 2/6] Improve coverage --- tests/test_free_functions.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 6aaba6be8..fb7aa3c47 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -49,6 +49,8 @@ offset2D, prism, hollow, + chamfer2D, + fillet2D, ) from cadquery.occ_impl.shapes import ( @@ -59,8 +61,7 @@ _get_edges, _adaptor_curve_to_edge, _shape_to_faces_shells, - chamfer2D, - fillet2D, + _get_faces, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -131,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(): @@ -777,11 +785,11 @@ def test_prism(box_shape): assert res2.faces().size() == 6 + 2 # subtractive prism with tilt - res3 = prism(box_shape, None, c, -0.1, (0, 1, 1), False) + res3 = prism(box_shape, None, c, box_shape.face("Z").Length() < c.Length() + # subtractive prism with a taper + res3 = prism(box_shape / c, ftop, c, box_shape.face(" Date: Sat, 23 May 2026 13:08:15 +0200 Subject: [PATCH 3/6] Add fillet2D --- cadquery/func.py | 2 ++ cadquery/occ_impl/shapes.py | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/cadquery/func.py b/cadquery/func.py index 79ea83a45..384bdc90a 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -57,6 +57,7 @@ prism, hollow, offset2D, + fillet2D, chamfer2D, ) @@ -122,4 +123,5 @@ "hollow", "offset2D", "chamfer2D", + "fillet2D", ] diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 6bd91c9e7..9492085e5 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5149,8 +5149,7 @@ def _get_faces(*shapes: Shape) -> Iterable[Face]: elif t == "Wire": yield face(s) elif t == "Compound": - for el in s: - yield from _get_faces(el) + yield from _get_faces(*s) else: raise ValueError(f"Required type(s): Edge, Wire, Face; encountered {t}") From a8da755fe9bf93f6bc931785b9442f6566c1fa1f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 23 May 2026 14:02:11 +0200 Subject: [PATCH 4/6] Update hollow --- cadquery/occ_impl/shapes.py | 7 +++++-- tests/test_free_functions.py | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 9492085e5..a0f51edf4 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6972,7 +6972,7 @@ def hollow( faces: Optional[Shape], t: float, tol: float = 1e-3, - kind: Literal["arc", "intersection"] = "arc", + kind: Literal["arc", "intersection"] = "intersection", ): """ Make a hollow solid by removing faces and applying thickness t. @@ -7014,7 +7014,10 @@ def hollow( @multidispatch def hollow( - s: Shape, t: float, tol: float = 1e-3, kind: Literal["arc", "intersection"] = "arc", + s: Shape, + t: float, + tol: float = 1e-3, + kind: Literal["arc", "intersection"] = "intersection", ) -> Solid: return hollow(s, None, t, tol, kind) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index fb7aa3c47..5b3e9690e 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -747,7 +747,7 @@ def test_hollow(box_shape): assert res1.faces().size() == 2 * box_shape.faces().size() assert res2.isValid() - assert res2.faces().size() == 20 + 2 * box_shape.faces().size() + assert res2.faces().size() == 2 * box_shape.faces().size() def test_hollow_open(box_shape): @@ -759,10 +759,10 @@ def test_hollow_open(box_shape): res2 = hollow(box_shape, box_shape.faces(">Z"), 0.1) assert res1.isValid() - assert res1.faces().size() == 5 + 5 + 1 + assert res1.faces().size() == 6 + 5 assert res2.isValid() - assert res2.faces().size() == 12 + 5 + 5 + 1 + assert res2.faces().size() == 6 + 5 def test_prism(box_shape): From 03dc3d244a32670808d693baadb9bbaf255ee931 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 23 May 2026 15:22:18 +0200 Subject: [PATCH 5/6] Tweak 0 taper prism --- cadquery/occ_impl/shapes.py | 28 ++++++++++++++++++++-------- tests/test_free_functions.py | 12 ++++++------ 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index a0f51edf4..0f583981b 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -7039,14 +7039,26 @@ def prism( s_tmp = ctx.wrapped for f in _get_faces(faces): - bldr = BRepFeat_MakeDPrism( - s_tmp, - f.wrapped, - base.face().wrapped if base else TopoDS_Face(), - radians(angle), - additive, - False, - ) + # 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): diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 5b3e9690e..48b584f29 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -811,28 +811,28 @@ def test_prism_taper(box_shape): assert res1.isValid() assert res1.Volume() > box_shape.Volume() - assert res1.faces().size() == 6 + 4 + 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 + assert res2.faces().size() == 6 + 4 # NB: side face is split into 3 assert res2.wire(">Z").Length() < c.Length() - # subtractive prism with a taper + # subtractive prism res3 = prism(box_shape / c, ftop, c, box_shape.face(" Date: Sat, 23 May 2026 15:30:18 +0200 Subject: [PATCH 6/6] Tweak fig display --- cadquery/fig.py | 6 ++++++ 1 file changed, 6 insertions(+) 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