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 01/22] 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 02/22] 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 03/22] 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 04/22] 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 05/22] 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 06/22] 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 From a3977364fc9ded809b1c01abe44ea11d2481bb87 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sat, 23 May 2026 19:06:06 +0200 Subject: [PATCH 07/22] Mypy fix --- cadquery/occ_impl/shapes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 0f583981b..20d4f9e91 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -7039,6 +7039,7 @@ def prism( 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( From 5ddea218ae864ef38afe438c976341c949d8875c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 24 May 2026 10:42:37 +0200 Subject: [PATCH 08/22] Add draft --- cadquery/func.py | 2 ++ cadquery/occ_impl/shapes.py | 25 ++++++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/cadquery/func.py b/cadquery/func.py index 384bdc90a..df9a975b2 100644 --- a/cadquery/func.py +++ b/cadquery/func.py @@ -59,6 +59,7 @@ offset2D, fillet2D, chamfer2D, + draft, ) __all__ = [ @@ -124,4 +125,5 @@ "offset2D", "chamfer2D", "fillet2D", + "draft", ] diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 20d4f9e91..a4d4b4539 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, @@ -7099,7 +7100,7 @@ def prism( False, ) - # dispatch on thickess type + # dispatch on thickens type if isinstance(t, Shape): bldr.Perform(t.face().wrapped) elif t is None: @@ -7112,6 +7113,28 @@ def prism( return _compound_or_shape(s_tmp) +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()) + + # %% diagnostics From 6dc603b509ea73134a23da761b50cf4f11c7daf4 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 24 May 2026 13:50:02 +0200 Subject: [PATCH 09/22] Implemen a test and improve draft --- cadquery/occ_impl/shapes.py | 26 ++++++++++++++++++++++++++ tests/test_free_functions.py | 21 +++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index a4d4b4539..7e5a9d800 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -7113,6 +7113,7 @@ def prism( 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. @@ -7135,6 +7136,31 @@ def draft(ctx: Shape, base: Shape, faces: Shape, angle: Real,) -> Shape: 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 diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 48b584f29..9a06c0eb8 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -51,6 +51,7 @@ hollow, chamfer2D, fillet2D, + draft, ) from cadquery.occ_impl.shapes import ( @@ -835,6 +836,26 @@ def test_prism_taper(box_shape): assert res4.wires("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) + + def test_clean(): b1 = box(1, 1, 1) From f4963c021e40c41a1ac4d5196786775128a3308c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 24 May 2026 14:25:54 +0200 Subject: [PATCH 10/22] Implement from/to prism --- cadquery/occ_impl/shapes.py | 10 +++++++--- tests/test_free_functions.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 7e5a9d800..3423950bd 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -7029,7 +7029,7 @@ def prism( ctx: Shape, base: Optional[Shape], faces: Shape, - t: Optional[Real | Shape], + t: Optional[Real | Shape | tuple[Shape, Shape]], angle: Real = 0.0, additive: bool = True, ) -> Shape: @@ -7062,9 +7062,11 @@ def prism( False, ) - # dispatch on thickess type + # 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: @@ -7080,7 +7082,7 @@ def prism( ctx: Shape, base: Optional[Shape], faces: Shape, - t: Optional[Real | Shape], + t: Optional[Real | Shape | tuple[Shape, Shape]], dir: VectorLike, additive: bool = True, ) -> Shape: @@ -7103,6 +7105,8 @@ def prism( # 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: diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 9a06c0eb8..a4c3c1f13 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -801,6 +801,25 @@ def test_prism(box_shape): assert len(res4.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 + def test_prism_taper(box_shape): @@ -835,6 +854,19 @@ def test_prism_taper(box_shape): assert res4.faces().size() == 6 + 1 assert res4.wires("Z")), + 5, + False, + ) + + assert res5.isValid() + assert res5.faces().size() == 6 + 2 * 3 + def test_draft(box_shape): From 07c1dcaf442cfbb755c78590f3a3b461fc495e64 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Sun, 24 May 2026 15:19:00 +0200 Subject: [PATCH 11/22] Add set ops for combining selectors --- cadquery/occ_impl/shapes.py | 30 ++++++++++++++++++++++++++++++ tests/test_shapes.py | 7 +++++++ 2 files changed, 37 insertions(+) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 3423950bd..35aa2d3bd 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -1960,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 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(" Date: Sun, 24 May 2026 18:37:25 +0200 Subject: [PATCH 12/22] Tweak coverage --- tests/test_free_functions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index a4c3c1f13..0388766b3 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -820,6 +820,18 @@ def test_prism(box_shape): 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): @@ -887,6 +899,9 @@ def test_draft(box_shape): 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(): From 56256ab5ebd1a5ae83b0226ff155ad2d4407115d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Jun 2026 08:41:11 +0200 Subject: [PATCH 13/22] Add history to mmany free functions --- cadquery/occ_impl/shapes.py | 748 +++++++++++++++++++++++++++++++---- tests/test_free_functions.py | 32 +- 2 files changed, 686 insertions(+), 94 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 35aa2d3bd..a62033ef0 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -1,6 +1,7 @@ from typing import ( Optional, Tuple, + TypeAlias, Union, Iterable, List, @@ -89,6 +90,7 @@ ) from OCP.BRepBuilderAPI import ( + BRepBuilderAPI_MakeShape, BRepBuilderAPI_MakeVertex, BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, @@ -211,6 +213,7 @@ from OCP.BRepTools import ( BRepTools, + BRepTools_History, BRepTools_WireExplorer, BRepTools_ReShape, ) @@ -5525,6 +5528,316 @@ def _adaptor_curve_to_edge(crv: Adaptor3d_Curve, p1: float, p2: float) -> TopoDS return bldr.Edge() +# %% history related helpers + + +class Op: + """ + Operation history element. + """ + + _name: str | None + _tracked: set[Shape] + _deleted: list[Shape] + _modified: dict[Shape, Shape] + _generated: dict[Shape, Shape] + _images: dict[Shape, Shape] + _first: dict[Shape, Shape] + _last: dict[Shape, Shape] + _first_shape: Shape + _last_shape: Shape + + def __init__(self, name: str | None = None): + + self._name = name if name else None + + self._tracked = set() + self._deleted = [] + self._modified = {} + self._generated = {} + self._images = {} + self._first = {} + self._last = {} + self._first_shape = compound() + self._last_shape = compound() + + def _get(self, d: dict[Shape, Shape], k: Shape): + + if k.ShapeType() == "Compound": + tmp: list[Shape] = [] + + for el in k: + val = d[el] + if val.ShapeType() == "Compound": + tmp.extend(val) + else: + tmp.append(val) + + return _normalize(compound(tmp)) + else: + return _normalize(d[k]) + + def modified(self, k: Shape): + + return self._get(self._modified, k) + + def generated(self, k: Shape): + + return self._get(self._generated, k) + + def images(self, k: Shape): + + return self._get(self._images, k) + + def first(self, k: Shape | None = None): + + if k: + return self._get(self._first, k) + + return _normalize(self._first_shape) + + def last(self, k: Shape | None = None): + + if k: + return self._get(self._last, k) + + return _normalize(self._last_shape) + + +def _combine_hist_dict(d1: dict[Shape, Shape], *ds: dict[Shape, Shape]): + """ + Helper for combining of history dicts. + If a key occurs twice, both values are added to a compound. + """ + + for d in ds: + common_keys = d1.keys() & d.keys() + new_keys = d.keys() - d1.keys() + + for k in common_keys: + d1[k] |= d[k] + + for k in new_keys: + d1[k] = d[k] + + +def _combine_ops(op: Op, *ops: Op) -> Op: + """ + Combine multiple history steps into one. Modifies first step in-place. + """ + + for el in ops: + + op._tracked.update(el._tracked) + op._deleted.extend(el._deleted) + _combine_hist_dict(op._modified, el._modified) + _combine_hist_dict(op._generated, el._generated) + _combine_hist_dict(op._images, el._images) + _combine_hist_dict(op._first, el._first) + _combine_hist_dict(op._last, el._last) + op._first_shape |= el._first_shape + op._last_shape |= el._last_shape + + return op + + +class History: + """ + Operation history. + """ + + ops: list[Op] + opDict: dict[str, Op] + _tracked = list[Shape] + + def __init__(self): + + self.ops = [] + self.opDict = dict() + + def __getitem__(self, ix: int | str): + + if isinstance(ix, str): + return self.opDict[ix] + else: + return self.ops[ix] + + def pop(self, n: int) -> list[Op]: + + rv = [] + + for _ in range(n): + rv.append(self.ops.pop()) + + return rv + + def append(self, op: Op, name: str | None = None): + + self.ops.append(op) + if name: + self.opDict[name] = op + + +BuilderType: TypeAlias = BOPAlgo_Builder | BRepBuilderAPI_MakeShape | BRepPrimAPI_MakePrism | BRepPrimAPI_MakeRevol | BRepTools_History + + +def _update_history( + history: History | None, + name: str | None, + shapes: Sequence[Shape], + *builders: BuilderType, +): + """ + Update history based on specified shapes and builders. + """ + + if history: + # construct the history step + op = Op() + + if name: + history.opDict[name] = op + + # track all subshapes + for shape in shapes: + op._tracked.update(shape.Faces()) + op._tracked.update(shape.Edges()) + op._tracked.update(shape.Vertices()) + + history.ops.append(op) + + # iterate over all builders and collect history information + builder: Any + for builder in builders: + has_first_last = isinstance( + builder, (BRepPrimAPI_MakeRevol, BRepPrimAPI_MakePrism,) + ) + has_first_last_shape = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + ), + ) + has_generated = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + BRepTools_History, + BRepBuilderAPI_MakeShape, + ), + ) + has_modifidied = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + BRepTools_History, + BRepBuilderAPI_MakeShape, + ), + ) + has_deleted = isinstance( + builder, + ( + BRepPrimAPI_MakeRevol, + BRepPrimAPI_MakePrism, + BRepOffsetAPI_MakePipeShell, + ), + ) + + for el in op._tracked: + wrapped = el.wrapped + + if has_deleted: + if builder.IsDeleted(wrapped): + op._deleted.append(el) + + if has_generated: + gen = _compound_or_shape(list(builder.Generated(wrapped))) + if gen: + if el in op._generated: + op._generated[el] |= gen + else: + op._generated[el] = gen + + if has_modifidied: + mod = _compound_or_shape(list(builder.Modified(wrapped))) + if mod: + if el in op._modified: + op._modified[el] |= mod + else: + op._modified[el] = mod + + if has_first_last: + op._first[el] = _compound_or_shape(builder.FirstShape(el.wrapped)) + op._last[el] = _compound_or_shape(builder.LastShape(el.wrapped)) + + if has_first_last_shape: + op._first_shape |= _compound_or_shape(builder.FirstShape()) + op._last_shape |= _compound_or_shape(builder.LastShape()) + + +def _replace_history_modified_values( + history: History | None, aux: History, +): + """ + Remap generated and modified in history using aux. Used when solid/shell is called inside a function. + """ + + if history: + last_op = history[-1] + last_aux = aux[-1] + + # handle generated + for k, v in last_op._generated.items(): + last_op._generated[k] = last_aux._modified.get(v, v) + + # handle modified + for k, v in last_op._modified.items(): + last_op._modified[k] = last_aux._modified.get(v, v) + + # handle last shape + last_op._last_shape = compound( + [last_aux._modified.get(el, el) for el in last_op._last_shape] + ) + + # handle first shape + last_op._first_shape = compound( + [last_aux._modified.get(el, el) for el in last_op._first_shape] + ) + + +def _update_images(history: History | None, *builders: BuilderType): + + if history is not None: + op = history.ops[-1] + + builder: Any + for builder in builders: + images = builder.Images() + + # store all subshape relations, assume subshapes not present in Images are mapped onto themselves + for s in op._tracked: + try: + op._images[s] = _compound_or_shape(list(images.Find(s.wrapped))) + except Standard_NoSuchObject: + op._images[s] = s + + +def _update_removed(history: History | None, shapes: Sequence[Shape]): + """ + Add shapes to the removed field of the last operation. + """ + + if history: + last_op = history[-1] + last_op.removed.extend(shapes) + + # %% alternative constructors ShapeHistory = Dict[Union[Shape, str], Shape] @@ -5697,7 +6010,7 @@ def faceOn(base: Shape, *fcs: Shape, tol=1e-6, N=20) -> Face | Compound: def _process_sewing_history( - builder: BRepBuilderAPI_Sewing, faces: List[Face], history: Optional[ShapeHistory], + history: History | None, faces: List[Face], builder: BRepBuilderAPI_Sewing, ): """ Reusable helper for processing sewing history. @@ -5705,14 +6018,10 @@ def _process_sewing_history( # fill history if provided if history is not None: - # collect shapes present in the history dict - for k, v in history.items(): - if isinstance(k, str): - history[k] = Face(builder.Modified(v.wrapped)) - # store all top-level shape relations + op = history[-1] for f in faces: - history[f] = Face(builder.Modified(f.wrapped)) + op._images[f] = Face(builder.Modified(f.wrapped)) @multidispatch @@ -5721,7 +6030,8 @@ def shell( tol: float = 1e-6, manifold: bool = True, ctx: Optional[Sequence[Shape] | Shape] = None, - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Build shell from faces. If ctx is specified, local sewing is performed. @@ -5743,7 +6053,17 @@ def shell( builder.Perform() sewed = builder.SewedShape() - _process_sewing_history(builder, faces, history) + + # if specified, use context for history mapping + if ctx: + if isinstance(ctx, Shape): + faces.extend(ctx.Faces()) + else: + for el in ctx: + faces.extend(el.Faces()) + + _update_history(history, name, faces, builder.GetContext().History()) + _process_sewing_history(history, faces, builder) rv = [] @@ -5771,7 +6091,8 @@ def shell( tol: float = 1e-6, manifold: bool = True, ctx: Optional[Sequence[Shape] | Shape] = None, - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Build shell from a sequence of faces. If ctx is specified, local sewing is performed. @@ -5782,7 +6103,11 @@ def shell( @multidispatch def solid( - s1: Shape, *sn: Shape, tol: float = 1e-6, history: Optional[ShapeHistory] = None, + s1: Shape, + *sn: Shape, + tol: float = 1e-6, + history: History | None = None, + name: str | None = None, ) -> Compound | Solid: """ Build solid from faces or shells. @@ -5798,7 +6123,11 @@ def solid( shells = [el.wrapped for el in shells_faces if isinstance(el, Shell)] if not shells: faces = [el for el in shells_faces if isinstance(el, Face)] - shells = [tcast(TopoDS_Shell, shell(*faces, tol=tol, history=history).wrapped)] + shells = [ + tcast( + TopoDS_Shell, shell(*faces, tol=tol, history=history, name=name).wrapped + ) + ] rvs = [builder.SolidFromShell(sh) for sh in shells] @@ -5810,14 +6139,19 @@ def solid( s: Sequence[Shape], inner: Optional[Sequence[Shape]] = None, tol: float = 1e-6, - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Solid: """ Build solid from a sequence of faces. """ builder = BRepBuilderAPI_MakeSolid() - builder.Add(_get_one(shell(*s, tol=tol, history=history), "Shell").wrapped) + builder.Add( + _get_one(shell(*s, tol=tol, history=history, name=name), "Shell").wrapped + ) + + n_inner = 0 if inner: for sh in _get(shell(*inner, tol=tol, history=history), "Shell"): @@ -5830,10 +6164,10 @@ def solid( sf.SetContext(ctx) sf.Perform() - # update history if applicable - if history is not None: - for k, v in history.items(): - history[k] = Shape.cast(ctx.Apply(v.wrapped)) + # combine histories of all shell operations if needed + if history and inner: + inner_op = history.ops.pop() + _combine_ops(history.ops[-1], inner_op) return _shape(sf.Solid(), Solid) @@ -6299,7 +6633,13 @@ def setThreads(n: int): def fuse( - s1: Shape, s2: Shape, *shapes: Shape, tol: float = 0.0, glue: GlueLiteral = None, + s1: Shape, + s2: Shape, + *shapes: Shape, + tol: float = 0.0, + glue: GlueLiteral = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Fuse at least two shapes. @@ -6319,10 +6659,19 @@ def fuse( builder.Perform() + _update_history(history, name, [s1, s2, *shapes], builder) + return _compound_or_shape(builder.Shape()) -def cut(s1: Shape, s2: Shape, tol: float = 0.0, glue: GlueLiteral = None) -> Shape: +def cut( + s1: Shape, + s2: Shape, + tol: float = 0.0, + glue: GlueLiteral = None, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Subtract two shapes. """ @@ -6338,11 +6687,18 @@ def cut(s1: Shape, s2: Shape, tol: float = 0.0, glue: GlueLiteral = None) -> Sha builder.Perform() + _update_history(history, name, [s1, s2], builder) + return _compound_or_shape(builder.Shape()) def intersect( - s1: Shape, s2: Shape, tol: float = 0.0, glue: GlueLiteral = None + s1: Shape, + s2: Shape, + tol: float = 0.0, + glue: GlueLiteral = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Intersect two shapes. @@ -6359,10 +6715,18 @@ def intersect( builder.Perform() + _update_history(history, name, [s1, s2], builder) + return _compound_or_shape(builder.Shape()) -def split(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: +def split( + s1: Shape, + s2: Shape, + tol: float = 0.0, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Split one shape with another. """ @@ -6370,6 +6734,8 @@ def split(s1: Shape, s2: Shape, tol: float = 0.0) -> Shape: builder = BRepAlgoAPI_Splitter() _bool_op(s1, s2, builder, tol) + _update_history(history, name, [s1, s2], builder) + return _compound_or_shape(builder.Shape()) @@ -6377,7 +6743,8 @@ def imprint( *shapes: Shape, tol: float = 0.0, glue: GlueLiteral = "full", - history: Optional[ShapeHistory] = None, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Imprint arbitrary number of shapes. @@ -6394,23 +6761,8 @@ def imprint( builder.Perform() # fill history if provided - if history is not None: - images = builder.Images() - - # collect shapes present in the history dict - for k, v in history.items(): - if isinstance(k, str): - try: - history[k] = _compound_or_shape(list(images.Find(v.wrapped))) - except Standard_NoSuchObject: - pass - - # store all top-level shape relations - for s in shapes: - try: - history[s] = _compound_or_shape(list(images.Find(s.wrapped))) - except Standard_NoSuchObject: - pass + _update_history(history, name, [*shapes], builder) + _update_images(history, builder) return _compound_or_shape(builder.Shape()) @@ -6475,7 +6827,13 @@ def cap( return _compound_or_shape(builder.Shape()) -def fillet(s: Shape, e: Shape, r: float) -> Shape: +def fillet( + s: Shape, + e: Shape, + r: float, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Fillet selected edges in a given shell or solid. """ @@ -6487,10 +6845,18 @@ def fillet(s: Shape, e: Shape, r: float) -> Shape: builder.Build() + _update_history(history, name, [e], builder) + return _compound_or_shape(builder.Shape()) -def chamfer(s: Shape, e: Shape, d: float) -> Shape: +def chamfer( + s: Shape, + e: Shape, + d: float, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Chamfer selected edges in a given shell or solid. """ @@ -6502,15 +6868,24 @@ def chamfer(s: Shape, e: Shape, d: float) -> Shape: builder.Build() + _update_history(history, name, [e], builder) + return _compound_or_shape(builder.Shape()) -def extrude(s: Shape, d: VectorLike, both: bool = False) -> Shape: +def extrude( + s: Shape, + d: VectorLike, + both: bool = False, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Extrude a shape. """ results = [] + builders = [] for el in _get(s, ("Vertex", "Edge", "Wire", "Face")): @@ -6524,16 +6899,28 @@ def extrude(s: Shape, d: VectorLike, both: bool = False) -> Shape: builder.Build() results.append(builder.Shape()) + builders.append(builder) + + _update_history(history, name, [s], *builders) return _compound_or_shape(results) -def revolve(s: Shape, p: VectorLike, d: VectorLike, a: float = 360): +def revolve( + s: Shape, + p: VectorLike, + d: VectorLike, + a: float = 360, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Revolve a shape. """ results = [] + builders = [] + ax = gp_Ax1(Vector(p).toPnt(), Vector(d).toDir()) for el in _get(s, ("Vertex", "Edge", "Wire", "Face")): @@ -6542,12 +6929,21 @@ def revolve(s: Shape, p: VectorLike, d: VectorLike, a: float = 360): builder.Build() results.append(builder.Shape()) + builders.append(builder) + + _update_history(history, name, [s], *builders) return _compound_or_shape(results) def offset( - s: Shape, t: float, cap=True, both: bool = False, tol: float = 1e-6 + s: Shape, + t: float, + cap=True, + both: bool = False, + tol: float = 1e-6, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Offset or thicken faces or shells. @@ -6556,10 +6952,12 @@ def offset( def _offset(t): results = [] + builders = [] for el in _get(s, ("Face", "Shell")): builder = BRepOffset_MakeOffset() + builders.append(builder) builder.Initialize( el.wrapped, @@ -6576,23 +6974,28 @@ def _offset(t): results.append(builder.Shape()) - return results + return results, builders if both: - results_pos = _offset(t) - results_neg = _offset(-t) + results_pos, builders1 = _offset(t) + results_neg, builders2 = _offset(-t) results_both = [ Shape(el1) + Shape(el2) for el1, el2 in zip(results_pos, results_neg) ] + _update_history(history, name, [s], *builders1, *builders2) + _update_removed(history, s.Faces()) + if len(results_both) == 1: rv = results_both[0] else: rv = Compound.makeCompound(results_both) else: - results = _offset(t) + results, builders = _offset(t) + _update_history(history, name, [s], *builders) + rv = _compound_or_shape(results) return rv @@ -6690,13 +7093,15 @@ def fillet2D(s: Shape, verts: Shape, r: float): } -@multimethod +@multidispatch def sweep( s: Shape, path: Shape, aux: Optional[Shape] = None, cap: bool = False, transition: Literal["transformed", "round", "right"] = "transformed", + history: History | None = None, + name: str | None = None, ) -> Shape: """ Sweep edge, wire or face along a path. For faces cap has no effect. @@ -6706,6 +7111,7 @@ def sweep( spine = _get_one_wire(path) results = [] + builders = [] def _make_builder(): @@ -6724,20 +7130,75 @@ def _make_builder(): # if faces were supplied if faces: + # for history handling + tops_hist = [] + bots_hist = [] + solid_hist = History() + for f in faces: - tmp = sweep(f.outerWire(), path, aux, True) + builder = _make_builder() + builders.append(builder) + + builder.Add(f.outerWire().wrapped, False, False) + builder.Build() + builder.MakeSolid() + + # for bookkeeping of inner sweeps and cap construction + builders_inner = [] + tops = [] + bots = [] + sides = [] + + # extract the outer side and initial cap + bot = Shape(builder.FirstShape()) + top = Shape(builder.LastShape()) + side = compound() + for el in f.outerWire(): + side |= _compound_or_shape(list(builder.Generated(el.wrapped))) + + for w in f.innerWires(): + builder_inner = _make_builder() + builders_inner.append(builder_inner) + + builder_inner.Add(w.wrapped, False, False) + builder_inner.Build() + builder_inner.MakeSolid() + + bots.append(Shape(builder_inner.FirstShape())) + tops.append(Shape(builder_inner.LastShape())) - # if needed subtract two sweeps - inner_wires = f.innerWires() - if inner_wires: - tmp -= sweep(compound(inner_wires), path, aux, True) + side_inner = compound() + for el in w: + side_inner |= _compound_or_shape( + list(builder_inner.Generated(el.wrapped)) + ) + + sides.append(side_inner) + + top -= compound(tops) + bot -= compound(bots) + + results.append(solid(side, *sides, top, bot, history=solid_hist).wrapped) + tops_hist.append(top) + bots_hist.append(bot) + + builders.extend(builders_inner) + + rv = _compound_or_shape(results) + + _update_history(history, name, faces + [spine], *builders) - results.append(tmp.wrapped) + if history: + history[-1]._last_shape = compound(tops_hist) + history[-1]._first_shape = compound(bots_hist) + # remapping is needed because of the additional solid call + _replace_history_modified_values(history, solid_hist) # otherwise sweep wires else: for w in _get_wires(s): builder = _make_builder() + builders.append(builder) builder.Add(w.wrapped, False, False) builder.Build() @@ -6747,16 +7208,22 @@ def _make_builder(): results.append(builder.Shape()) - return _compound_or_shape(results) + rv = _compound_or_shape(results) + _update_history(history, name, [s, path], *builders) -@multimethod + return rv + + +@multidispatch def sweep( s: Sequence[Shape], path: Shape, aux: Optional[Shape] = None, cap: bool = False, transition: Literal["transformed", "round", "right"] = "transformed", + history: History | None = None, + name: str | None = None, ) -> Shape: """ Sweep edges, wires or faces along a path, multiple sections are supported. @@ -6766,6 +7233,11 @@ def sweep( spine = _get_one_wire(path) results = [] + builders = [] + # for history handling + tops_hist = [] + bots_hist = [] + solid_hist = History() def _make_builder(): @@ -6782,8 +7254,10 @@ def _make_builder(): # try to construct sweeps using faces for el in _get_face_lists_strict(s): + # build outer part builder = _make_builder() + builders.append(builder) for f in el: builder.Add(f.outerWire().wrapped, False, False) @@ -6793,11 +7267,20 @@ def _make_builder(): # build inner parts builders_inner = [] + tops = [] + bots = [] + sides = [] + + # extract the outer side and initial cap + bot = Shape(builder.FirstShape()) + top = Shape(builder.LastShape()) + side = Shape(builder.Shape()).faces() % bot % top # initialize builders for w in el[0].innerWires(): builder_inner = _make_builder() builder_inner.Add(w.wrapped, False, False) + builders_inner.append(builder_inner) # add remaining sections @@ -6806,20 +7289,42 @@ def _make_builder(): builder_inner.Add(w.wrapped, False, False) # actually build - inner_parts = [] - for builder_inner in builders_inner: builder_inner.Build() builder_inner.MakeSolid() - inner_parts.append(Shape(builder_inner.Shape())) - results.append((Shape(builder.Shape()) - compound(inner_parts)).wrapped) + bots.append(Shape(builder_inner.FirstShape())) + tops.append(Shape(builder_inner.LastShape())) + + side_inner = Shape(builder_inner.Shape()).faces() % bots[-1] % tops[-1] + sides.append(side_inner) + + # assemble final result using sewing + top -= compound(tops) + bot -= compound(bots) + + results.append(solid(side, *sides, top, bot, history=solid_hist).wrapped) + tops_hist.append(top) + bots_hist.append(bot) + + builders.extend(builders_inner) + + # update history if there is a result + if results: + _update_history(history, name, [*s, path], *builders) + + if history: + history[-1]._last_shape = compound(tops_hist) + history[-1]._first_shape = compound(bots_hist) + # remapping is needed because of the additional solid call + _replace_history_modified_values(history, solid_hist) # if no faces were provided try with wires - if not results: + else: # construct sweeps for el2 in _get_wire_lists_strict(s): builder = _make_builder() + builders.append(builder) for w in el2: builder.Add(w.wrapped, False, False) @@ -6831,10 +7336,12 @@ def _make_builder(): results.append(builder.Shape()) + _update_history(history, name, [*s, path], *builders) + return _compound_or_shape(results) -@multimethod +@multidispatch def loft( s: Sequence[Shape], cap: bool = False, @@ -6845,12 +7352,19 @@ def loft( compat: bool = True, smoothing: bool = False, weights: Tuple[float, float, float] = (1, 1, 1), + history: History | None = None, + name: str | None = None, ) -> Shape: """ Loft edges, wires or faces. For faces cap has no effect. Do not mix faces with other types. """ results = [] + builders = [] + # for history handling + tops_hist = [] + bots_hist = [] + solid_hist = History() def _make_builder(cap): rv = BRepOffsetAPI_ThruSections(cap, ruled) @@ -6867,6 +7381,7 @@ def _make_builder(cap): for el in _get_face_lists(s): # build outer part builder = _make_builder(True) + builders.append(builder) # used to check if building inner parts makes sense has_vertex = False @@ -6881,7 +7396,16 @@ def _make_builder(cap): builder.Build() builder.Check() + # build inner parts builders_inner = [] + tops = [] + bots = [] + sides = [] + + # extract the outer side and initial cap + bot = Shape(builder.FirstShape()) if builder.FirstShape() else compound() + top = Shape(builder.LastShape()) if builder.LastShape() else compound() + side = Shape(builder.Shape()).faces() % bot % top # only initialize inner builders if no vertex was encountered if not has_vertex: @@ -6900,19 +7424,38 @@ def _make_builder(cap): builder_inner.AddWire(w.wrapped) # actually build - inner_parts = [] - for builder_inner in builders_inner: builder_inner.Build() builder_inner.Check() - inner_parts.append(Shape(builder_inner.Shape())) - results.append((Shape(builder.Shape()) - compound(inner_parts)).wrapped) + bots.append(Shape(builder_inner.FirstShape())) + tops.append(Shape(builder_inner.LastShape())) + + side_inner = Shape(builder_inner.Shape()).faces() % bots[-1] % tops[-1] + sides.append(side_inner) + + # assemble final result using sewing + top -= compound(tops) + bot -= compound(bots) + + results.append(solid(side, *sides, top, bot, history=solid_hist).wrapped) + tops_hist.append(top) + bots_hist.append(bot) + + if results: + _update_history(history, name, s, *builders, *builders_inner) + + if history: + history[-1]._last_shape = compound(tops_hist) + history[-1]._first_shape = compound(bots_hist) + # remapping is needed because of the additional solid call + _replace_history_modified_values(history, solid_hist) # otherwise construct using wires - if not results: + else: for el2 in _get_wire_lists(s): builder = _make_builder(cap) + builders.append(builder) for w2 in el2: if isinstance(w2, Wire): @@ -6925,11 +7468,15 @@ def _make_builder(cap): results.append(builder.Shape()) + _update_history(history, name, list(s), *builders) + return _compound_or_shape(results) -@multimethod +@multidispatch def loft( + s1: Shape, + s2: Shape, *s: Shape, cap: bool = False, ruled: bool = False, @@ -6939,12 +7486,26 @@ def loft( compat: bool = True, smoothing: bool = False, weights: Tuple[float, float, float] = (1, 1, 1), + history: History | None = None, + name: str | None = None, ) -> Shape: """ Variadic loft overload. """ - return loft(s, cap, ruled, continuity, parametrization, degree, compat) + return loft( + [s1, s2, *s], + cap, + ruled, + continuity, + parametrization, + degree, + compat, + smoothing, + weights, + history, + name, + ) @multidispatch @@ -7004,6 +7565,8 @@ def hollow( t: float, tol: float = 1e-3, kind: Literal["arc", "intersection"] = "intersection", + history: History | None = None, + name: str | None = None, ): """ Make a hollow solid by removing faces and applying thickness t. @@ -7040,6 +7603,8 @@ def hollow( # fix needed for the orientations rv = _compound_or_shape(sol.Shape()).fix() + _update_history(history, name, [s], bldr) + return rv @@ -7049,9 +7614,11 @@ def hollow( t: float, tol: float = 1e-3, kind: Literal["arc", "intersection"] = "intersection", + history: History | None = None, + name: str | None = None, ) -> Solid: - return hollow(s, None, t, tol, kind) + return hollow(s, None, t, tol, kind, history, name) @multidispatch @@ -7062,11 +7629,15 @@ def prism( t: Optional[Real | Shape | tuple[Shape, Shape]], angle: Real = 0.0, additive: bool = True, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Build a drafted prismatic feature that can be additive or subtractive. """ + builders = [] + s_tmp = ctx.wrapped for f in _get_faces(faces): @@ -7092,6 +7663,8 @@ def prism( False, ) + builders.append(bldr) + # dispatch on thickens type if isinstance(t, Shape): bldr.Perform(t.face().wrapped) @@ -7104,6 +7677,8 @@ def prism( s_tmp = bldr.Shape() + _update_history(history, name, [ctx, faces], *builders) + return _compound_or_shape(s_tmp) @@ -7115,11 +7690,15 @@ def prism( t: Optional[Real | Shape | tuple[Shape, Shape]], dir: VectorLike, additive: bool = True, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Build a (potentially tilted) prismatic feature that can be additive or subtractive. """ + builders = [] + s_tmp = ctx.wrapped for f in _get_faces(faces): @@ -7132,6 +7711,8 @@ def prism( False, ) + builders.append(bldr) + # dispatch on thickens type if isinstance(t, Shape): bldr.Perform(t.face().wrapped) @@ -7144,11 +7725,20 @@ def prism( s_tmp = bldr.Shape() + _update_history(history, name, [ctx, faces], *builders) + return _compound_or_shape(s_tmp) @multidispatch -def draft(ctx: Shape, base: Shape, faces: Shape, angle: Real,) -> Shape: +def draft( + ctx: Shape, + base: Shape, + faces: Shape, + angle: Real, + history: History | None = None, + name: str | None = None, +) -> Shape: """ Add a draft angle to the specified faces. """ @@ -7167,12 +7757,20 @@ def draft(ctx: Shape, base: Shape, faces: Shape, angle: Real,) -> Shape: bldr.Build() + _update_history(history, name, [ctx], bldr) + return _compound_or_shape(bldr.Shape()) @multidispatch def draft( - ctx: Shape, base: Shape, faces: Shape, dir: VectorLike, angle: Real, + ctx: Shape, + base: Shape, + faces: Shape, + dir: VectorLike, + angle: Real, + history: History | None = None, + name: str | None = None, ) -> Shape: """ Add a draft angle to the specified faces. @@ -7192,6 +7790,8 @@ def draft( bldr.Build() + _update_history(history, name, [ctx], bldr) + return _compound_or_shape(bldr.Shape()) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 0388766b3..9a76bcdd4 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -55,6 +55,7 @@ ) from cadquery.occ_impl.shapes import ( + History, _get_one_wire, _get_wires, _get, @@ -232,20 +233,20 @@ def test_sewing(): sh = b.remove(ftop) # regular local sewing - history1 = dict(ftop=ftop) + history1 = History() res1 = shell(sh.faces("not Date: Fri, 5 Jun 2026 11:16:22 +0200 Subject: [PATCH 14/22] Improve coverage --- cadquery/occ_impl/shapes.py | 33 +++++++++++++++-------------- tests/test_free_functions.py | 40 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index a62033ef0..146324569 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5577,26 +5577,30 @@ def _get(self, d: dict[Shape, Shape], k: Shape): else: return _normalize(d[k]) - def modified(self, k: Shape): + def modified(self, k: Shape) -> Shape: return self._get(self._modified, k) - def generated(self, k: Shape): + def generated(self, k: Shape) -> Shape: return self._get(self._generated, k) - def images(self, k: Shape): + def deleted(self) -> Shape: + + return _normalize(compound(self._deleted)) + + def images(self, k: Shape) -> Shape: return self._get(self._images, k) - def first(self, k: Shape | None = None): + def first(self, k: Shape | None = None) -> Shape: if k: return self._get(self._first, k) return _normalize(self._first_shape) - def last(self, k: Shape | None = None): + def last(self, k: Shape | None = None) -> Shape: if k: return self._get(self._last, k) @@ -5662,14 +5666,9 @@ def __getitem__(self, ix: int | str): else: return self.ops[ix] - def pop(self, n: int) -> list[Op]: - - rv = [] - - for _ in range(n): - rv.append(self.ops.pop()) + def pop(self) -> Op: - return rv + return self.ops.pop() def append(self, op: Op, name: str | None = None): @@ -5695,8 +5694,7 @@ def _update_history( # construct the history step op = Op() - if name: - history.opDict[name] = op + history.append(op, name) # track all subshapes for shape in shapes: @@ -5704,8 +5702,6 @@ def _update_history( op._tracked.update(shape.Edges()) op._tracked.update(shape.Vertices()) - history.ops.append(op) - # iterate over all builders and collect history information builder: Any for builder in builders: @@ -5728,6 +5724,7 @@ def _update_history( BRepOffsetAPI_MakePipeShell, BRepTools_History, BRepBuilderAPI_MakeShape, + BOPAlgo_Builder, ), ) has_modifidied = isinstance( @@ -5738,6 +5735,7 @@ def _update_history( BRepOffsetAPI_MakePipeShell, BRepTools_History, BRepBuilderAPI_MakeShape, + BOPAlgo_Builder, ), ) has_deleted = isinstance( @@ -5746,6 +5744,7 @@ def _update_history( BRepPrimAPI_MakeRevol, BRepPrimAPI_MakePrism, BRepOffsetAPI_MakePipeShell, + BOPAlgo_Builder, ), ) @@ -6166,7 +6165,7 @@ def solid( # combine histories of all shell operations if needed if history and inner: - inner_op = history.ops.pop() + inner_op = history.pop() _combine_ops(history.ops[-1], inner_op) return _shape(sf.Solid(), Solid) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 9a76bcdd4..6288c907c 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -52,6 +52,7 @@ chamfer2D, fillet2D, draft, + isSubshape, ) from cadquery.occ_impl.shapes import ( @@ -1227,3 +1228,42 @@ def test_closest(): p1, p2 = closest(s1, s2) assert (p1 - p2).Length == approx(4) + + +# %% history +def test_history_bool(): + + b1 = box(1, 1, 1) + b2 = box(1, 0.5, 0.1) + + hist = History() + res = cut(b1, b2, history=hist, name="cut") + + assert hist[0] == hist["cut"] + assert b2.face("X")).size() == 2 + + with pytest.raises(KeyError): + hist["cut"].generated(b1.face(">Z")) + + res2 = imprint(res, b2, history=hist, name="imprint") + + op = hist["imprint"] + + assert isSubshape(op.images(b1.face(">Z")), res2.solid(">Z")) + + +def test_history_extrude(): + + pass + + +def test_sweep(): + + pass + + +def test_loft(): + + pass From 39c994fba499344e58a962ca4aeba3c060928723 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:34:52 +0200 Subject: [PATCH 15/22] More tests for history --- tests/test_free_functions.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 6288c907c..28d8b71db 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1256,14 +1256,37 @@ def test_history_bool(): def test_history_extrude(): - pass + hist = History() + f = plane(1, 1) + res = extrude(f, (0, 0, 1), history=hist) + op = hist[-1] -def test_sweep(): + sides = op.generated(f.edges()) + top = op.last() + bot = op.first() + + assert isSubshape(top, res) + assert isSubshape(bot, res) + + assert top == res.face(">Z") + assert bot == res.face(" Date: Fri, 5 Jun 2026 12:15:14 +0200 Subject: [PATCH 16/22] Sweep history test --- tests/test_free_functions.py | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 28d8b71db..80b8baacf 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1284,7 +1284,28 @@ def test_history_extrude(): def test_history_sweep(): - pass + hist = History() + + f1 = plane(1, 1) - face(circle(0.1)) + f2 = (plane(2, 2) - face(circle(0.1))).moved(z=1) + p = segment((0, 0, 0), (0, 0, 1)) + + res = sweep([f1, f2], p, history=hist) + + op = hist[-1] + + top = op.last() + bot = op.first() + side = op.generated(f1.outerWire().edges()) + inner = op.generated(f1.innerWires()[0].edges()) + + assert isSubshape(top, res) + assert isSubshape(bot, res) + assert top == res.face(">Z") + assert bot == res.face(" Date: Fri, 5 Jun 2026 13:06:03 +0200 Subject: [PATCH 17/22] Test simple sweep --- tests/test_free_functions.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 80b8baacf..4f1c1857e 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1286,6 +1286,7 @@ def test_history_sweep(): hist = History() + # sweep with inner shapes f1 = plane(1, 1) - face(circle(0.1)) f2 = (plane(2, 2) - face(circle(0.1))).moved(z=1) p = segment((0, 0, 0), (0, 0, 1)) @@ -1307,6 +1308,24 @@ def test_history_sweep(): assert inner.faces().size() == 1 assert (top | bot | side | inner).size() == 7 + # simple sweep + f = plane(1, 1) + p = segment((0, 0, 0), (0, 0, 1)) + + res = sweep(f, p, history=hist) + + op = hist[-1] + + top = op.last() + bot = op.first() + side = op.generated(f.outerWire().edges()) + + assert isSubshape(top, res) + assert isSubshape(bot, res) + assert top == res.face(">Z") + assert bot == res.face(" Date: Fri, 5 Jun 2026 13:32:31 +0200 Subject: [PATCH 18/22] Test history with loft --- tests/test_free_functions.py | 41 +++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 4f1c1857e..1549ee31b 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1329,4 +1329,43 @@ def test_history_sweep(): def test_history_loft(): - pass + h = History() + + # loft to a vertex + f = plane(1, 1) + v = vertex(0, 0, 1) + + res = loft(f, v, history=h) + + op = h[-1] + + bot = op.first() + side = op.generated(f.edges()) + + assert isSubshape(bot, res) + assert bot == res.face("Z") + + for el in side: + assert isSubshape(el, res) + From 783e34f1db6dfad79d38722444638d0fcce2d3aa Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:43:27 +0200 Subject: [PATCH 19/22] Test offset with history --- tests/test_free_functions.py | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 1549ee31b..677b4e4d2 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -65,6 +65,7 @@ _adaptor_curve_to_edge, _shape_to_faces_shells, _get_faces, + _combine_hist_dict, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -1369,3 +1370,46 @@ def test_history_loft(): for el in side: assert isSubshape(el, res) + +def test_history_offset(): + + h = History() + f = plane(1,1) + + offset(f, 0.1, both=True, history=h) + + op = h[-1] + + fs_offset = op.generated(f) + sides = op.generated(f.edges()) + + assert fs_offset.faces().size() == 2 + assert sides.edges().size() == 2 * 4 + + offset(f, 0.1, both=False, history=h) + + op = h[-1] + + fs_offset = op.generated(f) + sides = op.generated(f.edges()) + + assert fs_offset.faces().size() == 1 + assert sides.edges().size() == 4 + + +def test_comibine_hist_dict(): + + f = plane(1,1) + v = vertex(0,0,0) + e = segment((0,0), (0,1)) + + d1 = {f:v} + d2 = {f:e} + + d = _combine_hist_dict(d1, d2) + + assert f in d + assert isinstance(d[f], Compound) + assert v in d[f] + assert e in d[f] + From 2785dab65583914609865c836a5be8747be8427b Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:47:16 +0200 Subject: [PATCH 20/22] Blacken tests --- tests/test_free_functions.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 677b4e4d2..236c79326 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1374,7 +1374,7 @@ def test_history_loft(): def test_history_offset(): h = History() - f = plane(1,1) + f = plane(1, 1) offset(f, 0.1, both=True, history=h) @@ -1394,17 +1394,17 @@ def test_history_offset(): sides = op.generated(f.edges()) assert fs_offset.faces().size() == 1 - assert sides.edges().size() == 4 + assert sides.edges().size() == 4 def test_comibine_hist_dict(): - f = plane(1,1) - v = vertex(0,0,0) - e = segment((0,0), (0,1)) + f = plane(1, 1) + v = vertex(0, 0, 0) + e = segment((0, 0), (0, 1)) - d1 = {f:v} - d2 = {f:e} + d1 = {f: v} + d2 = {f: e} d = _combine_hist_dict(d1, d2) @@ -1412,4 +1412,3 @@ def test_comibine_hist_dict(): assert isinstance(d[f], Compound) assert v in d[f] assert e in d[f] - From 3b3617b26e475e2856eac4cb91e819458eb1d7d6 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:03:36 +0200 Subject: [PATCH 21/22] Fix test failures --- cadquery/occ_impl/shapes.py | 49 +++++++++++++++++++++++++----------- tests/test_free_functions.py | 10 ++++---- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 146324569..7c89c7792 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5577,33 +5577,51 @@ def _get(self, d: dict[Shape, Shape], k: Shape): else: return _normalize(d[k]) - def modified(self, k: Shape) -> Shape: + def modified(self, s: Shape) -> Shape: + """ + Shapes modified from s. + """ - return self._get(self._modified, k) + return self._get(self._modified, s) - def generated(self, k: Shape) -> Shape: + def generated(self, s: Shape) -> Shape: + """ + Shapes generated from s. + """ - return self._get(self._generated, k) + return self._get(self._generated, s) def deleted(self) -> Shape: + """ + Deleted shapes. + """ return _normalize(compound(self._deleted)) - def images(self, k: Shape) -> Shape: + def images(self, s: Shape) -> Shape: + """ + Images of s. + """ - return self._get(self._images, k) + return self._get(self._images, s) - def first(self, k: Shape | None = None) -> Shape: + def first(self, s: Shape | None = None) -> Shape: + """ + First shape (e.g. bottom face) or first shape generated from s. + """ - if k: - return self._get(self._first, k) + if s: + return self._get(self._first, s) return _normalize(self._first_shape) - def last(self, k: Shape | None = None) -> Shape: + def last(self, s: Shape | None = None) -> Shape: + """ + Last shape (e.g. top face) or last shape generated from s. + """ - if k: - return self._get(self._last, k) + if s: + return self._get(self._last, s) return _normalize(self._last_shape) @@ -5659,7 +5677,7 @@ def __init__(self): self.ops = [] self.opDict = dict() - def __getitem__(self, ix: int | str): + def __getitem__(self, ix: int | str) -> Op: if isinstance(ix, str): return self.opDict[ix] @@ -5725,6 +5743,7 @@ def _update_history( BRepTools_History, BRepBuilderAPI_MakeShape, BOPAlgo_Builder, + BRepOffset_MakeOffset, ), ) has_modifidied = isinstance( @@ -5736,6 +5755,7 @@ def _update_history( BRepTools_History, BRepBuilderAPI_MakeShape, BOPAlgo_Builder, + BRepOffset_MakeOffset, ), ) has_deleted = isinstance( @@ -5745,6 +5765,7 @@ def _update_history( BRepPrimAPI_MakePrism, BRepOffsetAPI_MakePipeShell, BOPAlgo_Builder, + BRepOffset_MakeOffset, ), ) @@ -5834,7 +5855,7 @@ def _update_removed(history: History | None, shapes: Sequence[Shape]): if history: last_op = history[-1] - last_op.removed.extend(shapes) + last_op._deleted.extend(shapes) # %% alternative constructors diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 236c79326..0b8ab7c11 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1406,9 +1406,9 @@ def test_comibine_hist_dict(): d1 = {f: v} d2 = {f: e} - d = _combine_hist_dict(d1, d2) + _combine_hist_dict(d1, d2) - assert f in d - assert isinstance(d[f], Compound) - assert v in d[f] - assert e in d[f] + assert f in d1 + assert isinstance(d1[f], Compound) + assert v in d1[f] + assert e in d1[f] From b32345d9537d3938f8c295cf93fee3a616ac355d Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 5 Jun 2026 15:37:49 +0200 Subject: [PATCH 22/22] Rename helper function for clarity --- cadquery/occ_impl/shapes.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 7c89c7792..afd8ab1d9 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5801,7 +5801,7 @@ def _update_history( op._last_shape |= _compound_or_shape(builder.LastShape()) -def _replace_history_modified_values( +def _remap_history_values( history: History | None, aux: History, ): """ @@ -7212,7 +7212,7 @@ def _make_builder(): history[-1]._last_shape = compound(tops_hist) history[-1]._first_shape = compound(bots_hist) # remapping is needed because of the additional solid call - _replace_history_modified_values(history, solid_hist) + _remap_history_values(history, solid_hist) # otherwise sweep wires else: @@ -7337,7 +7337,7 @@ def _make_builder(): history[-1]._last_shape = compound(tops_hist) history[-1]._first_shape = compound(bots_hist) # remapping is needed because of the additional solid call - _replace_history_modified_values(history, solid_hist) + _remap_history_values(history, solid_hist) # if no faces were provided try with wires else: @@ -7469,7 +7469,7 @@ def _make_builder(cap): history[-1]._last_shape = compound(tops_hist) history[-1]._first_shape = compound(bots_hist) # remapping is needed because of the additional solid call - _replace_history_modified_values(history, solid_hist) + _remap_history_values(history, solid_hist) # otherwise construct using wires else: