Refactored Solid.extrude_until, moved split to Shape, fixed misc typing problems

This commit is contained in:
gumyr 2025-12-01 20:04:48 -05:00
parent a8fc16b344
commit 2fa0dd22da
9 changed files with 449 additions and 342 deletions

View file

@ -47,9 +47,10 @@ with BuildPart() as ppp109:
split(bisect_by=Plane.YZ) split(bisect_by=Plane.YZ)
extrude(amount=6) extrude(amount=6)
f = ppp109.faces().filter_by(Axis((0, 0, 0), (-1, 0, 1)))[0] f = ppp109.faces().filter_by(Axis((0, 0, 0), (-1, 0, 1)))[0]
# extrude(f, until=Until.NEXT) # throws a warning extrude(f, until=Until.NEXT)
extrude(f, amount=10) fillet(ppp109.edges().filter_by(Axis.Y).sort_by(Axis.Z)[2], 16)
fillet(ppp109.edge(Select.NEW), 16) # extrude(f, amount=10)
# fillet(ppp109.edges(Select.NEW), 16)
show(ppp109) show(ppp109)
@ -59,4 +60,4 @@ want_mass = 307.23
tolerance = 1 tolerance = 1
delta = abs(got_mass - want_mass) delta = abs(got_mass - want_mass)
print(f"Mass: {got_mass:0.2f} g") 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=}"

View file

@ -223,8 +223,8 @@ def extrude(
new_solids.append( new_solids.append(
Solid.extrude_until( Solid.extrude_until(
section=face, face,
target_object=target_object, target=target_object,
direction=plane.z_dir * direction, direction=plane.z_dir * direction,
until=until, until=until,
) )

View file

@ -233,11 +233,11 @@ from .shape_core import (
shapetype, shapetype,
topods_dim, topods_dim,
unwrap_topods_compound, unwrap_topods_compound,
_topods_bool_op,
) )
from .utils import ( from .utils import (
_extrude_topods_shape, _extrude_topods_shape,
_make_topods_face_from_wires, _make_topods_face_from_wires,
_topods_bool_op,
isclose_b, isclose_b,
) )
from .zero_d import Vertex, topo_explore_common_vertex from .zero_d import Vertex, topo_explore_common_vertex
@ -1377,144 +1377,6 @@ class Mixin1D(Shape[TOPODS]):
return (visible_edges, hidden_edges) 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: def start_point(self) -> Vector:
"""The start point of this edge """The start point of this edge

View file

@ -100,6 +100,7 @@ from OCP.BRepFeat import BRepFeat_SplitShape
from OCP.BRepGProp import BRepGProp, BRepGProp_Face from OCP.BRepGProp import BRepGProp, BRepGProp_Face
from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter from OCP.BRepIntCurveSurface import BRepIntCurveSurface_Inter
from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.BRepMesh import BRepMesh_IncrementalMesh
from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace
from OCP.BRepTools import BRepTools from OCP.BRepTools import BRepTools
from OCP.gce import gce_MakeLin from OCP.gce import gce_MakeLin
from OCP.Geom import Geom_Line from OCP.Geom import Geom_Line
@ -196,7 +197,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
ta.TopAbs_COMPSOLID: "CompSolid", ta.TopAbs_COMPSOLID: "CompSolid",
} }
shape_properties_LUT = { shape_properties_LUT: dict[TopAbs_ShapeEnum:function] = {
ta.TopAbs_VERTEX: None, ta.TopAbs_VERTEX: None,
ta.TopAbs_EDGE: BRepGProp.LinearProperties_s, ta.TopAbs_EDGE: BRepGProp.LinearProperties_s,
ta.TopAbs_WIRE: 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""" """solids - all the solids in this Shape"""
return ShapeList() 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 @overload
def split_by_perimeter( def split_by_perimeter(
self, perimeter: Edge | Wire, keep: Literal[Keep.INSIDE, Keep.OUTSIDE] 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()) 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]: def _topods_entities(shape: TopoDS_Shape, topo_type: Shapes) -> list[TopoDS_Shape]:
"""Return the TopoDS_Shapes of topo_type from this TopoDS_Shape""" """Return the TopoDS_Shapes of topo_type from this TopoDS_Shape"""
out = {} # using dict to prevent duplicates out = {} # using dict to prevent duplicates

View file

@ -54,11 +54,10 @@ license:
from __future__ import annotations from __future__ import annotations
import platform
import warnings
from collections.abc import Iterable, Sequence from collections.abc import Iterable, Sequence
from math import radians, cos, tan 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 from typing_extensions import Self
import OCP.TopAbs as ta 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.GeomAbs import GeomAbs_Intersection, GeomAbs_JoinType
from OCP.LocOpe import LocOpe_DPrism from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeFix import ShapeFix_Solid 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.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.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 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 ( from build123d.geometry import (
DEG2RAD, DEG2RAD,
TOLERANCE, TOLERANCE,
@ -107,7 +113,19 @@ from build123d.geometry import (
) )
from .one_d import Edge, Wire, Mixin1D 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 .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell
from .utils import ( from .utils import (
_extrude_topods_shape, _extrude_topods_shape,
@ -126,7 +144,6 @@ class Mixin3D(Shape[TOPODS]):
"""Additional methods to add to 3D Shape classes""" """Additional methods to add to 3D Shape classes"""
project_to_viewport = Mixin1D.project_to_viewport project_to_viewport = Mixin1D.project_to_viewport
split = Mixin1D.split
find_intersection_points = Mixin2D.find_intersection_points find_intersection_points = Mixin2D.find_intersection_points
vertices = Mixin1D.vertices vertices = Mixin1D.vertices
@ -507,7 +524,9 @@ class Mixin3D(Shape[TOPODS]):
for obj in common_set: for obj in common_set:
match (obj, target): match (obj, target):
case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()):
operation = BRepAlgoAPI_Section() operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = (
BRepAlgoAPI_Section()
)
result = bool_op((obj,), (target,), operation) result = bool_op((obj,), (target,), operation)
if ( if (
not isinstance(obj, Edge | Wire) not isinstance(obj, Edge | Wire)
@ -610,8 +629,10 @@ class Mixin3D(Shape[TOPODS]):
try: try:
new_shape = self.__class__(fillet_builder.Shape()) new_shape = self.__class__(fillet_builder.Shape())
if not new_shape.is_valid: if not new_shape.is_valid:
raise fillet_exception # raise fillet_exception
except fillet_exception: raise Standard_Failure
# except fillet_exception:
except (Standard_Failure, StdFail_NotDone):
return __max_fillet(window_min, window_mid, current_iteration + 1) return __max_fillet(window_min, window_mid, current_iteration + 1)
# These numbers work, are they close enough? - if not try larger window # 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 # Unfortunately, MacOS doesn't support the StdFail_NotDone exception so platform
# specific exceptions are required. # specific exceptions are required.
if platform.system() == "Darwin": # if platform.system() == "Darwin":
fillet_exception = Standard_Failure # fillet_exception = Standard_Failure
else: # else:
fillet_exception = StdFail_NotDone # fillet_exception = StdFail_NotDone
max_radius = __max_fillet(0.0, 2 * self.bounding_box().diagonal, 0) 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) inner_comp = _make_topods_compound_from_shapes(inner_solids)
# subtract from the outer solid # 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 @classmethod
def extrude_taper( def extrude_taper(
@ -933,7 +964,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
direction.length / cos(radians(taper)), direction.length / cos(radians(taper)),
radians(taper), radians(taper),
) )
new_solid = Solid(prism_builder.Shape()) new_solid = Solid(TopoDS.Solid_s(prism_builder.Shape()))
else: else:
# Determine the offset to get the taper # Determine the offset to get the taper
offset_amt = -direction.length * tan(radians(taper)) offset_amt = -direction.length * tan(radians(taper))
@ -972,110 +1003,116 @@ class Solid(Mixin3D[TopoDS_Solid]):
@classmethod @classmethod
def extrude_until( def extrude_until(
cls, cls,
section: Face, profile: Face,
target_object: Compound | Solid, target: Compound | Solid,
direction: VectorLike, direction: VectorLike,
until: Until = Until.NEXT, until: Until = Until.NEXT,
) -> Compound | Solid: ) -> Solid:
"""extrude_until """extrude_until
Extrude section in provided direction until it encounters either the Extrude `profile` in the provided `direction` until it encounters a
NEXT or LAST surface of target_object. Note that the bounding surface bounding surface on the `target`. The termination surface is chosen
must be larger than the extruded face where they contact. 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: Args:
section (Face): Face to extrude profile (Face): The face to extrude.
target_object (Union[Compound, Solid]): object to limit extrusion target (Union[Compound, Solid]): The object that limits the extrusion.
direction (VectorLike): extrusion direction direction (VectorLike): Extrusion direction.
until (Until, optional): surface to limit extrusion. Defaults to Until.NEXT. until (Until, optional): Surface selection mode controlling which
intersection to stop at. Defaults to ``Until.NEXT``.
Raises: Raises:
ValueError: provided face does not intersect target_object ValueError: If the provided profile does not intersect the target.
Returns: Returns:
Union[Compound, Solid]: extruded Face Solid: The extruded and limited solid.
""" """
direction = Vector(direction) direction = Vector(direction)
if until in [Until.PREVIOUS, Until.FIRST]: if until in [Until.PREVIOUS, Until.FIRST]:
direction *= -1 direction *= -1
until = Until.NEXT if until == Until.PREVIOUS else Until.LAST until = Until.NEXT if until == Until.PREVIOUS else Until.LAST
max_dimension = find_max_dimension([section, target_object]) # 1: Create extrusion of length the maximum distance between profile and target
clipping_direction = ( max_dimension = find_max_dimension([profile, target])
direction * max_dimension extrusion = Solid.extrude(profile, direction * max_dimension)
if until == Until.NEXT
else -direction * max_dimension
)
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 # 2: Intersect the extrusion with the target to find the target's modified faces
# and exclude the planar faces normal to the direction of extrusion and these intersect_op = BRepAlgoAPI_Common(target.wrapped, extrusion.wrapped)
# will have no volume when extruded intersect_op.Build()
faces = [] intersection = intersect_op.Shape()
for face in section.project_to_shape(target_object, direction): face_exp = TopExp_Explorer(intersection, ta.TopAbs_FACE)
if isinstance(face, Face): if not face_exp.More():
faces.append(face) 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: else:
faces += face.faces() raise RuntimeError(f"Invalid sewn shape {type(top_level_shape)}")
clip_faces = [ modified_target_surfaces = modified_target_surfaces.sort_by(
f lambda s: s.distance_to(profile)
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: keep: Literal[Keep.TOP, Keep.BOTTOM] = (
raise ValueError("provided face does not intersect target_object") 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]
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,
) )
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] # 4: Split the extrusion by the appropriate shell
clipped_extrusion = extrusion.split(limit, keep=keep)
return result # 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:
# isinstance(clipped_extrusion, list):
return ShapeList(clipped_extrusion).sort_by(
Axis(profile.center(), direction)
)[0]
@classmethod @classmethod
def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid: def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid:
@ -1106,6 +1143,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Box Solid: Box
""" """
return cls( return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeBox( BRepPrimAPI_MakeBox(
plane.to_gp_ax2(), plane.to_gp_ax2(),
length, length,
@ -1113,6 +1151,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
height, height,
).Shape() ).Shape()
) )
)
@classmethod @classmethod
def make_cone( def make_cone(
@ -1138,6 +1177,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Full or partial cone Solid: Full or partial cone
""" """
return cls( return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeCone( BRepPrimAPI_MakeCone(
plane.to_gp_ax2(), plane.to_gp_ax2(),
base_radius, base_radius,
@ -1146,6 +1186,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
angle * DEG2RAD, angle * DEG2RAD,
).Shape() ).Shape()
) )
)
@classmethod @classmethod
def make_cylinder( def make_cylinder(
@ -1169,6 +1210,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Full or partial cylinder Solid: Full or partial cylinder
""" """
return cls( return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeCylinder( BRepPrimAPI_MakeCylinder(
plane.to_gp_ax2(), plane.to_gp_ax2(),
radius, radius,
@ -1176,6 +1218,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
angle * DEG2RAD, angle * DEG2RAD,
).Shape() ).Shape()
) )
)
@classmethod @classmethod
def make_loft(cls, objs: Iterable[Vertex | Wire], ruled: bool = False) -> Solid: def make_loft(cls, objs: Iterable[Vertex | Wire], ruled: bool = False) -> Solid:
@ -1195,7 +1238,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Returns: Returns:
Solid: Lofted object Solid: Lofted object
""" """
return cls(_make_loft(objs, True, ruled)) return cls(TopoDS.Solid_s(_make_loft(objs, True, ruled)))
@classmethod @classmethod
def make_sphere( def make_sphere(
@ -1221,6 +1264,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: sphere Solid: sphere
""" """
return cls( return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeSphere( BRepPrimAPI_MakeSphere(
plane.to_gp_ax2(), plane.to_gp_ax2(),
radius, radius,
@ -1229,6 +1273,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
angle3 * DEG2RAD, angle3 * DEG2RAD,
).Shape() ).Shape()
) )
)
@classmethod @classmethod
def make_torus( def make_torus(
@ -1255,6 +1300,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Full or partial torus Solid: Full or partial torus
""" """
return cls( return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeTorus( BRepPrimAPI_MakeTorus(
plane.to_gp_ax2(), plane.to_gp_ax2(),
major_radius, major_radius,
@ -1264,6 +1310,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
major_angle * DEG2RAD, major_angle * DEG2RAD,
).Shape() ).Shape()
) )
)
@classmethod @classmethod
def make_wedge( def make_wedge(
@ -1293,6 +1340,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: wedge Solid: wedge
""" """
return cls( return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeWedge( BRepPrimAPI_MakeWedge(
plane.to_gp_ax2(), plane.to_gp_ax2(),
delta_x, delta_x,
@ -1304,6 +1352,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
max_z, max_z,
).Solid() ).Solid()
) )
)
@classmethod @classmethod
def revolve( def revolve(
@ -1340,7 +1389,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
True, True,
) )
return cls(revol_builder.Shape()) return cls(TopoDS.Solid_s(revol_builder.Shape()))
@classmethod @classmethod
def sweep( def sweep(
@ -1488,7 +1537,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
if make_solid: if make_solid:
builder.MakeSolid() builder.MakeSolid()
return cls(builder.Shape()) return cls(TopoDS.Solid_s(builder.Shape()))
@classmethod @classmethod
def thicken( def thicken(
@ -1544,7 +1593,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
) )
offset_builder.MakeOffsetShape() offset_builder.MakeOffsetShape()
try: try:
result = Solid(offset_builder.Shape()) result = Solid(TopoDS.Solid_s(offset_builder.Shape()))
except StdFail_NotDone as err: except StdFail_NotDone as err:
raise RuntimeError("Error applying thicken to given surface") from err raise RuntimeError("Error applying thicken to given surface") from err
@ -1591,7 +1640,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
try: try:
draft_angle_builder.Build() draft_angle_builder.Build()
result = Solid(draft_angle_builder.Shape()) result = Solid(TopoDS.Solid_s(draft_angle_builder.Shape()))
except StdFail_NotDone as err: except StdFail_NotDone as err:
raise DraftAngleError( raise DraftAngleError(
"Draft build failed on the given solid.", "Draft build failed on the given solid.",

View file

@ -145,6 +145,7 @@ from .shape_core import (
ShapeList, ShapeList,
SkipClean, SkipClean,
_sew_topods_faces, _sew_topods_faces,
_topods_bool_op,
_topods_entities, _topods_entities,
_topods_face_normal_at, _topods_face_normal_at,
downcast, downcast,
@ -155,7 +156,6 @@ from .utils import (
_extrude_topods_shape, _extrude_topods_shape,
_make_loft, _make_loft,
_make_topods_face_from_wires, _make_topods_face_from_wires,
_topods_bool_op,
find_max_dimension, find_max_dimension,
) )
from .zero_d import Vertex from .zero_d import Vertex
@ -171,7 +171,6 @@ class Mixin2D(ABC, Shape[TOPODS]):
"""Additional methods to add to Face and Shell class""" """Additional methods to add to Face and Shell class"""
project_to_viewport = Mixin1D.project_to_viewport project_to_viewport = Mixin1D.project_to_viewport
split = Mixin1D.split
vertices = Mixin1D.vertices vertices = Mixin1D.vertices
vertex = Mixin1D.vertex vertex = Mixin1D.vertex

View file

@ -24,7 +24,6 @@ Key Features:
- `_make_topods_face_from_wires`: Generates planar faces with optional holes. - `_make_topods_face_from_wires`: Generates planar faces with optional holes.
- **Boolean Operations**: - **Boolean Operations**:
- `_topods_bool_op`: Generic Boolean operations for TopoDS_Shapes.
- `new_edges`: Identifies newly created edges from combined shapes. - `new_edges`: Identifies newly created edges from combined shapes.
- **Enhanced Math**: - **Enhanced Math**:
@ -282,45 +281,6 @@ def _make_topods_face_from_wires(
return TopoDS.Face_s(sf_f.Result()) 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]: def delta(shapes_one: Iterable[Shape], shapes_two: Iterable[Shape]) -> list[Shape]:
"""Compare the OCCT objects of each list and return the differences""" """Compare the OCCT objects of each list and return the differences"""
shapes_one = list(shapes_one) shapes_one = list(shapes_one)

View file

@ -68,8 +68,8 @@ from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge from OCP.TopoDS import TopoDS, TopoDS_Shape, TopoDS_Vertex, TopoDS_Edge
from OCP.gp import gp_Pnt from OCP.gp import gp_Pnt
from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane from build123d.geometry import Matrix, Vector, VectorLike, Location, Axis, Plane
from build123d.build_enums import Keep
from .shape_core import Shape, ShapeList, downcast, shapetype from .shape_core import Shape, ShapeList, TrimmingTool, downcast, shapetype
if TYPE_CHECKING: # pragma: no cover if TYPE_CHECKING: # pragma: no cover
@ -161,7 +161,7 @@ class Vertex(Shape[TopoDS_Vertex]):
shape_type = shapetype(obj) shape_type = shapetype(obj)
# NB downcast is needed to handle TopoDS_Shape types # 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 @classmethod
def extrude(cls, obj: Shape, direction: VectorLike) -> Vertex: def extrude(cls, obj: Shape, direction: VectorLike) -> Vertex:
@ -312,6 +312,10 @@ class Vertex(Shape[TopoDS_Vertex]):
"""The center of a vertex is itself!""" """The center of a vertex is itself!"""
return Vector(self) 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]: def to_tuple(self) -> tuple[float, float, float]:
"""Return vertex as three tuple of floats""" """Return vertex as three tuple of floats"""
warnings.warn( warnings.warn(

View file

@ -330,6 +330,60 @@ class TestExtrude(unittest.TestCase):
extrude(until=Until.NEXT) extrude(until=Until.NEXT)
self.assertAlmostEqual(test.part.volume, 10**3 - 8**3 + 1**2 * 8, 5) 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): def test_extrude_face(self):
with BuildPart(Plane.XZ) as box: with BuildPart(Plane.XZ) as box:
with BuildSketch(Plane.XZ, mode=Mode.PRIVATE) as square: with BuildSketch(Plane.XZ, mode=Mode.PRIVATE) as square: