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)
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)
@ -59,4 +60,4 @@ 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=}"

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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
)
direction_axis = Axis(section.center(), clipping_direction)
# Create a linear extrusion to start
extrusion = Solid.extrude(section, 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)
# 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)
# 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:
faces += face.faces()
raise RuntimeError(f"Invalid sewn shape {type(top_level_shape)}")
clip_faces = [
f
for f in faces
if not (f.is_planar and f.normal_at().dot(direction) == 0.0)
modified_target_surfaces = modified_target_surfaces.sort_by(
lambda s: s.distance_to(profile)
)
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")
# 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,
keep: Literal[Keep.TOP, Keep.BOTTOM] = (
Keep.TOP if until in [Until.NEXT, Until.PREVIOUS] else Keep.BOTTOM
)
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
def from_bounding_box(cls, bbox: BoundBox | OrientedBoundBox) -> Solid:
@ -1106,6 +1143,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Box
"""
return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeBox(
plane.to_gp_ax2(),
length,
@ -1113,6 +1151,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
height,
).Shape()
)
)
@classmethod
def make_cone(
@ -1138,6 +1177,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Full or partial cone
"""
return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeCone(
plane.to_gp_ax2(),
base_radius,
@ -1146,6 +1186,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
angle * DEG2RAD,
).Shape()
)
)
@classmethod
def make_cylinder(
@ -1169,6 +1210,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Full or partial cylinder
"""
return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeCylinder(
plane.to_gp_ax2(),
radius,
@ -1176,6 +1218,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
angle * DEG2RAD,
).Shape()
)
)
@classmethod
def make_loft(cls, objs: Iterable[Vertex | Wire], ruled: bool = False) -> Solid:
@ -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,6 +1264,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: sphere
"""
return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeSphere(
plane.to_gp_ax2(),
radius,
@ -1229,6 +1273,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
angle3 * DEG2RAD,
).Shape()
)
)
@classmethod
def make_torus(
@ -1255,6 +1300,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: Full or partial torus
"""
return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeTorus(
plane.to_gp_ax2(),
major_radius,
@ -1264,6 +1310,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
major_angle * DEG2RAD,
).Shape()
)
)
@classmethod
def make_wedge(
@ -1293,6 +1340,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
Solid: wedge
"""
return cls(
TopoDS.Solid_s(
BRepPrimAPI_MakeWedge(
plane.to_gp_ax2(),
delta_x,
@ -1304,6 +1352,7 @@ class Solid(Mixin3D[TopoDS_Solid]):
max_z,
).Solid()
)
)
@classmethod
def revolve(
@ -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.",

View file

@ -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

View file

@ -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)

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.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(

View file

@ -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: