diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 0919312..f645aa6 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -713,149 +713,78 @@ class Compound(Mixin3D[TopoDS_Compound]): return results - def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Vertex | Edge | Face | Solid]: - """Intersect Compound with Shape or geometry object + def _intersect( + self, + other: Shape, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Single-object intersection for Compound (OR semantics). + + Distributes intersection over elements, collecting all results: + Compound([a, b]).intersect(s) = (a ∩ s) ∪ (b ∩ s) + Compound([a, b]).intersect(Compound([c, d])) = (a ∩ c) ∪ (a ∩ d) ∪ (b ∩ c) ∪ (b ∩ d) + + Handles both build123d assemblies (children) and OCCT Compounds (list()). + Nested Compounds are handled by recursion. Args: - to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect + other: Shape to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts + (only relevant when Solids are involved) + """ + # Get self elements: assembly children or OCCT direct children + self_elements = self.children if self.children else list(self) + + if not self_elements: + return None + + results: ShapeList = ShapeList() + + # Distribute over elements (OR semantics for Compound arguments) + if isinstance(other, Compound): + other_elements = other.children if other.children else list(other) + else: + other_elements = [other] + + for self_elem in self_elements: + for other_elem in other_elements: + intersection = self_elem._intersect( + other_elem, tolerance, include_touched + ) + if intersection: + results.extend(intersection) + + # Remove duplicates using Shape's __hash__ + unique = ShapeList(set(results)) + + return unique if unique else None + + def touch( + self, other: Shape, tolerance: float = 1e-6 + ) -> ShapeList[Vertex | Edge | Face]: + """Distribute touch over compound elements. + + Iterates over elements and collects touch results. Only Solid and + Face elements produce boundary contacts; other shapes return empty. + + Args: + other: Shape to check boundary contacts with + tolerance: tolerance for contact detection Returns: - ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges, - faces, and/or solids. + ShapeList of boundary contact geometry (empty if no contact) """ + results: ShapeList = ShapeList() - def to_vector(objs: Iterable) -> ShapeList: - return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + # Get elements: assembly children or OCCT direct children + elements = self.children if self.children else list(self) - def to_vertex(objs: Iterable) -> ShapeList: - return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + for elem in elements: + results.extend(elem.touch(other, tolerance)) - def bool_op( - args: Sequence, - tools: Sequence, - operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, - ) -> ShapeList: - # Wrap Shape._bool_op for corrected output - intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) - if isinstance(intersections, ShapeList): - return intersections - if isinstance(intersections, Shape) and not intersections.is_null: - return ShapeList([intersections]) - return ShapeList() - - def expand_compound(compound: Compound) -> ShapeList: - shapes = ShapeList(compound.children) - for shape_type in [Vertex, Edge, Wire, Face, Shell, Solid]: - shapes.extend(compound.get_type(shape_type)) - return shapes - - def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: - # Remove lower order shapes from list which *appear* to be part of - # a higher order shape using a lazy distance check - # (sufficient for vertices, may be an issue for higher orders) - order_groups = [] - for order in orders: - order_groups.append( - ShapeList([s for s in shapes if isinstance(s, order)]) - ) - - filtered_shapes = order_groups[-1] - for i in range(len(order_groups) - 1): - los = order_groups[i] - his: list = sum(order_groups[i + 1 :], []) - filtered_shapes.extend( - ShapeList( - lo - for lo in los - if all(lo.distance_to(hi) > TOLERANCE for hi in his) - ) - ) - - return filtered_shapes - - common_set: ShapeList[Shape] = expand_compound(self) - target: ShapeList | Shape - for other in to_intersect: - # Conform target type - match other: - case Axis(): - # BRepAlgoAPI_Section seems happier if Edge isnt infinite - bbox = self.bounding_box() - dist = self.distance_to(other.position) - dist = dist if dist >= 1 else 1 - target = Edge.make_line( - other.position - other.direction * bbox.diagonal * dist, - other.position + other.direction * bbox.diagonal * dist, - ) - case Plane(): - target = Face(other) - case Vector(): - target = Vertex(other) - case Location(): - target = Vertex(other.position) - case Compound(): - target = expand_compound(other) - case _ if issubclass(type(other), Shape): - target = other - case _: - raise ValueError(f"Unsupported type to_intersect: {type(other)}") - - # Find common matches - common: list[Vertex | Edge | Wire | Face | Shell | Solid] = [] - result: ShapeList - for obj in common_set: - if isinstance(target, Shape): - target = ShapeList([target]) - result = ShapeList() - for t in target: - operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( - BRepAlgoAPI_Section() - ) - result.extend(bool_op((obj,), (t,), operation)) - if ( - not isinstance(obj, Edge | Wire) - and not isinstance(t, Edge | Wire) - ) or ( - isinstance(obj, Solid | Compound) - or isinstance(t, Solid | Compound) - ): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - # Many Solid + Edge combinations need Common - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (t,), operation)) - - if result: - common.extend(result) - - expanded: ShapeList = ShapeList() - if common: - for shape in common: - if isinstance(shape, Compound): - expanded.extend(expand_compound(shape)) - else: - expanded.append(shape) - - if expanded: - common_set = ShapeList() - for shape in expanded: - if isinstance(shape, Wire): - common_set.extend(shape.edges()) - elif isinstance(shape, Shell): - common_set.extend(shape.faces()) - else: - common_set.append(shape) - common_set = to_vertex(set(to_vector(common_set))) - common_set = filter_shapes_by_order( - common_set, [Vertex, Edge, Face, Solid] - ) - else: - return None - - return ShapeList(common_set) + return ShapeList(set(results)) def project_to_viewport( self, diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index d77f254..de5d335 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -88,7 +88,12 @@ from OCP.BRepOffsetAPI import BRepOffsetAPI_MakeOffset from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepProj import BRepProj_Projection from OCP.BRepTools import BRepTools, BRepTools_WireExplorer -from OCP.GC import GC_MakeArcOfCircle, GC_MakeArcOfEllipse, GC_MakeArcOfParabola, GC_MakeArcOfHyperbola +from OCP.GC import ( + GC_MakeArcOfCircle, + GC_MakeArcOfEllipse, + GC_MakeArcOfParabola, + GC_MakeArcOfHyperbola, +) from OCP.GCPnts import ( GCPnts_AbscissaPoint, GCPnts_QuasiUniformDeflection, @@ -708,126 +713,53 @@ class Mixin1D(Shape[TOPODS]): return Vector(curve.Value(umax)) - def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Vertex | Edge]: - """Intersect Edge with Shape or geometry object + def _intersect( + self, + other: Shape, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Single-object intersection for Edge/Wire. + + Returns same-dimension overlap or crossing geometry: + - 1D + 1D → Edge (collinear overlap) + Vertex (crossing) + - 1D + Face/Solid/Compound → delegates to other._intersect(self) Args: - to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect - - Returns: - ShapeList[Vertex | Edge] | None: ShapeList of vertices and/or edges + other: Shape to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts + (only relevant when Solids are involved) """ - def to_vector(objs: Iterable) -> ShapeList: - return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + results: ShapeList = ShapeList() - def to_vertex(objs: Iterable) -> ShapeList: - return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + # 1D + 1D: Common (collinear overlap) + Section (crossing vertices) + if isinstance(other, (Edge, Wire)): + common = self._bool_op( + (self,), (other,), BRepAlgoAPI_Common(), as_list=True + ) + results.extend(common.expand()) + section = self._bool_op( + (self,), (other,), BRepAlgoAPI_Section(), as_list=True + ) + # Extract vertices from section (edges already in Common for wires) + for shape in section: + if isinstance(shape, Vertex) and not shape.is_null: + results.append(shape) - def bool_op( - args: Sequence, - tools: Sequence, - operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, - ) -> ShapeList: - # Wrap Shape._bool_op for corrected output - intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) - if isinstance(intersections, ShapeList): - return intersections or ShapeList() - if isinstance(intersections, Shape) and not intersections.is_null: - return ShapeList([intersections]) - return ShapeList() + # 1D + Vertex: point containment on edge + elif isinstance(other, Vertex): + if other.distance_to(self) <= tolerance: + results.append(other) - def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: - # Remove lower order shapes from list which *appear* to be part of - # a higher order shape using a lazy distance check - # (sufficient for vertices, may be an issue for higher orders) - order_groups = [] - for order in orders: - order_groups.append( - ShapeList([s for s in shapes if isinstance(s, order)]) - ) + # Delegate to higher-order shapes (Face, Solid, etc.) + else: + result = other._intersect(self, tolerance, include_touched) + if result: + results.extend(result) - filtered_shapes = order_groups[-1] - for i in range(len(order_groups) - 1): - los = order_groups[i] - his: list = sum(order_groups[i + 1 :], []) - filtered_shapes.extend( - ShapeList( - lo - for lo in los - if all(lo.distance_to(hi) > TOLERANCE for hi in his) - ) - ) - - return filtered_shapes - - common_set: ShapeList[Vertex | Edge | Wire] = ShapeList([self]) - target: Shape | Plane - for other in to_intersect: - # Conform target type - match other: - case Axis(): - # BRepAlgoAPI_Section seems happier if Edge isnt infinite - bbox = self.bounding_box() - dist = self.distance_to(other.position) - dist = dist if dist >= 1 else 1 - target = Edge.make_line( - other.position - other.direction * bbox.diagonal * dist, - other.position + other.direction * bbox.diagonal * dist, - ) - case Plane(): - target = other - case Vector(): - target = Vertex(other) - case Location(): - target = Vertex(other.position) - case _ if issubclass(type(other), Shape): - target = other - case _: - raise ValueError(f"Unsupported type to_intersect: {type(other)}") - - # Find common matches - common: list[Vertex | Edge | Wire] = [] - result: ShapeList | None - for obj in common_set: - match (obj, target): - case (_, Plane()): - assert isinstance(other.wrapped, gp_Pln) - target = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face()) - operation1 = BRepAlgoAPI_Section() - result = bool_op((obj,), (target,), operation1) - operation2 = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (target,), operation2)) - - case (_, Vertex() | Edge() | Wire()): - operation1 = BRepAlgoAPI_Section() - section = bool_op((obj,), (target,), operation1) - result = section - if not section: - operation2 = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (target,), operation2)) - - case _ if issubclass(type(target), Shape): - result = target.intersect(obj) - - if result: - common.extend(result) - - if common: - common_set = ShapeList() - for shape in common: - if isinstance(shape, Wire): - common_set.extend(shape.edges()) - else: - common_set.append(shape) - common_set = to_vertex(set(to_vector(common_set))) - common_set = filter_shapes_by_order(common_set, [Vertex, Edge]) - else: - return None - - return ShapeList(common_set) + return results if results else None def location_at( self, diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index f443d62..1aec711 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1343,66 +1343,66 @@ class Shape(NodeMixin, Generic[TOPODS]): ) def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Self]: - """Intersection of the arguments and this shape + self, + *to_intersect: Shape | Vector | Location | Axis | Plane, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Find where bodies/interiors meet (overlap or crossing geometry). + + This is the main entry point for intersection operations. Handles + geometry conversion and delegates to subclass _intersect() implementations. + + Semantics: + - Multiple arguments use AND (chaining): c.intersect(s1, s2) = c ∩ s1 ∩ s2 + - Compound arguments use OR (distribution): c.intersect(Compound([s1, s2])) + = (c ∩ s1) ∪ (c ∩ s2) Args: - to_intersect (sequence of Union[Shape, Axis, Plane]): Shape(s) to - intersect with + to_intersect: Shape(s) or geometry objects to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts without interior + overlap (only relevant when Solids are involved) Returns: - None | ShapeList[Self]: Resulting ShapeList may contain different class - than self + ShapeList of intersection results, or None if no intersection """ - def _to_vertex(vec: Vector) -> Vertex: - """Helper method to convert vector to shape""" - return self.__class__.cast( - downcast( - BRepBuilderAPI_MakeVertex(gp_Pnt(vec.X, vec.Y, vec.Z)).Vertex() - ) - ) + if not to_intersect: + return None - def _to_edge(axis: Axis) -> Edge: - """Helper method to convert axis to shape""" - return self.__class__.cast( - BRepBuilderAPI_MakeEdge( - Geom_Line( - axis.position.to_pnt(), - axis.direction.to_dir(), - ) - ).Edge() - ) + # Runtime import to avoid circular imports. Allows type safe actions in helpers + from build123d.topology.helpers import convert_to_shapes - def _to_face(plane: Plane) -> Face: - """Helper method to convert plane to shape""" - return self.__class__.cast(BRepBuilderAPI_MakeFace(plane.wrapped).Face()) + shapes = convert_to_shapes(self, to_intersect) - # Convert any geometry objects into their respective topology objects - objs = [] - for obj in to_intersect: - if isinstance(obj, Vector): - objs.append(_to_vertex(obj)) - elif isinstance(obj, Axis): - objs.append(_to_edge(obj)) - elif isinstance(obj, Plane): - objs.append(_to_face(obj)) - elif isinstance(obj, Location): - if obj.wrapped is None: - raise ValueError("Cannot intersect with an empty location") - objs.append(_to_vertex(tcast(Vector, obj.position))) - else: - objs.append(obj) + # Chained iteration for AND semantics: c.intersect(s1, s2) = c ∩ s1 ∩ s2 + common_set = ShapeList([self]) + for other in shapes: + next_set = ShapeList() + for obj in common_set: + result = obj._intersect(other, tolerance, include_touched) + if result: + next_set.extend(result.expand()) + if not next_set: + return None # AND semantics: if any step fails, no intersection + common_set = ShapeList(set(next_set)) # deduplicate + return common_set if common_set else None - # Find the shape intersections - intersect_op = BRepAlgoAPI_Common() - intersections = self._bool_op((self,), objs, intersect_op) - if isinstance(intersections, ShapeList): - return intersections or None - if isinstance(intersections, Shape) and not intersections.is_null: - return ShapeList([intersections]) - return None + def touch(self, other: Shape, tolerance: float = 1e-6) -> ShapeList: + """Find boundary contacts between this shape and another. + + Base implementation returns empty ShapeList. Subclasses (Mixin2D, Mixin3D, + Compound) override this to provide actual touch detection. + + Args: + other: Shape to find contacts with + tolerance: tolerance for contact detection + + Returns: + ShapeList of contact shapes (empty for base implementation) + """ + return ShapeList() def is_equal(self, other: Shape) -> bool: """Returns True if two shapes are equal, i.e. if they share the same diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 5b0fb30..daf9f9f 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -62,6 +62,7 @@ from typing_extensions import Self import OCP.TopAbs as ta from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Section from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeSolid +from OCP.BRepExtrema import BRepExtrema_DistShapeShape from OCP.BRepClass3d import BRepClass3d_SolidClassifier from OCP.BRepFeat import BRepFeat_MakeDPrism from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet @@ -95,6 +96,7 @@ from OCP.TopoDS import ( TopoDS_Shell, TopoDS_Solid, TopoDS_Wire, + TopoDS_Compound, ) from OCP.gp import gp_Ax2, gp_Pnt from build123d.build_enums import CenterOf, GeomType, Keep, Kind, Transition, Until @@ -425,131 +427,68 @@ class Mixin3D(Shape[TOPODS]): return return_value - def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Vertex | Edge | Face | Solid]: - """Intersect Solid with Shape or geometry object + def _intersect( + self, + other: Shape, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Single-object intersection for Solid. + + Returns same-dimension overlap or crossing geometry: + - Solid + Solid → Solid (volume overlap) + - Solid + Face → Face (portion in/on solid) + - Solid + Edge → Edge (portion through solid) Args: - to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect - - Returns: - ShapeList[Vertex | Edge | Face | Solid] | None: ShapeList of vertices, edges, - faces, and/or solids. + other: Shape to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts + (shapes touching the solid's surface without penetrating) """ - def to_vector(objs: Iterable) -> ShapeList: - return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + results: ShapeList = ShapeList() - def to_vertex(objs: Iterable) -> ShapeList: - return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) + # Solid + Solid/Face/Shell/Edge/Wire: use Common + if isinstance(other, (Solid, Face, Shell, Edge, Wire)): + intersection = self._bool_op( + (self,), (other,), BRepAlgoAPI_Common(), as_list=True + ) + results.extend(intersection.expand()) + # Solid + Vertex: point containment check + elif isinstance(other, Vertex): + if self.is_inside(Vector(other), tolerance): + results.append(other) - def bool_op( - args: Sequence, - tools: Sequence, - operation: BRepAlgoAPI_Common | BRepAlgoAPI_Section, - ) -> ShapeList: - # Wrap Shape._bool_op for corrected output - intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) - if isinstance(intersections, ShapeList): - return intersections or ShapeList() - if isinstance(intersections, Shape) and not intersections.is_null: - return ShapeList([intersections]) - return ShapeList() + # Delegate to higher-order shapes (Compound) + else: + result = other._intersect(self, tolerance, include_touched) + if result: + results.extend(result) - def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: - # Remove lower order shapes from list which *appear* to be part of - # a higher order shape using a lazy distance check - # (sufficient for vertices, may be an issue for higher orders) - order_groups = [] - for order in orders: - order_groups.append( - ShapeList([s for s in shapes if isinstance(s, order)]) + # Add boundary contacts if requested (only Solid has touch method) + if include_touched and isinstance(self, Solid): + results.extend(self.touch(other, tolerance)) + results = ShapeList(set(results)) + # Filter: remove lower-dimensional shapes that are boundaries of + # higher-dimensional results (e.g., vertices on edges, edges on faces) + edges_in_results = [r for r in results if isinstance(r, Edge)] + faces_in_results = [r for r in results if isinstance(r, Face)] + results = ShapeList( + r + for r in results + if not ( + isinstance(r, Vertex) + and any(r.distance_to(e) <= tolerance for e in edges_in_results) ) - - filtered_shapes = order_groups[-1] - for i in range(len(order_groups) - 1): - los = order_groups[i] - his: list = sum(order_groups[i + 1 :], []) - filtered_shapes.extend( - ShapeList( - lo - for lo in los - if all(lo.distance_to(hi) > TOLERANCE for hi in his) + and not ( + isinstance(r, Edge) + and any( + r.center().distance_to(f) <= tolerance for f in faces_in_results ) ) - - return filtered_shapes - - common_set: ShapeList[Vertex | Edge | Face | Solid] = ShapeList([self]) - target: Shape - for other in to_intersect: - # Conform target type - match other: - case Axis(): - # BRepAlgoAPI_Section seems happier if Edge isnt infinite - bbox = self.bounding_box() - dist = self.distance_to(other.position) - dist = dist if dist >= 1 else 1 - target = Edge.make_line( - other.position - other.direction * bbox.diagonal * dist, - other.position + other.direction * bbox.diagonal * dist, - ) - case Plane(): - target = Face(other) - case Vector(): - target = Vertex(other) - case Location(): - target = Vertex(other.position) - case _ if issubclass(type(other), Shape): - target = other - case _: - raise ValueError(f"Unsupported type to_intersect: {type(other)}") - - # Find common matches - common: list[Vertex | Edge | Wire | Face | Shell | Solid] = [] - result: ShapeList | None - for obj in common_set: - match (obj, target): - case (_, Vertex() | Edge() | Wire() | Face() | Shell() | Solid()): - operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( - BRepAlgoAPI_Section() - ) - result = bool_op((obj,), (target,), operation) - if ( - not isinstance(obj, Edge | Wire) - and not isinstance(target, (Edge | Wire)) - ) or (isinstance(obj, Solid) or isinstance(target, Solid)): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - # Many Solid + Edge combinations need Common - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (target,), operation)) - - case _ if issubclass(type(target), Shape): - result = target.intersect(obj) - - if result: - common.extend(result) - - if common: - common_set = ShapeList() - for shape in common: - if isinstance(shape, Wire): - common_set.extend(shape.edges()) - elif isinstance(shape, Shell): - common_set.extend(shape.faces()) - else: - common_set.append(shape) - common_set = to_vertex(set(to_vector(common_set))) - common_set = filter_shapes_by_order( - common_set, [Vertex, Edge, Face, Solid] - ) - else: - return None - - return ShapeList(common_set) + ) + return results if results else None def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool: """Returns whether or not the point is inside a solid or compound @@ -794,6 +733,178 @@ class Solid(Mixin3D[TopoDS_Solid]): # when density == 1, mass == volume return Shape.compute_mass(self) + # ---- Instance Methods ---- + + def touch( + self, other: Shape, tolerance: float = 1e-6 + ) -> ShapeList[Vertex | Edge | Face]: + """Find where this Solid's boundary contacts another shape. + + Returns geometry where boundaries contact without interior overlap: + - Solid + Solid → Face + Edge + Vertex (all boundary contacts) + - Solid + Face/Shell → Edge + Vertex (face boundary on solid boundary) + - Solid + Edge/Wire → Vertex (edge endpoints on solid boundary) + - Solid + Vertex → Vertex if on boundary + - Solid + Compound → distributes over compound elements + + Args: + other: Shape to check boundary contacts with + tolerance: tolerance for contact detection + + Returns: + ShapeList of boundary contact geometry (empty if no contact) + """ + results: ShapeList = ShapeList() + + if isinstance(other, Solid): + # Solid + Solid: find all boundary contacts (faces, edges, vertices) + # Pre-calculate bounding boxes (optimal=False for speed, used only for filtering) + self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()] + other_faces = [(f, f.bounding_box(optimal=False)) for f in other.faces()] + self_edges = [(e, e.bounding_box(optimal=False)) for e in self.edges()] + other_edges = [(e, e.bounding_box(optimal=False)) for e in other.edges()] + + # Face-Face contacts (collect first) + found_faces: ShapeList = ShapeList() + for sf, sf_bb in self_faces: + for of, of_bb in other_faces: + if not sf_bb.overlaps(of_bb, tolerance): + continue + common = self._bool_op( + (sf,), (of,), BRepAlgoAPI_Common(), as_list=True + ) + found_faces.extend(s for s in common if not s.is_null) + results.extend(found_faces) + + # Edge-Edge contacts (skip if on any found face) + found_edges: ShapeList = ShapeList() + for se, se_bb in self_edges: + for oe, oe_bb in other_edges: + if not se_bb.overlaps(oe_bb, tolerance): + continue + common = self._bool_op( + (se,), (oe,), BRepAlgoAPI_Common(), as_list=True + ) + for s in common: + if s.is_null: + continue + # Skip if edge is on any found face (use midpoint) + mid = s.center() + on_face = any( + f.distance_to(mid) <= tolerance for f in found_faces + ) + if not on_face: + found_edges.append(s) + results.extend(found_edges) + + # Vertex-Vertex contacts (skip if on any found face or edge) + found_vertices: ShapeList = ShapeList() + for sv in self.vertices(): + for ov in other.vertices(): + if sv.distance_to(ov) <= tolerance: + on_face = any( + sv.distance_to(f) <= tolerance for f in found_faces + ) + on_edge = any( + sv.distance_to(e) <= tolerance for e in found_edges + ) + already = any( + sv.distance_to(v) <= tolerance for v in found_vertices + ) + if not on_face and not on_edge and not already: + results.append(sv) + found_vertices.append(sv) + break + + # Tangent contacts (skip if on any found face or edge) + # Use Mixin2D.touch() on face pairs to find tangent contacts (e.g., sphere touching box) + for sf, sf_bb in self_faces: + for of, of_bb in other_faces: + if not sf_bb.overlaps(of_bb, tolerance): + continue + tangent_vertices = sf.touch(of, tolerance, found_faces, found_edges) + for v in tangent_vertices: + already_found = any( + v.distance_to(existing) <= tolerance + for existing in found_vertices + ) + if not already_found: + results.append(v) + found_vertices.append(v) + + elif isinstance(other, (Face, Shell)): + # Solid + Face: find where face boundary meets solid boundary + # Pre-calculate bounding boxes (optimal=False for speed, used only for filtering) + self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()] + other_edges = [(e, e.bounding_box(optimal=False)) for e in other.edges()] + + # Check face's edges touching solid's faces + for oe, oe_bb in other_edges: + for sf, sf_bb in self_faces: + if not oe_bb.overlaps(sf_bb, tolerance): + continue + common = self._bool_op( + (oe,), (sf,), BRepAlgoAPI_Common(), as_list=True + ) + results.extend(s for s in common if not s.is_null) + # Check face's vertices touching solid's edges (corner coincident) + for ov in other.vertices(): + for se in self.edges(): + if ov.distance_to(se) <= tolerance: + results.append(ov) + break + + elif isinstance(other, (Edge, Wire)): + # Solid + Edge: find where edge endpoints touch solid boundary + # Pre-calculate bounding boxes (optimal=False for speed, used only for filtering) + self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()] + other_bb = other.bounding_box(optimal=False) + + for ov in other.vertices(): + for sf, _ in self_faces: + if ov.distance_to(sf) <= tolerance: + results.append(ov) + break + # Use BRepExtrema to find tangent contacts (edge tangent to surface) + # Only valid if edge doesn't penetrate solid (Common returns nothing) + # If Common returns something, contact points are entry/exit (intersect, not touches) + common_result = self._bool_op( + (self,), (other,), BRepAlgoAPI_Common(), as_list=True + ) + if not common_result: # No penetration - could be tangent + for sf, sf_bb in self_faces: + if not sf_bb.overlaps(other_bb, tolerance): + continue + extrema = BRepExtrema_DistShapeShape(sf.wrapped, other.wrapped) + if extrema.IsDone() and extrema.Value() <= tolerance: + for i in range(1, extrema.NbSolution() + 1): + pnt1 = extrema.PointOnShape1(i) + pnt2 = extrema.PointOnShape2(i) + # Verify points are actually close + if pnt1.Distance(pnt2) > tolerance: + continue + new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z()) + # Only add if not already covered by existing results + already_found = any( + new_vertex.distance_to(r) <= tolerance for r in results + ) + if not already_found: + results.append(new_vertex) + + elif isinstance(other, Vertex): + # Solid + Vertex: check if vertex is on boundary + for sf in self.faces(): + if other.distance_to(sf) <= tolerance: + results.append(other) + break + + # Delegate to other shapes (Compound iterates, others return empty) + else: + results.extend(other.touch(self, tolerance)) + + # Remove duplicates using Shape's __hash__ and __eq__ + return ShapeList(set(results)) + # ---- Class Methods ---- @classmethod diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index c841291..5548b3c 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -69,6 +69,7 @@ from OCP.BRep import BRep_Builder, BRep_Tool from OCP.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface from OCP.BRepAlgo import BRepAlgo from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Section +from OCP.BRepExtrema import BRepExtrema_DistShapeShape from OCP.BRepBuilderAPI import ( BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, @@ -115,7 +116,13 @@ from OCP.TColStd import ( TColStd_HArray2OfReal, ) from OCP.TopExp import TopExp -from OCP.TopoDS import TopoDS, TopoDS_Face, TopoDS_Shape, TopoDS_Shell, TopoDS_Solid +from OCP.TopoDS import ( + TopoDS, + TopoDS_Face, + TopoDS_Shape, + TopoDS_Shell, + TopoDS_Solid, +) from OCP.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_ListOfShape from ocp_gordon import interpolate_curve_network from typing_extensions import Self @@ -274,127 +281,205 @@ class Mixin2D(ABC, Shape[TOPODS]): return result - def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> None | ShapeList[Vertex | Edge | Face]: - """Intersect Face with Shape or geometry object + def _intersect( + self, + other: Shape, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Single-object intersection for Face/Shell. + + Returns same-dimension overlap or crossing geometry: + - 2D + 2D → Face (coplanar overlap) + Edge (crossing curves) + - 2D + Edge → Edge (on surface) + Vertex (piercing) + - 2D + Solid/Compound → delegates to other._intersect(self) Args: - to_intersect (Shape | Vector | Location | Axis | Plane): objects to intersect - - Returns: - ShapeList[Vertex | Edge | Face] | None: ShapeList of vertices, edges, and/or - faces. + other: Shape to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts + (only relevant when Solids are involved) """ - def to_vector(objs: Iterable) -> ShapeList: - return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs]) + def filter_edges( + section_edges: ShapeList[Edge], common_edges: ShapeList[Edge] + ) -> ShapeList[Edge]: + """Filter section edges, keeping only edges not on common face boundaries.""" + # Pre-compute bounding boxes for both sets (optimal=False for speed, filtering only) + section_bboxes = [(e, e.bounding_box(optimal=False)) for e in section_edges] + common_bboxes = [ + (ce, ce.bounding_box(optimal=False)) for ce in common_edges + ] - def to_vertex(objs: Iterable) -> ShapeList: - return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs]) - - def bool_op( - args: Sequence, - tools: Sequence, - operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common, - ) -> ShapeList: - # Wrap Shape._bool_op for corrected output - intersections: Shape | ShapeList = Shape()._bool_op(args, tools, operation) - if isinstance(intersections, ShapeList): - return intersections or ShapeList() - if isinstance(intersections, Shape) and not intersections.is_null: - return ShapeList([intersections]) - return ShapeList() - - def filter_shapes_by_order(shapes: ShapeList, orders: list) -> ShapeList: - # Remove lower order shapes from list which *appear* to be part of - # a higher order shape using a lazy distance check - # (sufficient for vertices, may be an issue for higher orders) - order_groups = [] - for order in orders: - order_groups.append( - ShapeList([s for s in shapes if isinstance(s, order)]) + # Filter: remove section edges that coincide with common face boundaries + filtered: ShapeList = ShapeList() + for edge, edge_bbox in section_bboxes: + is_common = any( + edge_bbox.overlaps(ce_bbox, tolerance) + and edge.distance_to(ce) <= tolerance + for ce, ce_bbox in common_bboxes ) + if not is_common: + filtered.append(edge) + return filtered - filtered_shapes = order_groups[-1] - for i in range(len(order_groups) - 1): - los = order_groups[i] - his: list = sum(order_groups[i + 1 :], []) - filtered_shapes.extend( - ShapeList( - lo - for lo in los - if all(lo.distance_to(hi) > TOLERANCE for hi in his) - ) - ) + results: ShapeList = ShapeList() - return filtered_shapes + # 2D + 2D: Common (coplanar overlap) AND Section (crossing curves) + if isinstance(other, (Face, Shell)): + # Common for coplanar overlap + common = self._bool_op( + (self,), (other,), BRepAlgoAPI_Common(), as_list=True + ) + common_faces = common.expand() + results.extend(common_faces) - common_set: ShapeList[Vertex | Edge | Face | Shell] = ShapeList([self]) - target: Shape - for other in to_intersect: - # Conform target type - match other: - case Axis(): - # BRepAlgoAPI_Section seems happier if Edge isnt infinite - bbox = self.bounding_box() - dist = self.distance_to(other.position) - dist = dist if dist >= 1 else 1 - target = Edge.make_line( - other.position - other.direction * bbox.diagonal * dist, - other.position + other.direction * bbox.diagonal * dist, - ) - case Plane(): - target = Face(other) - case Vector(): - target = Vertex(other) - case Location(): - target = Vertex(other.position) - case _ if issubclass(type(other), Shape): - target = other - case _: - raise ValueError(f"Unsupported type to_intersect: {type(other)}") + # Section for crossing curves (only edges, not vertices) + # Vertices from Section are boundary contacts (touch), not intersections + section = self._bool_op( + (self,), (other,), BRepAlgoAPI_Section(), as_list=True + ) + section_edges = ShapeList( + [s for s in section if isinstance(s, Edge)] + ).expand() - # Find common matches - common: list[Vertex | Edge | Wire | Face | Shell] = [] - result: ShapeList | None - for obj in common_set: - match (obj, target): - case (_, Vertex() | Edge() | Wire() | Face() | Shell()): - operation: BRepAlgoAPI_Section | BRepAlgoAPI_Common = ( - BRepAlgoAPI_Section() - ) - result = bool_op((obj,), (target,), operation) - if not isinstance(obj, Edge | Wire) and not isinstance( - target, (Edge | Wire) - ): - # Face + Edge combinations may produce an intersection - # with Common but always with Section. - # No easy way to deduplicate - operation = BRepAlgoAPI_Common() - result.extend(bool_op((obj,), (target,), operation)) - - case _ if issubclass(type(target), Shape): - result = target.intersect(obj) - - if result: - common.extend(result) - - if common: - common_set = ShapeList() - for shape in common: - if isinstance(shape, Wire): - common_set.extend(shape.edges()) - elif isinstance(shape, Shell): - common_set.extend(shape.faces()) - else: - common_set.append(shape) - common_set = to_vertex(set(to_vector(common_set))) - common_set = filter_shapes_by_order(common_set, [Vertex, Edge, Face]) + if not common_faces: + # No coplanar overlap - all section edges are valid crossings + results.extend(section_edges) else: - return None + # Filter out edges on common face boundaries + # (Section returns boundary of overlap region which are not crossings) + common_edges: ShapeList[Edge] = ShapeList() + for face in common_faces: + common_edges.extend(face.edges()) + results.extend(filter_edges(section_edges, common_edges)) - return ShapeList(common_set) + # 2D + Edge: Section for intersection + elif isinstance(other, (Edge, Wire)): + section = self._bool_op( + (self,), (other,), BRepAlgoAPI_Section(), as_list=True + ) + results.extend(section) + + # 2D + Vertex: point containment on surface + elif isinstance(other, Vertex): + if other.distance_to(self) <= tolerance: + results.append(other) + + # Delegate to higher-order shapes (Solid, etc.) + else: + result = other._intersect(self, tolerance, include_touched) + if result: + results.extend(result) + + # Add boundary contacts if requested + if include_touched and isinstance(other, (Face, Shell)): + found_faces = ShapeList(r for r in results if isinstance(r, Face)) + found_edges = ShapeList(r for r in results if isinstance(r, Edge)) + results.extend(self.touch(other, tolerance, found_faces, found_edges)) + + return results if results else None + + def touch( + self, + other: Shape, + tolerance: float = 1e-6, + _found_faces: ShapeList | None = None, + _found_edges: ShapeList | None = None, + ) -> ShapeList: + """Find boundary contacts between this 2D shape and another shape. + + Returns the highest-dimensional contact at each location, filtered to + avoid returning lower-dimensional boundaries of higher-dimensional contacts. + + For Face/Shell: + - Face + Face → Vertex (shared corner or crossing point without edge/face overlap) + - Face + Edge/Vertex → no touch (intersect already returns dim 0) + + Args: + other: Shape to find contacts with + tolerance: tolerance for contact detection + _found_faces: (internal) pre-found faces to filter against + _found_edges: (internal) pre-found edges to filter against + + Returns: + ShapeList of contact shapes (Vertex only for 2D+2D) + """ + results: ShapeList = ShapeList() + + if isinstance(other, (Face, Shell)): + # Get intersect results to filter against if not provided + if _found_faces is None or _found_edges is None: + _intersect_results = self._intersect( + other, tolerance, include_touched=False + ) + _found_faces = ShapeList() + _found_edges = ShapeList() + if _intersect_results: + for r in _intersect_results: + if isinstance(r, Face): + _found_faces.append(r) + elif isinstance(r, Edge): + _found_edges.append(r) + + found_faces = _found_faces + found_edges = _found_edges + + # Use BRepExtrema to find all contact points (vertex-vertex, vertex-edge, vertex-face) + found_vertices: ShapeList = ShapeList() + extrema = BRepExtrema_DistShapeShape(self.wrapped, other.wrapped) + if extrema.IsDone() and extrema.Value() <= tolerance: + for i in range(1, extrema.NbSolution() + 1): + pnt1 = extrema.PointOnShape1(i) + pnt2 = extrema.PointOnShape2(i) + if pnt1.Distance(pnt2) > tolerance: + continue + + contact_pt = Vector(pnt1.X(), pnt1.Y(), pnt1.Z()) + new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z()) + + # Check if point is on edge boundary of either face + on_self_edge = any( + new_vertex.distance_to(e) <= tolerance for e in self.edges() + ) + on_other_edge = any( + new_vertex.distance_to(e) <= tolerance for e in other.edges() + ) + + # Skip if point is on edges of both faces (edge-edge intersection) + if on_self_edge and on_other_edge: + continue + + # For tangent contacts (point in interior of both faces), + # verify normals are parallel or anti-parallel (tangent surfaces) + if not on_self_edge and not on_other_edge: + normal1 = self.normal_at(contact_pt) + normal2 = other.normal_at(contact_pt) + dot = normal1.dot(normal2) + if abs(dot) < 0.99: # Not parallel or anti-parallel + continue + + # Filter: only keep vertices that are not boundaries of + # higher-dimensional contacts (faces or edges) and not duplicates + on_face = any( + new_vertex.distance_to(f) <= tolerance for f in found_faces + ) + on_edge = any( + new_vertex.distance_to(e) <= tolerance for e in found_edges + ) + already = any( + new_vertex.distance_to(v) <= tolerance for v in found_vertices + ) + if not on_face and not on_edge and not already: + results.append(new_vertex) + found_vertices.append(new_vertex) + + # Face + Edge/Vertex: no touch (intersect already covers dim 0) + # Delegate to other shapes (Compound iterates, others return empty) + else: + results.extend(other.touch(self, tolerance)) + + return results @abstractmethod def location_at(self, *args: Any, **kwargs: Any) -> Location: diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index dc536e9..205a59a 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -168,44 +168,31 @@ class Vertex(Shape[TopoDS_Vertex]): """extrude - invalid operation for Vertex""" raise NotImplementedError("Vertices can't be created by extrusion") - def intersect( - self, *to_intersect: Shape | Vector | Location | Axis | Plane - ) -> ShapeList[Vertex] | None: - """Intersection of vertex and geometric objects or shapes. + def _intersect( + self, + other: Shape, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Single-object intersection for Vertex. + + For a vertex (0D), intersection means the vertex lies on/in the other shape. Args: - to_intersect (sequence of [Shape | Vector | Location | Axis | Plane]): - Objects(s) to intersect with - - Returns: - ShapeList[Vertex] | None: Vertex intersection in a ShapeList or None + other: Shape to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts + (only relevant when Solids are involved) """ - common = Vector(self) - result: Shape | ShapeList[Shape] | Vector | None - for obj in to_intersect: - # Treat as Vector, otherwise call intersection from Shape - match obj: - case Vertex(): - result = common.intersect(Vector(obj)) - case Vector() | Location() | Axis() | Plane(): - result = obj.intersect(common) - case _ if issubclass(type(obj), Shape): - result = obj.intersect(self) - case _: - raise ValueError(f"Unsupported type to_intersect:: {type(obj)}") - if isinstance(result, Vector) and result == common: - pass - elif ( - isinstance(result, list) - and len(result) == 1 - and Vector(result[0]) == common - ): - pass - else: - return None + if isinstance(other, Vertex): + # Vertex + Vertex: check distance + if self.distance_to(other) <= tolerance: + return ShapeList([self]) + return None - return ShapeList([self]) + # Delegate to higher-dimensional shape (including Compound) + return other._intersect(self, tolerance, include_touched) # ---- Instance Methods ----