diff --git a/docs/assets/ttt/ttt-ppp0109.py b/docs/assets/ttt/ttt-ppp0109.py index b00b0bc..49863af 100644 --- a/docs/assets/ttt/ttt-ppp0109.py +++ b/docs/assets/ttt/ttt-ppp0109.py @@ -47,16 +47,17 @@ with BuildPart() as ppp109: split(bisect_by=Plane.YZ) extrude(amount=6) f = ppp109.faces().filter_by(Axis((0, 0, 0), (-1, 0, 1)))[0] - # extrude(f, until=Until.NEXT) # throws a warning - extrude(f, amount=10) - fillet(ppp109.edge(Select.NEW), 16) + extrude(f, until=Until.NEXT) + fillet(ppp109.edges().filter_by(Axis.Y).sort_by(Axis.Z)[2], 16) + # extrude(f, amount=10) + # fillet(ppp109.edges(Select.NEW), 16) show(ppp109) -got_mass = ppp109.part.volume*densb +got_mass = ppp109.part.volume * densb want_mass = 307.23 tolerance = 1 delta = abs(got_mass - want_mass) print(f"Mass: {got_mass:0.2f} g") -assert delta < tolerance, f'{got_mass=}, {want_mass=}, {delta=}, {tolerance=}' +assert delta < tolerance, f"{got_mass=}, {want_mass=}, {delta=}, {tolerance=}" diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index ee765d0..e3fe8dd 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -223,8 +223,8 @@ def extrude( new_solids.append( Solid.extrude_until( - section=face, - target_object=target_object, + face, + target=target_object, direction=plane.z_dir * direction, until=until, ) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index f48c7bb..2b9a211 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -233,11 +233,11 @@ from .shape_core import ( shapetype, topods_dim, unwrap_topods_compound, + _topods_bool_op, ) from .utils import ( _extrude_topods_shape, _make_topods_face_from_wires, - _topods_bool_op, isclose_b, ) from .zero_d import Vertex, topo_explore_common_vertex @@ -1377,144 +1377,6 @@ class Mixin1D(Shape[TOPODS]): return (visible_edges, hidden_edges) - @overload - def split( - self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM] - ) -> Self | list[Self] | None: - """split and keep inside or outside""" - - @overload - def split(self, tool: TrimmingTool, keep: Literal[Keep.ALL]) -> list[Self]: - """split and return the unordered pieces""" - - @overload - def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[ - Self | list[Self] | None, - Self | list[Self] | None, - ]: - """split and keep inside and outside""" - - @overload - def split(self, tool: TrimmingTool) -> Self | list[Self] | None: - """split and keep inside (default)""" - - def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP): - """split - - Split this shape by the provided plane or face. - - Args: - surface (Plane | Face): surface to segment shape - keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP. - - Returns: - Shape: result of split - Returns: - Self | list[Self] | None, - Tuple[Self | list[Self] | None]: The result of the split operation. - - - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None` - if no top is found. - - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None` - if no bottom is found. - - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is - either a `Self` or `list[Self]`, or `None` if no corresponding part is found. - """ - if self._wrapped is None or not tool: - raise ValueError("Can't split an empty edge/wire/tool") - - shape_list = TopTools_ListOfShape() - shape_list.Append(self.wrapped) - - # Define the splitting tool - trim_tool = ( - BRepBuilderAPI_MakeFace(tool.wrapped).Face() # gp_Pln to Face - if isinstance(tool, Plane) - else tool.wrapped - ) - tool_list = TopTools_ListOfShape() - tool_list.Append(trim_tool) - - # Create the splitter algorithm - splitter = BRepAlgoAPI_Splitter() - - # Set the shape to be split and the splitting tool (plane face) - splitter.SetArguments(shape_list) - splitter.SetTools(tool_list) - - # Perform the splitting operation - splitter.Build() - - split_result = downcast(splitter.Shape()) - # Remove unnecessary TopoDS_Compound around single shape - if isinstance(split_result, TopoDS_Compound): - split_result = unwrap_topods_compound(split_result, True) - - # For speed the user may just want all the objects which they - # can sort more efficiently then the generic algorithm below - if keep == Keep.ALL: - return ShapeList( - self.__class__.cast(part) - for part in get_top_level_topods_shapes(split_result) - ) - - if not isinstance(tool, Plane): - # Get a TopoDS_Face to work with from the tool - if isinstance(trim_tool, TopoDS_Shell): - face_explorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE) - tool_face = TopoDS.Face_s(face_explorer.Current()) - else: - tool_face = trim_tool - - # Create a reference point off the +ve side of the tool - surface_gppnt = gp_Pnt() - surface_normal = gp_Vec() - u_min, u_max, v_min, v_max = BRepTools.UVBounds_s(tool_face) - BRepGProp_Face(tool_face).Normal( - (u_min + u_max) / 2, (v_min + v_max) / 2, surface_gppnt, surface_normal - ) - normalized_surface_normal = Vector( - surface_normal.X(), surface_normal.Y(), surface_normal.Z() - ).normalized() - surface_point = Vector(surface_gppnt) - ref_point = surface_point + normalized_surface_normal - - # Create a HalfSpace - Solidish object to determine top/bottom - # Note: BRepPrimAPI_MakeHalfSpace takes either a TopoDS_Shell or TopoDS_Face but the - # mypy expects only a TopoDS_Shell here - half_space_maker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt()) - # type: ignore - tool_solid = half_space_maker.Solid() - - tops: list[Shape] = [] - bottoms: list[Shape] = [] - properties = GProp_GProps() - for part in get_top_level_topods_shapes(split_result): - sub_shape = self.__class__.cast(part) - if isinstance(tool, Plane): - is_up = tool.to_local_coords(sub_shape).center().Z >= 0 - else: - # Intersect self and the thickened tool - is_up_obj = _topods_bool_op( - (part,), (tool_solid,), BRepAlgoAPI_Common() - ) - # Check for valid intersections - BRepGProp.LinearProperties_s(is_up_obj, properties) - # Mass represents the total length for linear properties - is_up = properties.Mass() >= TOLERANCE - (tops if is_up else bottoms).append(sub_shape) - - top = None if not tops else tops[0] if len(tops) == 1 else tops - bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms - - if keep == Keep.BOTH: - return (top, bottom) - if keep == Keep.TOP: - return top - if keep == Keep.BOTTOM: - return bottom - return None - def start_point(self) -> Vector: """The start point of this edge diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 1a3d4a8..2760367 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -100,6 +100,7 @@ from OCP.BRepFeat import BRepFeat_SplitShape from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.BRepMesh import BRepMesh_IncrementalMesh +from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepTools import BRepTools from OCP.gce import gce_MakeLin from OCP.Geom import Geom_Line @@ -196,7 +197,7 @@ class Shape(NodeMixin, Generic[TOPODS]): ta.TopAbs_COMPSOLID: "CompSolid", } - shape_properties_LUT = { + shape_properties_LUT: dict[TopAbs_ShapeEnum:function] = { ta.TopAbs_VERTEX: None, ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, ta.TopAbs_WIRE: BRepGProp.LinearProperties_s, @@ -1786,6 +1787,144 @@ class Shape(NodeMixin, Generic[TOPODS]): """solids - all the solids in this Shape""" return ShapeList() + @overload + def split( + self, tool: TrimmingTool, keep: Literal[Keep.TOP, Keep.BOTTOM] + ) -> Self | list[Self] | None: + """split and keep inside or outside""" + + @overload + def split(self, tool: TrimmingTool, keep: Literal[Keep.ALL]) -> list[Self]: + """split and return the unordered pieces""" + + @overload + def split(self, tool: TrimmingTool, keep: Literal[Keep.BOTH]) -> tuple[ + Self | list[Self] | None, + Self | list[Self] | None, + ]: + """split and keep inside and outside""" + + @overload + def split(self, tool: TrimmingTool) -> Self | list[Self] | None: + """split and keep inside (default)""" + + def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP): + """split + + Split this shape by the provided plane or face. + + Args: + surface (Plane | Face): surface to segment shape + keep (Keep, optional): which object(s) to save. Defaults to Keep.TOP. + + Returns: + Shape: result of split + Returns: + Self | list[Self] | None, + Tuple[Self | list[Self] | None]: The result of the split operation. + + - **Keep.TOP**: Returns the top as a `Self` or `list[Self]`, or `None` + if no top is found. + - **Keep.BOTTOM**: Returns the bottom as a `Self` or `list[Self]`, or `None` + if no bottom is found. + - **Keep.BOTH**: Returns a tuple `(inside, outside)` where each element is + either a `Self` or `list[Self]`, or `None` if no corresponding part is found. + """ + if self._wrapped is None or not tool: + raise ValueError("Can't split an empty edge/wire/tool") + + shape_list = TopTools_ListOfShape() + shape_list.Append(self.wrapped) + + # Define the splitting tool + trim_tool = ( + BRepBuilderAPI_MakeFace(tool.wrapped).Face() # gp_Pln to Face + if isinstance(tool, Plane) + else tool.wrapped + ) + tool_list = TopTools_ListOfShape() + tool_list.Append(trim_tool) + + # Create the splitter algorithm + splitter = BRepAlgoAPI_Splitter() + + # Set the shape to be split and the splitting tool (plane face) + splitter.SetArguments(shape_list) + splitter.SetTools(tool_list) + + # Perform the splitting operation + splitter.Build() + + split_result = downcast(splitter.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(split_result, TopoDS_Compound): + split_result = unwrap_topods_compound(split_result, True) + + # For speed the user may just want all the objects which they + # can sort more efficiently then the generic algorithm below + if keep == Keep.ALL: + return ShapeList( + self.__class__.cast(part) + for part in get_top_level_topods_shapes(split_result) + ) + + if not isinstance(tool, Plane): + # Get a TopoDS_Face to work with from the tool + if isinstance(trim_tool, TopoDS_Shell): + face_explorer = TopExp_Explorer(trim_tool, ta.TopAbs_FACE) + tool_face = TopoDS.Face_s(face_explorer.Current()) + else: + tool_face = trim_tool + + # Create a reference point off the +ve side of the tool + surface_gppnt = gp_Pnt() + surface_normal = gp_Vec() + u_min, u_max, v_min, v_max = BRepTools.UVBounds_s(tool_face) + BRepGProp_Face(tool_face).Normal( + (u_min + u_max) / 2, (v_min + v_max) / 2, surface_gppnt, surface_normal + ) + normalized_surface_normal = Vector( + surface_normal.X(), surface_normal.Y(), surface_normal.Z() + ).normalized() + surface_point = Vector(surface_gppnt) + ref_point = surface_point + normalized_surface_normal + + # Create a HalfSpace - Solidish object to determine top/bottom + # Note: BRepPrimAPI_MakeHalfSpace takes either a TopoDS_Shell or TopoDS_Face but the + # mypy expects only a TopoDS_Shell here + half_space_maker = BRepPrimAPI_MakeHalfSpace(trim_tool, ref_point.to_pnt()) + # type: ignore + tool_solid = half_space_maker.Solid() + + tops: list[Shape] = [] + bottoms: list[Shape] = [] + properties = GProp_GProps() + for part in get_top_level_topods_shapes(split_result): + sub_shape = self.__class__.cast(part) + if isinstance(tool, Plane): + is_up = tool.to_local_coords(sub_shape).center().Z >= 0 + else: + # Intersect self and the thickened tool + is_up_obj = _topods_bool_op( + (part,), (tool_solid,), BRepAlgoAPI_Common() + ) + # Check for valid intersections + BRepGProp.LinearProperties_s(is_up_obj, properties) + # Mass represents the total length for linear properties + is_up = properties.Mass() >= TOLERANCE + (tops if is_up else bottoms).append(sub_shape) + + top = None if not tops else tops[0] if len(tops) == 1 else tops + bottom = None if not bottoms else bottoms[0] if len(bottoms) == 1 else bottoms + + if keep == Keep.BOTH: + return (top, bottom) + if keep == Keep.TOP: + return top + if keep == Keep.BOTTOM: + return bottom + return None + @overload def split_by_perimeter( self, perimeter: Edge | Wire, keep: Literal[Keep.INSIDE, Keep.OUTSIDE] @@ -3011,6 +3150,45 @@ def _sew_topods_faces(faces: Iterable[TopoDS_Face]) -> TopoDS_Shape: return downcast(shell_builder.SewedShape()) +def _topods_bool_op( + args: Iterable[TopoDS_Shape], + tools: Iterable[TopoDS_Shape], + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, +) -> TopoDS_Shape: + """Generic boolean operation for TopoDS_Shapes + + Args: + args: Iterable[TopoDS_Shape]: + tools: Iterable[TopoDS_Shape]: + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter: + + Returns: TopoDS_Shape + + """ + args = list(args) + tools = list(tools) + arg = TopTools_ListOfShape() + for obj in args: + arg.Append(obj) + + tool = TopTools_ListOfShape() + for obj in tools: + tool.Append(obj) + + operation.SetArguments(arg) + operation.SetTools(tool) + + operation.SetRunParallel(True) + operation.Build() + + result = downcast(operation.Shape()) + # Remove unnecessary TopoDS_Compound around single shape + if isinstance(result, TopoDS_Compound): + result = unwrap_topods_compound(result, True) + + return result + + def _topods_entities(shape: TopoDS_Shape, topo_type: Shapes) -> list[TopoDS_Shape]: """Return the TopoDS_Shapes of topo_type from this TopoDS_Shape""" out = {} # using dict to prevent duplicates diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 279f46f..ed0011a 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -54,11 +54,10 @@ license: from __future__ import annotations -import platform -import warnings from collections.abc import Iterable, Sequence from math import radians, cos, tan -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal, overload +from typing import cast as tcast from typing_extensions import Self import OCP.TopAbs as ta @@ -86,13 +85,20 @@ from OCP.GProp import GProp_GProps from OCP.GeomAbs import GeomAbs_Intersection, GeomAbs_JoinType from OCP.LocOpe import LocOpe_DPrism from OCP.ShapeFix import ShapeFix_Solid -from OCP.Standard import Standard_Failure +from OCP.Standard import Standard_Failure, Standard_TypeMismatch from OCP.StdFail import StdFail_NotDone -from OCP.TopExp import TopExp +from OCP.TopExp import TopExp, TopExp_Explorer from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape -from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Solid, TopoDS_Wire +from OCP.TopoDS import ( + TopoDS, + TopoDS_Face, + TopoDS_Shape, + TopoDS_Shell, + TopoDS_Solid, + TopoDS_Wire, +) from OCP.gp import gp_Ax2, gp_Pnt -from build123d.build_enums import CenterOf, GeomType, Kind, Transition, Until +from build123d.build_enums import CenterOf, GeomType, Keep, Kind, Transition, Until from build123d.geometry import ( DEG2RAD, TOLERANCE, @@ -107,7 +113,19 @@ from build123d.geometry import ( ) from .one_d import Edge, Wire, Mixin1D -from .shape_core import TOPODS, Shape, ShapeList, Joint, downcast, shapetype +from .shape_core import ( + TOPODS, + Shape, + ShapeList, + Joint, + TrimmingTool, + downcast, + shapetype, + _sew_topods_faces, + get_top_level_topods_shapes, + unwrap_topods_compound, +) + from .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell from .utils import ( _extrude_topods_shape, @@ -126,7 +144,6 @@ class Mixin3D(Shape[TOPODS]): """Additional methods to add to 3D Shape classes""" project_to_viewport = Mixin1D.project_to_viewport - split = Mixin1D.split find_intersection_points = Mixin2D.find_intersection_points vertices = Mixin1D.vertices @@ -507,7 +524,9 @@ class Mixin3D(Shape[TOPODS]): for obj in common_set: match (obj, target): case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): - operation = BRepAlgoAPI_Section() + operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( + BRepAlgoAPI_Section() + ) result = bool_op((obj,), (target,), operation) if ( not isinstance(obj, Edge | Wire) @@ -610,8 +629,10 @@ class Mixin3D(Shape[TOPODS]): try: new_shape = self.__class__(fillet_builder.Shape()) if not new_shape.is_valid: - raise fillet_exception - except fillet_exception: + # raise fillet_exception + raise Standard_Failure + # except fillet_exception: + except (Standard_Failure, StdFail_NotDone): return __max_fillet(window_min, window_mid, current_iteration + 1) # These numbers work, are they close enough? - if not try larger window @@ -630,10 +651,10 @@ class Mixin3D(Shape[TOPODS]): # Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform # specific exceptions are required. - if platform.system() == "Darwin": - fillet_exception = Standard_Failure - else: - fillet_exception = StdFail_NotDone + # if platform.system() == "Darwin": + # fillet_exception = Standard_Failure + # else: + # fillet_exception = StdFail_NotDone max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0) @@ -892,7 +913,17 @@ class Solid(Mixin3D[TopoDS_Solid]): inner_comp = _make_topods_compound_from_shapes(inner_solids) # subtract from the outer solid - return Solid(BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape()) + difference = BRepAlgoAPI_Cut(outer_solid, inner_comp).Shape() + + # convert to a TopoDS_Solid - might be wrapped in a TopoDS_Compound + try: + result = TopoDS.Solid_s(difference) + except Standard_TypeMismatch: + result = TopoDS.Solid_s( + unwrap_topods_compound(TopoDS.Compound_s(difference), True) + ) + + return Solid(result) @classmethod def extrude_taper( @@ -933,7 +964,7 @@ class Solid(Mixin3D[TopoDS_Solid]): direction.length / cos(radians(taper)), radians(taper), ) - new_solid = Solid(prism_builder.Shape()) + new_solid = Solid(TopoDS.Solid_s(prism_builder.Shape())) else: # Determine the offset to get the taper offset_amt = -direction.length * tan(radians(taper)) @@ -972,110 +1003,116 @@ class Solid(Mixin3D[TopoDS_Solid]): @classmethod def extrude_until( cls, - section: Face, - target_object: Compound | Solid, + profile: Face, + target: Compound | Solid, direction: VectorLike, until: Until = Until.NEXT, - ) -> Compound | Solid: + ) -> Solid: """extrude_until - Extrude section in provided direction until it encounters either the - NEXT or LAST surface of target_object. Note that the bounding surface - must be larger than the extruded face where they contact. + Extrude `profile` in the provided `direction` until it encounters a + bounding surface on the `target`. The termination surface is chosen + according to the `until` option: + + * ``Until.NEXT`` — Extrude forward until the first intersecting surface. + * ``Until.LAST`` — Extrude forward through all intersections, stopping at + the farthest surface. + * ``Until.PREVIOUS`` — Reverse the extrusion direction and stop at the + first intersecting surface behind the profile. + * ``Until.FIRST`` — Reverse the direction and stop at the farthest + surface behind the profile. + + When ``Until.PREVIOUS`` or ``Until.FIRST`` are used, the extrusion + direction is automatically inverted before execution. + + Note: + The bounding surface on the target must be large enough to + completely cover the extruded profile at the contact region. + Partial overlaps may yield open or invalid solids. Args: - section (Face): Face to extrude - target_object (Union[Compound, Solid]): object to limit extrusion - direction (VectorLike): extrusion direction - until (Until, optional): surface to limit extrusion. Defaults to Until.NEXT. + profile (Face): The face to extrude. + target (Union[Compound, Solid]): The object that limits the extrusion. + direction (VectorLike): Extrusion direction. + until (Until, optional): Surface selection mode controlling which + intersection to stop at. Defaults to ``Until.NEXT``. Raises: - ValueError: provided face does not intersect target_object + ValueError: If the provided profile does not intersect the target. Returns: - Union[Compound, Solid]: extruded Face + Solid: The extruded and limited solid. """ direction = Vector(direction) if until in [Until.PREVIOUS, Until.FIRST]: direction *= -1 until = Until.NEXT if until == Until.PREVIOUS else Until.LAST - max_dimension = find_max_dimension([section, target_object]) - clipping_direction = ( - direction * max_dimension - if until == Until.NEXT - else -direction * max_dimension + # 1: Create extrusion of length the maximum distance between profile and target + max_dimension = find_max_dimension([profile, target]) + extrusion = Solid.extrude(profile, direction * max_dimension) + + # 2: Intersect the extrusion with the target to find the target's modified faces + intersect_op = BRepAlgoAPI_Common(target.wrapped, extrusion.wrapped) + intersect_op.Build() + intersection = intersect_op.Shape() + face_exp = TopExp_Explorer(intersection, ta.TopAbs_FACE) + if not face_exp.More(): + raise ValueError("No intersection: extrusion does not contact target") + + # Find the faces from the intersection that originated on the target + history = intersect_op.History() + modified_target_faces = [] + face_explorer = TopExp_Explorer(target.wrapped, ta.TopAbs_FACE) + while face_explorer.More(): + target_face = TopoDS.Face_s(face_explorer.Current()) + modified_los: TopTools_ListOfShape = history.Modified(target_face) + while not modified_los.IsEmpty(): + modified_face = TopoDS.Face_s(modified_los.First()) + modified_los.RemoveFirst() + modified_target_faces.append(modified_face) + face_explorer.Next() + + # 3: Sew the resulting faces into shells - one for each surface the extrusion + # passes through and sort by distance from the profile + sewed_shape = _sew_topods_faces(modified_target_faces) + + # From the sewed shape extract the shells and single faces + top_level_shapes = get_top_level_topods_shapes(sewed_shape) + modified_target_surfaces: ShapeList[Face | Shell] = ShapeList() + + # For each of the top level Shells and Faces + for top_level_shape in top_level_shapes: + if isinstance(top_level_shape, TopoDS_Face): + modified_target_surfaces.append(Face(top_level_shape)) + elif isinstance(top_level_shape, TopoDS_Shell): + modified_target_surfaces.append(Shell(top_level_shape)) + else: + raise RuntimeError(f"Invalid sewn shape {type(top_level_shape)}") + + modified_target_surfaces = modified_target_surfaces.sort_by( + lambda s: s.distance_to(profile) ) - direction_axis = Axis(section.center(), clipping_direction) - # Create a linear extrusion to start - extrusion = Solid.extrude(section, direction * max_dimension) - - # Project section onto the shape to generate faces that will clip the extrusion - # and exclude the planar faces normal to the direction of extrusion and these - # will have no volume when extruded - faces = [] - for face in section.project_to_shape(target_object, direction): - if isinstance(face, Face): - faces.append(face) - else: - faces += face.faces() - - clip_faces = [ - f - for f in faces - if not (f.is_planar and f.normal_at().dot(direction) == 0.0) + limit = modified_target_surfaces[ + 0 if until in [Until.NEXT, Until.PREVIOUS] else -1 ] - if not clip_faces: - raise ValueError("provided face does not intersect target_object") + keep: Literal[Keep.TOP, Keep.BOTTOM] = ( + Keep.TOP if until in [Until.NEXT, Until.PREVIOUS] else Keep.BOTTOM + ) - # Create the objects that will clip the linear extrusion - clipping_objects = [ - Solid.extrude(f, clipping_direction).fix() for f in clip_faces - ] - clipping_objects = [o for o in clipping_objects if o.volume > 1e-9] + # 4: Split the extrusion by the appropriate shell + clipped_extrusion = extrusion.split(limit, keep=keep) - if until == Until.NEXT: - trimmed_extrusion = extrusion.cut(target_object) - if isinstance(trimmed_extrusion, ShapeList): - closest_extrusion = trimmed_extrusion.sort_by(direction_axis)[0] - else: - closest_extrusion = trimmed_extrusion - for clipping_object in clipping_objects: - # It's possible for clipping faces to self intersect when they are extruded - # thus they could be non manifold which results failed boolean operations - # - so skip these objects - try: - extrusion_shapes = closest_extrusion.cut(clipping_object) - except Exception: - warnings.warn( - "clipping error - extrusion may be incorrect", - stacklevel=2, - ) + # 5: Return the appropriate type + if clipped_extrusion is None: + raise RuntimeError("Extrusion is None") # None isn't an option here + elif isinstance(clipped_extrusion, Solid): + return clipped_extrusion else: - base_part = extrusion.intersect(target_object) - if isinstance(base_part, ShapeList): - extrusion_parts = base_part - elif base_part is None: - extrusion_parts = ShapeList() - else: - extrusion_parts = ShapeList([base_part]) - for clipping_object in clipping_objects: - try: - clipped_extrusion = extrusion.intersect(clipping_object) - if clipped_extrusion is not None: - extrusion_parts.append( - clipped_extrusion.solids().sort_by(direction_axis)[0] - ) - except Exception: - warnings.warn( - "clipping error - extrusion may be incorrect", - stacklevel=2, - ) - extrusion_shapes = Solid.fuse(*extrusion_parts) - - result = extrusion_shapes.solids().sort_by(direction_axis)[0] - - return result + # isinstance(clipped_extrusion, list): + return ShapeList(clipped_extrusion).sort_by( + Axis(profile.center(), direction) + )[0] @classmethod def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid: @@ -1106,12 +1143,14 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Box """ return cls( - BRepPrimAPI_MakeBox( - plane.to_gp_ax2(), - length, - width, - height, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeBox( + plane.to_gp_ax2(), + length, + width, + height, + ).Shape() + ) ) @classmethod @@ -1138,13 +1177,15 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Full or partial cone """ return cls( - BRepPrimAPI_MakeCone( - plane.to_gp_ax2(), - base_radius, - top_radius, - height, - angle * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeCone( + plane.to_gp_ax2(), + base_radius, + top_radius, + height, + angle * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1169,12 +1210,14 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Full or partial cylinder """ return cls( - BRepPrimAPI_MakeCylinder( - plane.to_gp_ax2(), - radius, - height, - angle * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeCylinder( + plane.to_gp_ax2(), + radius, + height, + angle * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1195,7 +1238,7 @@ class Solid(Mixin3D[TopoDS_Solid]): Returns: Solid: Lofted object """ - return cls(_make_loft(objs, True, ruled)) + return cls(TopoDS.Solid_s(_make_loft(objs, True, ruled))) @classmethod def make_sphere( @@ -1221,13 +1264,15 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: sphere """ return cls( - BRepPrimAPI_MakeSphere( - plane.to_gp_ax2(), - radius, - angle1 * DEG2RAD, - angle2 * DEG2RAD, - angle3 * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeSphere( + plane.to_gp_ax2(), + radius, + angle1 * DEG2RAD, + angle2 * DEG2RAD, + angle3 * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1255,14 +1300,16 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: Full or partial torus """ return cls( - BRepPrimAPI_MakeTorus( - plane.to_gp_ax2(), - major_radius, - minor_radius, - start_angle * DEG2RAD, - end_angle * DEG2RAD, - major_angle * DEG2RAD, - ).Shape() + TopoDS.Solid_s( + BRepPrimAPI_MakeTorus( + plane.to_gp_ax2(), + major_radius, + minor_radius, + start_angle * DEG2RAD, + end_angle * DEG2RAD, + major_angle * DEG2RAD, + ).Shape() + ) ) @classmethod @@ -1293,16 +1340,18 @@ class Solid(Mixin3D[TopoDS_Solid]): Solid: wedge """ return cls( - BRepPrimAPI_MakeWedge( - plane.to_gp_ax2(), - delta_x, - delta_y, - delta_z, - min_x, - min_z, - max_x, - max_z, - ).Solid() + TopoDS.Solid_s( + BRepPrimAPI_MakeWedge( + plane.to_gp_ax2(), + delta_x, + delta_y, + delta_z, + min_x, + min_z, + max_x, + max_z, + ).Solid() + ) ) @classmethod @@ -1340,7 +1389,7 @@ class Solid(Mixin3D[TopoDS_Solid]): True, ) - return cls(revol_builder.Shape()) + return cls(TopoDS.Solid_s(revol_builder.Shape())) @classmethod def sweep( @@ -1488,7 +1537,7 @@ class Solid(Mixin3D[TopoDS_Solid]): if make_solid: builder.MakeSolid() - return cls(builder.Shape()) + return cls(TopoDS.Solid_s(builder.Shape())) @classmethod def thicken( @@ -1544,7 +1593,7 @@ class Solid(Mixin3D[TopoDS_Solid]): ) offset_builder.MakeOffsetShape() try: - result = Solid(offset_builder.Shape()) + result = Solid(TopoDS.Solid_s(offset_builder.Shape())) except StdFail_NotDone as err: raise RuntimeError("Error applying thicken to given surface") from err @@ -1591,7 +1640,7 @@ class Solid(Mixin3D[TopoDS_Solid]): try: draft_angle_builder.Build() - result = Solid(draft_angle_builder.Shape()) + result = Solid(TopoDS.Solid_s(draft_angle_builder.Shape())) except StdFail_NotDone as err: raise DraftAngleError( "Draft build failed on the given solid.", diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index ec920c0..a131c50 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -145,6 +145,7 @@ from .shape_core import ( ShapeList, SkipClean, _sew_topods_faces, + _topods_bool_op, _topods_entities, _topods_face_normal_at, downcast, @@ -155,7 +156,6 @@ from .utils import ( _extrude_topods_shape, _make_loft, _make_topods_face_from_wires, - _topods_bool_op, find_max_dimension, ) from .zero_d import Vertex @@ -171,7 +171,6 @@ class Mixin2D(ABC, Shape[TOPODS]): """Additional methods to add to Face and Shell class""" project_to_viewport = Mixin1D.project_to_viewport - split = Mixin1D.split vertices = Mixin1D.vertices vertex = Mixin1D.vertex diff --git a/src/build123d/topology/utils.py b/src/build123d/topology/utils.py index dbccc80..b59bcca 100644 --- a/src/build123d/topology/utils.py +++ b/src/build123d/topology/utils.py @@ -24,7 +24,6 @@ Key Features: - `_make_topods_face_from_wires`: Generates planar faces with optional holes. - **Boolean Operations**: - - `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes. - `new_edges`: Identifies newly created edges from combined shapes. - **Enhanced Math**: @@ -282,45 +281,6 @@ def _make_topods_face_from_wires( return TopoDS.Face_s(sf_f.Result()) -def _topods_bool_op( - args: Iterable[TopoDS_Shape], - tools: Iterable[TopoDS_Shape], - operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, -) -> TopoDS_Shape: - """Generic boolean operation for TopoDS_Shapes - - Args: - args: Iterable[TopoDS_Shape]: - tools: Iterable[TopoDS_Shape]: - operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter: - - Returns: TopoDS_Shape - - """ - args = list(args) - tools = list(tools) - arg = TopTools_ListOfShape() - for obj in args: - arg.Append(obj) - - tool = TopTools_ListOfShape() - for obj in tools: - tool.Append(obj) - - operation.SetArguments(arg) - operation.SetTools(tool) - - operation.SetRunParallel(True) - operation.Build() - - result = downcast(operation.Shape()) - # Remove unnecessary TopoDS_Compound around single shape - if isinstance(result, TopoDS_Compound): - result = unwrap_topods_compound(result, True) - - return result - - def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]: """Compare the OCCT objects of each list and return the differences""" shapes_one = list(shapes_one) diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index cf53676..dc536e9 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -68,8 +68,8 @@ from OCP.TopExp import TopExp_Explorer from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge from OCP.gp import gp_Pnt from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane - -from .shape_core import Shape, ShapeList, downcast, shapetype +from build123d.build_enums import Keep +from .shape_core import Shape, ShapeList, TrimmingTool, downcast, shapetype if TYPE_CHECKING: # pragma: no cover @@ -161,7 +161,7 @@ class Vertex(Shape[TopoDS_Vertex]): shape_type = shapetype(obj) # NB downcast is needed to handle TopoDS_Shape types - return constructor_lut[shape_type](downcast(obj)) + return constructor_lut[shape_type](TopoDS.Vertex_s(obj)) @classmethod def extrude(cls, obj: Shape, direction: VectorLike) -> Vertex: @@ -312,6 +312,10 @@ class Vertex(Shape[TopoDS_Vertex]): """The center of a vertex is itself!""" return Vector(self) + def split(self, tool: TrimmingTool, keep: Keep = Keep.TOP): + """split - not implemented""" + raise NotImplementedError("Vertices cannot be split.") + def to_tuple(self) -> tuple[float, float, float]: """Return vertex as three tuple of floats""" warnings.warn( diff --git a/tests/test_build_part.py b/tests/test_build_part.py index 0f6331a..d5dd6c7 100644 --- a/tests/test_build_part.py +++ b/tests/test_build_part.py @@ -330,6 +330,60 @@ class TestExtrude(unittest.TestCase): extrude(until=Until.NEXT) self.assertAlmostEqual(test.part.volume, 10**3 - 8**3 + 1**2 * 8, 5) + def test_extrude_until2(self): + target = Box(10, 5, 5) - Pos(X=2.5) * Cylinder(0.5, 5) + pln = Plane((7, 0, 7), z_dir=(-1, 0, -1)) + profile = (pln * Circle(1)).face() + extrusion = extrude(profile, dir=pln.z_dir, until=Until.NEXT, target=target) + self.assertLess(extrusion.bounding_box().min.Z, 2.5) + + def test_extrude_until3(self): + with BuildPart() as p: + with BuildSketch(Plane.XZ): + Rectangle(8, 8, align=Align.MIN) + with Locations((1, 1)): + Rectangle(7, 7, align=Align.MIN, mode=Mode.SUBTRACT) + extrude(amount=2, both=True) + with BuildSketch( + Plane((-2, 0, -2), x_dir=(0, 1, 0), z_dir=(1, 0, 1)) + ) as profile: + Rectangle(4, 1) + extrude(until=Until.NEXT) + + self.assertAlmostEqual(p.part.volume, 72.313, 2) + + def test_extrude_until_errors(self): + with self.assertRaises(ValueError): + extrude( + Rectangle(1, 1), + until=Until.NEXT, + dir=(0, 0, 1), + target=Pos(Z=-10) * Box(1, 1, 1), + ) + + def test_extrude_until_invalid_sewn_shape(self): + profile = Face.make_rect(1, 1) + target = Box(2, 2, 2) + direction = Vector(0, 0, 1) + + bad_shape = Box(1, 1, 1).wrapped # not a Face or Shell → forces RuntimeError + + with patch( + "build123d.topology.three_d.get_top_level_topods_shapes", + return_value=[bad_shape], + ): + with self.assertRaises(RuntimeError): + extrude(profile, dir=direction, until=Until.NEXT, target=target) + + def test_extrude_until_invalid_split(self): + profile = Face.make_rect(1, 1) + target = Box(2, 2, 2) + direction = Vector(0, 0, 1) + + with patch("build123d.topology.three_d.Solid.split", return_value=None): + with self.assertRaises(RuntimeError): + extrude(profile, dir=direction, until=Until.NEXT, target=target) + def test_extrude_face(self): with BuildPart(Plane.XZ) as box: with BuildSketch(Plane.XZ, mode=Mode.PRIVATE) as square: