From bc42a4eab358a088e4a20faa3cdecf69ed1b54ba Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Sep 2026 11:59:05 +0200 Subject: [PATCH 1/7] Rework solid history handling --- cadquery/occ_impl/shapes.py | 82 ++++++++++++++++++++++++++++++++----- 1 file changed, 72 insertions(+), 10 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 31b125e37..f678ac702 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5807,6 +5807,54 @@ def _combine_ops(op: Op, *ops: Op) -> Op: return op +def _apply_reshape(op: Op, ctx: ShapeBuild_ReShape) -> Op: + """ + Apply (if applicable) additional ReShape history to an exisitn Op. Used by solid. + """ + + hist = ctx.History() + + if hist.HasModified(): + + # update modified + for key, val in op._modified.items(): + + processed = [] + + for subshape in val: + modified = hist.Modified(subshape.wrapped) + + if modified: + processed.extend([Shape.cast(el) for el in modified]) + else: + processed.append(subshape) + + op._modified[key] = compound(processed) + + # update images + for key, val in op._images.items(): + + mod = hist.Modified(val.wrapped) + if mod: + op._images[key] = Shape.cast(mod.First()) + + return op + + +def _polish_images(op: Op, s: Shape) -> Op: + """ + Workaround for Reshape context not tracking (face, face) relations when fixing solids. + """ + + face_dict = {f: f for f in s.faces()} + + for k, v in op._images.items(): + if isinstance(k, Face): + op._images[k] = compound([face_dict[f] for f in v.Faces()]) + + return op + + class History: """ Operation history. @@ -6367,7 +6415,9 @@ def solid( Build solid from faces or shells. """ + ctx = ShapeBuild_ReShape() builder = ShapeFix_Solid() + builder.SetContext(ctx) # get both Shells and Faces s = [s1, *sn] @@ -6377,13 +6427,21 @@ 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, name=name).wrapped + rvs = [ + builder.SolidFromShell( + TopoDS.Shell(shell(*faces, tol=tol, history=history, name=name).wrapped) ) ] - rvs = [builder.SolidFromShell(sh) for sh in shells] + if history: + _apply_reshape(history.ops[-1], ctx) + + else: + rvs = [builder.SolidFromShell(sh) for sh in shells] + + if history: + _update_history(history, name, shells_faces, ctx.History()) + # FIXME what about images? return tcast(Compound | Solid, _compound_or_shape(rvs)) @@ -6403,8 +6461,6 @@ def solid( builder = BRepBuilderAPI_MakeSolid() 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): builder.Add(sh.wrapped) @@ -6416,12 +6472,18 @@ def solid( sf.SetContext(ctx) sf.Perform() + rv = _shape(sf.Solid(), Solid) + # combine histories of all shell operations if needed - if history and inner: - inner_op = history.pop() - _combine_ops(history.ops[-1], inner_op) + if history: + if inner: + inner_op = history.pop() + _combine_ops(history.ops[-1], inner_op) - return _shape(sf.Solid(), Solid) + _apply_reshape(history.ops[-1], ctx) + _polish_images(history.ops[-1], rv) + + return rv @multimethod From fe7a9e5bb4cf138b5bb7853e7a9821a669ec0155 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:43:17 +0200 Subject: [PATCH 2/7] Add and fix tests --- cadquery/occ_impl/shapes.py | 2 +- tests/test_free_functions.py | 64 +++++++++++++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 24d6cb482..405fc4e2d 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5833,7 +5833,7 @@ def _apply_reshape(op: Op, ctx: ShapeBuild_ReShape) -> Op: for key, val in op._images.items(): mod = hist.Modified(val.wrapped) - if mod: + if not mod.IsEmpty(): op._images[key] = Shape.cast(mod.First()) return op diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 6228b607c..8e59fde13 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1447,7 +1447,7 @@ def test_history_offset(): assert sides.edges().size() == 4 -def test_comibine_hist_dict(): +def test_combine_hist_dict(): f = plane(1, 1) v = vertex(0, 0, 0) @@ -1562,6 +1562,68 @@ def _check(op, edges): _check(op, edges) +@fixture +def nested_spheres() -> tuple[Solid, Solid]: + + return sphere(0.5), sphere(0.25) + + +@fixture +def tiny_edge_box_faces() -> list[Face]: + """ + Faces of a box with one face manipulated to contain a tiny edge. + """ + + faces = box(1, 1, 1).Faces() + + edges = faces[0].outerWire().Edges() + edge = edges[0] + + start, end = edge.startPoint(), edge.endPoint() + sliver_end = start + (end - start) * 1e-5 + + faces[0] = face( + wire(segment(start, sliver_end), segment(sliver_end, end), *edges[1:]) + ) + + return faces + + +def test_solid_history(nested_spheres, tiny_edge_box_faces): + + # helper to check if images are subshapes and have correct orientation + def check_faces_helper(faces, op, s): + + for f in faces: + assert isSubshape(op.images(f), s) + assert any(f_res.isEqual(op.images(f)) for f_res in s.faces()) + + h = History() + + sphere_outer, sphere_inner = nested_spheres + + # regular case + face_outer = sphere_outer.face() + face_inner = sphere_inner.face() + + s1 = solid([face_outer], inner=[face_inner], history=h) + + check_faces_helper((face_outer, face_inner), h[-1], s1) + + # manipulated orientation case + face_outer = sphere_outer.face().reverse() + face_inner = sphere_inner.face() + + s2 = solid([face_outer], inner=[face_inner], history=h) + + check_faces_helper((face_outer, face_inner), h[-1], s2) + + # manipulated edges case + s3 = solid(*tiny_edge_box_faces, history=h) + + check_faces_helper(tiny_edge_box_faces, h[-1], s3) + + def test_hlr(): s1 = box(1, 1, 1) From 762cb02f6c201360c29b38a897c926f1d1a6477f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:51:04 +0200 Subject: [PATCH 3/7] Add a test case --- cadquery/occ_impl/shapes.py | 10 +++++++--- tests/test_free_functions.py | 11 ++++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 405fc4e2d..ae1f62cd7 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5844,7 +5844,7 @@ def _polish_images(op: Op, s: Shape) -> Op: Workaround for Reshape context not tracking (face, face) relations when fixing solids. """ - face_dict = {f: f for f in s.faces()} + face_dict = {f: f for f in s.Faces()} for k, v in op._images.items(): if isinstance(k, Face): @@ -6439,9 +6439,13 @@ def solid( if history: _update_history(history, name, shells_faces, ctx.History()) - # FIXME what about images? - return tcast(Compound | Solid, _compound_or_shape(rvs)) + rv = tcast(Compound | Solid, _compound_or_shape(rvs)) + + if history: + _polish_images(history.ops[-1], rv) + + return rv @multidispatch diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 8e59fde13..04df9c061 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1596,7 +1596,7 @@ def check_faces_helper(faces, op, s): for f in faces: assert isSubshape(op.images(f), s) - assert any(f_res.isEqual(op.images(f)) for f_res in s.faces()) + assert any(f_res.isEqual(op.images(f)) for f_res in s.Faces()) h = History() @@ -1618,10 +1618,15 @@ def check_faces_helper(faces, op, s): check_faces_helper((face_outer, face_inner), h[-1], s2) + # another manipulated orientation case + s3 = solid(face_outer, history=h) + + check_faces_helper((face_outer,), h[-1], s3) + # manipulated edges case - s3 = solid(*tiny_edge_box_faces, history=h) + s4 = solid(*tiny_edge_box_faces, history=h) - check_faces_helper(tiny_edge_box_faces, h[-1], s3) + check_faces_helper(tiny_edge_box_faces, h[-1], s4) def test_hlr(): From 6fe1677d21f8943b9c265831cd81b45207b58f43 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:26:24 +0200 Subject: [PATCH 4/7] Another case --- tests/test_free_functions.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 04df9c061..ff0860f04 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1580,7 +1580,7 @@ def tiny_edge_box_faces() -> list[Face]: edge = edges[0] start, end = edge.startPoint(), edge.endPoint() - sliver_end = start + (end - start) * 1e-5 + sliver_end = start + (end - start) * 1e-6 faces[0] = face( wire(segment(start, sliver_end), segment(sliver_end, end), *edges[1:]) @@ -1628,6 +1628,11 @@ def check_faces_helper(faces, op, s): check_faces_helper(tiny_edge_box_faces, h[-1], s4) + # manipulated edges case + s5 = solid(tiny_edge_box_faces, history=h) + + check_faces_helper(tiny_edge_box_faces, h[-1], s5) + def test_hlr(): From 9b4f1feccb6ea4bdae2f8044d25ae236c4a3e449 Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:09 +0200 Subject: [PATCH 5/7] Update history handling for shell args --- cadquery/occ_impl/shapes.py | 16 +++++++++++++--- tests/test_free_functions.py | 4 ++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index ae1f62cd7..145eaca13 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -6420,9 +6420,10 @@ def solid( # get both Shells and Faces s = [s1, *sn] shells_faces = [f for el in s for f in _get(el, (Shell, Face))] + # try to collect shells + shells = [el.wrapped for el in shells_faces if isinstance(el, Shell)] # if no shells are present, use faces to construct them - 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)] rvs = [ @@ -6432,15 +6433,24 @@ def solid( ] if history: + # NB: reusing the history from shell() _apply_reshape(history.ops[-1], ctx) + rv = tcast(Compound | Solid, _compound_or_shape(rvs)) + + # otherwise construct solids with provided shells else: rvs = [builder.SolidFromShell(sh) for sh in shells] + rv = tcast(Compound | Solid, _compound_or_shape(rvs)) if history: + # update history - this is likely a noop for this branch _update_history(history, name, shells_faces, ctx.History()) - - rv = tcast(Compound | Solid, _compound_or_shape(rvs)) + # update images by hand + op = history.ops[-1] + for el in op._tracked: + if isSubshape(el, rv): + op._images[el] = el if history: _polish_images(history.ops[-1], rv) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index ff0860f04..04196cfc7 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -1633,6 +1633,10 @@ def check_faces_helper(faces, op, s): check_faces_helper(tiny_edge_box_faces, h[-1], s5) + # solid from shells + s6 = solid(sphere_outer.shell(), history=h) + check_faces_helper(sphere_outer.Faces(), h[-1], s6) + def test_hlr(): From cdcf38a3f9a04d79a047c6df84e14fb53f6d942f Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:11:06 +0200 Subject: [PATCH 6/7] History/Op construction tweaks --- cadquery/occ_impl/shapes.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index 145eaca13..33d5e8b6b 100644 --- a/cadquery/occ_impl/shapes.py +++ b/cadquery/occ_impl/shapes.py @@ -5496,6 +5496,19 @@ def _compound_or_shape(s: TopoDS_Shape | Sequence[TopoDS_Shape]) -> Shape: return rv +def _compound(s: TopoDS_Shape | Sequence[TopoDS_Shape]) -> Compound: + """ + Convert a list of TopoDS_Shape to a Compound. + """ + + if isinstance(s, TopoDS_Shape): + rv = Compound.makeCompound([_normalize(Shape.cast(s))]) + else: + rv = Compound.makeCompound([_normalize(Shape.cast(el)) for el in s]) + + return rv + + def _shape(s: TopoDS_Shape, _: type[T]) -> T: """ Cast a TopoDS_Shape to a Shape of the specfied type. @@ -5988,7 +6001,7 @@ def _update_history( if has_modifidied: try: - mod = _compound_or_shape(list(builder.Modified(wrapped))) + mod = _compound(list(builder.Modified(wrapped))) if mod: if el in op._modified: op._modified[el] |= mod @@ -6031,12 +6044,12 @@ def _remap_history_values(history: History | None, aux: History,) -> None: # handle last shape last_op._last_shape = compound( - [last_aux._modified.get(el, el) for el in last_op._last_shape] + [_normalize(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] + [_normalize(last_aux._modified.get(el, el)) for el in last_op._first_shape] ) From 7691860cdbd483de8ea6b0daa1b4a91bdddcb59c Mon Sep 17 00:00:00 2001 From: adam-urbanczyk <13981538+adam-urbanczyk@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:10:15 +0200 Subject: [PATCH 7/7] Coverage tweak --- tests/test_free_functions.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 04196cfc7..24f7d8722 100644 --- a/tests/test_free_functions.py +++ b/tests/test_free_functions.py @@ -70,6 +70,7 @@ _combine_hist_dict, enclose, split, + _compound, ) from OCP.BOPAlgo import BOPAlgo_CheckStatus @@ -187,6 +188,15 @@ def test__shape_to_faces_shells(): _shape_to_faces_shells(vertex(0, 0, 0).wrapped) +def test__compound(box_shape): + + res1 = _compound(box_shape.wrapped) + assert isinstance(res1, Compound) + + res2 = _compound([box_shape.wrapped]) + assert isinstance(res2, Compound) + + # %% constructors