diff --git a/cadquery/occ_impl/shapes.py b/cadquery/occ_impl/shapes.py index fba948fed..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. @@ -5805,6 +5818,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 not mod.IsEmpty(): + 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. @@ -5940,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 @@ -5983,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] ) @@ -6365,25 +6426,49 @@ 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] 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)] - 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: + # NB: reusing the history from shell() + _apply_reshape(history.ops[-1], ctx) + + rv = tcast(Compound | Solid, _compound_or_shape(rvs)) - return 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()) + # 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) + + return rv @multidispatch @@ -6401,8 +6486,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) @@ -6414,12 +6497,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) + + _apply_reshape(history.ops[-1], ctx) + _polish_images(history.ops[-1], rv) - return _shape(sf.Solid(), Solid) + return rv @multimethod diff --git a/tests/test_free_functions.py b/tests/test_free_functions.py index 6228b607c..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 @@ -1447,7 +1457,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 +1572,82 @@ 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-6 + + 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) + + # another manipulated orientation case + s3 = solid(face_outer, history=h) + + check_faces_helper((face_outer,), h[-1], s3) + + # manipulated edges case + s4 = solid(*tiny_edge_box_faces, history=h) + + 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) + + # solid from shells + s6 = solid(sphere_outer.shell(), history=h) + check_faces_helper(sphere_outer.Faces(), h[-1], s6) + + def test_hlr(): s1 = box(1, 1, 1)