diff --git a/src/build123d/topology.py b/src/build123d/topology.py index 40f68826..8653c430 100644 --- a/src/build123d/topology.py +++ b/src/build123d/topology.py @@ -1732,13 +1732,6 @@ def __add__(self, other: Union[list[Shape], Shape]) -> Self: else: sum_shape = self.fuse(*summands) - # Simplify Compounds if possible - sum_shape = ( - sum_shape.unwrap(fully=True) - if isinstance(sum_shape, Compound) - else sum_shape - ) - if SkipClean.clean: sum_shape = sum_shape.clean() @@ -1782,12 +1775,6 @@ def __sub__(self, other: Union[Shape, Iterable[Shape]]) -> Self: # Do the actual cut operation difference = self.cut(*subtrahends) - # Simplify Compounds if possible - difference = ( - difference.unwrap(fully=True) - if isinstance(difference, Compound) - else difference - ) # To allow the @, % and ^ operators to work 1D objects must be type Curve if minuend_dim == 1: difference = Curve(Compound(difference.edges()).wrapped) @@ -1805,13 +1792,6 @@ def __and__(self, other: Shape) -> Self: if new_shape.wrapped is not None and SkipClean.clean: new_shape = new_shape.clean() - # Simplify Compounds if possible - new_shape = ( - new_shape.unwrap(fully=True) - if isinstance(new_shape, Compound) - else new_shape - ) - # To allow the @, % and ^ operators to work 1D objects must be type Curve if self._dim == 1: new_shape = Curve(Compound(new_shape.edges()).wrapped) @@ -2621,7 +2601,11 @@ def _bool_op( operation.SetRunParallel(True) operation.Build() - result = Shape.cast(operation.Shape()) + result = downcast(operation.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(result, TopoDS_Compound): + result = unwrap_topods_compound(result, True) + result = Shape.cast(result) base = args[0] if isinstance(args, tuple) else args base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) @@ -2829,7 +2813,12 @@ def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP) -> Self: # Perform the splitting operation splitter.Build() - result = Compound(downcast(splitter.Shape())).unwrap(fully=False) + result = downcast(splitter.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(result, TopoDS_Compound): + result = unwrap_topods_compound(result, False) + result = Shape.cast(result) + if keep != Keep.BOTH: if not isinstance(tool, Plane): # Create solids from the surfaces for sorting @@ -2843,7 +2832,8 @@ def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP) -> Self: (tops if is_up else bottoms).append(part) result = Compound(tops) if keep == Keep.TOP else Compound(bottoms) - return result.unwrap(fully=True) + result_wrapped = unwrap_topods_compound(result.wrapped, fully=True) + return Shape.cast(result_wrapped) @overload def split_by_perimeter( @@ -4415,7 +4405,9 @@ def position_face(orig_face: "Face") -> "Face": # Align the text from the bounding box align = tuplify(align, 2) - text_flat = text_flat.translate(text_flat.bounding_box().to_align_offset(align)) + text_flat = text_flat.translate( + Vector(*text_flat.bounding_box().to_align_offset(align)) + ) if text_path is not None: path_length = text_path.length @@ -8464,7 +8456,10 @@ def closest_to_end(current: Wire, unplaced_edges: list[Edge]) -> Edge: wire_builder.Build() if not wire_builder.IsDone(): if wire_builder.Error() == BRepBuilderAPI_NonManifoldWire: - warnings.warn("Wire is non manifold", stacklevel=2) + warnings.warn( + "Wire is non manifold (e.g. branching, self intersecting)", + stacklevel=2, + ) elif wire_builder.Error() == BRepBuilderAPI_EmptyWire: raise RuntimeError("Wire is empty") elif wire_builder.Error() == BRepBuilderAPI_DisconnectedWire: @@ -9232,6 +9227,34 @@ def topo_explore_common_vertex( return None # No common vertex found +def unwrap_topods_compound( + compound: TopoDS_Compound, fully: bool = True +) -> Union[TopoDS_Compound, TopoDS_Shape]: + """Strip unnecessary Compound wrappers + + Args: + compound (TopoDS_Compound): The TopoDS_Compound to unwrap. + fully (bool, optional): return base shape without any TopoDS_Compound + wrappers (otherwise one TopoDS_Compound is left). Defaults to True. + + Returns: + Union[TopoDS_Compound, TopoDS_Shape]: base shape + """ + + if compound.NbChildren() == 1: + iterator = TopoDS_Iterator(compound) + single_element = downcast(iterator.Value()) + + # If the single element is another TopoDS_Compound, unwrap it recursively + if isinstance(single_element, TopoDS_Compound): + return unwrap_topods_compound(single_element, fully) + + return single_element if fully else compound + + # If there are no elements or more than one element, return TopoDS_Compound + return compound + + class SkipClean: """Skip clean context for use in operator driven code where clean=False wouldn't work""" diff --git a/tests/test_direct_api.py b/tests/test_direct_api.py index 8f5e62de..307dad07 100644 --- a/tests/test_direct_api.py +++ b/tests/test_direct_api.py @@ -90,6 +90,7 @@ polar, new_edges, delta, + unwrap_topods_compound, ) from build123d.jupyter_tools import display @@ -1577,6 +1578,28 @@ def test_parse_intersect_args(self): with self.assertRaises(TypeError): Vector(1, 1, 1) & ("x", "y", "z") + def test_unwrap_topods_compound(self): + # Complex Compound + b1 = Box(1, 1, 1).solid() + b2 = Box(2, 2, 2).solid() + c1 = Compound([b1, b2]) + c2 = Compound([b1, c1]) + c3 = Compound([c2]) + c4 = Compound([c3]) + self.assertEqual(c4.wrapped.NbChildren(), 1) + c5 = Compound(unwrap_topods_compound(c4.wrapped, False)) + self.assertEqual(c5.wrapped.NbChildren(), 2) + + # unwrap fully + c0 = Compound([b1]) + c1 = Compound([c0]) + result = Shape.cast(unwrap_topods_compound(c1.wrapped, True)) + self.assertTrue(isinstance(result, Solid)) + + # unwrap not fully + result = Shape.cast(unwrap_topods_compound(c1.wrapped, False)) + self.assertTrue(isinstance(result, Compound)) + class TestImportExport(DirectApiTestCase): def test_import_export(self): @@ -2579,7 +2602,7 @@ def test_plane_init(self): ( Axis.X.direction, # plane x_dir Axis.Z.direction, # plane y_dir - -Axis.Y.direction, # plane z_dir + -Axis.Y.direction, # plane z_dir ), # Trapezoid face, positive y coordinate (