From c946d6203b3582f8061f0fedf7231c33e87f43b7 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:42:08 +0100 Subject: [PATCH 01/42] Add an overlaps method to Bounding Boxes, helpful for filtering --- src/build123d/geometry.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/build123d/geometry.py b/src/build123d/geometry.py index ad04b0d..7a356fb 100644 --- a/src/build123d/geometry.py +++ b/src/build123d/geometry.py @@ -1152,6 +1152,20 @@ class BoundBox: and second_box.max.Z < self.max.Z ) + def overlaps(self, other: BoundBox, tolerance: float = TOLERANCE) -> bool: + """Check if this bounding box overlaps with another. + + Args: + other: BoundBox to check overlap with + tolerance: Distance tolerance for overlap detection + + Returns: + True if bounding boxes overlap (share any volume), False otherwise + """ + if self.wrapped is None or other.wrapped is None: + return False + return self.wrapped.Distance(other.wrapped) <= tolerance + def to_align_offset(self, align: Align2DType | Align3DType) -> Vector: """Amount to move object to achieve the desired alignment""" return to_align_offset(self.min, self.max, align) From 50a06f7603e7e42780a62a0079214f86d247b2f5 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:43:05 +0100 Subject: [PATCH 02/42] Add a method to expand Shell, Wire and Compound objects in ShapeLists --- src/build123d/topology/shape_core.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 6e84222..d68cf90 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -2606,6 +2606,26 @@ class ShapeList(list[T]): """Differences between two ShapeLists operator -""" return ShapeList(set(self) - set(other)) + def expand(self) -> ShapeList: + """Expand by dissolving compounds, wires, and shells, filtering nulls. + + Returns: + ShapeList with compounds dissolved to children, wires to edges, + shells to faces, and nulls filtered out + """ + expanded: ShapeList = ShapeList() + for shape in self: + if hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Compound): + # Recursively expand nested compounds + expanded.extend(ShapeList(list(shape)).expand()) + elif hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Shell): + expanded.extend(shape.faces()) + elif hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Wire): + expanded.extend(shape.edges()) + elif not shape.is_null: + expanded.append(shape) + return expanded + def center(self) -> Vector: """The average of the center of objects within the ShapeList""" if not self: From b5ddeeacc638def45a980b59452b1cd46da8c726 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:44:14 +0100 Subject: [PATCH 03/42] add a flag keywork as_list to _bool_op to simplify intersection result handling --- src/build123d/topology/shape_core.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index d68cf90..f443d62 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -2271,7 +2271,8 @@ class Shape(NodeMixin, Generic[TOPODS]): args: Iterable[Shape], tools: Iterable[Shape], operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, - ) -> Self | ShapeList[Self]: + as_list: bool = False, + ) -> Self | ShapeList: """Generic boolean operation Args: @@ -2279,8 +2280,11 @@ class Shape(NodeMixin, Generic[TOPODS]): tools: Iterable[Shape]: operation: Union[BRepAlgoAPI_BooleanOperation: BRepAlgoAPI_Splitter]: + as_list: If True, always return ShapeList (wrapping single results, + returning empty ShapeList for null results) Returns: + Shape, ShapeList, or empty ShapeList depending on result and as_list """ args = list(args) @@ -2340,6 +2344,12 @@ class Shape(NodeMixin, Generic[TOPODS]): result = highest_order[0].cast(topo_result) base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) + # Handle as_list mode + if as_list: + if result.is_null: + return ShapeList() + return ShapeList([result]) + return result def _ocp_section( From 2f92b2e03ed25861e9fe5dde47c7b59c1cc2e714 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:46:33 +0100 Subject: [PATCH 04/42] Add a helper function that allows type safe actions in Shape for types that are subclassed from Shape --- src/build123d/topology/helpers.py | 50 +++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/build123d/topology/helpers.py diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py new file mode 100644 index 0000000..3f9505f --- /dev/null +++ b/src/build123d/topology/helpers.py @@ -0,0 +1,50 @@ +"""Helper functions for topology operations.""" + +from __future__ import annotations + +from build123d.geometry import Axis, Location, Plane, Vector +from build123d.topology.shape_core import Shape +from build123d.topology.zero_d import Vertex +from build123d.topology.one_d import Edge +from build123d.topology.two_d import Face + + +def convert_to_shapes( + shape: Shape, + objects: tuple[Shape | Vector | Location | Axis | Plane, ...], +) -> list[Shape]: + """Convert geometry objects to shapes. + + Args: + shape: The shape context (used for bounding box when converting Axis) + objects: Tuple of geometry objects to convert + + Returns: + List of Shape objects + """ + results = [] + for obj in objects: + if isinstance(obj, Vector): + results.append(Vertex(obj.X, obj.Y, obj.Z)) + elif isinstance(obj, Location): + pos = obj.position + results.append(Vertex(pos.X, pos.Y, pos.Z)) + elif isinstance(obj, Axis): + # Convert to finite edge based on bounding box + bbox = shape.bounding_box(optimal=False) + dist = shape.distance_to(obj.position) + # Be sure to avoid zero length edge for vertex on axis intersection + half_length = max(bbox.diagonal, 1) * max(dist, 1) + results.append( + Edge.make_line( + obj.position - obj.direction * half_length, + obj.position + obj.direction * half_length, + ) + ) + elif isinstance(obj, Plane): + results.append(Face(obj)) + elif isinstance(obj, Shape): + results.append(obj) + else: + raise ValueError(f"Unsupported type for intersect: {type(obj)}") + return results From d3ab5b24faaa760f35d82e8fe43dcab0dfdc2d13 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:48:31 +0100 Subject: [PATCH 05/42] reimplement intersect as intersect+touch top-down to avoid expensive filtering --- src/build123d/topology/composite.py | 199 +++++----------- src/build123d/topology/one_d.py | 158 ++++--------- src/build123d/topology/shape_core.py | 100 ++++---- src/build123d/topology/three_d.py | 341 ++++++++++++++++++--------- src/build123d/topology/two_d.py | 305 +++++++++++++++--------- src/build123d/topology/zero_d.py | 53 ++--- 6 files changed, 600 insertions(+), 556 deletions(-) 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 ---- From 2d38e5d266bebd11c68093d1dd051e3218b44b57 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:49:25 +0100 Subject: [PATCH 06/42] add include_touched to intersect for tests that relied on getting tocu results, too --- tests/test_direct_api/test_location.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_direct_api/test_location.py b/tests/test_direct_api/test_location.py index d22cb6c..d7db489 100644 --- a/tests/test_direct_api/test_location.py +++ b/tests/test_direct_api/test_location.py @@ -398,14 +398,14 @@ class TestLocation(unittest.TestCase): self.assertIsNone(b.intersect(b.moved(Pos(X=10)))) - # Look for common vertices + # Look for common vertices (endpoint-endpoint contacts are "touch", not "intersect") e1 = Edge.make_line((0, 0), (1, 0)) e2 = Edge.make_line((1, 0), (1, 1)) e3 = Edge.make_line((1, 0), (2, 0)) - i = e1.intersect(e2) + i = e1.intersect(e2, include_touched=True) self.assertEqual(len(i.vertices()), 1) self.assertEqual(tuple(i.vertex()), (1, 0, 0)) - i = e1.intersect(e3) + i = e1.intersect(e3, include_touched=True) self.assertEqual(len(i.vertices()), 1) self.assertEqual(tuple(i.vertex()), (1, 0, 0)) From 792a87a1fa9ad9da45ddb9678ec7931c0e47eb80 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 12:51:30 +0100 Subject: [PATCH 07/42] add suuport for include_touched keyword, split some tests into intersect/touch, add new tests and fix xpass and xfail --- tests/test_direct_api/test_intersection.py | 106 +++++++++++++-------- 1 file changed, 65 insertions(+), 41 deletions(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 758fd6f..581b45e 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -16,14 +16,19 @@ class Case: expected: list | Vector | Location | Axis | Plane name: str xfail: None | str = None + include_touched: bool = False @pytest.mark.skip -def run_test(obj, target, expected): +def run_test(obj, target, expected, include_touched=False): + # Only Shape objects support include_touched parameter + kwargs = {} + if include_touched and isinstance(obj, Shape): + kwargs["include_touched"] = include_touched if isinstance(target, list): - result = obj.intersect(*target) + result = obj.intersect(*target, **kwargs) else: - result = obj.intersect(target) + result = obj.intersect(target, **kwargs) if INTERSECT_DEBUG: show([obj, target, result]) if expected is None: @@ -50,11 +55,15 @@ def make_params(matrix): marks = [pytest.mark.xfail(reason=case.xfail)] else: marks = [] - uid = f"{i} {obj_type}, {tar_type}, {case.name}" - params.append(pytest.param(case.object, case.target, case.expected, marks=marks, id=uid)) - if tar_type != obj_type and not isinstance(case.target, list): - uid = f"{i + 1} {tar_type}, {obj_type}, {case.name}" - params.append(pytest.param(case.target, case.object, case.expected, marks=marks, id=uid)) + # Add include_touched info to test id if specified + touched_suffix = ", touched" if case.include_touched else "" + uid = f"{i} {obj_type}, {tar_type}, {case.name}{touched_suffix}" + params.append(pytest.param(case.object, case.target, case.expected, case.include_touched, marks=marks, id=uid)) + # Swap obj and target to test symmetry, but NOT for include_touched tests + # (swapping may change behavior with boundary contacts) + if tar_type != obj_type and not isinstance(case.target, list) and not case.include_touched: + uid = f"{i + 1} {tar_type}, {obj_type}, {case.name}{touched_suffix}" + params.append(pytest.param(case.target, case.object, case.expected, case.include_touched, marks=marks, id=uid)) return params @@ -118,9 +127,9 @@ geometry_matrix = [ Case(lc1, lc1, Location, "coincident, co-z", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(geometry_matrix)) -def test_geometry(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(geometry_matrix)) +def test_geometry(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # Shape test matrices @@ -147,9 +156,9 @@ shape_0d_matrix = [ Case(vt1, [vt1, lc1], [Vertex], "multi to_intersect, coincident", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(shape_0d_matrix)) -def test_shape_0d(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_0d_matrix)) +def test_shape_0d(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # 1d Shapes @@ -216,9 +225,9 @@ shape_1d_matrix = [ Case(wi5, [ed1, Vector(1, 0)], [Vertex], "multi to_intersect, multi intersect", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(shape_1d_matrix)) -def test_shape_1d(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_1d_matrix)) +def test_shape_1d(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # 2d Shapes @@ -272,7 +281,9 @@ shape_2d_matrix = [ Case(fc1, fc3, [Edge], "intersecting", None), Case(fc1, fc4, [Face], "coplanar", None), Case(fc1, fc5, [Edge], "intersecting edge", None), - Case(fc1, fc6, [Vertex], "intersecting vertex", None), + # Face + Face crossing vertex: now requires include_touched + Case(fc1, fc6, None, "crossing vertex", None), + Case(fc1, fc6, [Vertex], "crossing vertex", None, True), Case(fc1, fc7, [Edge, Edge], "multi-intersecting", None), Case(fc7, Pos(Y=2) * fc7, [Face], "cyl intersecting", None), @@ -287,9 +298,9 @@ shape_2d_matrix = [ Case(fc7, [wi5, fc1], [Vertex], "multi to_intersect, intersecting", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(shape_2d_matrix)) -def test_shape_2d(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_2d_matrix)) +def test_shape_2d(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # 3d Shapes sl1 = Box(2, 2, 2).solid() @@ -323,8 +334,10 @@ shape_3d_matrix = [ Case(sl1, ed3, None, "non-coincident", None), Case(sl1, ed1, [Edge], "intersecting", None), - Case(sl1, Pos(0, 1, 1) * ed1, [Edge], "edge collinear", "duplicate edges, BRepAlgoAPI_Common and _Section both return edge"), - Case(sl1, Pos(1, 1, 1) * ed1, [Vertex], "corner coincident", None), + Case(sl1, Pos(0, 1, 1) * ed1, [Edge], "edge collinear", None), # xfail removed + # Solid + Edge corner coincident: now requires include_touched + Case(sl1, Pos(1, 1, 1) * ed1, None, "corner coincident", None), + Case(sl1, Pos(1, 1, 1) * ed1, [Vertex], "corner coincident", None, True), Case(Pos(2.1, 1) * sl1, ed4, [Edge, Edge], "multi-intersect", None), Case(Pos(2, .5, -1) * sl1, wi6, None, "non-coincident", None), @@ -333,8 +346,12 @@ shape_3d_matrix = [ Case(sl2, fc1, None, "non-coincident", None), Case(sl1, fc1, [Face], "intersecting", None), - Case(Pos(3.5, 0, 1) * sl1, fc1, [Edge], "edge collinear", None), - Case(Pos(3.5, 3.5) * sl1, fc1, [Vertex], "corner coincident", None), + # Solid + Face edge collinear: now requires include_touched + Case(Pos(3.5, 0, 1) * sl1, fc1, None, "edge collinear", None), + Case(Pos(3.5, 0, 1) * sl1, fc1, [Edge], "edge collinear", None, True), + # Solid + Face corner coincident: now requires include_touched + Case(Pos(3.5, 3.5) * sl1, fc1, None, "corner coincident", None), + Case(Pos(3.5, 3.5) * sl1, fc1, [Vertex], "corner coincident", None, True), Case(Pos(.9) * sl1, fc7, [Face, Face], "multi-intersecting", None), Case(sl2, sh1, None, "non-coincident", None), @@ -342,17 +359,24 @@ shape_3d_matrix = [ Case(sl1, sl2, None, "non-coincident", None), Case(sl1, Pos(1, 1, 1) * sl1, [Solid], "intersecting", None), - Case(sl1, Pos(2, 2, 1) * sl1, [Edge], "edge collinear", None), - Case(sl1, Pos(2, 2, 2) * sl1, [Vertex], "corner coincident", None), + # Solid + Solid edge collinear: now requires include_touched + Case(sl1, Pos(2, 2, 1) * sl1, None, "edge collinear", None), + Case(sl1, Pos(2, 2, 1) * sl1, [Edge], "edge collinear", None, True), + # Solid + Solid corner coincident: now requires include_touched + Case(sl1, Pos(2, 2, 2) * sl1, None, "corner coincident", None), + Case(sl1, Pos(2, 2, 2) * sl1, [Vertex], "corner coincident", None, True), Case(sl1, Pos(.45) * sl3, [Solid, Solid], "multi-intersect", None), + # New test: Solid + Solid face coincident (touch) + Case(sl1, Pos(2, 0, 0) * sl1, None, "face coincident", None), + Case(sl1, Pos(2, 0, 0) * sl1, [Face], "face coincident", None, True), Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid], "multi to_intersect, intersecting", None), Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(Z=.5) * fc1], [Face], "multi to_intersect, intersecting", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(shape_3d_matrix)) -def test_shape_3d(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_3d_matrix)) +def test_shape_3d(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # Compound Shapes cp1 = Compound(GridLocations(5, 0, 2, 1) * Vertex()) @@ -400,15 +424,15 @@ shape_compound_matrix = [ Case(cp2, [cp3, cp4], [Edge, Edge], "multi to_intersect, intersecting", None), - Case(cv1, cp3, [Edge, Edge], "intersecting", "duplicate edges, BRepAlgoAPI_Common and _Section both return edge"), + Case(cv1, cp3, [Edge, Edge, Edge, Edge], "intersecting", None), # xfail removed Case(sk1, cp3, [Face, Face], "intersecting", None), Case(pt1, cp3, [Face, Face], "intersecting", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(shape_compound_matrix)) -def test_shape_compound(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_compound_matrix)) +def test_shape_compound(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # FreeCAD issue example c1 = CenterArc((0, 0), 10, 0, 360).edge() @@ -437,9 +461,9 @@ freecad_matrix = [ Case(c2, vert, [Vertex], "circle, vert, intersect", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(freecad_matrix)) -def test_freecad(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(freecad_matrix)) +def test_freecad(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # Issue tests @@ -460,9 +484,9 @@ issues_matrix = [ Case(e1, f1, [Edge], "issue #697", None), ] -@pytest.mark.parametrize("obj, target, expected", make_params(issues_matrix)) -def test_issues(obj, target, expected): - run_test(obj, target, expected) +@pytest.mark.parametrize("obj, target, expected, include_touched", make_params(issues_matrix)) +def test_issues(obj, target, expected, include_touched): + run_test(obj, target, expected, include_touched) # Exceptions @@ -489,4 +513,4 @@ def make_exception_params(matrix): @pytest.mark.parametrize("obj, target, expected", make_exception_params(exception_matrix)) def test_exceptions(obj, target, expected): with pytest.raises(Exception): - obj.intersect(target) \ No newline at end of file + obj.intersect(target) From d4ba9ab2d64d07b88e6a6d59c0f7ffe39e1425e8 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 16 Jan 2026 13:27:04 +0100 Subject: [PATCH 08/42] temporarily add the summary of the approach --- INTERSECT-SUMMARY.md | 481 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 481 insertions(+) create mode 100644 INTERSECT-SUMMARY.md diff --git a/INTERSECT-SUMMARY.md b/INTERSECT-SUMMARY.md new file mode 100644 index 0000000..0175539 --- /dev/null +++ b/INTERSECT-SUMMARY.md @@ -0,0 +1,481 @@ +# Intersection Refactoring Summary + +## Current Implementation + +The current implementation uses the (simplified) pattern + +```python +result = BRepAlgoAPI_Section() + BRepAlgoAPI_Common() +filter_shapes_by_order(result, [Vertex, Edge, Face, Solid]) +``` + +Unfortunately, filtering of found objects up to the highest order is not trivial in OCCT and can take a significant time per comparision, especially when solids with curved surfaces are involved. +And given the apporach, n x m comparisions are needed in the filter function (performance details see see https://github.com/gumyr/build123d/issues/1147). + +## Goal of the new apporach + +- Define "real" intersections and distinguish them from touches (single point touch for faces, edge touch for solids, tangential touch, ...) + - The definition of intersect should be based on "what a CAD user expects", e.g. solid-solid = solid, face-face = face|edge, ... +- Calculate intersect in the most efficient way, specifically for each shape type combination. + - No use of n x m comparisions with faces involved (note that comparisions of edges are significantly cheaper, in some test 5-15 times faster) + - For every costly OCCT method when filtering results, a non-optimal bounding box comparision should be done as early exit (no bbox overlap => no need to do the costly calculation) +- Separate touch methods that calculate all possible touch results for the faces and solids + - intersect methods get a parameter `include_touched` that add touch results to the intersect results + +### Intersect vs Touch + +The distinction between `intersect` and `touch` is based on result dimension: + +- **Intersect**: Returns results down to a minimum dimension (interior overlap or crossing) +- **Touch**: Returns boundary contacts with dimension below the minimum intersect dimension, filtered to the highest dimension at each contact location + +| Combination | Intersect result dims | Touch dims | +| --------------- | --------------------- | ---------------------------- | +| Solid + Solid | 3 (Solid) | 0, 1, 2 (Vertex, Edge, Face) | +| Solid + Face | 2 (Face) | 0, 1 (Vertex, Edge) | +| Solid + Edge | 1 (Edge) | 0 (Vertex) | +| Solid + Vertex | 0 (Vertex) | — | +| Face + Face | 1, 2 (Edge, Face) | 0 (Vertex) | +| Face + Edge | 0, 1 (Vertex, Edge) | — | +| Face + Vertex | 0 (Vertex) | — | +| Edge + Edge | 0, 1 (Vertex, Edge) | — | +| Edge + Vertex | 0 (Vertex) | — | +| Vertex + Vertex | 0 (Vertex) | — | + +**Touch filtering**: At each contact location, only the highest-dimensional shape is returned. Lower-dimensional shapes that are boundaries of higher-dimensional contacts are filtered out. Note that this can get more expensive than the intersect implementation. + +**Examples**: + +- Two boxes sharing a face: `touch` → `[Face]` (not the 4 edges and 4 vertices of that face) +- Two boxes sharing an edge: `touch` → `[Edge]` (not the 2 endpoint vertices) +- Two boxes sharing only a corner: `touch` → `[Vertex]` +- Two faces with coplanar overlap AND crossing curve: `intersect` → `[Face, Edge]` + +### Multi-object and Compound handling + +| Routine | Semantics | +| -------------------------------------------------------------------------------------------- | --------------- | +| BRepAlgoAPI_Common(c.wrapped, [c1.wrapped, c2.wrapped]). | OR, partitioned | +| BRepAlgoAPI_Common(c.wrapped, [TopoDS_Compound([c1.wrapped, c2.wrapped])]), with c1 ∩ c2 = ∅ | OR \* | +| c.intersect(c1, c2) | AND | +| c.intersect(Compound([c1, c2])) | OR | +| c.intersect(Compound(children=[c1, c2])) | OR | + +Key: + +- AND: c ∩ c1 ∩ c2 +- OR: c ∩ (c1 ∪ c2) + +\* A compound as tool shall not have overlapping solids according to OCCT docs + +### Tangent Contact Validation + +For tangent contacts (surfaces touching at a point), the `touch()` method validates: + +1. **Edge boundary check**: Points near edges of both faces (within `tolerance`) are filtered out as edge-edge intersections, not vertex touches. Users should increase tolerance if BRepExtrema returns inaccurate points near edges. + +2. **Normal direction check**: For points in the interior of both faces, normals must be parallel (dot ≈ 1) or anti-parallel (dot ≈ -1), meaning surfaces are tangent. This filters out false positives where surfaces cross at an angle. + +3. **Crossing vertices**: Points on an edge of one face meeting the interior of another (perpendicular normals) are valid crossing vertices. + +## Call Flow + +Legend: + +- → handle: handles directly +- → delegate: calls `other._intersect(self, ...)` +- → distribute: iterates elements, calls `elem._intersect(...)` +- `t`: `include_touched` passed through + +### intersect() Call Flow + +| Vertex.\_intersect(other) | | +| ------------------------------- | ------------------------------ | +| `_intersect(Vertex, Vertex, )` | → handle (distance check) | +| `_intersect(Vertex, *, t)` | → other.\_intersect(Vertex, t) | + +| Mixin1D.\_intersect(other) [Edge, Wire] | | +| --------------------------------------- | ----------------------------- | +| `_intersect(Edge, Edge, )` | → handle (Common + Section) | +| `_intersect(Edge, Wire, )` | → handle (Common + Section) | +| `_intersect(Edge, Vertex, )` | → handle (distance check) | +| `_intersect(Edge, *, t)` | → `other._intersect(Edge, t)` | +| `_intersect(Wire, ..., )` | → same as Edge | + +| Mixin2D.\_intersect(other) [Face, Shell] | | +| ---------------------------------------- | ------------------------------ | +| `_intersect(Face, Face, )` | → handle (Common + Section) | +| `_intersect(Face, Shell, )` | → handle (Common + Section) | +| `_intersect(Face, Edge, )` | → handle (Section) | +| `_intersect(Face, Wire, )` | → handle (Section) | +| `_intersect(Face, Vertex, )` | → handle (distance check) | +| `_intersect(Face, *, t)` | → `other._intersect(Face, t)` | +| `_intersect(Shell, ..., )` | → same as Face | +| If `include_touched==True`: | also calls `self.touch(other)` | + +| Mixin3D.\_intersect(other) [Solid] | | +| ---------------------------------- | ------------------------------ | +| `_intersect(Solid, Solid, )` | → handle (Common) | +| `_intersect(Solid, Face, )` | → handle (Common) | +| `_intersect(Solid, Shell, )` | → handle (Common) | +| `_intersect(Solid, Edge, )` | → handle (Common) | +| `_intersect(Solid, Wire, )` | → handle (Common) | +| `_intersect(Solid, Vertex, )` | → handle (is_inside) | +| `_intersect(Solid, *, t)` | → `other._intersect(Solid, t)` | +| If `include_touched==True`: | also calls `self.touch(other)` | + +| Compound.\_intersect(other) | | +| ----------------------------------- | ----------------------- | +| `_intersect(Compound, Compound, t)` | → distribute all-vs-all | +| `_intersect(Compound, *, t)` | → distribute over self | + +**Delegation chains** (examples): + +- `Edge._intersect(Solid, t)` → `Solid._intersect(Edge, t)` → handle +- `Vertex._intersect(Face, t)` → `Face._intersect(Vertex, t)` → handle +- `Face._intersect(Solid, t)` → `Solid._intersect(Face, t)` → handle +- `Edge._intersect(Compound, t)` → `Compound._intersect(Edge, t)` → distribute + +### touch() Call Flow + +| Shape.touch(other) | | +| ------------------ | ------------------------------------------ | +| `touch(Shape, *)` | → returns empty `ShapeList()` (base impl.) | + +| Mixin2D.touch(other) [Face, Shell] | | +| ---------------------------------- | ------------------------------------- | +| `touch(Face, Face)` | → handle (BRepExtrema + normal check) | +| `touch(Face, Shell)` | → handle (BRepExtrema + normal check) | +| `touch(Face, *)` | → `other.touch(self)` (delegate) | + +| Mixin3D.touch(other) [Solid] | | +| ---------------------------- | -------------------------------------------------------- | +| `touch(Solid, Solid)` | → handle (Common faces/edges/vertices) | +| | + `.touch()` for tangent contacts | +| `touch(Solid, Face)` | → handle (Common edges + BRepExtrema) | +| `touch(Solid, Edge)` | → handle (Common vertices + BRepExtrema) | +| `touch(Solid, Vertex)` | → handle (distance check to faces) | +| `touch(Solid, *)` | → `other.touch(self)` (delegate) | + +| Compound.touch(other) | | +| --------------------- | ---------------------- | +| `touch(Compound, *)` | → distribute over self | + +**Code reuse**: `Mixin3D.touch()` calls `Mixin2D.touch()` (via `.touch()`) for Solid+Solid tangent vertex detection, ensuring consistent edge boundary and normal direction validation. + +## Comparison Optimizations with non-optimal Bounding Boxes + +### 1. Early Exit with Bounding Box Overlap + +In `touch()` and `_intersect()`, we compare many shape pairs (faces×faces, edges×edges). Before calling `BRepAlgoAPI_Common` or other expensive methods, we want to early detect pairs that don't need to be checked (early exit) +This can be done with `distance_to()` calls (which use `BRepExtrema_DistShapeShape`), or checking bounding boxes overlap: + +```python +# sf = , of = +# Option 1 +if sf.distance_to(of) > tolerance: + continue + +# Option 2 +if not sf_bb.overlaps(of_bb, tolerance): + continue +``` + +`BoundBox.overlaps()` uses OCCT's `Bnd_Box.Distance()` method. Option 2 (bbox) is less accurate but significantly faster, see below. + +### 2. Non-Optimal Bounding Boxes + +`Shape.bounding_box(optimal=True)` computes precise bounds but is slow for curved geometry. For early-exit filtering, we use `optimal=False`: + +| Object | Faces | Edges | optimal=True | optimal=False | Speedup | +| ----------- | ----- | ----- | ------------ | ------------- | -------- | +| ttt-ppp0102 | 10 | 17 | 86.7 ms | 0.12 ms | **729x** | +| ttt-ppp0107 | 44 | 95 | 59.7 ms | 0.16 ms | **373x** | +| ttt-ppp0104 | 23 | 62 | 12.6 ms | 0.05 ms | **252x** | +| ttt-ppp0106 | 32 | 89 | 12.2 ms | 0.08 ms | **153x** | +| ttt-ppp0101 | 32 | 84 | 0.3 ms | 0.08 ms | 4x | +| ttt-ppp0105 | 18 | 40 | 0.04 ms | 0.04 ms | 1x | + +**Accuracy trade-off** (non-optimal bbox expansion): + +| Object | Solid Expansion | Max Face Expansion | +| ----------- | --------------- | ------------------ | +| ttt-ppp0107 | 7.7% | 109.9% | +| ttt-ppp0106 | 0.0% | 65.5% | +| ttt-ppp0104 | 4.8% | 25.8% | +| ttt-ppp0102 | 0.0% | 8.3% | +| ttt-ppp0101 | 0.0% | 0.0% | + +Larger bboxes cause more false-positive overlaps → extra `BRepExtrema` checks, but the 100-800x speedup will most of the time outweigh this cost. + +### 3. Pre-calculate and Cache Bounding Boxes + +Without caching, nested loops recalculate bboxes n×m times: + +```python +# sf = , of = +# Before: bbox computed 32×32×2 = 2048 times for 32-face solids +for sf in self.faces(): + for of in other.faces(): + if not sf.bounding_box().overlaps(of.bounding_box(), tolerance): + +# After: bbox computed once per face +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()] +for sf, sf_bb in self_faces: + for of, of_bb in other_faces: + if not sf_bb.overlaps(of_bb, tolerance): +``` + +### 4. Performance Comparison + +Face×face pair comparisons using ttt-ppp01\* examples: + +| Object | Faces | Pairs | bbox (build+distance_to) | distance_to for all | Speedup | +| ----------- | ----- | ----- | ------------------------ | ------------------- | ----------- | +| ttt-ppp0107 | 44 | 1936 | 1.11 ms | 71,854 ms | **65,019x** | +| ttt-ppp0102 | 10 | 100 | 0.33 ms | 6,629 ms | **20,094x** | +| ttt-ppp0101 | 32 | 1024 | 0.59 ms | 5,119 ms | **8,684x** | +| ttt-ppp0106 | 32 | 1024 | 0.59 ms | 3,529 ms | **5,963x** | +| ttt-ppp0104 | 23 | 529 | 0.36 ms | 1,815 ms | **4,982x** | +| ttt-ppp0105 | 18 | 324 | 0.33 ms | 1,277 ms | **3,885x** | +| ttt-ppp0108 | 37 | 1369 | 0.79 ms | 2,938 ms | **3,705x** | + +Edge×edge pair comparisons using ttt-ppp01\* examples: + +| Object | Edges | Pairs | bbox (build+distance_to) | distance_to for all | Speedup | +| ----------- | ----- | ------ | ------------------------ | ------------------- | ----------- | +| ttt-ppp0107 | 95 | 9,025 | 2.98 ms | 45,254 ms | **15,203x** | +| ttt-ppp0102 | 17 | 289 | 0.39 ms | 4,801 ms | **12,188x** | +| ttt-ppp0101 | 84 | 7,056 | 2.40 ms | 6,200 ms | **2,584x** | +| ttt-ppp0104 | 62 | 3,844 | 1.45 ms | 2,320 ms | **1,597x** | +| ttt-ppp0108 | 101 | 10,201 | 3.16 ms | 3,476 ms | **1,100x** | +| ttt-ppp0105 | 40 | 1,600 | 0.84 ms | 723 ms | **859x** | + +The bbox approach is in any case significantly faster, making it essential for n×m pair operations in `touch()` and `_intersect()`. + +## Typing Workaround + +### Problem: Circular Dependencies + +``` +shape_core.py (Shape, ShapeList) + ↑ imports + │ + ┌───┴───┬───────┬───────┬──────────┐ + │ │ │ │ │ +zero_d one_d two_d three_d composite +(Vertex) (Edge) (Face) (Solid) (Compound) + (Wire) (Shell) +``` + +`shape_core.py` defines base classes, but intersection logic needs to check types (`isinstance(x, Wire)`), call methods (`shape.faces()`), etc. Direct imports would cause circular import errors. + +### Solution: helpers.py as a Leaf Module + +**helpers.py** imports everything at module level (it's a leaf - no one imports from it at module level): + +```python +from build123d.topology.shape_core import Shape +from build123d.topology.one_d import Edge +from build123d.topology.two_d import Face +``` + +**Other modules** do runtime imports from helpers: + +```python +# In shape_core.py Shape.intersect() +def intersect(self, ...): + from build123d.topology.helpers import convert_to_shapes +``` + +Runtime imports happen after all modules are loaded, breaking the cycle. + +## Tests + +### Infrastructure Changes (support for `include_touched`) + +- Added `include_touched: bool = False` to `Case` dataclass +- Updated `run_test` to pass `include_touched` to `Shape.intersect` (geometry objects don't have it) +- Updated `make_params` to include `include_touched` in test parameters +- Updated all test function signatures and `@pytest.mark.parametrize` decorators + +### Behavioral: Solid boundary contacts (intersect vs touch separation) + +| Test Case | Before | After (no touch) | After (with touch) | +| -------------------------------- | ---------- | ---------------- | ------------------ | +| Solid + Edge, corner coincident | `[Vertex]` | `None` | `[Vertex]` | +| Solid + Face, edge collinear | `[Edge]` | `None` | `[Edge]` | +| Solid + Face, corner coincident | `[Vertex]` | `None` | `[Vertex]` | +| Solid + Solid, edge collinear | `[Edge]` | `None` | `[Edge]` | +| Solid + Solid, corner coincident | `[Vertex]` | `None` | `[Vertex]` | +| Solid + Solid, face coincident | N/A (new) | `None` | `[Face]` | +| Solid + Solid, tangential point | N/A (new) | `None` | `[Vertex]` | + +### Behavioral: Face boundary contacts (intersect vs touch separation) + +| Test Case | Before | After (no touch) | After (with touch) | +| ---------------------------- | ---------- | ---------------- | ------------------ | +| Face + Face, crossing vertex | `[Vertex]` | `None` | `[Vertex]` | + +Two non-coplanar faces that cross at a single point (due to finite extent) now return the vertex via `touch()` rather than `intersect()`. Added `Mixin2D.touch()` method. + +These represent the semantic change: boundary contacts are **not** interior intersections, so `intersect()` returns `None`. Use `include_touched=True` to get them. + +### Behavioral: Tangent edge (Edge lying on Solid surface) + +| Test Case | Result | Notes | +| -------------------------- | -------- | ------------------------------------------- | +| Solid + Edge, tangent edge | `[Edge]` | Edge on cylinder surface is an intersection | + +A tangent edge (lying ON a solid's surface) is treated as an **intersection** (1D result), not a touch. This is because `touch` for Solid+Edge only returns Vertex (0D). The edge is returned by `BRepAlgoAPI_Common` since it's "common" to both shapes. + +### New test cases: Common + Section (mixed overlap and crossing) + +| Test Case | Result | Description | +| ---------------------------------- | ---------------- | ------------------------------------------------ | +| Edge + Edge, spline common+section | `[Edge, Vertex]` | Spline with collinear segment and crossing point | +| Face + Face, common+section | `[Face, Edge]` | Face with coplanar overlap and crossing curve | + +These test cases verify correct handling when both `BRepAlgoAPI_Common` (overlap) and `BRepAlgoAPI_Section` (crossing) return results for the same shape pair. + +### Bug fixes / xfail removals + +| Test Case | Before | After | +| ------------------------------ | ------------------------------------- | ---------------------------------- | +| Solid + Edge, edge collinear | `[Edge]` with xfail "duplicate edges" | `[Edge]` passing | +| Curve + Compound, intersecting | `[Edge, Edge]` with xfail | `[Edge, Edge, Edge, Edge]` passing | + +### New test: edge tolerance filtering + +Added `test_touch_edge_tolerance()` to test filtering of false positive vertices: + +- Tests torus (fillet) surface vs cylinder surface where BRepExtrema finds a point near edges of both faces +- With `tolerance=1e-3`, the point is detected as on both edges and filtered out +- `touch(tolerance=1e-3)` returns empty, `intersect(include_touched=True, tolerance=1e-3)` returns only `[Edge]` + +### Performance tests + +#### Summary + +| name | dev | this branch | commit fa8e936 | this branch / dev | this branch / commit fa8e936 | +| ------------------------------------------------------- | ---------: | ----------: | -------------: | ----------------: | ---------------------------: | +| tests/test_benchmarks.py::test_mesher_benchmark[100] | 1.5717 | 1.1761 | 1.5013 | -25.17% | -21.66% | +| tests/test_benchmarks.py::test_mesher_benchmark[1000] | 3.1709 | 2.6653 | 2.9810 | -15.95% | -10.59% | +| tests/test_benchmarks.py::test_mesher_benchmark[10000] | 18.8172 | 18.3698 | 18.5138 | -2.38% | -0.78% | +| tests/test_benchmarks.py::test_mesher_benchmark[100000] | 272.6479 | 260.0706 | 349.1587 | -4.61% | -25.51% | +| tests/test_benchmarks.py::test_ppp_0101 | 2,840.2942 | 147.7914 | 146.8151 | -94.80% | +0.66% | +| tests/test_benchmarks.py::test_ppp_0102 | 183.6392 | 182.4804 | 181.5972 | -0.63% | +0.49% | +| tests/test_benchmarks.py::test_ppp_0103 | 68.3975 | 68.1508 | 68.0329 | -0.36% | +0.17% | +| tests/test_benchmarks.py::test_ppp_0104 | 114.2050 | 113.7093 | 113.0657 | -0.43% | +0.57% | +| tests/test_benchmarks.py::test_ppp_0105 | 83.0605 | 80.7737 | 80.0031 | -2.75% | +0.96% | +| tests/test_benchmarks.py::test_ppp_0106 | 9,311.8187 | 82.1598 | 82.4856 | -99.12% | -0.40% | +| tests/test_benchmarks.py::test_ppp_0107 | 308.6340 | 296.7623 | 298.2377 | -3.85% | -0.49% | +| tests/test_benchmarks.py::test_ppp_0108 | 136.9441 | 83.1816 | 82.4641 | -39.25% | +0.87% | +| tests/test_benchmarks.py::test_ppp_0109 | 113.9680 | 109.5960 | 128.6220 | -3.84% | -14.79% | +| tests/test_benchmarks.py::test_ppp_0110 | 244.0596 | 223.9091 | 222.1242 | -8.26% | +0.80% | +| tests/test_benchmarks.py::test_ttt_23_02_02 | 646.0093 | 628.2953 | 631.9749 | -2.74% | -0.58% | +| tests/test_benchmarks.py::test_ttt_23_T_24 | 236.9038 | 148.0144 | 146.1597 | -37.52% | +1.27% | +| tests/test_benchmarks.py::test_ttt_24_SPO_06 | 150.4492 | 144.2704 | 142.6785 | -4.11% | +1.12% | + +\* Changed to use `extrude(UNTIL)` as in dev + +#### Details + +- **Against dev ()** + + ```text + ---------------------------------------------------------------------------------------------- benchmark: 17 tests ---------------------------------------------------------------------------------------------- + Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations + ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + test_mesher_benchmark[100] 1.4136 (1.0) 79.2088 (1.15) 2.6825 (1.0) 7.8029 (14.67) 1.5717 (1.0) 0.3249 (1.0) 1;18 372.7835 (1.0) 99 1 + test_mesher_benchmark[1000] 2.8029 (1.98) 93.6249 (1.35) 3.9942 (1.49) 7.2583 (13.64) 3.1709 (2.02) 0.8664 (2.67) 2;2 250.3631 (0.67) 302 1 + test_mesher_benchmark[10000] 18.0781 (12.79) 108.9087 (1.58) 30.7976 (11.48) 28.3765 (53.34) 18.8172 (11.97) 0.6993 (2.15) 8;8 32.4701 (0.09) 51 1 + test_mesher_benchmark[100000] 262.4835 (185.68) 350.2263 (5.07) 299.1632 (111.52) 42.1747 (79.28) 272.6479 (173.48) 73.7383 (226.95) 1;0 3.3427 (0.01) 5 1 + test_ppp_0101 2,837.7637 (>1000.0) 2,842.9180 (41.12) 2,840.0422 (>1000.0) 2.0992 (3.95) 2,840.2942 (>1000.0) 3.3399 (10.28) 2;0 0.3521 (0.00) 5 1 + test_ppp_0102 182.9260 (129.40) 185.0174 (2.68) 183.7750 (68.51) 0.7393 (1.39) 183.6392 (116.84) 0.8202 (2.52) 2;0 5.4414 (0.01) 6 1 + test_ppp_0103 66.9251 (47.34) 69.1312 (1.0) 68.3137 (25.47) 0.5320 (1.0) 68.3975 (43.52) 0.5088 (1.57) 4;1 14.6384 (0.04) 15 1 + test_ppp_0104 112.7356 (79.75) 115.8168 (1.68) 114.0572 (42.52) 0.9064 (1.70) 114.2050 (72.66) 1.0003 (3.08) 3;0 8.7675 (0.02) 9 1 + test_ppp_0105 80.5439 (56.98) 101.4349 (1.47) 84.6426 (31.55) 5.5137 (10.36) 83.0605 (52.85) 3.3439 (10.29) 1;1 11.8144 (0.03) 13 1 + test_ppp_0106 9,240.8689 (>1000.0) 9,385.3153 (135.76) 9,312.1906 (>1000.0) 65.8610 (123.80) 9,311.8187 (>1000.0) 124.3155 (382.61) 2;0 0.1074 (0.00) 5 1 + test_ppp_0107 301.7400 (213.45) 314.9581 (4.56) 308.1962 (114.89) 5.5353 (10.40) 308.6340 (196.37) 9.5510 (29.40) 2;0 3.2447 (0.01) 5 1 + test_ppp_0108 135.0608 (95.54) 140.0305 (2.03) 136.7690 (50.99) 1.5956 (3.00) 136.9441 (87.13) 1.7559 (5.40) 3;1 7.3116 (0.02) 8 1 + test_ppp_0109 111.1487 (78.63) 116.3623 (1.68) 113.8869 (42.46) 1.4392 (2.71) 113.9680 (72.51) 1.4837 (4.57) 2;0 8.7806 (0.02) 9 1 + test_ppp_0110 242.1086 (171.27) 247.1587 (3.58) 244.1418 (91.01) 1.8841 (3.54) 244.0596 (155.29) 2.0497 (6.31) 2;0 4.0960 (0.01) 5 1 + test_ttt_23_02_02 632.3757 (447.34) 672.3315 (9.73) 652.9795 (243.42) 16.8402 (31.65) 646.0093 (411.03) 26.8589 (82.66) 2;0 1.5314 (0.00) 5 1 + test_ttt_24_SPO_06 222.7247 (157.56) 240.4287 (3.48) 232.5369 (86.69) 7.9419 (14.93) 236.9038 (150.73) 13.4055 (41.26) 1;0 4.3004 (0.01) 5 1 + test_ttt_23_T_24 148.6132 (105.13) 153.2385 (2.22) 150.9910 (56.29) 1.9488 (3.66) 150.4492 (95.73) 3.2687 (10.06) 2;0 6.6229 (0.02) 5 1 + ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + ``` + +- **With this PR** + + ```text + ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ + Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations + -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + test_mesher_benchmark[100] 1.0956 (1.0) 69.3311 (1.0) 1.9552 (1.0) 5.4247 (8.91) 1.1761 (1.0) 0.2108 (1.0) 1;30 511.4671 (1.0) 159 1 + test_mesher_benchmark[1000] 2.5016 (2.28) 81.8406 (1.18) 3.4415 (1.76) 5.3939 (8.86) 2.6653 (2.27) 1.1725 (5.56) 2;2 290.5734 (0.57) 351 1 + test_mesher_benchmark[10000] 17.9532 (16.39) 87.1914 (1.26) 30.6284 (15.67) 25.7683 (42.34) 18.3698 (15.62) 0.5098 (2.42) 10;10 32.6494 (0.06) 53 1 + test_mesher_benchmark[100000] 253.4639 (231.35) 403.4200 (5.82) 300.8966 (153.90) 65.6729 (107.91) 260.0706 (221.12) 92.8300 (440.36) 1;0 3.3234 (0.01) 5 1 + test_ppp_0101 146.7774 (133.97) 149.4735 (2.16) 147.9447 (75.67) 0.8912 (1.46) 147.7914 (125.66) 1.1141 (5.29) 2;0 6.7593 (0.01) 7 1 + test_ppp_0102 180.5352 (164.78) 185.5049 (2.68) 182.7296 (93.46) 1.8601 (3.06) 182.4804 (155.15) 3.0510 (14.47) 2;0 5.4726 (0.01) 6 1 + test_ppp_0103 67.2411 (61.37) 124.2962 (1.79) 72.2580 (36.96) 14.5204 (23.86) 68.1508 (57.95) 1.1235 (5.33) 1;2 13.8393 (0.03) 15 1 + test_ppp_0104 111.7916 (102.04) 115.5179 (1.67) 113.7953 (58.20) 1.2992 (2.13) 113.7093 (96.68) 1.9166 (9.09) 4;0 8.7877 (0.02) 9 1 + test_ppp_0105 71.0350 (64.84) 87.2390 (1.26) 80.2263 (41.03) 4.7796 (7.85) 80.7737 (68.68) 7.4897 (35.53) 5;0 12.4647 (0.02) 13 1 + test_ppp_0106 78.6643 (71.80) 83.4581 (1.20) 81.7541 (41.81) 1.4432 (2.37) 82.1598 (69.86) 1.8966 (9.00) 5;0 12.2318 (0.02) 12 1 + test_ppp_0107 290.1933 (264.88) 302.6345 (4.37) 296.2779 (151.54) 5.0355 (8.27) 296.7623 (252.32) 8.2492 (39.13) 2;0 3.3752 (0.01) 5 1 + test_ppp_0108 82.7089 (75.49) 85.0884 (1.23) 83.5954 (42.76) 0.8842 (1.45) 83.1816 (70.73) 1.5849 (7.52) 4;0 11.9624 (0.02) 12 1 + test_ppp_0109 108.6753 (99.19) 110.3898 (1.59) 109.6267 (56.07) 0.6086 (1.0) 109.5960 (93.18) 0.7843 (3.72) 4;0 9.1219 (0.02) 10 1 + test_ppp_0110 221.1847 (201.89) 226.9487 (3.27) 224.2491 (114.70) 2.2437 (3.69) 223.9091 (190.38) 3.3070 (15.69) 2;0 4.4593 (0.01) 5 1 + test_ttt_23_02_02 627.2946 (572.57) 639.3529 (9.22) 630.5674 (322.51) 4.9883 (8.20) 628.2953 (534.21) 4.1876 (19.86) 1;1 1.5859 (0.00) 5 1 + test_ttt_23_T_24 146.2842 (133.52) 149.4915 (2.16) 147.9576 (75.68) 1.2978 (2.13) 148.0144 (125.85) 2.1337 (10.12) 2;0 6.7587 (0.01) 5 1 + test_ttt_24_SPO_06 143.4400 (130.93) 145.8922 (2.10) 144.6000 (73.96) 1.0524 (1.73) 144.2704 (122.67) 2.0361 (9.66) 4;0 6.9156 (0.01) 7 1 + -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + ``` + +- **Before all intersect PR (commit fa8e936)** + + ```text + ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ + Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations + -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + test_mesher_benchmark[100] 1.4098 (1.0) 74.0287 (16.20) 2.5949 (1.0) 7.4405 (12.63) 1.5013 (1.0) 0.1877 (1.0) 1;18 385.3707 (1.0) 95 1 + test_mesher_benchmark[1000] 2.8225 (2.00) 4.5707 (1.0) 3.3640 (1.30) 0.5890 (1.0) 2.9810 (1.99) 1.2073 (6.43) 61;0 297.2643 (0.77) 185 1 + test_mesher_benchmark[10000] 18.2586 (12.95) 96.1952 (21.05) 31.0653 (11.97) 28.0866 (47.68) 18.5138 (12.33) 0.4388 (2.34) 9;9 32.1902 (0.08) 53 1 + test_mesher_benchmark[100000] 267.0532 (189.42) 350.7605 (76.74) 317.7271 (122.44) 44.4738 (75.50) 349.1587 (232.57) 80.6410 (429.56) 2;0 3.1474 (0.01) 5 1 + test_ppp_0101 145.2433 (103.02) 149.5188 (32.71) 147.0663 (56.68) 1.3792 (2.34) 146.8151 (97.79) 1.4942 (7.96) 2;0 6.7997 (0.02) 7 1 + test_ppp_0102 178.8649 (126.87) 184.7600 (40.42) 181.7531 (70.04) 1.9309 (3.28) 181.5972 (120.96) 1.4921 (7.95) 2;1 5.5020 (0.01) 6 1 + test_ppp_0103 66.1185 (46.90) 68.7325 (15.04) 67.7935 (26.13) 0.7213 (1.22) 68.0329 (45.32) 0.8712 (4.64) 4;0 14.7507 (0.04) 15 1 + test_ppp_0104 111.4481 (79.05) 114.5727 (25.07) 113.1267 (43.60) 1.0848 (1.84) 113.0657 (75.31) 1.5002 (7.99) 4;0 8.8396 (0.02) 9 1 + test_ppp_0105 75.2770 (53.39) 86.6317 (18.95) 80.6485 (31.08) 3.1719 (5.38) 80.0031 (53.29) 3.3093 (17.63) 3;0 12.3995 (0.03) 12 1 + test_ppp_0106 80.9383 (57.41) 83.6762 (18.31) 82.3659 (31.74) 0.8217 (1.39) 82.4856 (54.94) 1.0667 (5.68) 4;0 12.1409 (0.03) 12 1 + test_ppp_0107 291.7345 (206.93) 302.4655 (66.17) 297.8876 (114.80) 4.0816 (6.93) 298.2377 (198.65) 5.4786 (29.18) 2;0 3.3570 (0.01) 5 1 + test_ppp_0108 80.2130 (56.90) 86.2986 (18.88) 82.6410 (31.85) 1.5109 (2.57) 82.4641 (54.93) 1.1424 (6.09) 2;2 12.1005 (0.03) 12 1 + test_ppp_0109 126.3475 (89.62) 129.0997 (28.24) 128.2563 (49.43) 0.9785 (1.66) 128.6220 (85.67) 1.2114 (6.45) 2;0 7.7969 (0.02) 8 1 + test_ppp_0110 219.2367 (155.51) 223.5040 (48.90) 221.4452 (85.34) 1.9318 (3.28) 222.1242 (147.95) 3.4878 (18.58) 2;0 4.5158 (0.01) 5 1 + test_ttt_23_02_02 613.0934 (434.87) 645.0137 (141.12) 631.2053 (243.25) 12.1928 (20.70) 631.9749 (420.94) 16.7976 (89.48) 2;0 1.5843 (0.00) 5 1 + test_ttt_23_T_24 143.7815 (101.98) 148.1351 (32.41) 146.1890 (56.34) 1.6663 (2.83) 146.1597 (97.35) 2.3439 (12.49) 2;0 6.8405 (0.02) 5 1 + test_ttt_24_SPO_06 139.9076 (99.24) 144.1027 (31.53) 142.3341 (54.85) 1.4964 (2.54) 142.6785 (95.03) 2.3097 (12.30) 2;0 7.0257 (0.02) 7 1 + -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + ``` + + Note: Changed test_ppp_0109 to use `extrude(UNITL)` instead of `extrude` as in `dev`branch and this PR + + ```diff + diff --git a/docs/assets/ttt/ttt-ppp0109.py b/docs/assets/ttt/ttt-ppp0109.py + index b00b0bc..82a4260 100644 + --- a/docs/assets/ttt/ttt-ppp0109.py + +++ b/docs/assets/ttt/ttt-ppp0109.py + @@ -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.edge(Select.NEW), 16) + ``` From de9ddf50ff40e23eb041c18edf8de929ae8686fb Mon Sep 17 00:00:00 2001 From: Bernhard Date: Sat, 17 Jan 2026 14:26:39 +0100 Subject: [PATCH 09/42] fuse extrude results in builder mode to match algebra mode --- src/build123d/operations_part.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index e3fe8dd..7dab522 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -230,16 +230,14 @@ def extrude( ) ) + if len(new_solids) > 1: + fused_solids = new_solids.pop().fuse(*new_solids) + new_solids = fused_solids if isinstance(fused_solids, list) else [fused_solids] + if clean: + new_solids = [solid.clean() for solid in new_solids] + if context is not None: context._add_to_context(*new_solids, clean=clean, mode=mode) - else: - if len(new_solids) > 1: - fused_solids = new_solids.pop().fuse(*new_solids) - new_solids = ( - fused_solids if isinstance(fused_solids, list) else [fused_solids] - ) - if clean: - new_solids = [solid.clean() for solid in new_solids] return Part(ShapeList(new_solids).solids()) From 499510a1c761e124726bc569109685e59af39434 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Sat, 17 Jan 2026 16:56:22 +0100 Subject: [PATCH 10/42] update after aligning extrude both for builder mode with fuse to algebra mode --- INTERSECT-SUMMARY.md | 141 ++++++++++++++++--------------- src/build123d/operations_part.py | 2 +- 2 files changed, 72 insertions(+), 71 deletions(-) diff --git a/INTERSECT-SUMMARY.md b/INTERSECT-SUMMARY.md index 0175539..7f70135 100644 --- a/INTERSECT-SUMMARY.md +++ b/INTERSECT-SUMMARY.md @@ -14,20 +14,20 @@ And given the apporach, n x m comparisions are needed in the filter function (pe ## Goal of the new apporach -- Define "real" intersections and distinguish them from touches (single point touch for faces, edge touch for solids, tangential touch, ...) - - The definition of intersect should be based on "what a CAD user expects", e.g. solid-solid = solid, face-face = face|edge, ... -- Calculate intersect in the most efficient way, specifically for each shape type combination. - - No use of n x m comparisions with faces involved (note that comparisions of edges are significantly cheaper, in some test 5-15 times faster) - - For every costly OCCT method when filtering results, a non-optimal bounding box comparision should be done as early exit (no bbox overlap => no need to do the costly calculation) -- Separate touch methods that calculate all possible touch results for the faces and solids - - intersect methods get a parameter `include_touched` that add touch results to the intersect results +- Define "real" intersections and distinguish them from touches (single point touch for faces, edge touch for solids, tangential touch, ...) + - The definition of intersect should be based on "what a CAD user expects", e.g. solid-solid = solid, face-face = face|edge, ... +- Calculate intersect in the most efficient way, specifically for each shape type combination. + - No use of n x m comparisions with faces involved (note that comparisions of edges are significantly cheaper, in some test 5-15 times faster) + - For every costly OCCT method when filtering results, a non-optimal bounding box comparision should be done as early exit (no bbox overlap => no need to do the costly calculation) +- Separate touch methods that calculate all possible touch results for the faces and solids + - intersect methods get a parameter `include_touched` that add touch results to the intersect results ### Intersect vs Touch The distinction between `intersect` and `touch` is based on result dimension: -- **Intersect**: Returns results down to a minimum dimension (interior overlap or crossing) -- **Touch**: Returns boundary contacts with dimension below the minimum intersect dimension, filtered to the highest dimension at each contact location +- **Intersect**: Returns results down to a minimum dimension (interior overlap or crossing) +- **Touch**: Returns boundary contacts with dimension below the minimum intersect dimension, filtered to the highest dimension at each contact location | Combination | Intersect result dims | Touch dims | | --------------- | --------------------- | ---------------------------- | @@ -46,10 +46,10 @@ The distinction between `intersect` and `touch` is based on result dimension: **Examples**: -- Two boxes sharing a face: `touch` → `[Face]` (not the 4 edges and 4 vertices of that face) -- Two boxes sharing an edge: `touch` → `[Edge]` (not the 2 endpoint vertices) -- Two boxes sharing only a corner: `touch` → `[Vertex]` -- Two faces with coplanar overlap AND crossing curve: `intersect` → `[Face, Edge]` +- Two boxes sharing a face: `touch` → `[Face]` (not the 4 edges and 4 vertices of that face) +- Two boxes sharing an edge: `touch` → `[Edge]` (not the 2 endpoint vertices) +- Two boxes sharing only a corner: `touch` → `[Vertex]` +- Two faces with coplanar overlap AND crossing curve: `intersect` → `[Face, Edge]` ### Multi-object and Compound handling @@ -63,8 +63,8 @@ The distinction between `intersect` and `touch` is based on result dimension: Key: -- AND: c ∩ c1 ∩ c2 -- OR: c ∩ (c1 ∪ c2) +- AND: c ∩ c1 ∩ c2 +- OR: c ∩ (c1 ∪ c2) \* A compound as tool shall not have overlapping solids according to OCCT docs @@ -82,10 +82,10 @@ For tangent contacts (surfaces touching at a point), the `touch()` method valida Legend: -- → handle: handles directly -- → delegate: calls `other._intersect(self, ...)` -- → distribute: iterates elements, calls `elem._intersect(...)` -- `t`: `include_touched` passed through +- → handle: handles directly +- → delegate: calls `other._intersect(self, ...)` +- → distribute: iterates elements, calls `elem._intersect(...)` +- `t`: `include_touched` passed through ### intersect() Call Flow @@ -131,10 +131,10 @@ Legend: **Delegation chains** (examples): -- `Edge._intersect(Solid, t)` → `Solid._intersect(Edge, t)` → handle -- `Vertex._intersect(Face, t)` → `Face._intersect(Vertex, t)` → handle -- `Face._intersect(Solid, t)` → `Solid._intersect(Face, t)` → handle -- `Edge._intersect(Compound, t)` → `Compound._intersect(Edge, t)` → distribute +- `Edge._intersect(Solid, t)` → `Solid._intersect(Edge, t)` → handle +- `Vertex._intersect(Face, t)` → `Face._intersect(Vertex, t)` → handle +- `Face._intersect(Solid, t)` → `Solid._intersect(Face, t)` → handle +- `Edge._intersect(Compound, t)` → `Compound._intersect(Edge, t)` → distribute ### touch() Call Flow @@ -295,10 +295,10 @@ Runtime imports happen after all modules are loaded, breaking the cycle. ### Infrastructure Changes (support for `include_touched`) -- Added `include_touched: bool = False` to `Case` dataclass -- Updated `run_test` to pass `include_touched` to `Shape.intersect` (geometry objects don't have it) -- Updated `make_params` to include `include_touched` in test parameters -- Updated all test function signatures and `@pytest.mark.parametrize` decorators +- Added `include_touched: bool = False` to `Case` dataclass +- Updated `run_test` to pass `include_touched` to `Shape.intersect` (geometry objects don't have it) +- Updated `make_params` to include `include_touched` in test parameters +- Updated all test function signatures and `@pytest.mark.parametrize` decorators ### Behavioral: Solid boundary contacts (intersect vs touch separation) @@ -350,9 +350,9 @@ These test cases verify correct handling when both `BRepAlgoAPI_Common` (overlap Added `test_touch_edge_tolerance()` to test filtering of false positive vertices: -- Tests torus (fillet) surface vs cylinder surface where BRepExtrema finds a point near edges of both faces -- With `tolerance=1e-3`, the point is detected as on both edges and filtered out -- `touch(tolerance=1e-3)` returns empty, `intersect(include_touched=True, tolerance=1e-3)` returns only `[Edge]` +- Tests torus (fillet) surface vs cylinder surface where BRepExtrema finds a point near edges of both faces +- With `tolerance=1e-3`, the point is detected as on both edges and filtered out +- `touch(tolerance=1e-3)` returns empty, `intersect(include_touched=True, tolerance=1e-3)` returns only `[Edge]` ### Performance tests @@ -360,29 +360,29 @@ Added `test_touch_edge_tolerance()` to test filtering of false positive vertices | name | dev | this branch | commit fa8e936 | this branch / dev | this branch / commit fa8e936 | | ------------------------------------------------------- | ---------: | ----------: | -------------: | ----------------: | ---------------------------: | -| tests/test_benchmarks.py::test_mesher_benchmark[100] | 1.5717 | 1.1761 | 1.5013 | -25.17% | -21.66% | -| tests/test_benchmarks.py::test_mesher_benchmark[1000] | 3.1709 | 2.6653 | 2.9810 | -15.95% | -10.59% | -| tests/test_benchmarks.py::test_mesher_benchmark[10000] | 18.8172 | 18.3698 | 18.5138 | -2.38% | -0.78% | -| tests/test_benchmarks.py::test_mesher_benchmark[100000] | 272.6479 | 260.0706 | 349.1587 | -4.61% | -25.51% | -| tests/test_benchmarks.py::test_ppp_0101 | 2,840.2942 | 147.7914 | 146.8151 | -94.80% | +0.66% | -| tests/test_benchmarks.py::test_ppp_0102 | 183.6392 | 182.4804 | 181.5972 | -0.63% | +0.49% | -| tests/test_benchmarks.py::test_ppp_0103 | 68.3975 | 68.1508 | 68.0329 | -0.36% | +0.17% | -| tests/test_benchmarks.py::test_ppp_0104 | 114.2050 | 113.7093 | 113.0657 | -0.43% | +0.57% | -| tests/test_benchmarks.py::test_ppp_0105 | 83.0605 | 80.7737 | 80.0031 | -2.75% | +0.96% | -| tests/test_benchmarks.py::test_ppp_0106 | 9,311.8187 | 82.1598 | 82.4856 | -99.12% | -0.40% | -| tests/test_benchmarks.py::test_ppp_0107 | 308.6340 | 296.7623 | 298.2377 | -3.85% | -0.49% | -| tests/test_benchmarks.py::test_ppp_0108 | 136.9441 | 83.1816 | 82.4641 | -39.25% | +0.87% | -| tests/test_benchmarks.py::test_ppp_0109 | 113.9680 | 109.5960 | 128.6220 | -3.84% | -14.79% | -| tests/test_benchmarks.py::test_ppp_0110 | 244.0596 | 223.9091 | 222.1242 | -8.26% | +0.80% | -| tests/test_benchmarks.py::test_ttt_23_02_02 | 646.0093 | 628.2953 | 631.9749 | -2.74% | -0.58% | -| tests/test_benchmarks.py::test_ttt_23_T_24 | 236.9038 | 148.0144 | 146.1597 | -37.52% | +1.27% | -| tests/test_benchmarks.py::test_ttt_24_SPO_06 | 150.4492 | 144.2704 | 142.6785 | -4.11% | +1.12% | +| tests/test_benchmarks.py::test_mesher_benchmark[100] | 1.5717 | 1.1397 | 1.5013 | -27.5% | -24.1% | +| tests/test_benchmarks.py::test_mesher_benchmark[1000] | 3.1709 | 2.5261 | 2.9810 | -20.3% | -15.3% | +| tests/test_benchmarks.py::test_mesher_benchmark[10000] | 18.8172 | 17.6889 | 18.5138 | -6.0% | -4.5% | +| tests/test_benchmarks.py::test_mesher_benchmark[100000] | 272.6479 | 254.8131 | 349.1587 | -6.5% | -27.0% | +| tests/test_benchmarks.py::test_ppp_0101 | 2,840.2942 | 148.3832 | 146.8151 | -94.8% | +1.1% | +| tests/test_benchmarks.py::test_ppp_0102 | 183.6392 | 176.3687 | 181.5972 | -4.0% | -2.9% | +| tests/test_benchmarks.py::test_ppp_0103 | 68.3975 | 69.5209 | 68.0329 | +1.6% | +2.2% | +| tests/test_benchmarks.py::test_ppp_0104 | 114.2050 | 115.6945 | 113.0657 | +1.3% | +2.3% | +| tests/test_benchmarks.py::test_ppp_0105 | 83.0605 | 78.0547 | 80.0031 | -6.0% | -2.4% | +| tests/test_benchmarks.py::test_ppp_0106 | 9,311.8187 | 85.0790 | 82.4856 | -99.1% | +3.1% | +| tests/test_benchmarks.py::test_ppp_0107 | 308.6340 | 286.2196 | 298.2377 | -7.3% | -4.0% | +| tests/test_benchmarks.py::test_ppp_0108 | 136.9441 | 69.9309 | 82.4641 | -48.9% | -15.2% | +| tests/test_benchmarks.py::test_ppp_0109 | 113.9680 | 111.8273 | 128.6220 | -1.9% | -13.1% | +| tests/test_benchmarks.py::test_ppp_0110 | 244.0596 | 217.1883 | 222.1242 | -11.0% | -2.2% | +| tests/test_benchmarks.py::test_ttt_23_02_02 | 646.0093 | 620.3012 | 631.9749 | -4.0% | -1.8% | +| tests/test_benchmarks.py::test_ttt_23_T_24 | 236.9038 | 147.6732 | 146.1597 | -37.7% | +1.0% | +| tests/test_benchmarks.py::test_ttt_24_SPO_06 | 150.4492 | 144.6859 | 142.6785 | -3.8% | +1.4% | \* Changed to use `extrude(UNTIL)` as in dev #### Details -- **Against dev ()** +- **Against dev ()** ```text ---------------------------------------------------------------------------------------------- benchmark: 17 tests ---------------------------------------------------------------------------------------------- @@ -408,33 +408,34 @@ Added `test_touch_edge_tolerance()` to test filtering of false positive vertices ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` -- **With this PR** +- **With this PR** - ```text + ````text ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - test_mesher_benchmark[100] 1.0956 (1.0) 69.3311 (1.0) 1.9552 (1.0) 5.4247 (8.91) 1.1761 (1.0) 0.2108 (1.0) 1;30 511.4671 (1.0) 159 1 - test_mesher_benchmark[1000] 2.5016 (2.28) 81.8406 (1.18) 3.4415 (1.76) 5.3939 (8.86) 2.6653 (2.27) 1.1725 (5.56) 2;2 290.5734 (0.57) 351 1 - test_mesher_benchmark[10000] 17.9532 (16.39) 87.1914 (1.26) 30.6284 (15.67) 25.7683 (42.34) 18.3698 (15.62) 0.5098 (2.42) 10;10 32.6494 (0.06) 53 1 - test_mesher_benchmark[100000] 253.4639 (231.35) 403.4200 (5.82) 300.8966 (153.90) 65.6729 (107.91) 260.0706 (221.12) 92.8300 (440.36) 1;0 3.3234 (0.01) 5 1 - test_ppp_0101 146.7774 (133.97) 149.4735 (2.16) 147.9447 (75.67) 0.8912 (1.46) 147.7914 (125.66) 1.1141 (5.29) 2;0 6.7593 (0.01) 7 1 - test_ppp_0102 180.5352 (164.78) 185.5049 (2.68) 182.7296 (93.46) 1.8601 (3.06) 182.4804 (155.15) 3.0510 (14.47) 2;0 5.4726 (0.01) 6 1 - test_ppp_0103 67.2411 (61.37) 124.2962 (1.79) 72.2580 (36.96) 14.5204 (23.86) 68.1508 (57.95) 1.1235 (5.33) 1;2 13.8393 (0.03) 15 1 - test_ppp_0104 111.7916 (102.04) 115.5179 (1.67) 113.7953 (58.20) 1.2992 (2.13) 113.7093 (96.68) 1.9166 (9.09) 4;0 8.7877 (0.02) 9 1 - test_ppp_0105 71.0350 (64.84) 87.2390 (1.26) 80.2263 (41.03) 4.7796 (7.85) 80.7737 (68.68) 7.4897 (35.53) 5;0 12.4647 (0.02) 13 1 - test_ppp_0106 78.6643 (71.80) 83.4581 (1.20) 81.7541 (41.81) 1.4432 (2.37) 82.1598 (69.86) 1.8966 (9.00) 5;0 12.2318 (0.02) 12 1 - test_ppp_0107 290.1933 (264.88) 302.6345 (4.37) 296.2779 (151.54) 5.0355 (8.27) 296.7623 (252.32) 8.2492 (39.13) 2;0 3.3752 (0.01) 5 1 - test_ppp_0108 82.7089 (75.49) 85.0884 (1.23) 83.5954 (42.76) 0.8842 (1.45) 83.1816 (70.73) 1.5849 (7.52) 4;0 11.9624 (0.02) 12 1 - test_ppp_0109 108.6753 (99.19) 110.3898 (1.59) 109.6267 (56.07) 0.6086 (1.0) 109.5960 (93.18) 0.7843 (3.72) 4;0 9.1219 (0.02) 10 1 - test_ppp_0110 221.1847 (201.89) 226.9487 (3.27) 224.2491 (114.70) 2.2437 (3.69) 223.9091 (190.38) 3.3070 (15.69) 2;0 4.4593 (0.01) 5 1 - test_ttt_23_02_02 627.2946 (572.57) 639.3529 (9.22) 630.5674 (322.51) 4.9883 (8.20) 628.2953 (534.21) 4.1876 (19.86) 1;1 1.5859 (0.00) 5 1 - test_ttt_23_T_24 146.2842 (133.52) 149.4915 (2.16) 147.9576 (75.68) 1.2978 (2.13) 148.0144 (125.85) 2.1337 (10.12) 2;0 6.7587 (0.01) 5 1 - test_ttt_24_SPO_06 143.4400 (130.93) 145.8922 (2.10) 144.6000 (73.96) 1.0524 (1.73) 144.2704 (122.67) 2.0361 (9.66) 4;0 6.9156 (0.01) 7 1 - -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - ``` + test_mesher_benchmark[100] 1.0574 (1.0) 70.4643 (1.0) 1.8813 (1.0) 5.4627 (8.54) 1.1397 (1.0) 0.2145 (1.06) 1;30 531.5461 (1.0) 162 1 + test_mesher_benchmark[1000] 2.4607 (2.33) 77.6734 (1.10) 3.2727 (1.74) 5.4347 (8.50) 2.5261 (2.22) 0.9649 (4.76) 2;2 305.5623 (0.57) 379 1 + test_mesher_benchmark[10000] 17.5591 (16.61) 80.8897 (1.15) 27.3925 (14.56) 23.6991 (37.06) 17.6889 (15.52) 0.2028 (1.0) 2;2 36.5064 (0.07) 13 1 + test_mesher_benchmark[100000] 251.0655 (237.44) 392.6514 (5.57) 296.1126 (157.40) 62.7306 (98.09) 254.8131 (223.57) 89.8318 (443.07) 1;0 3.3771 (0.01) 5 1 + test_ppp_0101 145.0982 (137.22) 149.1414 (2.12) 147.7855 (78.55) 1.4913 (2.33) 148.3832 (130.19) 2.0036 (9.88) 1;0 6.7666 (0.01) 7 1 + test_ppp_0102 175.2033 (165.70) 178.3188 (2.53) 176.4481 (93.79) 1.0492 (1.64) 176.3687 (154.75) 0.7466 (3.68) 2;1 5.6674 (0.01) 6 1 + test_ppp_0103 67.6037 (63.94) 120.4952 (1.71) 72.7045 (38.65) 13.2495 (20.72) 69.5209 (61.00) 1.0713 (5.28) 1;2 13.7543 (0.03) 15 1 + test_ppp_0104 114.5725 (108.36) 116.3083 (1.65) 115.5820 (61.44) 0.6395 (1.0) 115.6945 (101.51) 0.9241 (4.56) 4;0 8.6519 (0.02) 9 1 + test_ppp_0105 75.7650 (71.65) 79.4025 (1.13) 77.9072 (41.41) 1.0489 (1.64) 78.0547 (68.49) 1.7325 (8.54) 2;0 12.8358 (0.02) 13 1 + test_ppp_0106 84.4754 (79.89) 86.6812 (1.23) 85.3290 (45.36) 0.6590 (1.03) 85.0790 (74.65) 1.0335 (5.10) 3;0 11.7193 (0.02) 12 1 + test_ppp_0107 285.1950 (269.72) 288.7532 (4.10) 286.7046 (152.40) 1.3588 (2.12) 286.2196 (251.13) 1.7592 (8.68) 2;0 3.4879 (0.01) 5 1 + test_ppp_0108 65.4866 (61.93) 70.9025 (1.01) 69.5202 (36.95) 1.3768 (2.15) 69.9309 (61.36) 0.8998 (4.44) 3;2 14.3843 (0.03) 15 1 + test_ppp_0109 110.2980 (104.31) 113.0410 (1.60) 111.7263 (59.39) 0.7629 (1.19) 111.8273 (98.12) 0.4203 (2.07) 3;3 8.9504 (0.02) 9 1 + test_ppp_0110 214.6389 (202.99) 218.8224 (3.11) 217.1718 (115.44) 1.7101 (2.67) 217.1883 (190.56) 2.6129 (12.89) 1;0 4.6046 (0.01) 5 1 + test_ttt_23_02_02 617.3580 (583.86) 623.1470 (8.84) 620.1268 (329.63) 2.1388 (3.34) 620.3012 (544.25) 2.6976 (13.30) 2;0 1.6126 (0.00) 5 1 + test_ttt_23_T_24 145.4708 (137.58) 148.7882 (2.11) 147.1501 (78.22) 1.3564 (2.12) 147.6732 (129.57) 2.1137 (10.43) 2;0 6.7958 (0.01) 5 1 + test_ttt_24_SPO_06 143.1023 (135.34) 147.4160 (2.09) 144.8468 (76.99) 1.3698 (2.14) 144.6859 (126.95) 1.3559 (6.69) 2;1 6.9038 (0.01) 7 1 + -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` -- **Before all intersect PR (commit fa8e936)** + ```` + +- **Before all intersect PR (commit fa8e936)** ```text ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ diff --git a/src/build123d/operations_part.py b/src/build123d/operations_part.py index 7dab522..67f33d6 100644 --- a/src/build123d/operations_part.py +++ b/src/build123d/operations_part.py @@ -230,7 +230,7 @@ def extrude( ) ) - if len(new_solids) > 1: + if both and len(new_solids) > 1: fused_solids = new_solids.pop().fuse(*new_solids) new_solids = fused_solids if isinstance(fused_solids, list) else [fused_solids] if clean: From 1bdfff2427c7fcdc07391e1d1ba6e7bcaf3eae3e Mon Sep 17 00:00:00 2001 From: Bernhard Date: Sat, 17 Jan 2026 18:22:54 +0100 Subject: [PATCH 11/42] fix typing issues --- src/build123d/topology/shape_core.py | 21 ++++++++++++--------- src/build123d/topology/three_d.py | 4 ++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 1aec711..bd74bf4 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1379,7 +1379,7 @@ class Shape(NodeMixin, Generic[TOPODS]): # Chained iteration for AND semantics: c.intersect(s1, s2) = c ∩ s1 ∩ s2 common_set = ShapeList([self]) for other in shapes: - next_set = ShapeList() + next_set: ShapeList = ShapeList() for obj in common_set: result = obj._intersect(other, tolerance, include_touched) if result: @@ -2625,15 +2625,18 @@ class ShapeList(list[T]): """ expanded: ShapeList = ShapeList() for shape in self: - if hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Compound): - # Recursively expand nested compounds - expanded.extend(ShapeList(list(shape)).expand()) - elif hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Shell): - expanded.extend(shape.faces()) - elif hasattr(shape, "wrapped") and isinstance(shape.wrapped, TopoDS_Wire): - expanded.extend(shape.edges()) - elif not shape.is_null: + if isinstance(shape, Vector): expanded.append(shape) + elif hasattr(shape, "wrapped"): + if isinstance(shape.wrapped, TopoDS_Compound): + # Recursively expand nested compounds + expanded.extend(ShapeList(list(shape)).expand()) + elif isinstance(shape.wrapped, TopoDS_Shell): + expanded.extend(shape.faces()) + elif isinstance(shape.wrapped, TopoDS_Wire): + expanded.extend(shape.edges()) + elif not shape.is_null: + expanded.append(shape) return expanded def center(self) -> Vector: diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index daf9f9f..1a07c7f 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -479,12 +479,12 @@ class Mixin3D(Shape[TOPODS]): for r in results if not ( isinstance(r, Vertex) - and any(r.distance_to(e) <= tolerance for e in edges_in_results) + and any(e.distance_to(r) <= tolerance for e in edges_in_results) ) and not ( isinstance(r, Edge) and any( - r.center().distance_to(f) <= tolerance for f in faces_in_results + f.distance_to(r.center()) <= tolerance for f in faces_in_results ) ) ) From 8ddb6ff0dcfab964321332fded09a576f36c6c05 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 09:52:46 +0100 Subject: [PATCH 12/42] create Shape._bool_op_list to make ty type checking happy --- src/build123d/topology/one_d.py | 8 +++--- src/build123d/topology/shape_core.py | 38 ++++++++++++++++++++-------- src/build123d/topology/three_d.py | 20 +++++++-------- src/build123d/topology/two_d.py | 12 ++++----- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index de5d335..e5a2f22 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -736,12 +736,12 @@ class Mixin1D(Shape[TOPODS]): # 1D + 1D: Common (collinear overlap) + Section (crossing vertices) if isinstance(other, (Edge, Wire)): - common = self._bool_op( - (self,), (other,), BRepAlgoAPI_Common(), as_list=True + common = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Common() ) results.extend(common.expand()) - section = self._bool_op( - (self,), (other,), BRepAlgoAPI_Section(), as_list=True + section = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Section() ) # Extract vertices from section (edges already in Common for wires) for shape in section: diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index bd74bf4..42a40ed 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -2271,7 +2271,6 @@ class Shape(NodeMixin, Generic[TOPODS]): args: Iterable[Shape], tools: Iterable[Shape], operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, - as_list: bool = False, ) -> Self | ShapeList: """Generic boolean operation @@ -2280,11 +2279,9 @@ class Shape(NodeMixin, Generic[TOPODS]): tools: Iterable[Shape]: operation: Union[BRepAlgoAPI_BooleanOperation: BRepAlgoAPI_Splitter]: - as_list: If True, always return ShapeList (wrapping single results, - returning empty ShapeList for null results) Returns: - Shape, ShapeList, or empty ShapeList depending on result and as_list + Shape or ShapeList depending on result """ args = list(args) @@ -2344,14 +2341,35 @@ class Shape(NodeMixin, Generic[TOPODS]): result = highest_order[0].cast(topo_result) base.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"]) - # Handle as_list mode - if as_list: - if result.is_null: - return ShapeList() - return ShapeList([result]) - return result + def _bool_op_list( + self, + args: Iterable[Shape], + tools: Iterable[Shape], + operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter, + ) -> ShapeList: + """Generic boolean operation that always returns ShapeList. + + Wrapper around _bool_op that guarantees ShapeList return type, + wrapping single results and returning empty ShapeList for null results. + + Args: + args: Iterable[Shape]: + tools: Iterable[Shape]: + operation: Union[BRepAlgoAPI_BooleanOperation, BRepAlgoAPI_Splitter]: + + Returns: + ShapeList (possibly empty) + + """ + result = self._bool_op(args, tools, operation) + if isinstance(result, ShapeList): + return result + if result.is_null: + return ShapeList() + return ShapeList([result]) + def _ocp_section( self: Shape, other: Vertex | Edge | Wire | Face ) -> tuple[ShapeList[Vertex], ShapeList[Edge]]: diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 1a07c7f..59c9acf 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -451,8 +451,8 @@ class Mixin3D(Shape[TOPODS]): # 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 + intersection = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Common() ) results.extend(intersection.expand()) # Solid + Vertex: point containment check @@ -770,8 +770,8 @@ class Solid(Mixin3D[TopoDS_Solid]): 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 + common = self._bool_op_list( + (sf,), (of,), BRepAlgoAPI_Common() ) found_faces.extend(s for s in common if not s.is_null) results.extend(found_faces) @@ -782,8 +782,8 @@ class Solid(Mixin3D[TopoDS_Solid]): 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 + common = self._bool_op_list( + (se,), (oe,), BRepAlgoAPI_Common() ) for s in common: if s.is_null: @@ -843,8 +843,8 @@ class Solid(Mixin3D[TopoDS_Solid]): 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 + common = self._bool_op_list( + (oe,), (sf,), BRepAlgoAPI_Common() ) results.extend(s for s in common if not s.is_null) # Check face's vertices touching solid's edges (corner coincident) @@ -868,8 +868,8 @@ class Solid(Mixin3D[TopoDS_Solid]): # 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 + common_result = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Common() ) if not common_result: # No penetration - could be tangent for sf, sf_bb in self_faces: diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 5548b3c..2ae81f5 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -328,16 +328,16 @@ class Mixin2D(ABC, Shape[TOPODS]): # 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 = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Common() ) common_faces = common.expand() results.extend(common_faces) # 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 = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Section() ) section_edges = ShapeList( [s for s in section if isinstance(s, Edge)] @@ -356,8 +356,8 @@ class Mixin2D(ABC, Shape[TOPODS]): # 2D + Edge: Section for intersection elif isinstance(other, (Edge, Wire)): - section = self._bool_op( - (self,), (other,), BRepAlgoAPI_Section(), as_list=True + section = self._bool_op_list( + (self,), (other,), BRepAlgoAPI_Section() ) results.extend(section) From 293e4e8803a1c8a4556a4037c1f79ad56533d412 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 09:54:09 +0100 Subject: [PATCH 13/42] add base _intersect method to Shape to make tyy type checking happy --- src/build123d/topology/shape_core.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 42a40ed..ff99093 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1389,6 +1389,27 @@ class Shape(NodeMixin, Generic[TOPODS]): common_set = ShapeList(set(next_set)) # deduplicate return common_set if common_set else None + def _intersect( + self, + other: Shape, + tolerance: float = 1e-6, + include_touched: bool = False, + ) -> ShapeList | None: + """Single-object intersection implementation. + + Base implementation returns None. Subclasses (Vertex, Mixin1D, Mixin2D, + Mixin3D, Compound) override this to provide actual intersection logic. + + Args: + other: Shape to intersect with + tolerance: tolerance for intersection detection + include_touched: if True, include boundary contacts + + Returns: + ShapeList of intersection shapes, or None if no intersection + """ + return None + def touch(self, other: Shape, tolerance: float = 1e-6) -> ShapeList: """Find boundary contacts between this shape and another. From d17b7d307ee5143540a4a178f25d94db04f3aa90 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 17:52:29 +0100 Subject: [PATCH 14/42] handle normal check for Shell in touch() --- src/build123d/topology/two_d.py | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 2ae81f5..0f5b6d3 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -453,11 +453,32 @@ class Mixin2D(ABC, Shape[TOPODS]): # 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 + # Find the specific face containing the contact point + self_face: Face | None = None + other_face: Face | None = None + + if isinstance(self, Face): + self_face = self + else: # Shell - find face containing point + for f in self.faces(): + if f.distance_to(contact_pt) <= tolerance: + self_face = f + break + + if isinstance(other, Face): + other_face = other + else: # Shell - find face containing point + for f in other.faces(): + if f.distance_to(contact_pt) <= tolerance: + other_face = f + break + + if self_face and other_face: + normal1 = self_face.normal_at(contact_pt) + normal2 = other_face.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 From 318f273a2cb9253f39509ff0bd111b03adeebbaf Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 17:53:50 +0100 Subject: [PATCH 15/42] deduplicate edges in Solid.touch for Face/Shell --- src/build123d/topology/three_d.py | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 59c9acf..959ac69 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -770,9 +770,7 @@ class Solid(Mixin3D[TopoDS_Solid]): for of, of_bb in other_faces: if not sf_bb.overlaps(of_bb, tolerance): continue - common = self._bool_op_list( - (sf,), (of,), BRepAlgoAPI_Common() - ) + common = self._bool_op_list((sf,), (of,), BRepAlgoAPI_Common()) found_faces.extend(s for s in common if not s.is_null) results.extend(found_faces) @@ -782,9 +780,7 @@ class Solid(Mixin3D[TopoDS_Solid]): for oe, oe_bb in other_edges: if not se_bb.overlaps(oe_bb, tolerance): continue - common = self._bool_op_list( - (se,), (oe,), BRepAlgoAPI_Common() - ) + common = self._bool_op_list((se,), (oe,), BRepAlgoAPI_Common()) for s in common: if s.is_null: continue @@ -839,14 +835,25 @@ class Solid(Mixin3D[TopoDS_Solid]): other_edges = [(e, e.bounding_box(optimal=False)) for e in other.edges()] # Check face's edges touching solid's faces + # Track found edges to avoid duplicates (edge may touch multiple adjacent faces) + found_edges: list[Edge] = [] 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_list( - (oe,), (sf,), BRepAlgoAPI_Common() - ) - results.extend(s for s in common if not s.is_null) + common = self._bool_op_list((oe,), (sf,), BRepAlgoAPI_Common()) + for s in common: + if s.is_null or not isinstance(s, Edge): + continue + # Check if geometrically same edge already found + already = any( + (s.center() - e.center()).length <= tolerance + and abs(s.length - e.length) <= tolerance + for e in found_edges + ) + if not already: + results.append(s) + found_edges.append(s) # Check face's vertices touching solid's edges (corner coincident) for ov in other.vertices(): for se in self.edges(): @@ -868,9 +875,7 @@ class Solid(Mixin3D[TopoDS_Solid]): # 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_list( - (self,), (other,), BRepAlgoAPI_Common() - ) + common_result = self._bool_op_list((self,), (other,), BRepAlgoAPI_Common()) if not common_result: # No penetration - could be tangent for sf, sf_bb in self_faces: if not sf_bb.overlaps(other_bb, tolerance): From 06ebe6c968da3c896cf1d2c917007edc35cbdaae Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 17:54:21 +0100 Subject: [PATCH 16/42] encapsulate filtering into sub-function --- src/build123d/topology/three_d.py | 51 ++++++++++++++++++------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 959ac69..9900c9a 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -447,13 +447,37 @@ class Mixin3D(Shape[TOPODS]): (shapes touching the solid's surface without penetrating) """ + def filter_redundant_touches(items: ShapeList) -> ShapeList: + """Remove vertices/edges that lie on higher-dimensional results.""" + edges = [r for r in items if isinstance(r, Edge)] + faces = [r for r in items if isinstance(r, Face)] + solids = [r for r in items if isinstance(r, Solid)] + return ShapeList( + r + for r in items + if not ( + isinstance(r, Vertex) + and ( + any(e.distance_to(r) <= tolerance for e in edges) + or any(f.distance_to(r) <= tolerance for f in faces) + or any( + sf.distance_to(r) <= tolerance + for s in solids + for sf in s.faces() + ) + ) + ) + and not ( + isinstance(r, Edge) + and any(f.distance_to(r.center()) <= tolerance for f in faces) + ) + ) + results: ShapeList = ShapeList() # Solid + Solid/Face/Shell/Edge/Wire: use Common if isinstance(other, (Solid, Face, Shell, Edge, Wire)): - intersection = self._bool_op_list( - (self,), (other,), BRepAlgoAPI_Common() - ) + intersection = self._bool_op_list((self,), (other,), BRepAlgoAPI_Common()) results.extend(intersection.expand()) # Solid + Vertex: point containment check elif isinstance(other, Vertex): @@ -469,25 +493,8 @@ class Mixin3D(Shape[TOPODS]): # 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(e.distance_to(r) <= tolerance for e in edges_in_results) - ) - and not ( - isinstance(r, Edge) - and any( - f.distance_to(r.center()) <= tolerance for f in faces_in_results - ) - ) - ) + results = filter_redundant_touches(ShapeList(set(results))) + return results if results else None def is_inside(self, point: VectorLike, tolerance: float = 1.0e-6) -> bool: From d9abb0306dc0c811b5cfddbbc664640286a934d9 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 18:53:24 +0100 Subject: [PATCH 17/42] add more touch test cases --- tests/test_direct_api/test_intersection.py | 93 ++++++++++++++++++---- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 581b45e..0712412 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -238,6 +238,16 @@ fc4 = Rot(Z=45) * Rectangle(5, 5).face() fc5 = Pos(2.5, 2.5, 2.5) * Rot(0, 90) * Rectangle(5, 5).face() fc6 = Pos(2.5, 2.5) * Rot(0, 90, 45, Extrinsic.XYZ) * Rectangle(5, 5).face() fc7 = (Rot(90) * Cylinder(2, 4)).faces().filter_by(GeomType.CYLINDER)[0] +fc8 = make_face( + Polyline( + (-1.5, 1, 1), + (-1.5, -1, 1), + (3.5, -1, -1), + (3.5, 1, -1), + (-1.5, 1, 1), + ) +) +fc9 = Pos(-2) * mirror(fc8, Plane.XY) fc11 = Rectangle(4, 4).face() fc22 = sweep(Rot(90) * CenterArc((0, 0), 2, 0, 180), Line((0, 2), (0, -2))) @@ -246,6 +256,13 @@ sh2 = Pos(Z=1) * sh1 sh3 = Shell([Pos(-4) * fc11, fc22, Pos(2, 0, -2) * Rot(0, 90) * fc11]) sh4 = Shell([Pos(-4) * fc11, fc22, Pos(4) * fc11]) sh5 = Pos(Z=1) * Shell([Pos(-2, 0, -2) * Rot(0, -90) * fc11, fc22, Pos(2, 0, -2) * Rot(0, 90) * fc11]) +sh6 = Box(2, 2, 2).shell() + +# Shell tangent touch test objects (half spheres) +_half_sphere_solid = Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2) +sh7 = Shell(_half_sphere_solid.faces()) +sh8 = Pos(2, 0, 0) * sh7 # tangent at (1, 0, 0) +fc10 = Pos(1, 0, 0) * (Rot(0, 90, 0) * Rectangle(2, 2).face()) # tangent to sphere at x=1 shape_2d_matrix = [ Case(fc1, vl2, None, "non-coincident", None), @@ -292,6 +309,15 @@ shape_2d_matrix = [ Case(sh1, fc1, [Face, Edge], "coplanar + intersecting", None), Case(sh4, fc1, [Face, Face], "2 coplanar", None), Case(sh5, fc1, [Edge, Edge], "2 intersecting", None), + Case(sh6, Pos(0,0,1) * fc1, [Face], "2 intersecting boundary", None), + Case(sh6, Pos(2, 1, 1) * sh6, [Face], "2 intersecting boundary", None), + + # Shell + Face tangent touch + Case(sh7, fc10, None, "tangent touch", None), + Case(sh7, fc10, [Vertex], "tangent touch", None, True), + # Shell + Shell tangent touch + Case(sh7, sh8, None, "tangent touch", None), + Case(sh7, sh8, [Vertex], "tangent touch", None, True), Case(fc1, [fc4, Pos(2, 2) * fc1], [Face], "multi to_intersect, intersecting", None), Case(fc1, [ed1, Pos(2.5, 2.5) * fc1], [Edge], "multi to_intersect, intersecting", None), @@ -306,6 +332,7 @@ def test_shape_2d(obj, target, expected, include_touched): sl1 = Box(2, 2, 2).solid() sl2 = Pos(Z=5) * Box(2, 2, 2).solid() sl3 = Cylinder(2, 1).solid() - Cylinder(1.5, 1).solid() +sl4 = Box(3, 1, 1) wi7 = Wire([l1 := sl3.faces().sort_by(Axis.Z)[-1].edges()[0].trim(.3, .4), l2 := l1.trim(2, 3), @@ -327,6 +354,7 @@ shape_3d_matrix = [ Case(sl1, pl3, None, "non-coincident", None), Case(sl1, pl2, [Face], "intersecting", None), + Case(sl1, pl2.offset(1), [Face], "intersecting boondary", None), Case(sl2, vt1, None, "non-coincident", None), Case(Pos(2) * sl1, vt1, [Vertex], "contained", None), @@ -339,13 +367,17 @@ shape_3d_matrix = [ Case(sl1, Pos(1, 1, 1) * ed1, None, "corner coincident", None), Case(sl1, Pos(1, 1, 1) * ed1, [Vertex], "corner coincident", None, True), Case(Pos(2.1, 1) * sl1, ed4, [Edge, Edge], "multi-intersect", None), + Case(Pos(2.1, 1, -1) * sl1, ed4, [Edge, Edge], "multi-intersect, boundary", None), Case(Pos(2, .5, -1) * sl1, wi6, None, "non-coincident", None), Case(Pos(2, .5, 1) * sl1, wi6, [Edge, Edge], "multi-intersecting", None), + Case(Pos(2, .5, 2) * sl1, wi6, [Edge, Edge], "multi-intersecting, boundary", None), Case(sl3, wi7, [Edge, Edge], "multi-coincident, is_equal check", None), Case(sl2, fc1, None, "non-coincident", None), Case(sl1, fc1, [Face], "intersecting", None), + Case(Pos(0,0,-1) * sl1, fc1, [Face], "intersecting, boundary", None), + Case(Pos(0,0,1) * sl1, fc1, [Face], "intersecting, boundary", None), # Solid + Face edge collinear: now requires include_touched Case(Pos(3.5, 0, 1) * sl1, fc1, None, "edge collinear", None), Case(Pos(3.5, 0, 1) * sl1, fc1, [Edge], "edge collinear", None, True), @@ -353,15 +385,23 @@ shape_3d_matrix = [ Case(Pos(3.5, 3.5) * sl1, fc1, None, "corner coincident", None), Case(Pos(3.5, 3.5) * sl1, fc1, [Vertex], "corner coincident", None, True), Case(Pos(.9) * sl1, fc7, [Face, Face], "multi-intersecting", None), + Case(Pos(.9,1) * sl1, fc7, [Face, Face], "multi-intersecting", None), + Case(Pos(.9,1.5) * sl1, fc7, [Face, Face], "multi-intersecting", None), Case(sl2, sh1, None, "non-coincident", None), Case(Pos(-2) * sl1, sh1, [Face, Face], "multi-intersecting", None), + Case(Pos(-2) * sl1, sh1, [Face, Face], "multi-intersecting", None), + Case(Pos(-2,3) * sl1, sh1, None, "multi-intersecting", None), + Case(Pos(-2,3) * sl1, sh1, [Edge, Edge], "multi-intersecting", None, True), Case(sl1, sl2, None, "non-coincident", None), Case(sl1, Pos(1, 1, 1) * sl1, [Solid], "intersecting", None), # Solid + Solid edge collinear: now requires include_touched Case(sl1, Pos(2, 2, 1) * sl1, None, "edge collinear", None), Case(sl1, Pos(2, 2, 1) * sl1, [Edge], "edge collinear", None, True), + # Solid + Solid face collinear: now requires include_touched + Case(sl1, Pos(2, 1.5, 1) * sl1, None, "edge collinear", None), + Case(sl1, Pos(2, 1.5, 1) * sl1, [Face], "edge collinear", None, True), # Solid + Solid corner coincident: now requires include_touched Case(sl1, Pos(2, 2, 2) * sl1, None, "corner coincident", None), Case(sl1, Pos(2, 2, 2) * sl1, [Vertex], "corner coincident", None, True), @@ -371,6 +411,10 @@ shape_3d_matrix = [ Case(sl1, Pos(2, 0, 0) * sl1, [Face], "face coincident", None, True), Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid], "multi to_intersect, intersecting", None), + Case(Pos(0, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid, Solid], "multi to_intersect, intersecting", None), + Case(Pos(0.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid, Solid], "multi to_intersect, intersecting", None), + Case(Pos(0.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Face, Face, Solid, Solid], "multi to_intersect, intersecting", None, True), + Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(Z=.5) * fc1], [Face], "multi to_intersect, intersecting", None), ] @@ -383,6 +427,8 @@ cp1 = Compound(GridLocations(5, 0, 2, 1) * Vertex()) cp2 = Compound(GridLocations(5, 0, 2, 1) * Line((0, -1), (0, 1))) cp3 = Compound(GridLocations(5, 0, 2, 1) * Rectangle(2, 2)) cp4 = Compound(GridLocations(5, 0, 2, 1) * Box(2, 2, 2)) +cp5 = Compound([fc8, fc9]) +cp6 = Compound(GridLocations(4, 0, 2, 1) * Rectangle(2, 2)) cv1 = Curve() + [ed1, ed2, ed3] sk1 = Sketch() + [fc1, fc2, fc3] @@ -391,43 +437,56 @@ pt1 = Part() + [sl1, sl2, sl3] shape_compound_matrix = [ Case(cp1, vl1, None, "non-coincident", None), - Case(Pos(-.5) * cp1, vl1, [Vertex], "intersecting", None), - + Case(Pos(-0.5) * cp1, vl1, [Vertex], "intersecting", None), Case(cp2, lc1, None, "non-coincident", None), - Case(Pos(-.5) * cp2, lc1, [Vertex], "intersecting", None), - + Case(Pos(-0.5) * cp2, lc1, [Vertex], "intersecting", None), Case(Pos(Z=1) * cp3, ax1, None, "non-coincident", None), Case(cp3, ax1, [Edge, Edge], "intersecting", None), - Case(Pos(Z=3) * cp4, pl2, None, "non-coincident", None), Case(cp4, pl2, [Face, Face], "intersecting", None), - + Case(Pos(Z=1) * cp4, pl2, [Face, Face], "non-coincident, boundary", None), + Case(Pos(Z=-1) * cp4, pl2, [Face, Face], "non-coincident, boundary", None), Case(cp1, vt1, None, "non-coincident", None), - Case(Pos(-.5) * cp1, vt1, [Vertex], "intersecting", None), - + Case(Pos(-0.5) * cp1, vt1, [Vertex], "intersecting", None), Case(Pos(Z=1) * cp2, ed1, None, "non-coincident", None), Case(cp2, ed1, [Vertex], "intersecting", None), - Case(Pos(Z=1) * cp3, fc1, None, "non-coincident", None), Case(cp3, fc1, [Face, Face], "intersecting", None), - + Case(Pos(1) * cp3, fc1, [Face, Edge], "intersectingPos(0.5), ", None), Case(Pos(Z=5) * cp4, sl1, None, "non-coincident", None), Case(Pos(2) * cp4, sl1, [Solid], "intersecting", None), - + Case(cp4, sl4, None, "intersecting", None), + Case(cp4, sl4, [Face, Face], "intersecting", None, True), + Case(cp4, Pos(0, 1, 1) * sl4, [Face, Face], "intersecting", None, True), + Case(cp4, Pos(0, 1, 1.5) * sl4, [Edge, Edge], "intersecting", None, True), + Case(cp4, Pos(0, 1.5, 1.5) * sl4, [Vertex, Vertex], "intersecting", None, True), Case(cp1, Pos(Z=1) * cp1, None, "non-coincident", None), Case(cp1, cp2, [Vertex, Vertex], "intersecting", None), Case(cp2, cp3, [Edge, Edge], "intersecting", None), + Case(Pos(0, 2, 0) * cp2, cp3, [Vertex, Vertex], "intersecting", None), Case(cp3, cp4, [Face, Face], "intersecting", None), - - Case(cp1, Compound(children=cp1.get_type(Vertex)), [Vertex, Vertex], "mixed child type", None), - Case(cp4, Compound(children=cp3.get_type(Face)), [Face, Face], "mixed child type", None), - + Case(cp5, cp4, [Face, Face], "intersecting", None), + Case(cp5, cp4, [Face, Face, Edge, Edge], "intersecting", None, True), + Case( + cp1, + Compound(children=cp1.get_type(Vertex)), + [Vertex, Vertex], + "mixed child type", + None, + ), + Case( + cp4, + Compound(children=cp3.get_type(Face)), + [Face, Face], + "mixed child type", + None, + ), Case(cp2, [cp3, cp4], [Edge, Edge], "multi to_intersect, intersecting", None), - Case(cv1, cp3, [Edge, Edge, Edge, Edge], "intersecting", None), # xfail removed Case(sk1, cp3, [Face, Face], "intersecting", None), Case(pt1, cp3, [Face, Face], "intersecting", None), - + Case(pt1, cp6, [Face, Face], "intersecting", None), + Case(pt1, cp6, [Face, Face, Edge, Edge], "intersecting", None, True), ] @pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_compound_matrix)) From 2e5d81d1403e1db89c67f4350bbc7fb7eec4b368 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 18:53:58 +0100 Subject: [PATCH 18/42] update to ,latest state of tests and performance run --- INTERSECT-SUMMARY.md | 147 +++++++++++++++++++++++-------------------- 1 file changed, 79 insertions(+), 68 deletions(-) diff --git a/INTERSECT-SUMMARY.md b/INTERSECT-SUMMARY.md index 7f70135..760c560 100644 --- a/INTERSECT-SUMMARY.md +++ b/INTERSECT-SUMMARY.md @@ -293,13 +293,49 @@ Runtime imports happen after all modules are loaded, breaking the cycle. ## Tests -### Infrastructure Changes (support for `include_touched`) +### Test Case Counts + +| | dev branch | this branch | change | +| --------------------- | ---------: | ----------: | -----: | +| Case definitions | 199 | 241 | +42 | +| Parametrized tests \* | 322 | 379 | +57 | + +\* Parametrized tests include symmetry swaps (A×B also tested as B×A) where applicable + +**Breakdown by matrix:** + +| Matrix | dev | this | change | +| --------------------- | --: | ---: | -----: | +| geometry_matrix | 47 | 47 | 0 | +| shape_0d_matrix | 20 | 20 | 0 | +| shape_1d_matrix | 60 | 60 | 0 | +| shape_2d_matrix | 64 | 73 | +9 | +| shape_3d_matrix | 65 | 96 | +31 | +| shape_compound_matrix | 43 | 60 | +17 | +| freecad_matrix | 15 | 15 | 0 | +| issues_matrix | 8 | 8 | 0 | + +### Changes Summary + +**Infrastructure changes:** - Added `include_touched: bool = False` to `Case` dataclass - Updated `run_test` to pass `include_touched` to `Shape.intersect` (geometry objects don't have it) -- Updated `make_params` to include `include_touched` in test parameters +- Updated `make_params` to include `include_touched` in test parameters; symmetry swaps disabled for `include_touched` tests - Updated all test function signatures and `@pytest.mark.parametrize` decorators +**New test objects:** + +- `sh7`, `sh8`: Half-sphere shells for tangent touch testing +- `fc10`: Tangent face for sphere tangent contact + +**New test case categories:** + +- Face+Face crossing vertex: paired tests (without touch → `None`, with `include_touched` → `[Vertex]`) +- Shell+Face/Shell tangent touch: tests for tangent surface contacts +- Solid+Edge/Face/Solid boundary contacts: paired tests for corner/edge/face coincidence +- Compound+Shape with `include_touched`: tests for boundary contacts through compounds + ### Behavioral: Solid boundary contacts (intersect vs touch separation) | Test Case | Before | After (no touch) | After (with touch) | @@ -310,35 +346,19 @@ Runtime imports happen after all modules are loaded, breaking the cycle. | Solid + Solid, edge collinear | `[Edge]` | `None` | `[Edge]` | | Solid + Solid, corner coincident | `[Vertex]` | `None` | `[Vertex]` | | Solid + Solid, face coincident | N/A (new) | `None` | `[Face]` | -| Solid + Solid, tangential point | N/A (new) | `None` | `[Vertex]` | -### Behavioral: Face boundary contacts (intersect vs touch separation) +### Behavioral: Face/Shell boundary contacts (intersect vs touch separation) | Test Case | Before | After (no touch) | After (with touch) | | ---------------------------- | ---------- | ---------------- | ------------------ | | Face + Face, crossing vertex | `[Vertex]` | `None` | `[Vertex]` | +| Shell + Face, tangent touch | N/A (new) | `None` | `[Vertex]` | +| Shell + Shell, tangent touch | N/A (new) | `None` | `[Vertex]` | Two non-coplanar faces that cross at a single point (due to finite extent) now return the vertex via `touch()` rather than `intersect()`. Added `Mixin2D.touch()` method. These represent the semantic change: boundary contacts are **not** interior intersections, so `intersect()` returns `None`. Use `include_touched=True` to get them. -### Behavioral: Tangent edge (Edge lying on Solid surface) - -| Test Case | Result | Notes | -| -------------------------- | -------- | ------------------------------------------- | -| Solid + Edge, tangent edge | `[Edge]` | Edge on cylinder surface is an intersection | - -A tangent edge (lying ON a solid's surface) is treated as an **intersection** (1D result), not a touch. This is because `touch` for Solid+Edge only returns Vertex (0D). The edge is returned by `BRepAlgoAPI_Common` since it's "common" to both shapes. - -### New test cases: Common + Section (mixed overlap and crossing) - -| Test Case | Result | Description | -| ---------------------------------- | ---------------- | ------------------------------------------------ | -| Edge + Edge, spline common+section | `[Edge, Vertex]` | Spline with collinear segment and crossing point | -| Face + Face, common+section | `[Face, Edge]` | Face with coplanar overlap and crossing curve | - -These test cases verify correct handling when both `BRepAlgoAPI_Common` (overlap) and `BRepAlgoAPI_Section` (crossing) return results for the same shape pair. - ### Bug fixes / xfail removals | Test Case | Before | After | @@ -346,39 +366,31 @@ These test cases verify correct handling when both `BRepAlgoAPI_Common` (overlap | Solid + Edge, edge collinear | `[Edge]` with xfail "duplicate edges" | `[Edge]` passing | | Curve + Compound, intersecting | `[Edge, Edge]` with xfail | `[Edge, Edge, Edge, Edge]` passing | -### New test: edge tolerance filtering - -Added `test_touch_edge_tolerance()` to test filtering of false positive vertices: - -- Tests torus (fillet) surface vs cylinder surface where BRepExtrema finds a point near edges of both faces -- With `tolerance=1e-3`, the point is detected as on both edges and filtered out -- `touch(tolerance=1e-3)` returns empty, `intersect(include_touched=True, tolerance=1e-3)` returns only `[Edge]` - ### Performance tests #### Summary | name | dev | this branch | commit fa8e936 | this branch / dev | this branch / commit fa8e936 | | ------------------------------------------------------- | ---------: | ----------: | -------------: | ----------------: | ---------------------------: | -| tests/test_benchmarks.py::test_mesher_benchmark[100] | 1.5717 | 1.1397 | 1.5013 | -27.5% | -24.1% | -| tests/test_benchmarks.py::test_mesher_benchmark[1000] | 3.1709 | 2.5261 | 2.9810 | -20.3% | -15.3% | -| tests/test_benchmarks.py::test_mesher_benchmark[10000] | 18.8172 | 17.6889 | 18.5138 | -6.0% | -4.5% | -| tests/test_benchmarks.py::test_mesher_benchmark[100000] | 272.6479 | 254.8131 | 349.1587 | -6.5% | -27.0% | -| tests/test_benchmarks.py::test_ppp_0101 | 2,840.2942 | 148.3832 | 146.8151 | -94.8% | +1.1% | -| tests/test_benchmarks.py::test_ppp_0102 | 183.6392 | 176.3687 | 181.5972 | -4.0% | -2.9% | -| tests/test_benchmarks.py::test_ppp_0103 | 68.3975 | 69.5209 | 68.0329 | +1.6% | +2.2% | -| tests/test_benchmarks.py::test_ppp_0104 | 114.2050 | 115.6945 | 113.0657 | +1.3% | +2.3% | -| tests/test_benchmarks.py::test_ppp_0105 | 83.0605 | 78.0547 | 80.0031 | -6.0% | -2.4% | -| tests/test_benchmarks.py::test_ppp_0106 | 9,311.8187 | 85.0790 | 82.4856 | -99.1% | +3.1% | -| tests/test_benchmarks.py::test_ppp_0107 | 308.6340 | 286.2196 | 298.2377 | -7.3% | -4.0% | -| tests/test_benchmarks.py::test_ppp_0108 | 136.9441 | 69.9309 | 82.4641 | -48.9% | -15.2% | -| tests/test_benchmarks.py::test_ppp_0109 | 113.9680 | 111.8273 | 128.6220 | -1.9% | -13.1% | -| tests/test_benchmarks.py::test_ppp_0110 | 244.0596 | 217.1883 | 222.1242 | -11.0% | -2.2% | -| tests/test_benchmarks.py::test_ttt_23_02_02 | 646.0093 | 620.3012 | 631.9749 | -4.0% | -1.8% | -| tests/test_benchmarks.py::test_ttt_23_T_24 | 236.9038 | 147.6732 | 146.1597 | -37.7% | +1.0% | -| tests/test_benchmarks.py::test_ttt_24_SPO_06 | 150.4492 | 144.6859 | 142.6785 | -3.8% | +1.4% | +| tests/test_benchmarks.py::test_mesher_benchmark[100] | 1.5717 | 1.0907 | 1.5013 | -30.6% | -27.3% | +| tests/test_benchmarks.py::test_mesher_benchmark[1000] | 3.1709 | 2.6054 | 2.9810 | -17.8% | -12.6% | +| tests/test_benchmarks.py::test_mesher_benchmark[10000] | 18.8172 | 17.9687 | 18.5138 | -4.5% | -2.9% | +| tests/test_benchmarks.py::test_mesher_benchmark[100000] | 272.6479 | 256.7096 | 349.1587 | -5.8% | -26.5% | +| tests/test_benchmarks.py::test_ppp_0101 | 2,840.2942 | 141.2135 | 146.8151 | -95.0% | -3.8% | +| tests/test_benchmarks.py::test_ppp_0102 | 183.6392 | 176.0781 | 181.5972 | -4.1% | -3.0% | +| tests/test_benchmarks.py::test_ppp_0103 | 68.3975 | 66.1329 | 68.0329 | -3.3% | -2.8% | +| tests/test_benchmarks.py::test_ppp_0104 | 114.2050 | 110.7626 | 113.0657 | -3.0% | -2.0% | +| tests/test_benchmarks.py::test_ppp_0105 | 83.0605 | 75.6668 | 80.0031 | -8.9% | -5.4% | +| tests/test_benchmarks.py::test_ppp_0106 | 9,311.8187 | 80.2450 | 82.4856 | -99.1% | -2.7% | +| tests/test_benchmarks.py::test_ppp_0107 | 308.6340 | 284.8052 | 298.2377 | -7.7% | -4.5% | +| tests/test_benchmarks.py::test_ppp_0108 | 136.9441 | 65.5078 | 82.4641 | -52.2% | -20.6% | +| tests/test_benchmarks.py::test_ppp_0109 | 113.9680 | 106.2103 | 128.6220 | -6.8% | -17.4% | +| tests/test_benchmarks.py::test_ppp_0110 | 244.0596 | 213.4498 | 222.1242 | -12.5% | -3.9% | +| tests/test_benchmarks.py::test_ttt_23_02_02 | 646.0093 | 597.4992 | 631.9749 | -7.5% | -5.5% | +| tests/test_benchmarks.py::test_ttt_23_T_24 | 236.9038 | 141.1910 | 146.1597 | -40.4% | -3.4% | +| tests/test_benchmarks.py::test_ttt_24_SPO_06 | 150.4492 | 137.7853 | 142.6785 | -8.4% | -3.4% | -\* Changed to use `extrude(UNTIL)` as in dev +Note: Changed test_ppp_0109 to use `extrude(UNITL)` instead of `extrude` as in `dev`branch and this PR #### Details @@ -410,30 +422,29 @@ Added `test_touch_edge_tolerance()` to test filtering of false positive vertices - **With this PR** - ````text + ```text ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - test_mesher_benchmark[100] 1.0574 (1.0) 70.4643 (1.0) 1.8813 (1.0) 5.4627 (8.54) 1.1397 (1.0) 0.2145 (1.06) 1;30 531.5461 (1.0) 162 1 - test_mesher_benchmark[1000] 2.4607 (2.33) 77.6734 (1.10) 3.2727 (1.74) 5.4347 (8.50) 2.5261 (2.22) 0.9649 (4.76) 2;2 305.5623 (0.57) 379 1 - test_mesher_benchmark[10000] 17.5591 (16.61) 80.8897 (1.15) 27.3925 (14.56) 23.6991 (37.06) 17.6889 (15.52) 0.2028 (1.0) 2;2 36.5064 (0.07) 13 1 - test_mesher_benchmark[100000] 251.0655 (237.44) 392.6514 (5.57) 296.1126 (157.40) 62.7306 (98.09) 254.8131 (223.57) 89.8318 (443.07) 1;0 3.3771 (0.01) 5 1 - test_ppp_0101 145.0982 (137.22) 149.1414 (2.12) 147.7855 (78.55) 1.4913 (2.33) 148.3832 (130.19) 2.0036 (9.88) 1;0 6.7666 (0.01) 7 1 - test_ppp_0102 175.2033 (165.70) 178.3188 (2.53) 176.4481 (93.79) 1.0492 (1.64) 176.3687 (154.75) 0.7466 (3.68) 2;1 5.6674 (0.01) 6 1 - test_ppp_0103 67.6037 (63.94) 120.4952 (1.71) 72.7045 (38.65) 13.2495 (20.72) 69.5209 (61.00) 1.0713 (5.28) 1;2 13.7543 (0.03) 15 1 - test_ppp_0104 114.5725 (108.36) 116.3083 (1.65) 115.5820 (61.44) 0.6395 (1.0) 115.6945 (101.51) 0.9241 (4.56) 4;0 8.6519 (0.02) 9 1 - test_ppp_0105 75.7650 (71.65) 79.4025 (1.13) 77.9072 (41.41) 1.0489 (1.64) 78.0547 (68.49) 1.7325 (8.54) 2;0 12.8358 (0.02) 13 1 - test_ppp_0106 84.4754 (79.89) 86.6812 (1.23) 85.3290 (45.36) 0.6590 (1.03) 85.0790 (74.65) 1.0335 (5.10) 3;0 11.7193 (0.02) 12 1 - test_ppp_0107 285.1950 (269.72) 288.7532 (4.10) 286.7046 (152.40) 1.3588 (2.12) 286.2196 (251.13) 1.7592 (8.68) 2;0 3.4879 (0.01) 5 1 - test_ppp_0108 65.4866 (61.93) 70.9025 (1.01) 69.5202 (36.95) 1.3768 (2.15) 69.9309 (61.36) 0.8998 (4.44) 3;2 14.3843 (0.03) 15 1 - test_ppp_0109 110.2980 (104.31) 113.0410 (1.60) 111.7263 (59.39) 0.7629 (1.19) 111.8273 (98.12) 0.4203 (2.07) 3;3 8.9504 (0.02) 9 1 - test_ppp_0110 214.6389 (202.99) 218.8224 (3.11) 217.1718 (115.44) 1.7101 (2.67) 217.1883 (190.56) 2.6129 (12.89) 1;0 4.6046 (0.01) 5 1 - test_ttt_23_02_02 617.3580 (583.86) 623.1470 (8.84) 620.1268 (329.63) 2.1388 (3.34) 620.3012 (544.25) 2.6976 (13.30) 2;0 1.6126 (0.00) 5 1 - test_ttt_23_T_24 145.4708 (137.58) 148.7882 (2.11) 147.1501 (78.22) 1.3564 (2.12) 147.6732 (129.57) 2.1137 (10.43) 2;0 6.7958 (0.01) 5 1 - test_ttt_24_SPO_06 143.1023 (135.34) 147.4160 (2.09) 144.8468 (76.99) 1.3698 (2.14) 144.6859 (126.95) 1.3559 (6.69) 2;1 6.9038 (0.01) 7 1 - -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- ``` - - ```` + test_mesher_benchmark[100] 1.0508 (1.0) 68.2757 (1.01) 1.8248 (1.0) 5.2479 (9.85) 1.0907 (1.0) 0.0957 (1.0) 1;35 548.0108 (1.0) 165 1 + test_mesher_benchmark[1000] 2.4724 (2.35) 79.2095 (1.17) 3.4835 (1.91) 6.5495 (12.29) 2.6054 (2.39) 0.8534 (8.92) 2;2 287.0715 (0.52) 237 1 + test_mesher_benchmark[10000] 17.3113 (16.48) 88.3289 (1.31) 28.8077 (15.79) 24.4154 (45.82) 17.9687 (16.48) 1.2889 (13.47) 9;10 34.7129 (0.06) 55 1 + test_mesher_benchmark[100000] 248.7809 (236.77) 391.4374 (5.80) 295.5854 (161.98) 62.5995 (117.48) 256.7096 (235.37) 91.1181 (952.56) 1;0 3.3831 (0.01) 5 1 + test_ppp_0101 140.5867 (133.80) 144.5263 (2.14) 141.7358 (77.67) 1.3575 (2.55) 141.2135 (129.47) 1.3019 (13.61) 1;1 7.0554 (0.01) 7 1 + test_ppp_0102 175.2740 (166.81) 176.7893 (2.62) 176.0563 (96.48) 0.5328 (1.0) 176.0781 (161.44) 0.5510 (5.76) 2;0 5.6800 (0.01) 6 1 + test_ppp_0103 65.6279 (62.46) 117.7910 (1.74) 70.3553 (38.56) 13.2053 (24.78) 66.1329 (60.64) 3.0131 (31.50) 1;1 14.2136 (0.03) 15 1 + test_ppp_0104 109.8469 (104.54) 112.9509 (1.67) 111.0283 (60.84) 0.9951 (1.87) 110.7626 (101.55) 1.2667 (13.24) 3;0 9.0067 (0.02) 9 1 + test_ppp_0105 74.3809 (70.79) 78.5015 (1.16) 76.0421 (41.67) 1.2363 (2.32) 75.6668 (69.38) 2.2091 (23.09) 6;0 13.1506 (0.02) 14 1 + test_ppp_0106 79.0039 (75.19) 81.5764 (1.21) 80.3973 (44.06) 0.7688 (1.44) 80.2450 (73.57) 1.1399 (11.92) 5;0 12.4382 (0.02) 13 1 + test_ppp_0107 281.8502 (268.24) 295.8377 (4.38) 286.5148 (157.01) 5.5815 (10.48) 284.8052 (261.13) 6.6192 (69.20) 1;0 3.4902 (0.01) 5 1 + test_ppp_0108 63.7172 (60.64) 67.5170 (1.0) 65.4839 (35.89) 1.0336 (1.94) 65.5078 (60.06) 1.3345 (13.95) 5;0 15.2709 (0.03) 15 1 + test_ppp_0109 105.3235 (100.24) 108.3105 (1.60) 106.3213 (58.27) 0.7871 (1.48) 106.2103 (97.38) 0.5853 (6.12) 2;1 9.4055 (0.02) 10 1 + test_ppp_0110 211.6483 (201.43) 214.4740 (3.18) 213.1039 (116.78) 1.3020 (2.44) 213.4498 (195.71) 2.4268 (25.37) 2;0 4.6925 (0.01) 5 1 + test_ttt_23_02_02 592.4995 (563.88) 604.5713 (8.95) 597.9193 (327.67) 4.3216 (8.11) 597.4992 (547.83) 3.8224 (39.96) 2;0 1.6725 (0.00) 5 1 + test_ttt_23_T_24 139.8359 (133.08) 142.1043 (2.10) 141.0751 (77.31) 0.8109 (1.52) 141.1910 (129.45) 0.7066 (7.39) 2;0 7.0884 (0.01) 5 1 + test_ttt_24_SPO_06 135.6548 (129.10) 144.6452 (2.14) 138.6152 (75.96) 2.9556 (5.55) 137.7853 (126.33) 3.0196 (31.57) 2;1 7.2142 (0.01) 8 1 + -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- + ``` - **Before all intersect PR (commit fa8e936)** From bfb018e672308d02b5bbda0c2c829cfc146dc317 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 18:54:44 +0100 Subject: [PATCH 19/42] removed analysis file --- INTERSECT-SUMMARY.md | 493 ------------------------------------------- 1 file changed, 493 deletions(-) delete mode 100644 INTERSECT-SUMMARY.md diff --git a/INTERSECT-SUMMARY.md b/INTERSECT-SUMMARY.md deleted file mode 100644 index 760c560..0000000 --- a/INTERSECT-SUMMARY.md +++ /dev/null @@ -1,493 +0,0 @@ -# Intersection Refactoring Summary - -## Current Implementation - -The current implementation uses the (simplified) pattern - -```python -result = BRepAlgoAPI_Section() + BRepAlgoAPI_Common() -filter_shapes_by_order(result, [Vertex, Edge, Face, Solid]) -``` - -Unfortunately, filtering of found objects up to the highest order is not trivial in OCCT and can take a significant time per comparision, especially when solids with curved surfaces are involved. -And given the apporach, n x m comparisions are needed in the filter function (performance details see see https://github.com/gumyr/build123d/issues/1147). - -## Goal of the new apporach - -- Define "real" intersections and distinguish them from touches (single point touch for faces, edge touch for solids, tangential touch, ...) - - The definition of intersect should be based on "what a CAD user expects", e.g. solid-solid = solid, face-face = face|edge, ... -- Calculate intersect in the most efficient way, specifically for each shape type combination. - - No use of n x m comparisions with faces involved (note that comparisions of edges are significantly cheaper, in some test 5-15 times faster) - - For every costly OCCT method when filtering results, a non-optimal bounding box comparision should be done as early exit (no bbox overlap => no need to do the costly calculation) -- Separate touch methods that calculate all possible touch results for the faces and solids - - intersect methods get a parameter `include_touched` that add touch results to the intersect results - -### Intersect vs Touch - -The distinction between `intersect` and `touch` is based on result dimension: - -- **Intersect**: Returns results down to a minimum dimension (interior overlap or crossing) -- **Touch**: Returns boundary contacts with dimension below the minimum intersect dimension, filtered to the highest dimension at each contact location - -| Combination | Intersect result dims | Touch dims | -| --------------- | --------------------- | ---------------------------- | -| Solid + Solid | 3 (Solid) | 0, 1, 2 (Vertex, Edge, Face) | -| Solid + Face | 2 (Face) | 0, 1 (Vertex, Edge) | -| Solid + Edge | 1 (Edge) | 0 (Vertex) | -| Solid + Vertex | 0 (Vertex) | — | -| Face + Face | 1, 2 (Edge, Face) | 0 (Vertex) | -| Face + Edge | 0, 1 (Vertex, Edge) | — | -| Face + Vertex | 0 (Vertex) | — | -| Edge + Edge | 0, 1 (Vertex, Edge) | — | -| Edge + Vertex | 0 (Vertex) | — | -| Vertex + Vertex | 0 (Vertex) | — | - -**Touch filtering**: At each contact location, only the highest-dimensional shape is returned. Lower-dimensional shapes that are boundaries of higher-dimensional contacts are filtered out. Note that this can get more expensive than the intersect implementation. - -**Examples**: - -- Two boxes sharing a face: `touch` → `[Face]` (not the 4 edges and 4 vertices of that face) -- Two boxes sharing an edge: `touch` → `[Edge]` (not the 2 endpoint vertices) -- Two boxes sharing only a corner: `touch` → `[Vertex]` -- Two faces with coplanar overlap AND crossing curve: `intersect` → `[Face, Edge]` - -### Multi-object and Compound handling - -| Routine | Semantics | -| -------------------------------------------------------------------------------------------- | --------------- | -| BRepAlgoAPI_Common(c.wrapped, [c1.wrapped, c2.wrapped]). | OR, partitioned | -| BRepAlgoAPI_Common(c.wrapped, [TopoDS_Compound([c1.wrapped, c2.wrapped])]), with c1 ∩ c2 = ∅ | OR \* | -| c.intersect(c1, c2) | AND | -| c.intersect(Compound([c1, c2])) | OR | -| c.intersect(Compound(children=[c1, c2])) | OR | - -Key: - -- AND: c ∩ c1 ∩ c2 -- OR: c ∩ (c1 ∪ c2) - -\* A compound as tool shall not have overlapping solids according to OCCT docs - -### Tangent Contact Validation - -For tangent contacts (surfaces touching at a point), the `touch()` method validates: - -1. **Edge boundary check**: Points near edges of both faces (within `tolerance`) are filtered out as edge-edge intersections, not vertex touches. Users should increase tolerance if BRepExtrema returns inaccurate points near edges. - -2. **Normal direction check**: For points in the interior of both faces, normals must be parallel (dot ≈ 1) or anti-parallel (dot ≈ -1), meaning surfaces are tangent. This filters out false positives where surfaces cross at an angle. - -3. **Crossing vertices**: Points on an edge of one face meeting the interior of another (perpendicular normals) are valid crossing vertices. - -## Call Flow - -Legend: - -- → handle: handles directly -- → delegate: calls `other._intersect(self, ...)` -- → distribute: iterates elements, calls `elem._intersect(...)` -- `t`: `include_touched` passed through - -### intersect() Call Flow - -| Vertex.\_intersect(other) | | -| ------------------------------- | ------------------------------ | -| `_intersect(Vertex, Vertex, )` | → handle (distance check) | -| `_intersect(Vertex, *, t)` | → other.\_intersect(Vertex, t) | - -| Mixin1D.\_intersect(other) [Edge, Wire] | | -| --------------------------------------- | ----------------------------- | -| `_intersect(Edge, Edge, )` | → handle (Common + Section) | -| `_intersect(Edge, Wire, )` | → handle (Common + Section) | -| `_intersect(Edge, Vertex, )` | → handle (distance check) | -| `_intersect(Edge, *, t)` | → `other._intersect(Edge, t)` | -| `_intersect(Wire, ..., )` | → same as Edge | - -| Mixin2D.\_intersect(other) [Face, Shell] | | -| ---------------------------------------- | ------------------------------ | -| `_intersect(Face, Face, )` | → handle (Common + Section) | -| `_intersect(Face, Shell, )` | → handle (Common + Section) | -| `_intersect(Face, Edge, )` | → handle (Section) | -| `_intersect(Face, Wire, )` | → handle (Section) | -| `_intersect(Face, Vertex, )` | → handle (distance check) | -| `_intersect(Face, *, t)` | → `other._intersect(Face, t)` | -| `_intersect(Shell, ..., )` | → same as Face | -| If `include_touched==True`: | also calls `self.touch(other)` | - -| Mixin3D.\_intersect(other) [Solid] | | -| ---------------------------------- | ------------------------------ | -| `_intersect(Solid, Solid, )` | → handle (Common) | -| `_intersect(Solid, Face, )` | → handle (Common) | -| `_intersect(Solid, Shell, )` | → handle (Common) | -| `_intersect(Solid, Edge, )` | → handle (Common) | -| `_intersect(Solid, Wire, )` | → handle (Common) | -| `_intersect(Solid, Vertex, )` | → handle (is_inside) | -| `_intersect(Solid, *, t)` | → `other._intersect(Solid, t)` | -| If `include_touched==True`: | also calls `self.touch(other)` | - -| Compound.\_intersect(other) | | -| ----------------------------------- | ----------------------- | -| `_intersect(Compound, Compound, t)` | → distribute all-vs-all | -| `_intersect(Compound, *, t)` | → distribute over self | - -**Delegation chains** (examples): - -- `Edge._intersect(Solid, t)` → `Solid._intersect(Edge, t)` → handle -- `Vertex._intersect(Face, t)` → `Face._intersect(Vertex, t)` → handle -- `Face._intersect(Solid, t)` → `Solid._intersect(Face, t)` → handle -- `Edge._intersect(Compound, t)` → `Compound._intersect(Edge, t)` → distribute - -### touch() Call Flow - -| Shape.touch(other) | | -| ------------------ | ------------------------------------------ | -| `touch(Shape, *)` | → returns empty `ShapeList()` (base impl.) | - -| Mixin2D.touch(other) [Face, Shell] | | -| ---------------------------------- | ------------------------------------- | -| `touch(Face, Face)` | → handle (BRepExtrema + normal check) | -| `touch(Face, Shell)` | → handle (BRepExtrema + normal check) | -| `touch(Face, *)` | → `other.touch(self)` (delegate) | - -| Mixin3D.touch(other) [Solid] | | -| ---------------------------- | -------------------------------------------------------- | -| `touch(Solid, Solid)` | → handle (Common faces/edges/vertices) | -| | + `.touch()` for tangent contacts | -| `touch(Solid, Face)` | → handle (Common edges + BRepExtrema) | -| `touch(Solid, Edge)` | → handle (Common vertices + BRepExtrema) | -| `touch(Solid, Vertex)` | → handle (distance check to faces) | -| `touch(Solid, *)` | → `other.touch(self)` (delegate) | - -| Compound.touch(other) | | -| --------------------- | ---------------------- | -| `touch(Compound, *)` | → distribute over self | - -**Code reuse**: `Mixin3D.touch()` calls `Mixin2D.touch()` (via `.touch()`) for Solid+Solid tangent vertex detection, ensuring consistent edge boundary and normal direction validation. - -## Comparison Optimizations with non-optimal Bounding Boxes - -### 1. Early Exit with Bounding Box Overlap - -In `touch()` and `_intersect()`, we compare many shape pairs (faces×faces, edges×edges). Before calling `BRepAlgoAPI_Common` or other expensive methods, we want to early detect pairs that don't need to be checked (early exit) -This can be done with `distance_to()` calls (which use `BRepExtrema_DistShapeShape`), or checking bounding boxes overlap: - -```python -# sf = , of = -# Option 1 -if sf.distance_to(of) > tolerance: - continue - -# Option 2 -if not sf_bb.overlaps(of_bb, tolerance): - continue -``` - -`BoundBox.overlaps()` uses OCCT's `Bnd_Box.Distance()` method. Option 2 (bbox) is less accurate but significantly faster, see below. - -### 2. Non-Optimal Bounding Boxes - -`Shape.bounding_box(optimal=True)` computes precise bounds but is slow for curved geometry. For early-exit filtering, we use `optimal=False`: - -| Object | Faces | Edges | optimal=True | optimal=False | Speedup | -| ----------- | ----- | ----- | ------------ | ------------- | -------- | -| ttt-ppp0102 | 10 | 17 | 86.7 ms | 0.12 ms | **729x** | -| ttt-ppp0107 | 44 | 95 | 59.7 ms | 0.16 ms | **373x** | -| ttt-ppp0104 | 23 | 62 | 12.6 ms | 0.05 ms | **252x** | -| ttt-ppp0106 | 32 | 89 | 12.2 ms | 0.08 ms | **153x** | -| ttt-ppp0101 | 32 | 84 | 0.3 ms | 0.08 ms | 4x | -| ttt-ppp0105 | 18 | 40 | 0.04 ms | 0.04 ms | 1x | - -**Accuracy trade-off** (non-optimal bbox expansion): - -| Object | Solid Expansion | Max Face Expansion | -| ----------- | --------------- | ------------------ | -| ttt-ppp0107 | 7.7% | 109.9% | -| ttt-ppp0106 | 0.0% | 65.5% | -| ttt-ppp0104 | 4.8% | 25.8% | -| ttt-ppp0102 | 0.0% | 8.3% | -| ttt-ppp0101 | 0.0% | 0.0% | - -Larger bboxes cause more false-positive overlaps → extra `BRepExtrema` checks, but the 100-800x speedup will most of the time outweigh this cost. - -### 3. Pre-calculate and Cache Bounding Boxes - -Without caching, nested loops recalculate bboxes n×m times: - -```python -# sf = , of = -# Before: bbox computed 32×32×2 = 2048 times for 32-face solids -for sf in self.faces(): - for of in other.faces(): - if not sf.bounding_box().overlaps(of.bounding_box(), tolerance): - -# After: bbox computed once per face -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()] -for sf, sf_bb in self_faces: - for of, of_bb in other_faces: - if not sf_bb.overlaps(of_bb, tolerance): -``` - -### 4. Performance Comparison - -Face×face pair comparisons using ttt-ppp01\* examples: - -| Object | Faces | Pairs | bbox (build+distance_to) | distance_to for all | Speedup | -| ----------- | ----- | ----- | ------------------------ | ------------------- | ----------- | -| ttt-ppp0107 | 44 | 1936 | 1.11 ms | 71,854 ms | **65,019x** | -| ttt-ppp0102 | 10 | 100 | 0.33 ms | 6,629 ms | **20,094x** | -| ttt-ppp0101 | 32 | 1024 | 0.59 ms | 5,119 ms | **8,684x** | -| ttt-ppp0106 | 32 | 1024 | 0.59 ms | 3,529 ms | **5,963x** | -| ttt-ppp0104 | 23 | 529 | 0.36 ms | 1,815 ms | **4,982x** | -| ttt-ppp0105 | 18 | 324 | 0.33 ms | 1,277 ms | **3,885x** | -| ttt-ppp0108 | 37 | 1369 | 0.79 ms | 2,938 ms | **3,705x** | - -Edge×edge pair comparisons using ttt-ppp01\* examples: - -| Object | Edges | Pairs | bbox (build+distance_to) | distance_to for all | Speedup | -| ----------- | ----- | ------ | ------------------------ | ------------------- | ----------- | -| ttt-ppp0107 | 95 | 9,025 | 2.98 ms | 45,254 ms | **15,203x** | -| ttt-ppp0102 | 17 | 289 | 0.39 ms | 4,801 ms | **12,188x** | -| ttt-ppp0101 | 84 | 7,056 | 2.40 ms | 6,200 ms | **2,584x** | -| ttt-ppp0104 | 62 | 3,844 | 1.45 ms | 2,320 ms | **1,597x** | -| ttt-ppp0108 | 101 | 10,201 | 3.16 ms | 3,476 ms | **1,100x** | -| ttt-ppp0105 | 40 | 1,600 | 0.84 ms | 723 ms | **859x** | - -The bbox approach is in any case significantly faster, making it essential for n×m pair operations in `touch()` and `_intersect()`. - -## Typing Workaround - -### Problem: Circular Dependencies - -``` -shape_core.py (Shape, ShapeList) - ↑ imports - │ - ┌───┴───┬───────┬───────┬──────────┐ - │ │ │ │ │ -zero_d one_d two_d three_d composite -(Vertex) (Edge) (Face) (Solid) (Compound) - (Wire) (Shell) -``` - -`shape_core.py` defines base classes, but intersection logic needs to check types (`isinstance(x, Wire)`), call methods (`shape.faces()`), etc. Direct imports would cause circular import errors. - -### Solution: helpers.py as a Leaf Module - -**helpers.py** imports everything at module level (it's a leaf - no one imports from it at module level): - -```python -from build123d.topology.shape_core import Shape -from build123d.topology.one_d import Edge -from build123d.topology.two_d import Face -``` - -**Other modules** do runtime imports from helpers: - -```python -# In shape_core.py Shape.intersect() -def intersect(self, ...): - from build123d.topology.helpers import convert_to_shapes -``` - -Runtime imports happen after all modules are loaded, breaking the cycle. - -## Tests - -### Test Case Counts - -| | dev branch | this branch | change | -| --------------------- | ---------: | ----------: | -----: | -| Case definitions | 199 | 241 | +42 | -| Parametrized tests \* | 322 | 379 | +57 | - -\* Parametrized tests include symmetry swaps (A×B also tested as B×A) where applicable - -**Breakdown by matrix:** - -| Matrix | dev | this | change | -| --------------------- | --: | ---: | -----: | -| geometry_matrix | 47 | 47 | 0 | -| shape_0d_matrix | 20 | 20 | 0 | -| shape_1d_matrix | 60 | 60 | 0 | -| shape_2d_matrix | 64 | 73 | +9 | -| shape_3d_matrix | 65 | 96 | +31 | -| shape_compound_matrix | 43 | 60 | +17 | -| freecad_matrix | 15 | 15 | 0 | -| issues_matrix | 8 | 8 | 0 | - -### Changes Summary - -**Infrastructure changes:** - -- Added `include_touched: bool = False` to `Case` dataclass -- Updated `run_test` to pass `include_touched` to `Shape.intersect` (geometry objects don't have it) -- Updated `make_params` to include `include_touched` in test parameters; symmetry swaps disabled for `include_touched` tests -- Updated all test function signatures and `@pytest.mark.parametrize` decorators - -**New test objects:** - -- `sh7`, `sh8`: Half-sphere shells for tangent touch testing -- `fc10`: Tangent face for sphere tangent contact - -**New test case categories:** - -- Face+Face crossing vertex: paired tests (without touch → `None`, with `include_touched` → `[Vertex]`) -- Shell+Face/Shell tangent touch: tests for tangent surface contacts -- Solid+Edge/Face/Solid boundary contacts: paired tests for corner/edge/face coincidence -- Compound+Shape with `include_touched`: tests for boundary contacts through compounds - -### Behavioral: Solid boundary contacts (intersect vs touch separation) - -| Test Case | Before | After (no touch) | After (with touch) | -| -------------------------------- | ---------- | ---------------- | ------------------ | -| Solid + Edge, corner coincident | `[Vertex]` | `None` | `[Vertex]` | -| Solid + Face, edge collinear | `[Edge]` | `None` | `[Edge]` | -| Solid + Face, corner coincident | `[Vertex]` | `None` | `[Vertex]` | -| Solid + Solid, edge collinear | `[Edge]` | `None` | `[Edge]` | -| Solid + Solid, corner coincident | `[Vertex]` | `None` | `[Vertex]` | -| Solid + Solid, face coincident | N/A (new) | `None` | `[Face]` | - -### Behavioral: Face/Shell boundary contacts (intersect vs touch separation) - -| Test Case | Before | After (no touch) | After (with touch) | -| ---------------------------- | ---------- | ---------------- | ------------------ | -| Face + Face, crossing vertex | `[Vertex]` | `None` | `[Vertex]` | -| Shell + Face, tangent touch | N/A (new) | `None` | `[Vertex]` | -| Shell + Shell, tangent touch | N/A (new) | `None` | `[Vertex]` | - -Two non-coplanar faces that cross at a single point (due to finite extent) now return the vertex via `touch()` rather than `intersect()`. Added `Mixin2D.touch()` method. - -These represent the semantic change: boundary contacts are **not** interior intersections, so `intersect()` returns `None`. Use `include_touched=True` to get them. - -### Bug fixes / xfail removals - -| Test Case | Before | After | -| ------------------------------ | ------------------------------------- | ---------------------------------- | -| Solid + Edge, edge collinear | `[Edge]` with xfail "duplicate edges" | `[Edge]` passing | -| Curve + Compound, intersecting | `[Edge, Edge]` with xfail | `[Edge, Edge, Edge, Edge]` passing | - -### Performance tests - -#### Summary - -| name | dev | this branch | commit fa8e936 | this branch / dev | this branch / commit fa8e936 | -| ------------------------------------------------------- | ---------: | ----------: | -------------: | ----------------: | ---------------------------: | -| tests/test_benchmarks.py::test_mesher_benchmark[100] | 1.5717 | 1.0907 | 1.5013 | -30.6% | -27.3% | -| tests/test_benchmarks.py::test_mesher_benchmark[1000] | 3.1709 | 2.6054 | 2.9810 | -17.8% | -12.6% | -| tests/test_benchmarks.py::test_mesher_benchmark[10000] | 18.8172 | 17.9687 | 18.5138 | -4.5% | -2.9% | -| tests/test_benchmarks.py::test_mesher_benchmark[100000] | 272.6479 | 256.7096 | 349.1587 | -5.8% | -26.5% | -| tests/test_benchmarks.py::test_ppp_0101 | 2,840.2942 | 141.2135 | 146.8151 | -95.0% | -3.8% | -| tests/test_benchmarks.py::test_ppp_0102 | 183.6392 | 176.0781 | 181.5972 | -4.1% | -3.0% | -| tests/test_benchmarks.py::test_ppp_0103 | 68.3975 | 66.1329 | 68.0329 | -3.3% | -2.8% | -| tests/test_benchmarks.py::test_ppp_0104 | 114.2050 | 110.7626 | 113.0657 | -3.0% | -2.0% | -| tests/test_benchmarks.py::test_ppp_0105 | 83.0605 | 75.6668 | 80.0031 | -8.9% | -5.4% | -| tests/test_benchmarks.py::test_ppp_0106 | 9,311.8187 | 80.2450 | 82.4856 | -99.1% | -2.7% | -| tests/test_benchmarks.py::test_ppp_0107 | 308.6340 | 284.8052 | 298.2377 | -7.7% | -4.5% | -| tests/test_benchmarks.py::test_ppp_0108 | 136.9441 | 65.5078 | 82.4641 | -52.2% | -20.6% | -| tests/test_benchmarks.py::test_ppp_0109 | 113.9680 | 106.2103 | 128.6220 | -6.8% | -17.4% | -| tests/test_benchmarks.py::test_ppp_0110 | 244.0596 | 213.4498 | 222.1242 | -12.5% | -3.9% | -| tests/test_benchmarks.py::test_ttt_23_02_02 | 646.0093 | 597.4992 | 631.9749 | -7.5% | -5.5% | -| tests/test_benchmarks.py::test_ttt_23_T_24 | 236.9038 | 141.1910 | 146.1597 | -40.4% | -3.4% | -| tests/test_benchmarks.py::test_ttt_24_SPO_06 | 150.4492 | 137.7853 | 142.6785 | -8.4% | -3.4% | - -Note: Changed test_ppp_0109 to use `extrude(UNITL)` instead of `extrude` as in `dev`branch and this PR - -#### Details - -- **Against dev ()** - - ```text - ---------------------------------------------------------------------------------------------- benchmark: 17 tests ---------------------------------------------------------------------------------------------- - Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - test_mesher_benchmark[100] 1.4136 (1.0) 79.2088 (1.15) 2.6825 (1.0) 7.8029 (14.67) 1.5717 (1.0) 0.3249 (1.0) 1;18 372.7835 (1.0) 99 1 - test_mesher_benchmark[1000] 2.8029 (1.98) 93.6249 (1.35) 3.9942 (1.49) 7.2583 (13.64) 3.1709 (2.02) 0.8664 (2.67) 2;2 250.3631 (0.67) 302 1 - test_mesher_benchmark[10000] 18.0781 (12.79) 108.9087 (1.58) 30.7976 (11.48) 28.3765 (53.34) 18.8172 (11.97) 0.6993 (2.15) 8;8 32.4701 (0.09) 51 1 - test_mesher_benchmark[100000] 262.4835 (185.68) 350.2263 (5.07) 299.1632 (111.52) 42.1747 (79.28) 272.6479 (173.48) 73.7383 (226.95) 1;0 3.3427 (0.01) 5 1 - test_ppp_0101 2,837.7637 (>1000.0) 2,842.9180 (41.12) 2,840.0422 (>1000.0) 2.0992 (3.95) 2,840.2942 (>1000.0) 3.3399 (10.28) 2;0 0.3521 (0.00) 5 1 - test_ppp_0102 182.9260 (129.40) 185.0174 (2.68) 183.7750 (68.51) 0.7393 (1.39) 183.6392 (116.84) 0.8202 (2.52) 2;0 5.4414 (0.01) 6 1 - test_ppp_0103 66.9251 (47.34) 69.1312 (1.0) 68.3137 (25.47) 0.5320 (1.0) 68.3975 (43.52) 0.5088 (1.57) 4;1 14.6384 (0.04) 15 1 - test_ppp_0104 112.7356 (79.75) 115.8168 (1.68) 114.0572 (42.52) 0.9064 (1.70) 114.2050 (72.66) 1.0003 (3.08) 3;0 8.7675 (0.02) 9 1 - test_ppp_0105 80.5439 (56.98) 101.4349 (1.47) 84.6426 (31.55) 5.5137 (10.36) 83.0605 (52.85) 3.3439 (10.29) 1;1 11.8144 (0.03) 13 1 - test_ppp_0106 9,240.8689 (>1000.0) 9,385.3153 (135.76) 9,312.1906 (>1000.0) 65.8610 (123.80) 9,311.8187 (>1000.0) 124.3155 (382.61) 2;0 0.1074 (0.00) 5 1 - test_ppp_0107 301.7400 (213.45) 314.9581 (4.56) 308.1962 (114.89) 5.5353 (10.40) 308.6340 (196.37) 9.5510 (29.40) 2;0 3.2447 (0.01) 5 1 - test_ppp_0108 135.0608 (95.54) 140.0305 (2.03) 136.7690 (50.99) 1.5956 (3.00) 136.9441 (87.13) 1.7559 (5.40) 3;1 7.3116 (0.02) 8 1 - test_ppp_0109 111.1487 (78.63) 116.3623 (1.68) 113.8869 (42.46) 1.4392 (2.71) 113.9680 (72.51) 1.4837 (4.57) 2;0 8.7806 (0.02) 9 1 - test_ppp_0110 242.1086 (171.27) 247.1587 (3.58) 244.1418 (91.01) 1.8841 (3.54) 244.0596 (155.29) 2.0497 (6.31) 2;0 4.0960 (0.01) 5 1 - test_ttt_23_02_02 632.3757 (447.34) 672.3315 (9.73) 652.9795 (243.42) 16.8402 (31.65) 646.0093 (411.03) 26.8589 (82.66) 2;0 1.5314 (0.00) 5 1 - test_ttt_24_SPO_06 222.7247 (157.56) 240.4287 (3.48) 232.5369 (86.69) 7.9419 (14.93) 236.9038 (150.73) 13.4055 (41.26) 1;0 4.3004 (0.01) 5 1 - test_ttt_23_T_24 148.6132 (105.13) 153.2385 (2.22) 150.9910 (56.29) 1.9488 (3.66) 150.4492 (95.73) 3.2687 (10.06) 2;0 6.6229 (0.02) 5 1 - ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - ``` - -- **With this PR** - - ```text - ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ - Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations - -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - test_mesher_benchmark[100] 1.0508 (1.0) 68.2757 (1.01) 1.8248 (1.0) 5.2479 (9.85) 1.0907 (1.0) 0.0957 (1.0) 1;35 548.0108 (1.0) 165 1 - test_mesher_benchmark[1000] 2.4724 (2.35) 79.2095 (1.17) 3.4835 (1.91) 6.5495 (12.29) 2.6054 (2.39) 0.8534 (8.92) 2;2 287.0715 (0.52) 237 1 - test_mesher_benchmark[10000] 17.3113 (16.48) 88.3289 (1.31) 28.8077 (15.79) 24.4154 (45.82) 17.9687 (16.48) 1.2889 (13.47) 9;10 34.7129 (0.06) 55 1 - test_mesher_benchmark[100000] 248.7809 (236.77) 391.4374 (5.80) 295.5854 (161.98) 62.5995 (117.48) 256.7096 (235.37) 91.1181 (952.56) 1;0 3.3831 (0.01) 5 1 - test_ppp_0101 140.5867 (133.80) 144.5263 (2.14) 141.7358 (77.67) 1.3575 (2.55) 141.2135 (129.47) 1.3019 (13.61) 1;1 7.0554 (0.01) 7 1 - test_ppp_0102 175.2740 (166.81) 176.7893 (2.62) 176.0563 (96.48) 0.5328 (1.0) 176.0781 (161.44) 0.5510 (5.76) 2;0 5.6800 (0.01) 6 1 - test_ppp_0103 65.6279 (62.46) 117.7910 (1.74) 70.3553 (38.56) 13.2053 (24.78) 66.1329 (60.64) 3.0131 (31.50) 1;1 14.2136 (0.03) 15 1 - test_ppp_0104 109.8469 (104.54) 112.9509 (1.67) 111.0283 (60.84) 0.9951 (1.87) 110.7626 (101.55) 1.2667 (13.24) 3;0 9.0067 (0.02) 9 1 - test_ppp_0105 74.3809 (70.79) 78.5015 (1.16) 76.0421 (41.67) 1.2363 (2.32) 75.6668 (69.38) 2.2091 (23.09) 6;0 13.1506 (0.02) 14 1 - test_ppp_0106 79.0039 (75.19) 81.5764 (1.21) 80.3973 (44.06) 0.7688 (1.44) 80.2450 (73.57) 1.1399 (11.92) 5;0 12.4382 (0.02) 13 1 - test_ppp_0107 281.8502 (268.24) 295.8377 (4.38) 286.5148 (157.01) 5.5815 (10.48) 284.8052 (261.13) 6.6192 (69.20) 1;0 3.4902 (0.01) 5 1 - test_ppp_0108 63.7172 (60.64) 67.5170 (1.0) 65.4839 (35.89) 1.0336 (1.94) 65.5078 (60.06) 1.3345 (13.95) 5;0 15.2709 (0.03) 15 1 - test_ppp_0109 105.3235 (100.24) 108.3105 (1.60) 106.3213 (58.27) 0.7871 (1.48) 106.2103 (97.38) 0.5853 (6.12) 2;1 9.4055 (0.02) 10 1 - test_ppp_0110 211.6483 (201.43) 214.4740 (3.18) 213.1039 (116.78) 1.3020 (2.44) 213.4498 (195.71) 2.4268 (25.37) 2;0 4.6925 (0.01) 5 1 - test_ttt_23_02_02 592.4995 (563.88) 604.5713 (8.95) 597.9193 (327.67) 4.3216 (8.11) 597.4992 (547.83) 3.8224 (39.96) 2;0 1.6725 (0.00) 5 1 - test_ttt_23_T_24 139.8359 (133.08) 142.1043 (2.10) 141.0751 (77.31) 0.8109 (1.52) 141.1910 (129.45) 0.7066 (7.39) 2;0 7.0884 (0.01) 5 1 - test_ttt_24_SPO_06 135.6548 (129.10) 144.6452 (2.14) 138.6152 (75.96) 2.9556 (5.55) 137.7853 (126.33) 3.0196 (31.57) 2;1 7.2142 (0.01) 8 1 - -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - ``` - -- **Before all intersect PR (commit fa8e936)** - - ```text - ----------------------------------------------------------------------------------------- benchmark: 17 tests ------------------------------------------------------------------------------------------ - Name (time in ms) Min Max Mean StdDev Median IQR Outliers OPS Rounds Iterations - -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - test_mesher_benchmark[100] 1.4098 (1.0) 74.0287 (16.20) 2.5949 (1.0) 7.4405 (12.63) 1.5013 (1.0) 0.1877 (1.0) 1;18 385.3707 (1.0) 95 1 - test_mesher_benchmark[1000] 2.8225 (2.00) 4.5707 (1.0) 3.3640 (1.30) 0.5890 (1.0) 2.9810 (1.99) 1.2073 (6.43) 61;0 297.2643 (0.77) 185 1 - test_mesher_benchmark[10000] 18.2586 (12.95) 96.1952 (21.05) 31.0653 (11.97) 28.0866 (47.68) 18.5138 (12.33) 0.4388 (2.34) 9;9 32.1902 (0.08) 53 1 - test_mesher_benchmark[100000] 267.0532 (189.42) 350.7605 (76.74) 317.7271 (122.44) 44.4738 (75.50) 349.1587 (232.57) 80.6410 (429.56) 2;0 3.1474 (0.01) 5 1 - test_ppp_0101 145.2433 (103.02) 149.5188 (32.71) 147.0663 (56.68) 1.3792 (2.34) 146.8151 (97.79) 1.4942 (7.96) 2;0 6.7997 (0.02) 7 1 - test_ppp_0102 178.8649 (126.87) 184.7600 (40.42) 181.7531 (70.04) 1.9309 (3.28) 181.5972 (120.96) 1.4921 (7.95) 2;1 5.5020 (0.01) 6 1 - test_ppp_0103 66.1185 (46.90) 68.7325 (15.04) 67.7935 (26.13) 0.7213 (1.22) 68.0329 (45.32) 0.8712 (4.64) 4;0 14.7507 (0.04) 15 1 - test_ppp_0104 111.4481 (79.05) 114.5727 (25.07) 113.1267 (43.60) 1.0848 (1.84) 113.0657 (75.31) 1.5002 (7.99) 4;0 8.8396 (0.02) 9 1 - test_ppp_0105 75.2770 (53.39) 86.6317 (18.95) 80.6485 (31.08) 3.1719 (5.38) 80.0031 (53.29) 3.3093 (17.63) 3;0 12.3995 (0.03) 12 1 - test_ppp_0106 80.9383 (57.41) 83.6762 (18.31) 82.3659 (31.74) 0.8217 (1.39) 82.4856 (54.94) 1.0667 (5.68) 4;0 12.1409 (0.03) 12 1 - test_ppp_0107 291.7345 (206.93) 302.4655 (66.17) 297.8876 (114.80) 4.0816 (6.93) 298.2377 (198.65) 5.4786 (29.18) 2;0 3.3570 (0.01) 5 1 - test_ppp_0108 80.2130 (56.90) 86.2986 (18.88) 82.6410 (31.85) 1.5109 (2.57) 82.4641 (54.93) 1.1424 (6.09) 2;2 12.1005 (0.03) 12 1 - test_ppp_0109 126.3475 (89.62) 129.0997 (28.24) 128.2563 (49.43) 0.9785 (1.66) 128.6220 (85.67) 1.2114 (6.45) 2;0 7.7969 (0.02) 8 1 - test_ppp_0110 219.2367 (155.51) 223.5040 (48.90) 221.4452 (85.34) 1.9318 (3.28) 222.1242 (147.95) 3.4878 (18.58) 2;0 4.5158 (0.01) 5 1 - test_ttt_23_02_02 613.0934 (434.87) 645.0137 (141.12) 631.2053 (243.25) 12.1928 (20.70) 631.9749 (420.94) 16.7976 (89.48) 2;0 1.5843 (0.00) 5 1 - test_ttt_23_T_24 143.7815 (101.98) 148.1351 (32.41) 146.1890 (56.34) 1.6663 (2.83) 146.1597 (97.35) 2.3439 (12.49) 2;0 6.8405 (0.02) 5 1 - test_ttt_24_SPO_06 139.9076 (99.24) 144.1027 (31.53) 142.3341 (54.85) 1.4964 (2.54) 142.6785 (95.03) 2.3097 (12.30) 2;0 7.0257 (0.02) 7 1 - -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- - ``` - - Note: Changed test_ppp_0109 to use `extrude(UNITL)` instead of `extrude` as in `dev`branch and this PR - - ```diff - diff --git a/docs/assets/ttt/ttt-ppp0109.py b/docs/assets/ttt/ttt-ppp0109.py - index b00b0bc..82a4260 100644 - --- a/docs/assets/ttt/ttt-ppp0109.py - +++ b/docs/assets/ttt/ttt-ppp0109.py - @@ -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.edge(Select.NEW), 16) - ``` From 859aecacff4e01bc08cd7a08e4727754b20adc2e Mon Sep 17 00:00:00 2001 From: Bernhard Date: Mon, 19 Jan 2026 18:58:55 +0100 Subject: [PATCH 20/42] rename variable to make mymp happy --- src/build123d/topology/three_d.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 9900c9a..833d864 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -843,7 +843,7 @@ class Solid(Mixin3D[TopoDS_Solid]): # Check face's edges touching solid's faces # Track found edges to avoid duplicates (edge may touch multiple adjacent faces) - found_edges: list[Edge] = [] + touching_edges: list[Edge] = [] for oe, oe_bb in other_edges: for sf, sf_bb in self_faces: if not oe_bb.overlaps(sf_bb, tolerance): @@ -856,11 +856,11 @@ class Solid(Mixin3D[TopoDS_Solid]): already = any( (s.center() - e.center()).length <= tolerance and abs(s.length - e.length) <= tolerance - for e in found_edges + for e in touching_edges ) if not already: results.append(s) - found_edges.append(s) + touching_edges.append(s) # Check face's vertices touching solid's edges (corner coincident) for ov in other.vertices(): for se in self.edges(): From 34ca82510bbb9612355c0d9282a76863f3832d64 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Tue, 20 Jan 2026 15:34:50 +0100 Subject: [PATCH 21/42] add a geometrical comparision method for edges --- src/build123d/topology/helpers.py | 128 +++++++++- tests/test_direct_api/test_geom_equal.py | 299 +++++++++++++++++++++++ 2 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 tests/test_direct_api/test_geom_equal.py diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py index 3f9505f..3e5e91e 100644 --- a/src/build123d/topology/helpers.py +++ b/src/build123d/topology/helpers.py @@ -2,10 +2,13 @@ from __future__ import annotations +from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + +from build123d.build_enums import GeomType from build123d.geometry import Axis, Location, Plane, Vector from build123d.topology.shape_core import Shape from build123d.topology.zero_d import Vertex -from build123d.topology.one_d import Edge +from build123d.topology.one_d import Edge, Wire from build123d.topology.two_d import Face @@ -48,3 +51,126 @@ def convert_to_shapes( else: raise ValueError(f"Unsupported type for intersect: {type(obj)}") return results + + +def geom_equal( + value1: Vector | Location | Vertex | Edge | Wire, + value2: Vector | Location | Vertex | Edge | Wire, + tol: float = 1e-6, + num_interpolation_points: int = 5, +) -> bool: + """Compare two geometric objects for equality within tolerance.""" + # Type must match + if type(value1) != type(value2): + return False + + # NOTE: == for Vector and Location values is tolerance based equality! + + if isinstance(value1, Vector): + return value1 == value2 + + elif isinstance(value1, Vertex): + return Vector(value1) == Vector(value2) + + elif isinstance(value1, Location): + return value1 == value2 + + elif isinstance(value1, Wire) and isinstance(value2, Wire): + edges1 = value1.edges() + edges2 = value2.edges() + if len(edges1) != len(edges2): + return False + return all(geom_equal(e1, e2, tol) for e1, e2 in zip(edges1, edges2)) + + elif isinstance(value1, Edge) and isinstance(value2, Edge): + # geom_type and location must match + if value1.geom_type != value2.geom_type: + return False + + if value1.location != value2.location: + return False + + # Common: start and end points + if (value1 @ 0) != (value2 @ 0) or (value1 @ 1) != (value2 @ 1): + return False + + ga1 = value1.geom_adaptor() + ga2 = value2.geom_adaptor() + + match value1.geom_type: + case GeomType.LINE: + # Line: fully defined by endpoints (already checked) + return True + + case GeomType.CIRCLE: + return abs(ga1.Circle().Radius() - ga2.Circle().Radius()) < tol + + case GeomType.ELLIPSE: + e1, e2 = ga1.Ellipse(), ga2.Ellipse() + return ( + abs(e1.MajorRadius() - e2.MajorRadius()) < tol + and abs(e1.MinorRadius() - e2.MinorRadius()) < tol + ) + + case GeomType.HYPERBOLA: + h1, h2 = ga1.Hyperbola(), ga2.Hyperbola() + return ( + abs(h1.MajorRadius() - h2.MajorRadius()) < tol + and abs(h1.MinorRadius() - h2.MinorRadius()) < tol + ) + + case GeomType.PARABOLA: + return abs(ga1.Parabola().Focal() - ga2.Parabola().Focal()) < tol + + case GeomType.BEZIER: + b1, b2 = ga1.Bezier(), ga2.Bezier() + if b1.Degree() != b2.Degree() or b1.NbPoles() != b2.NbPoles(): + return False + for i in range(1, b1.NbPoles() + 1): + if Vector(b1.Pole(i)) != Vector(b2.Pole(i)): + return False + if b1.IsRational() and abs(b1.Weight(i) - b2.Weight(i)) >= tol: + return False + return True + + case GeomType.BSPLINE: + s1, s2 = ga1.BSpline(), ga2.BSpline() + if s1.Degree() != s2.Degree(): + return False + if s1.IsPeriodic() != s2.IsPeriodic(): + return False + if s1.NbPoles() != s2.NbPoles() or s1.NbKnots() != s2.NbKnots(): + return False + for i in range(1, s1.NbPoles() + 1): + if Vector(s1.Pole(i)) != Vector(s2.Pole(i)): + return False + if s1.IsRational() and abs(s1.Weight(i) - s2.Weight(i)) >= tol: + return False + for i in range(1, s1.NbKnots() + 1): + if abs(s1.Knot(i) - s2.Knot(i)) >= tol: + return False + if s1.Multiplicity(i) != s2.Multiplicity(i): + return False + return True + + case GeomType.OFFSET: + oc1, oc2 = ga1.OffsetCurve(), ga2.OffsetCurve() + # Compare offset values and directions + if abs(oc1.Offset() - oc2.Offset()) >= tol: + return False + if Vector(oc1.Direction()) != Vector(oc2.Direction()): + return False + # Compare basis curves (recursive) + basis1 = Edge(BRepBuilderAPI_MakeEdge(oc1.BasisCurve()).Edge()) + basis2 = Edge(BRepBuilderAPI_MakeEdge(oc2.BasisCurve()).Edge()) + return geom_equal(basis1, basis2, tol) + + case _: + # OTHER/unknown: compare sample points + for i in range(1, num_interpolation_points + 1): + t = i / (num_interpolation_points + 1) + if (value1 @ t) != (value2 @ t): + return False + return True + + return False diff --git a/tests/test_direct_api/test_geom_equal.py b/tests/test_direct_api/test_geom_equal.py new file mode 100644 index 0000000..1f1ae90 --- /dev/null +++ b/tests/test_direct_api/test_geom_equal.py @@ -0,0 +1,299 @@ +"""Tests for geom_equal helper function.""" + +import pytest +from build123d import ( + Vector, + Vertex, + Location, + Edge, + Wire, + Spline, + Rectangle, + Circle, + Ellipse, + Bezier, + GeomType, +) +from build123d.topology.helpers import geom_equal + + +class TestGeomEqualVector: + """Tests for Vector comparison.""" + + def test_same_vector(self): + v1 = Vector(1, 2, 3) + v2 = Vector(1, 2, 3) + assert geom_equal(v1, v2) + + def test_different_vector(self): + v1 = Vector(1, 2, 3) + v2 = Vector(1, 2, 4) + assert not geom_equal(v1, v2) + + def test_vector_within_tolerance(self): + v1 = Vector(1, 2, 3) + v2 = Vector(1, 2, 3 + 1e-7) + assert geom_equal(v1, v2) + + def test_vector_outside_tolerance(self): + v1 = Vector(1, 2, 3) + v2 = Vector(1, 2, 3 + 1e-5) + assert not geom_equal(v1, v2) + + +class TestGeomEqualVertex: + """Tests for Vertex comparison.""" + + def test_same_vertex(self): + v1 = Vertex(1, 2, 3) + v2 = Vertex(1, 2, 3) + assert geom_equal(v1, v2) + + def test_different_vertex(self): + v1 = Vertex(1, 2, 3) + v2 = Vertex(1, 2, 4) + assert not geom_equal(v1, v2) + + def test_vertex_within_tolerance(self): + v1 = Vertex(1, 2, 3) + v2 = Vertex(1, 2, 3 + 1e-7) + assert geom_equal(v1, v2) + + +class TestGeomEqualLocation: + """Tests for Location comparison.""" + + def test_same_location(self): + loc1 = Location((1, 2, 3)) + loc2 = Location((1, 2, 3)) + assert geom_equal(loc1, loc2) + + def test_different_position(self): + loc1 = Location((1, 2, 3)) + loc2 = Location((1, 2, 4)) + assert not geom_equal(loc1, loc2) + + def test_different_orientation(self): + loc1 = Location((0, 0, 0), (0, 0, 0)) + loc2 = Location((0, 0, 0), (45, 0, 0)) + assert not geom_equal(loc1, loc2) + + +class TestGeomEqualEdgeLine: + """Tests for Edge LINE comparison.""" + + def test_same_line(self): + e1 = Edge.make_line((0, 0, 0), (1, 1, 1)) + e2 = Edge.make_line((0, 0, 0), (1, 1, 1)) + assert e1.geom_type == GeomType.LINE + assert geom_equal(e1, e2) + + def test_different_line(self): + e1 = Edge.make_line((0, 0, 0), (1, 1, 1)) + e2 = Edge.make_line((0, 0, 0), (1, 1, 2)) + assert not geom_equal(e1, e2) + + +class TestGeomEqualEdgeCircle: + """Tests for Edge CIRCLE comparison.""" + + def test_same_circle(self): + c1 = Circle(10) + c2 = Circle(10) + e1 = c1.edge() + e2 = c2.edge() + assert e1.geom_type == GeomType.CIRCLE + assert geom_equal(e1, e2) + + def test_different_radius(self): + c1 = Circle(10) + c2 = Circle(11) + e1 = c1.edge() + e2 = c2.edge() + assert not geom_equal(e1, e2) + + def test_same_arc(self): + e1 = Edge.make_circle(10, start_angle=0, end_angle=90) + e2 = Edge.make_circle(10, start_angle=0, end_angle=90) + assert geom_equal(e1, e2) + + def test_different_arc_angle(self): + e1 = Edge.make_circle(10, start_angle=0, end_angle=90) + e2 = Edge.make_circle(10, start_angle=0, end_angle=180) + assert not geom_equal(e1, e2) + + +class TestGeomEqualEdgeEllipse: + """Tests for Edge ELLIPSE comparison.""" + + def test_same_ellipse(self): + el1 = Ellipse(10, 5) + el2 = Ellipse(10, 5) + e1 = el1.edge() + e2 = el2.edge() + assert e1.geom_type == GeomType.ELLIPSE + assert geom_equal(e1, e2) + + def test_different_major_radius(self): + el1 = Ellipse(10, 5) + el2 = Ellipse(11, 5) + e1 = el1.edge() + e2 = el2.edge() + assert not geom_equal(e1, e2) + + def test_different_minor_radius(self): + el1 = Ellipse(10, 5) + el2 = Ellipse(10, 6) + e1 = el1.edge() + e2 = el2.edge() + assert not geom_equal(e1, e2) + + +class TestGeomEqualEdgeBezier: + """Tests for Edge BEZIER comparison.""" + + def test_same_bezier(self): + pts = [(0, 0), (1, 1), (2, 0)] + b1 = Bezier(*pts) + b2 = Bezier(*pts) + e1 = b1.edge() + e2 = b2.edge() + assert e1.geom_type == GeomType.BEZIER + assert geom_equal(e1, e2) + + def test_different_bezier(self): + b1 = Bezier((0, 0), (1, 1), (2, 0)) + b2 = Bezier((0, 0), (1, 2), (2, 0)) + e1 = b1.edge() + e2 = b2.edge() + assert not geom_equal(e1, e2) + + +class TestGeomEqualEdgeBSpline: + """Tests for Edge BSPLINE comparison.""" + + def test_same_spline(self): + v = [Vertex(p) for p in ((-2, 0), (-1, 0), (0, 0), (1, 0), (2, 0))] + s1 = Spline(*v) + s2 = Spline(*v) + e1 = s1.edge() + e2 = s2.edge() + assert e1.geom_type == GeomType.BSPLINE + assert geom_equal(e1, e2) + + def test_different_spline(self): + v1 = [Vertex(p) for p in ((-2, 0), (-1, 0), (0, 0), (1, 0), (2, 0))] + v2 = [Vertex(p) for p in ((-2, 0), (-1, 1), (0, 0), (1, 0), (2, 0))] + s1 = Spline(*v1) + s2 = Spline(*v2) + e1 = s1.edge() + e2 = s2.edge() + assert not geom_equal(e1, e2) + + def test_complex_spline(self): + v = [ + Vertex(p) + for p in ( + (-2, 0), + (-1, 0), + (0, 0), + (1, 0), + (2, 0), + (3, 0.1), + (4, 1), + (5, 2.2), + (6, 3), + (7, 2), + (8, -1), + ) + ] + s1 = Spline(*v) + s2 = Spline(*v) + e1 = s1.edge() + e2 = s2.edge() + assert geom_equal(e1, e2) + + +class TestGeomEqualEdgeOffset: + """Tests for Edge OFFSET comparison.""" + + def test_same_offset(self): + v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] + s = Spline(*v) + w = Wire([s.edge()]) + offset_wire1 = w.offset_2d(0.1) + offset_wire2 = w.offset_2d(0.1) + + offset_edges1 = [ + e for e in offset_wire1.edges() if e.geom_type == GeomType.OFFSET + ] + offset_edges2 = [ + e for e in offset_wire2.edges() if e.geom_type == GeomType.OFFSET + ] + + assert len(offset_edges1) > 0 + assert geom_equal(offset_edges1[0], offset_edges2[0]) + + def test_different_offset_value(self): + v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] + s = Spline(*v) + w = Wire([s.edge()]) + offset_wire1 = w.offset_2d(0.1) + offset_wire2 = w.offset_2d(0.2) + + offset_edges1 = [ + e for e in offset_wire1.edges() if e.geom_type == GeomType.OFFSET + ] + offset_edges2 = [ + e for e in offset_wire2.edges() if e.geom_type == GeomType.OFFSET + ] + + assert not geom_equal(offset_edges1[0], offset_edges2[0]) + + +class TestGeomEqualWire: + """Tests for Wire comparison.""" + + def test_same_rectangle_wire(self): + r1 = Rectangle(10, 5) + r2 = Rectangle(10, 5) + assert geom_equal(r1.wire(), r2.wire()) + + def test_different_rectangle_wire(self): + r1 = Rectangle(10, 5) + r2 = Rectangle(10, 6) + assert not geom_equal(r1.wire(), r2.wire()) + + def test_same_spline_wire(self): + v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] + s1 = Spline(*v) + s2 = Spline(*v) + w1 = Wire([s1.edge()]) + w2 = Wire([s2.edge()]) + assert geom_equal(w1, w2) + + def test_different_edge_count(self): + r1 = Rectangle(10, 5) + e = Edge.make_line((0, 0), (10, 0)) + w1 = r1.wire() + w2 = Wire([e]) + assert not geom_equal(w1, w2) + + +class TestGeomEqualTypeMismatch: + """Tests for type mismatch cases.""" + + def test_vector_vs_vertex(self): + v1 = Vector(1, 2, 3) + v2 = Vertex(1, 2, 3) + assert not geom_equal(v1, v2) + + def test_edge_vs_wire(self): + e = Edge.make_line((0, 0), (1, 1)) + w = Wire([e]) + assert not geom_equal(e, w) + + def test_different_geom_types(self): + line = Edge.make_line((0, 0, 0), (1, 1, 1)) + circle = Circle(10).edge() + assert not geom_equal(line, circle) From 47a3f24018ccf0439760f9cd1cabad6c70652685 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Tue, 20 Jan 2026 15:35:37 +0100 Subject: [PATCH 22/42] improve readability of touch and use the geometric comparision --- src/build123d/topology/three_d.py | 73 ++++++++++++++++--------------- src/build123d/topology/two_d.py | 20 ++++++--- 2 files changed, 51 insertions(+), 42 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 3a5aa65..1ad24ef 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -127,6 +127,7 @@ from .shape_core import ( ) from .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell +from .helpers import geom_equal from .utils import ( _extrude_topods_shape, find_max_dimension, @@ -743,7 +744,7 @@ class Solid(Mixin3D[TopoDS_Solid]): # ---- Instance Methods ---- def touch( - self, other: Shape, tolerance: float = 1e-6 + self, other: Shape, tolerance: float = 1e-6, check_num_points: int = 5 ) -> ShapeList[Vertex | Edge | Face]: """Find where this Solid's boundary contacts another shape. @@ -757,10 +758,35 @@ class Solid(Mixin3D[TopoDS_Solid]): Args: other: Shape to check boundary contacts with tolerance: tolerance for contact detection + check_num_points: number of interpolation points for edge-on-face check Returns: ShapeList of boundary contact geometry (empty if no contact) """ + # Helper functions for common geometric checks + def vertex_on_edge(v: Vertex, e: Edge) -> bool: + return v.distance_to(e) <= tolerance + + def vertex_on_face(v: Vertex, f: Face) -> bool: + return v.distance_to(f) <= tolerance + + def edge_on_face(e: Edge, f: Face) -> bool: + # Check start, end, and interpolated points are all on the face + for i in range(check_num_points + 2): + t = i / (check_num_points + 1) + if f.distance_to(e @ t) > tolerance: + return False + return True + + def is_duplicate(shape: Vertex | Edge, existing: Iterable[Shape]) -> bool: + if isinstance(shape, Vertex): + return any(shape.distance_to(s) <= tolerance for s in existing) + # Edge: use geom_equal for full geometric comparison + return any( + isinstance(e, Edge) and geom_equal(shape, e, tolerance) + for e in existing + ) + results: ShapeList = ShapeList() if isinstance(other, Solid): @@ -791,12 +817,8 @@ class Solid(Mixin3D[TopoDS_Solid]): 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: + # Skip if edge is on any found face + if not any(edge_on_face(s, f) for f in found_faces): found_edges.append(s) results.extend(found_edges) @@ -805,16 +827,9 @@ class Solid(Mixin3D[TopoDS_Solid]): 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: + on_face = any(vertex_on_face(sv, f) for f in found_faces) + on_edge = any(vertex_on_edge(sv, e) for e in found_edges) + if not on_face and not on_edge and not is_duplicate(sv, found_vertices): results.append(sv) found_vertices.append(sv) break @@ -827,11 +842,7 @@ class Solid(Mixin3D[TopoDS_Solid]): 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: + if not is_duplicate(v, found_vertices): results.append(v) found_vertices.append(v) @@ -853,18 +864,13 @@ class Solid(Mixin3D[TopoDS_Solid]): if s.is_null or not isinstance(s, Edge): continue # Check if geometrically same edge already found - already = any( - (s.center() - e.center()).length <= tolerance - and abs(s.length - e.length) <= tolerance - for e in touching_edges - ) - if not already: + if not is_duplicate(s, touching_edges): results.append(s) touching_edges.append(s) # 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: + if vertex_on_edge(ov, se): results.append(ov) break @@ -876,7 +882,7 @@ class Solid(Mixin3D[TopoDS_Solid]): for ov in other.vertices(): for sf, _ in self_faces: - if ov.distance_to(sf) <= tolerance: + if vertex_on_face(ov, sf): results.append(ov) break # Use BRepExtrema to find tangent contacts (edge tangent to surface) @@ -897,16 +903,13 @@ class Solid(Mixin3D[TopoDS_Solid]): 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: + if not is_duplicate(new_vertex, results): 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: + if vertex_on_face(other, sf): results.append(other) break diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index cba75ad..040b991 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -408,6 +408,13 @@ class Mixin2D(ABC, Shape[TOPODS]): Returns: ShapeList of contact shapes (Vertex only for 2D+2D) """ + # Helper functions for common geometric checks + def vertex_on_edge(v: Vertex, e: Edge) -> bool: + return v.distance_to(e) <= tolerance + + def is_duplicate_vertex(v: Vertex, existing: ShapeList) -> bool: + return any(v.distance_to(ev) <= tolerance for ev in existing) + results: ShapeList = ShapeList() if isinstance(other, (Face, Shell)): @@ -443,10 +450,10 @@ class Mixin2D(ABC, Shape[TOPODS]): # 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() + vertex_on_edge(new_vertex, e) for e in self.edges() ) on_other_edge = any( - new_vertex.distance_to(e) <= tolerance for e in other.edges() + vertex_on_edge(new_vertex, e) for e in other.edges() ) # Skip if point is on edges of both faces (edge-edge intersection) @@ -489,12 +496,11 @@ class Mixin2D(ABC, Shape[TOPODS]): 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 + vertex_on_edge(new_vertex, e) 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: + if not on_face and not on_edge and not is_duplicate_vertex( + new_vertex, found_vertices + ): results.append(new_vertex) found_vertices.append(new_vertex) From 441aef03d669adddf0a4af4c7d8e2e9b7e4bf0b6 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Tue, 20 Jan 2026 15:47:38 +0100 Subject: [PATCH 23/42] make mypy happy --- src/build123d/topology/helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py index 3e5e91e..a20c393 100644 --- a/src/build123d/topology/helpers.py +++ b/src/build123d/topology/helpers.py @@ -66,13 +66,13 @@ def geom_equal( # NOTE: == for Vector and Location values is tolerance based equality! - if isinstance(value1, Vector): + if isinstance(value1, Vector) and isinstance(value2, Vector): return value1 == value2 - elif isinstance(value1, Vertex): + elif isinstance(value1, Vertex) and isinstance(value2, Vertex): return Vector(value1) == Vector(value2) - elif isinstance(value1, Location): + elif isinstance(value1, Location) and isinstance(value2, Location): return value1 == value2 elif isinstance(value1, Wire) and isinstance(value2, Wire): From 1d55475ae418c837d22a8b2d2a89aebc6dca3520 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Tue, 20 Jan 2026 21:56:32 +0100 Subject: [PATCH 24/42] add location and axis check cor conic sections --- src/build123d/topology/helpers.py | 18 ++++++- tests/test_direct_api/test_geom_equal.py | 61 +++++------------------- 2 files changed, 27 insertions(+), 52 deletions(-) diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py index a20c393..15e6358 100644 --- a/src/build123d/topology/helpers.py +++ b/src/build123d/topology/helpers.py @@ -103,13 +103,20 @@ def geom_equal( return True case GeomType.CIRCLE: - return abs(ga1.Circle().Radius() - ga2.Circle().Radius()) < tol + c1, c2 = ga1.Circle(), ga2.Circle() + return ( + abs(c1.Radius() - c2.Radius()) < tol + and Vector(c1.Location()) == Vector(c2.Location()) + and Vector(c1.Axis().Direction()) == Vector(c2.Axis().Direction()) + ) case GeomType.ELLIPSE: e1, e2 = ga1.Ellipse(), ga2.Ellipse() return ( abs(e1.MajorRadius() - e2.MajorRadius()) < tol and abs(e1.MinorRadius() - e2.MinorRadius()) < tol + and Vector(e1.Location()) == Vector(e2.Location()) + and Vector(e1.Axis().Direction()) == Vector(e2.Axis().Direction()) ) case GeomType.HYPERBOLA: @@ -117,10 +124,17 @@ def geom_equal( return ( abs(h1.MajorRadius() - h2.MajorRadius()) < tol and abs(h1.MinorRadius() - h2.MinorRadius()) < tol + and Vector(h1.Location()) == Vector(h2.Location()) + and Vector(h1.Axis().Direction()) == Vector(h2.Axis().Direction()) ) case GeomType.PARABOLA: - return abs(ga1.Parabola().Focal() - ga2.Parabola().Focal()) < tol + p1, p2 = ga1.Parabola(), ga2.Parabola() + return ( + abs(p1.Focal() - p2.Focal()) < tol + and Vector(p1.Location()) == Vector(p2.Location()) + and Vector(p1.Axis().Direction()) == Vector(p2.Axis().Direction()) + ) case GeomType.BEZIER: b1, b2 = ga1.Bezier(), ga2.Bezier() diff --git a/tests/test_direct_api/test_geom_equal.py b/tests/test_direct_api/test_geom_equal.py index 1f1ae90..e003f0f 100644 --- a/tests/test_direct_api/test_geom_equal.py +++ b/tests/test_direct_api/test_geom_equal.py @@ -2,9 +2,7 @@ import pytest from build123d import ( - Vector, Vertex, - Location, Edge, Wire, Spline, @@ -17,30 +15,6 @@ from build123d import ( from build123d.topology.helpers import geom_equal -class TestGeomEqualVector: - """Tests for Vector comparison.""" - - def test_same_vector(self): - v1 = Vector(1, 2, 3) - v2 = Vector(1, 2, 3) - assert geom_equal(v1, v2) - - def test_different_vector(self): - v1 = Vector(1, 2, 3) - v2 = Vector(1, 2, 4) - assert not geom_equal(v1, v2) - - def test_vector_within_tolerance(self): - v1 = Vector(1, 2, 3) - v2 = Vector(1, 2, 3 + 1e-7) - assert geom_equal(v1, v2) - - def test_vector_outside_tolerance(self): - v1 = Vector(1, 2, 3) - v2 = Vector(1, 2, 3 + 1e-5) - assert not geom_equal(v1, v2) - - class TestGeomEqualVertex: """Tests for Vertex comparison.""" @@ -60,25 +34,6 @@ class TestGeomEqualVertex: assert geom_equal(v1, v2) -class TestGeomEqualLocation: - """Tests for Location comparison.""" - - def test_same_location(self): - loc1 = Location((1, 2, 3)) - loc2 = Location((1, 2, 3)) - assert geom_equal(loc1, loc2) - - def test_different_position(self): - loc1 = Location((1, 2, 3)) - loc2 = Location((1, 2, 4)) - assert not geom_equal(loc1, loc2) - - def test_different_orientation(self): - loc1 = Location((0, 0, 0), (0, 0, 0)) - loc2 = Location((0, 0, 0), (45, 0, 0)) - assert not geom_equal(loc1, loc2) - - class TestGeomEqualEdgeLine: """Tests for Edge LINE comparison.""" @@ -122,6 +77,17 @@ class TestGeomEqualEdgeCircle: e2 = Edge.make_circle(10, start_angle=0, end_angle=180) assert not geom_equal(e1, e2) + def test_different_circle_from_revolve(self): + """Two circles with same radius/endpoints but different center/axis.""" + from build123d import Axis, Line, RadiusArc, make_face, revolve + + f1 = make_face(RadiusArc((5, 0), (-5, 0), 15) + Line((5, 0), (-5, 0))) + p1 = revolve(f1, Axis.X, 90) + value1, value2 = p1.edges().filter_by(GeomType.CIRCLE) + value2 = value2.reversed() + # These circles have same endpoints after reversal but different center/axis + assert not geom_equal(value1, value2) + class TestGeomEqualEdgeEllipse: """Tests for Edge ELLIPSE comparison.""" @@ -283,11 +249,6 @@ class TestGeomEqualWire: class TestGeomEqualTypeMismatch: """Tests for type mismatch cases.""" - def test_vector_vs_vertex(self): - v1 = Vector(1, 2, 3) - v2 = Vertex(1, 2, 3) - assert not geom_equal(v1, v2) - def test_edge_vs_wire(self): e = Edge.make_line((0, 0), (1, 1)) w = Wire([e]) From e2b182a9f7822120cc5b2259f93a008ce4991282 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Tue, 20 Jan 2026 21:57:35 +0100 Subject: [PATCH 25/42] remove vector and location from geom_equal --- src/build123d/topology/helpers.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py index 15e6358..bac23fe 100644 --- a/src/build123d/topology/helpers.py +++ b/src/build123d/topology/helpers.py @@ -54,28 +54,24 @@ def convert_to_shapes( def geom_equal( - value1: Vector | Location | Vertex | Edge | Wire, - value2: Vector | Location | Vertex | Edge | Wire, + value1: Vertex | Edge | Wire, + value2: Vertex | Edge | Wire, tol: float = 1e-6, num_interpolation_points: int = 5, ) -> bool: - """Compare two geometric objects for equality within tolerance.""" + """Compare two geometric objects for equality within tolerance. + + Note: For Vector and Location, use the built-in == operator directly, + which already performs tolerance-based comparison. + """ # Type must match if type(value1) != type(value2): return False - # NOTE: == for Vector and Location values is tolerance based equality! - - if isinstance(value1, Vector) and isinstance(value2, Vector): - return value1 == value2 - - elif isinstance(value1, Vertex) and isinstance(value2, Vertex): + if isinstance(value1, Vertex) and isinstance(value2, Vertex): return Vector(value1) == Vector(value2) - elif isinstance(value1, Location) and isinstance(value2, Location): - return value1 == value2 - - elif isinstance(value1, Wire) and isinstance(value2, Wire): + if isinstance(value1, Wire) and isinstance(value2, Wire): edges1 = value1.edges() edges2 = value2.edges() if len(edges1) != len(edges2): From 0c31a243e1997276fc737f6f7855c89e71bed3a1 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 09:42:21 +0100 Subject: [PATCH 26/42] integrate geom_hash into Wire and Edge class and add tests --- src/build123d/topology/helpers.py | 138 +---- src/build123d/topology/one_d.py | 159 ++++++ src/build123d/topology/three_d.py | 3 +- tests/test_direct_api/test_geom_equal.py | 613 ++++++++++++++++++++--- 4 files changed, 715 insertions(+), 198 deletions(-) diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py index bac23fe..3f9505f 100644 --- a/src/build123d/topology/helpers.py +++ b/src/build123d/topology/helpers.py @@ -2,13 +2,10 @@ from __future__ import annotations -from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge - -from build123d.build_enums import GeomType from build123d.geometry import Axis, Location, Plane, Vector from build123d.topology.shape_core import Shape from build123d.topology.zero_d import Vertex -from build123d.topology.one_d import Edge, Wire +from build123d.topology.one_d import Edge from build123d.topology.two_d import Face @@ -51,136 +48,3 @@ def convert_to_shapes( else: raise ValueError(f"Unsupported type for intersect: {type(obj)}") return results - - -def geom_equal( - value1: Vertex | Edge | Wire, - value2: Vertex | Edge | Wire, - tol: float = 1e-6, - num_interpolation_points: int = 5, -) -> bool: - """Compare two geometric objects for equality within tolerance. - - Note: For Vector and Location, use the built-in == operator directly, - which already performs tolerance-based comparison. - """ - # Type must match - if type(value1) != type(value2): - return False - - if isinstance(value1, Vertex) and isinstance(value2, Vertex): - return Vector(value1) == Vector(value2) - - if isinstance(value1, Wire) and isinstance(value2, Wire): - edges1 = value1.edges() - edges2 = value2.edges() - if len(edges1) != len(edges2): - return False - return all(geom_equal(e1, e2, tol) for e1, e2 in zip(edges1, edges2)) - - elif isinstance(value1, Edge) and isinstance(value2, Edge): - # geom_type and location must match - if value1.geom_type != value2.geom_type: - return False - - if value1.location != value2.location: - return False - - # Common: start and end points - if (value1 @ 0) != (value2 @ 0) or (value1 @ 1) != (value2 @ 1): - return False - - ga1 = value1.geom_adaptor() - ga2 = value2.geom_adaptor() - - match value1.geom_type: - case GeomType.LINE: - # Line: fully defined by endpoints (already checked) - return True - - case GeomType.CIRCLE: - c1, c2 = ga1.Circle(), ga2.Circle() - return ( - abs(c1.Radius() - c2.Radius()) < tol - and Vector(c1.Location()) == Vector(c2.Location()) - and Vector(c1.Axis().Direction()) == Vector(c2.Axis().Direction()) - ) - - case GeomType.ELLIPSE: - e1, e2 = ga1.Ellipse(), ga2.Ellipse() - return ( - abs(e1.MajorRadius() - e2.MajorRadius()) < tol - and abs(e1.MinorRadius() - e2.MinorRadius()) < tol - and Vector(e1.Location()) == Vector(e2.Location()) - and Vector(e1.Axis().Direction()) == Vector(e2.Axis().Direction()) - ) - - case GeomType.HYPERBOLA: - h1, h2 = ga1.Hyperbola(), ga2.Hyperbola() - return ( - abs(h1.MajorRadius() - h2.MajorRadius()) < tol - and abs(h1.MinorRadius() - h2.MinorRadius()) < tol - and Vector(h1.Location()) == Vector(h2.Location()) - and Vector(h1.Axis().Direction()) == Vector(h2.Axis().Direction()) - ) - - case GeomType.PARABOLA: - p1, p2 = ga1.Parabola(), ga2.Parabola() - return ( - abs(p1.Focal() - p2.Focal()) < tol - and Vector(p1.Location()) == Vector(p2.Location()) - and Vector(p1.Axis().Direction()) == Vector(p2.Axis().Direction()) - ) - - case GeomType.BEZIER: - b1, b2 = ga1.Bezier(), ga2.Bezier() - if b1.Degree() != b2.Degree() or b1.NbPoles() != b2.NbPoles(): - return False - for i in range(1, b1.NbPoles() + 1): - if Vector(b1.Pole(i)) != Vector(b2.Pole(i)): - return False - if b1.IsRational() and abs(b1.Weight(i) - b2.Weight(i)) >= tol: - return False - return True - - case GeomType.BSPLINE: - s1, s2 = ga1.BSpline(), ga2.BSpline() - if s1.Degree() != s2.Degree(): - return False - if s1.IsPeriodic() != s2.IsPeriodic(): - return False - if s1.NbPoles() != s2.NbPoles() or s1.NbKnots() != s2.NbKnots(): - return False - for i in range(1, s1.NbPoles() + 1): - if Vector(s1.Pole(i)) != Vector(s2.Pole(i)): - return False - if s1.IsRational() and abs(s1.Weight(i) - s2.Weight(i)) >= tol: - return False - for i in range(1, s1.NbKnots() + 1): - if abs(s1.Knot(i) - s2.Knot(i)) >= tol: - return False - if s1.Multiplicity(i) != s2.Multiplicity(i): - return False - return True - - case GeomType.OFFSET: - oc1, oc2 = ga1.OffsetCurve(), ga2.OffsetCurve() - # Compare offset values and directions - if abs(oc1.Offset() - oc2.Offset()) >= tol: - return False - if Vector(oc1.Direction()) != Vector(oc2.Direction()): - return False - # Compare basis curves (recursive) - basis1 = Edge(BRepBuilderAPI_MakeEdge(oc1.BasisCurve()).Edge()) - basis2 = Edge(BRepBuilderAPI_MakeEdge(oc2.BasisCurve()).Edge()) - return geom_equal(basis1, basis2, tol) - - case _: - # OTHER/unknown: compare sample points - for i in range(1, num_interpolation_points + 1): - t = i / (num_interpolation_points + 1) - if (value1 @ t) != (value2 @ t): - return False - return True - - return False diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 99d3de9..4ccb50c 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -2619,6 +2619,131 @@ class Edge(Mixin1D[TopoDS_Edge]): raise ValueError("Can't find adaptor for empty edge") return BRepAdaptor_Curve(self.wrapped) + def geom_equal( + self, + other: Edge, + tol: float = 1e-6, + num_interpolation_points: int = 5, + ) -> bool: + """Compare two edges for geometric equality within tolerance. + + This compares the geometric properties of two edges, not their topological + identity. Two independently created edges with the same geometry will + return True. + + Args: + other: Edge to compare with + tol: Tolerance for numeric comparisons. Defaults to 1e-6. + num_interpolation_points: Number of points to sample for unknown + curve types. Defaults to 5. + + Returns: + bool: True if edges are geometrically equal within tolerance + """ + if not isinstance(other, Edge): + return False + + # geom_type must match + if self.geom_type != other.geom_type: + return False + + # Common: start and end points + if (self @ 0) != (other @ 0) or (self @ 1) != (other @ 1): + return False + + ga1 = self.geom_adaptor() + ga2 = other.geom_adaptor() + + match self.geom_type: + case GeomType.LINE: + # Line: fully defined by endpoints (already checked) + return True + + case GeomType.CIRCLE: + c1, c2 = ga1.Circle(), ga2.Circle() + return ( + abs(c1.Radius() - c2.Radius()) < tol + and Vector(c1.Location()) == Vector(c2.Location()) + and Vector(c1.Axis().Direction()) == Vector(c2.Axis().Direction()) + ) + + case GeomType.ELLIPSE: + e1, e2 = ga1.Ellipse(), ga2.Ellipse() + return ( + abs(e1.MajorRadius() - e2.MajorRadius()) < tol + and abs(e1.MinorRadius() - e2.MinorRadius()) < tol + and Vector(e1.Location()) == Vector(e2.Location()) + and Vector(e1.Axis().Direction()) == Vector(e2.Axis().Direction()) + ) + + case GeomType.HYPERBOLA: + h1, h2 = ga1.Hyperbola(), ga2.Hyperbola() + return ( + abs(h1.MajorRadius() - h2.MajorRadius()) < tol + and abs(h1.MinorRadius() - h2.MinorRadius()) < tol + and Vector(h1.Location()) == Vector(h2.Location()) + and Vector(h1.Axis().Direction()) == Vector(h2.Axis().Direction()) + ) + + case GeomType.PARABOLA: + p1, p2 = ga1.Parabola(), ga2.Parabola() + return ( + abs(p1.Focal() - p2.Focal()) < tol + and Vector(p1.Location()) == Vector(p2.Location()) + and Vector(p1.Axis().Direction()) == Vector(p2.Axis().Direction()) + ) + + case GeomType.BEZIER: + b1, b2 = ga1.Bezier(), ga2.Bezier() + if b1.Degree() != b2.Degree() or b1.NbPoles() != b2.NbPoles(): + return False + for i in range(1, b1.NbPoles() + 1): + if Vector(b1.Pole(i)) != Vector(b2.Pole(i)): + return False + if b1.IsRational() and abs(b1.Weight(i) - b2.Weight(i)) >= tol: + return False + return True + + case GeomType.BSPLINE: + s1, s2 = ga1.BSpline(), ga2.BSpline() + if s1.Degree() != s2.Degree(): + return False + if s1.IsPeriodic() != s2.IsPeriodic(): + return False + if s1.NbPoles() != s2.NbPoles() or s1.NbKnots() != s2.NbKnots(): + return False + for i in range(1, s1.NbPoles() + 1): + if Vector(s1.Pole(i)) != Vector(s2.Pole(i)): + return False + if s1.IsRational() and abs(s1.Weight(i) - s2.Weight(i)) >= tol: + return False + for i in range(1, s1.NbKnots() + 1): + if abs(s1.Knot(i) - s2.Knot(i)) >= tol: + return False + if s1.Multiplicity(i) != s2.Multiplicity(i): + return False + return True + + case GeomType.OFFSET: + oc1, oc2 = ga1.OffsetCurve(), ga2.OffsetCurve() + # Compare offset values and directions + if abs(oc1.Offset() - oc2.Offset()) >= tol: + return False + if Vector(oc1.Direction()) != Vector(oc2.Direction()): + return False + # Compare basis curves (recursive) + basis1 = Edge(BRepBuilderAPI_MakeEdge(oc1.BasisCurve()).Edge()) + basis2 = Edge(BRepBuilderAPI_MakeEdge(oc2.BasisCurve()).Edge()) + return basis1.geom_equal(basis2, tol) + + case _: + # OTHER/unknown: compare sample points + for i in range(1, num_interpolation_points + 1): + t = i / (num_interpolation_points + 1) + if (self @ t) != (other @ t): + return False + return True + def _occt_param_at( self, position: float, position_mode: PositionMode = PositionMode.PARAMETER ) -> tuple[BRepAdaptor_Curve, float, bool]: @@ -3660,6 +3785,40 @@ class Wire(Mixin1D[TopoDS_Wire]): return ordered_edges + def geom_equal( + self, + other: Wire, + tol: float = 1e-6, + num_interpolation_points: int = 5, + ) -> bool: + """Compare two wires for geometric equality within tolerance. + + This compares the geometric properties of two wires by comparing their + constituent edges pairwise. Two independently created wires with the + same geometry will return True. + + Args: + other: Wire to compare with + tol: Tolerance for numeric comparisons. Defaults to 1e-6. + num_interpolation_points: Number of points to sample for unknown + curve types. Defaults to 5. + + Returns: + bool: True if wires are geometrically equal within tolerance + """ + if not isinstance(other, Wire): + return False + + # Use order_edges to ensure consistent edge ordering and orientation + edges1 = self.order_edges() + edges2 = other.order_edges() + if len(edges1) != len(edges2): + return False + return all( + e1.geom_equal(e2, tol, num_interpolation_points) + for e1, e2 in zip(edges1, edges2) + ) + def param_at_point(self, point: VectorLike) -> float: """ Return the normalized wire parameter for the point closest to this wire. diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 1ad24ef..803043a 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -127,7 +127,6 @@ from .shape_core import ( ) from .two_d import sort_wires_by_build_order, Mixin2D, Face, Shell -from .helpers import geom_equal from .utils import ( _extrude_topods_shape, find_max_dimension, @@ -783,7 +782,7 @@ class Solid(Mixin3D[TopoDS_Solid]): return any(shape.distance_to(s) <= tolerance for s in existing) # Edge: use geom_equal for full geometric comparison return any( - isinstance(e, Edge) and geom_equal(shape, e, tolerance) + isinstance(e, Edge) and shape.geom_equal(e, tolerance) for e in existing ) diff --git a/tests/test_direct_api/test_geom_equal.py b/tests/test_direct_api/test_geom_equal.py index e003f0f..0771c0e 100644 --- a/tests/test_direct_api/test_geom_equal.py +++ b/tests/test_direct_api/test_geom_equal.py @@ -1,4 +1,4 @@ -"""Tests for geom_equal helper function.""" +"""Tests for Edge.geom_equal and Wire.geom_equal methods.""" import pytest from build123d import ( @@ -11,46 +11,28 @@ from build123d import ( Ellipse, Bezier, GeomType, + Location, + Plane, ) -from build123d.topology.helpers import geom_equal -class TestGeomEqualVertex: - """Tests for Vertex comparison.""" - - def test_same_vertex(self): - v1 = Vertex(1, 2, 3) - v2 = Vertex(1, 2, 3) - assert geom_equal(v1, v2) - - def test_different_vertex(self): - v1 = Vertex(1, 2, 3) - v2 = Vertex(1, 2, 4) - assert not geom_equal(v1, v2) - - def test_vertex_within_tolerance(self): - v1 = Vertex(1, 2, 3) - v2 = Vertex(1, 2, 3 + 1e-7) - assert geom_equal(v1, v2) - - -class TestGeomEqualEdgeLine: - """Tests for Edge LINE comparison.""" +class TestEdgeGeomEqualLine: + """Tests for Edge.geom_equal with LINE type.""" def test_same_line(self): e1 = Edge.make_line((0, 0, 0), (1, 1, 1)) e2 = Edge.make_line((0, 0, 0), (1, 1, 1)) assert e1.geom_type == GeomType.LINE - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) def test_different_line(self): e1 = Edge.make_line((0, 0, 0), (1, 1, 1)) e2 = Edge.make_line((0, 0, 0), (1, 1, 2)) - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) -class TestGeomEqualEdgeCircle: - """Tests for Edge CIRCLE comparison.""" +class TestEdgeGeomEqualCircle: + """Tests for Edge.geom_equal with CIRCLE type.""" def test_same_circle(self): c1 = Circle(10) @@ -58,24 +40,24 @@ class TestGeomEqualEdgeCircle: e1 = c1.edge() e2 = c2.edge() assert e1.geom_type == GeomType.CIRCLE - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) def test_different_radius(self): c1 = Circle(10) c2 = Circle(11) e1 = c1.edge() e2 = c2.edge() - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) def test_same_arc(self): e1 = Edge.make_circle(10, start_angle=0, end_angle=90) e2 = Edge.make_circle(10, start_angle=0, end_angle=90) - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) def test_different_arc_angle(self): e1 = Edge.make_circle(10, start_angle=0, end_angle=90) e2 = Edge.make_circle(10, start_angle=0, end_angle=180) - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) def test_different_circle_from_revolve(self): """Two circles with same radius/endpoints but different center/axis.""" @@ -86,11 +68,35 @@ class TestGeomEqualEdgeCircle: value1, value2 = p1.edges().filter_by(GeomType.CIRCLE) value2 = value2.reversed() # These circles have same endpoints after reversal but different center/axis - assert not geom_equal(value1, value2) + assert not value1.geom_equal(value2) + + def test_different_location(self): + """Circles with same radius but different center location.""" + e1 = Edge.make_circle(10) + e2 = Edge.make_circle(10).locate(Location((5, 0, 0))) + assert not e1.geom_equal(e2) + + def test_same_location(self): + """Circles with same radius and same non-origin location.""" + e1 = Edge.make_circle(10).locate(Location((5, 5, 0))) + e2 = Edge.make_circle(10).locate(Location((5, 5, 0))) + assert e1.geom_equal(e2) + + def test_different_axis(self): + """Circles with same radius but different axis direction.""" + e1 = Edge.make_circle(10, plane=Plane.XY) + e2 = Edge.make_circle(10, plane=Plane.XZ) + assert not e1.geom_equal(e2) + + def test_same_axis(self): + """Circles with same radius and same non-default axis.""" + e1 = Edge.make_circle(10, plane=Plane.YZ) + e2 = Edge.make_circle(10, plane=Plane.YZ) + assert e1.geom_equal(e2) -class TestGeomEqualEdgeEllipse: - """Tests for Edge ELLIPSE comparison.""" +class TestEdgeGeomEqualEllipse: + """Tests for Edge.geom_equal with ELLIPSE type.""" def test_same_ellipse(self): el1 = Ellipse(10, 5) @@ -98,25 +104,144 @@ class TestGeomEqualEdgeEllipse: e1 = el1.edge() e2 = el2.edge() assert e1.geom_type == GeomType.ELLIPSE - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) def test_different_major_radius(self): el1 = Ellipse(10, 5) el2 = Ellipse(11, 5) e1 = el1.edge() e2 = el2.edge() - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) def test_different_minor_radius(self): el1 = Ellipse(10, 5) el2 = Ellipse(10, 6) e1 = el1.edge() e2 = el2.edge() - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) + + def test_different_location(self): + """Ellipses with same radii but different center location.""" + e1 = Edge.make_ellipse(10, 5) + e2 = Edge.make_ellipse(10, 5).locate(Location((5, 0, 0))) + assert not e1.geom_equal(e2) + + def test_same_location(self): + """Ellipses with same radii and same non-origin location.""" + e1 = Edge.make_ellipse(10, 5).locate(Location((5, 5, 0))) + e2 = Edge.make_ellipse(10, 5).locate(Location((5, 5, 0))) + assert e1.geom_equal(e2) + + def test_different_axis(self): + """Ellipses with same radii but different axis direction.""" + e1 = Edge.make_ellipse(10, 5, plane=Plane.XY) + e2 = Edge.make_ellipse(10, 5, plane=Plane.XZ) + assert not e1.geom_equal(e2) + + def test_same_axis(self): + """Ellipses with same radii and same non-default axis.""" + e1 = Edge.make_ellipse(10, 5, plane=Plane.YZ) + e2 = Edge.make_ellipse(10, 5, plane=Plane.YZ) + assert e1.geom_equal(e2) -class TestGeomEqualEdgeBezier: - """Tests for Edge BEZIER comparison.""" +class TestEdgeGeomEqualHyperbola: + """Tests for Edge.geom_equal with HYPERBOLA type.""" + + def test_same_hyperbola(self): + e1 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45) + e2 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45) + assert e1.geom_type == GeomType.HYPERBOLA + assert e1.geom_equal(e2) + + def test_different_x_radius(self): + e1 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45) + e2 = Edge.make_hyperbola(11, 5, start_angle=-45, end_angle=45) + assert not e1.geom_equal(e2) + + def test_different_y_radius(self): + e1 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45) + e2 = Edge.make_hyperbola(10, 6, start_angle=-45, end_angle=45) + assert not e1.geom_equal(e2) + + def test_different_location(self): + """Hyperbolas with same radii but different center location.""" + e1 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45) + e2 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45).locate( + Location((5, 0, 0)) + ) + assert not e1.geom_equal(e2) + + def test_same_location(self): + """Hyperbolas with same radii and same non-origin location.""" + e1 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45).locate( + Location((5, 5, 0)) + ) + e2 = Edge.make_hyperbola(10, 5, start_angle=-45, end_angle=45).locate( + Location((5, 5, 0)) + ) + assert e1.geom_equal(e2) + + def test_different_axis(self): + """Hyperbolas with same radii but different axis direction.""" + e1 = Edge.make_hyperbola(10, 5, plane=Plane.XY, start_angle=-45, end_angle=45) + e2 = Edge.make_hyperbola(10, 5, plane=Plane.XZ, start_angle=-45, end_angle=45) + assert not e1.geom_equal(e2) + + def test_same_axis(self): + """Hyperbolas with same radii and same non-default axis.""" + e1 = Edge.make_hyperbola(10, 5, plane=Plane.YZ, start_angle=-45, end_angle=45) + e2 = Edge.make_hyperbola(10, 5, plane=Plane.YZ, start_angle=-45, end_angle=45) + assert e1.geom_equal(e2) + + +class TestEdgeGeomEqualParabola: + """Tests for Edge.geom_equal with PARABOLA type.""" + + def test_same_parabola(self): + e1 = Edge.make_parabola(5, start_angle=0, end_angle=60) + e2 = Edge.make_parabola(5, start_angle=0, end_angle=60) + assert e1.geom_type == GeomType.PARABOLA + assert e1.geom_equal(e2) + + def test_different_focal_length(self): + e1 = Edge.make_parabola(5, start_angle=0, end_angle=60) + e2 = Edge.make_parabola(6, start_angle=0, end_angle=60) + assert not e1.geom_equal(e2) + + def test_different_location(self): + """Parabolas with same focal length but different vertex location.""" + e1 = Edge.make_parabola(5, start_angle=0, end_angle=60) + e2 = Edge.make_parabola(5, start_angle=0, end_angle=60).locate( + Location((5, 0, 0)) + ) + assert not e1.geom_equal(e2) + + def test_same_location(self): + """Parabolas with same focal length and same non-origin location.""" + e1 = Edge.make_parabola(5, start_angle=0, end_angle=60).locate( + Location((5, 5, 0)) + ) + e2 = Edge.make_parabola(5, start_angle=0, end_angle=60).locate( + Location((5, 5, 0)) + ) + assert e1.geom_equal(e2) + + def test_different_axis(self): + """Parabolas with same focal length but different axis direction.""" + e1 = Edge.make_parabola(5, plane=Plane.XY, start_angle=0, end_angle=60) + e2 = Edge.make_parabola(5, plane=Plane.XZ, start_angle=0, end_angle=60) + assert not e1.geom_equal(e2) + + def test_same_axis(self): + """Parabolas with same focal length and same non-default axis.""" + e1 = Edge.make_parabola(5, plane=Plane.YZ, start_angle=0, end_angle=60) + e2 = Edge.make_parabola(5, plane=Plane.YZ, start_angle=0, end_angle=60) + assert e1.geom_equal(e2) + + +class TestEdgeGeomEqualBezier: + """Tests for Edge.geom_equal with BEZIER type.""" def test_same_bezier(self): pts = [(0, 0), (1, 1), (2, 0)] @@ -125,18 +250,18 @@ class TestGeomEqualEdgeBezier: e1 = b1.edge() e2 = b2.edge() assert e1.geom_type == GeomType.BEZIER - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) def test_different_bezier(self): b1 = Bezier((0, 0), (1, 1), (2, 0)) b2 = Bezier((0, 0), (1, 2), (2, 0)) e1 = b1.edge() e2 = b2.edge() - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) -class TestGeomEqualEdgeBSpline: - """Tests for Edge BSPLINE comparison.""" +class TestEdgeGeomEqualBSpline: + """Tests for Edge.geom_equal with BSPLINE type.""" def test_same_spline(self): v = [Vertex(p) for p in ((-2, 0), (-1, 0), (0, 0), (1, 0), (2, 0))] @@ -145,7 +270,7 @@ class TestGeomEqualEdgeBSpline: e1 = s1.edge() e2 = s2.edge() assert e1.geom_type == GeomType.BSPLINE - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) def test_different_spline(self): v1 = [Vertex(p) for p in ((-2, 0), (-1, 0), (0, 0), (1, 0), (2, 0))] @@ -154,7 +279,7 @@ class TestGeomEqualEdgeBSpline: s2 = Spline(*v2) e1 = s1.edge() e2 = s2.edge() - assert not geom_equal(e1, e2) + assert not e1.geom_equal(e2) def test_complex_spline(self): v = [ @@ -177,11 +302,11 @@ class TestGeomEqualEdgeBSpline: s2 = Spline(*v) e1 = s1.edge() e2 = s2.edge() - assert geom_equal(e1, e2) + assert e1.geom_equal(e2) -class TestGeomEqualEdgeOffset: - """Tests for Edge OFFSET comparison.""" +class TestEdgeGeomEqualOffset: + """Tests for Edge.geom_equal with OFFSET type.""" def test_same_offset(self): v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] @@ -198,7 +323,7 @@ class TestGeomEqualEdgeOffset: ] assert len(offset_edges1) > 0 - assert geom_equal(offset_edges1[0], offset_edges2[0]) + assert offset_edges1[0].geom_equal(offset_edges2[0]) def test_different_offset_value(self): v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] @@ -214,21 +339,212 @@ class TestGeomEqualEdgeOffset: e for e in offset_wire2.edges() if e.geom_type == GeomType.OFFSET ] - assert not geom_equal(offset_edges1[0], offset_edges2[0]) + assert not offset_edges1[0].geom_equal(offset_edges2[0]) -class TestGeomEqualWire: - """Tests for Wire comparison.""" +class TestEdgeGeomEqualTolerance: + """Tests for tolerance behavior in Edge.geom_equal.""" + + def test_circle_radius_within_tolerance(self): + """Circle radii differing by less than tolerance are equal.""" + e1 = Edge.make_circle(10.0) + e2 = Edge.make_circle(10.0 + 1e-7) # Within default tol=1e-6 + assert e1.geom_equal(e2) + + def test_circle_radius_outside_tolerance(self): + """Circle radii differing by more than tolerance are not equal.""" + e1 = Edge.make_circle(10.0) + e2 = Edge.make_circle(10.0 + 1e-5) # Outside default tol=1e-6 + assert not e1.geom_equal(e2) + + def test_tol_parameter_accepted(self): + """The tol parameter is accepted by geom_equal. + + Note: The tol parameter affects property comparisons (radius, focal length, + weights, knots, offset values) but endpoint comparison always uses Vector's + built-in TOLERANCE (1e-6). Since most geometric differences also change + endpoints, the custom tol has limited practical effect. + """ + e1 = Edge.make_line((0, 0), (1, 1)) + e2 = Edge.make_line((0, 0), (1, 1)) + # Parameter is accepted + assert e1.geom_equal(e2, tol=1e-9) + assert e1.geom_equal(e2, tol=0.1) + + def test_line_endpoint_within_tolerance(self): + """Line endpoints differing by less than tolerance are equal.""" + e1 = Edge.make_line((0, 0, 0), (1, 1, 1)) + e2 = Edge.make_line((0, 0, 0), (1 + 1e-7, 1, 1)) + assert e1.geom_equal(e2) + + def test_line_endpoint_outside_tolerance(self): + """Line endpoints differing by more than tolerance are not equal.""" + e1 = Edge.make_line((0, 0, 0), (1, 1, 1)) + e2 = Edge.make_line((0, 0, 0), (1.001, 1, 1)) + assert not e1.geom_equal(e2) + + +class TestEdgeGeomEqualReversed: + """Tests for reversed edge comparison.""" + + def test_line_reversed_not_equal(self): + """Reversed line is not equal (different direction).""" + e1 = Edge.make_line((0, 0), (1, 1)) + e2 = Edge.make_line((1, 1), (0, 0)) + assert not e1.geom_equal(e2) + + def test_arc_reversed_not_equal(self): + """Reversed arc is not equal.""" + e1 = Edge.make_circle(10, start_angle=0, end_angle=90) + e2 = Edge.make_circle(10, start_angle=0, end_angle=90).reversed() + # Reversed edge has swapped start/end points + assert not e1.geom_equal(e2) + + def test_spline_reversed_not_equal(self): + """Reversed spline is not equal.""" + pts = [(0, 0), (1, 1), (2, 0), (3, 1)] + s = Spline(*[Vertex(p) for p in pts]) + e1 = s.edge() + e2 = e1.reversed() + assert not e1.geom_equal(e2) + + +class TestEdgeGeomEqualArcVariations: + """Tests for arc edge cases.""" + + def test_full_circle_equal(self): + """Two full circles are equal.""" + e1 = Edge.make_circle(10) + e2 = Edge.make_circle(10) + assert e1.geom_equal(e2) + + def test_arc_different_start_same_sweep(self): + """Arcs with different start angles but same sweep are not equal.""" + e1 = Edge.make_circle(10, start_angle=0, end_angle=90) + e2 = Edge.make_circle(10, start_angle=90, end_angle=180) + # Same radius and sweep angle, but different positions + assert not e1.geom_equal(e2) + + def test_arc_same_endpoints_different_direction(self): + """Arcs with same endpoints but opposite sweep direction.""" + from build123d import AngularDirection + + e1 = Edge.make_circle( + 10, + start_angle=0, + end_angle=90, + angular_direction=AngularDirection.COUNTER_CLOCKWISE, + ) + e2 = Edge.make_circle( + 10, + start_angle=90, + end_angle=0, + angular_direction=AngularDirection.CLOCKWISE, + ) + # These trace different paths (short arc vs long arc) + assert not e1.geom_equal(e2) + + +class TestEdgeGeomEqualNumerical: + """Tests for numerical edge cases.""" + + def test_very_small_edge(self): + """Very small edges can be compared.""" + e1 = Edge.make_line((0, 0), (1e-6, 1e-6)) + e2 = Edge.make_line((0, 0), (1e-6, 1e-6)) + assert e1.geom_equal(e2) + + def test_very_large_coordinates(self): + """Edges with large coordinates can be compared.""" + e1 = Edge.make_line((1e6, 1e6), (1e6 + 1, 1e6 + 1)) + e2 = Edge.make_line((1e6, 1e6), (1e6 + 1, 1e6 + 1)) + assert e1.geom_equal(e2) + + def test_large_coordinates_small_difference(self): + """Small differences at large coordinates.""" + e1 = Edge.make_line((1e6, 1e6), (1e6 + 1, 1e6 + 1)) + e2 = Edge.make_line((1e6, 1e6), (1e6 + 1 + 1e-5, 1e6 + 1)) + # Difference is above tolerance + assert not e1.geom_equal(e2) + + +class TestEdgeGeomEqual3DPositioning: + """Tests for 3D positioning edge cases.""" + + def test_same_shape_different_z(self): + """Same 2D shape at different Z levels are not equal.""" + e1 = Edge.make_circle(10) + e2 = Edge.make_circle(10).locate(Location((0, 0, 5))) + assert not e1.geom_equal(e2) + + def test_line_in_3d(self): + """3D lines with same geometry are equal.""" + e1 = Edge.make_line((0, 0, 0), (1, 2, 3)) + e2 = Edge.make_line((0, 0, 0), (1, 2, 3)) + assert e1.geom_equal(e2) + + def test_line_in_3d_different(self): + """3D lines with different Z are not equal.""" + e1 = Edge.make_line((0, 0, 0), (1, 1, 0)) + e2 = Edge.make_line((0, 0, 0), (1, 1, 1)) + assert not e1.geom_equal(e2) + + +class TestEdgeGeomEqualSplineVariations: + """Tests for BSpline edge cases.""" + + def test_spline_control_point_within_tolerance(self): + """Splines with control points within tolerance are equal.""" + pts1 = [(0, 0), (1, 1), (2, 0)] + pts2 = [(0, 0), (1 + 1e-7, 1), (2, 0)] + e1 = Spline(*[Vertex(p) for p in pts1]).edge() + e2 = Spline(*[Vertex(p) for p in pts2]).edge() + assert e1.geom_equal(e2) + + def test_spline_control_point_outside_tolerance(self): + """Splines with control points outside tolerance are not equal.""" + pts1 = [(0, 0), (1, 1), (2, 0)] + pts2 = [(0, 0), (1.001, 1), (2, 0)] + e1 = Spline(*[Vertex(p) for p in pts1]).edge() + e2 = Spline(*[Vertex(p) for p in pts2]).edge() + assert not e1.geom_equal(e2) + + def test_spline_different_point_count(self): + """Splines with different number of control points are not equal.""" + pts1 = [(0, 0), (1, 1), (2, 0)] + pts2 = [(0, 0), (0.5, 0.5), (1, 1), (2, 0)] + e1 = Spline(*[Vertex(p) for p in pts1]).edge() + e2 = Spline(*[Vertex(p) for p in pts2]).edge() + # Different number of poles + assert not e1.geom_equal(e2) + + +class TestEdgeGeomEqualUnknownType: + """Tests for the fallback case (OTHER/unknown geom types).""" + + def test_interpolation_points_used(self): + """For unknown types, sample points are compared.""" + # Create edges that would use the fallback path + # Most common types are handled, but we can test the parameter + e1 = Edge.make_line((0, 0), (1, 1)) + e2 = Edge.make_line((0, 0), (1, 1)) + # Even with different num_interpolation_points, these should be equal + assert e1.geom_equal(e2, num_interpolation_points=3) + assert e1.geom_equal(e2, num_interpolation_points=10) + + +class TestWireGeomEqual: + """Tests for Wire.geom_equal method.""" def test_same_rectangle_wire(self): r1 = Rectangle(10, 5) r2 = Rectangle(10, 5) - assert geom_equal(r1.wire(), r2.wire()) + assert r1.wire().geom_equal(r2.wire()) def test_different_rectangle_wire(self): r1 = Rectangle(10, 5) r2 = Rectangle(10, 6) - assert not geom_equal(r1.wire(), r2.wire()) + assert not r1.wire().geom_equal(r2.wire()) def test_same_spline_wire(self): v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] @@ -236,25 +552,204 @@ class TestGeomEqualWire: s2 = Spline(*v) w1 = Wire([s1.edge()]) w2 = Wire([s2.edge()]) - assert geom_equal(w1, w2) + assert w1.geom_equal(w2) def test_different_edge_count(self): r1 = Rectangle(10, 5) e = Edge.make_line((0, 0), (10, 0)) w1 = r1.wire() w2 = Wire([e]) - assert not geom_equal(w1, w2) + assert not w1.geom_equal(w2) + + def test_identical_edge_objects(self): + """Two wires sharing the same edge objects.""" + e1 = Edge.make_line((0, 0), (1, 0)) + e2 = Edge.make_line((1, 0), (1, 1)) + e3 = Edge.make_line((1, 1), (0, 0)) + w1 = Wire([e1, e2, e3]) + w2 = Wire([e1, e2, e3]) # Same edge objects + assert w1.geom_equal(w2) + + def test_geometrically_equal_edges(self): + """Two wires with geometrically equal but distinct edge objects.""" + # Wire 1 + e1a = Edge.make_line((0, 0), (1, 0)) + e2a = Edge.make_line((1, 0), (1, 1)) + e3a = Edge.make_line((1, 1), (0, 0)) + w1 = Wire([e1a, e2a, e3a]) + # Wire 2 - same geometry, different objects + e1b = Edge.make_line((0, 0), (1, 0)) + e2b = Edge.make_line((1, 0), (1, 1)) + e3b = Edge.make_line((1, 1), (0, 0)) + w2 = Wire([e1b, e2b, e3b]) + assert w1.geom_equal(w2) + + def test_edges_different_start_point(self): + """Two closed wires with same geometry but different starting vertex are not equal.""" + # Wire 1: starts at (0,0) + e1a = Edge.make_line((0, 0), (1, 0)) + e2a = Edge.make_line((1, 0), (1, 1)) + e3a = Edge.make_line((1, 1), (0, 0)) + w1 = Wire([e1a, e2a, e3a]) + # Wire 2: starts at (1,1) due to different edge order in constructor + e3b = Edge.make_line((1, 1), (0, 0)) + e1b = Edge.make_line((0, 0), (1, 0)) + e2b = Edge.make_line((1, 0), (1, 1)) + w2 = Wire([e3b, e1b, e2b]) + # Different starting point means not equal + assert not w1.geom_equal(w2) + + def test_one_edge_reversed(self): + """Two wires where one has an edge with reversed direction.""" + # Wire 1: all edges in forward direction + e1a = Edge.make_line((0, 0), (1, 0)) + e2a = Edge.make_line((1, 0), (1, 1)) + e3a = Edge.make_line((1, 1), (0, 0)) + w1 = Wire([e1a, e2a, e3a]) + # Wire 2: middle edge is reversed (direction (1,1) -> (1,0) instead of (1,0) -> (1,1)) + e1b = Edge.make_line((0, 0), (1, 0)) + e2b = Edge.make_line((1, 1), (1, 0)) # Reversed! + e3b = Edge.make_line((1, 1), (0, 0)) + w2 = Wire([e1b, e2b, e3b]) + # order_edges should correct the orientation + assert w1.geom_equal(w2) + + def test_closed_wire(self): + """Two closed wires with same geometry.""" + w1 = Wire( + [ + Edge.make_line((0, 0), (2, 0)), + Edge.make_line((2, 0), (2, 2)), + Edge.make_line((2, 2), (0, 2)), + Edge.make_line((0, 2), (0, 0)), + ] + ) + w2 = Wire( + [ + Edge.make_line((0, 0), (2, 0)), + Edge.make_line((2, 0), (2, 2)), + Edge.make_line((2, 2), (0, 2)), + Edge.make_line((0, 2), (0, 0)), + ] + ) + assert w1.is_closed + assert w2.is_closed + assert w1.geom_equal(w2) + + def test_mixed_edge_types(self): + """Wires with mixed edge types (lines and arcs).""" + # Wire with line + arc + line + e1a = Edge.make_line((0, 0), (1, 0)) + e2a = Edge.make_circle(0.5, start_angle=0, end_angle=180).locate( + Location((1.5, 0, 0)) + ) + e3a = Edge.make_line((2, 0), (3, 0)) + w1 = Wire([e1a, e2a, e3a]) + + e1b = Edge.make_line((0, 0), (1, 0)) + e2b = Edge.make_circle(0.5, start_angle=0, end_angle=180).locate( + Location((1.5, 0, 0)) + ) + e3b = Edge.make_line((2, 0), (3, 0)) + w2 = Wire([e1b, e2b, e3b]) + + assert w1.geom_equal(w2) + + def test_mixed_edge_types_different(self): + """Wires with mixed edge types that differ.""" + # Wire 1: line + arc + e1a = Edge.make_line((0, 0), (1, 0)) + e2a = Edge.make_circle(0.5, start_angle=0, end_angle=180).locate( + Location((1.5, 0, 0)) + ) + w1 = Wire([e1a, e2a]) + + # Wire 2: line + different arc (different radius) + e1b = Edge.make_line((0, 0), (1, 0)) + e2b = Edge.make_circle(0.6, start_angle=0, end_angle=180).locate( + Location((1.6, 0, 0)) + ) + w2 = Wire([e1b, e2b]) + + assert not w1.geom_equal(w2) + + def test_all_edges_reversed_not_equal(self): + """Wire traced in opposite direction is not equal.""" + # Wire 1: (0,0) -> (3,0) + e1a = Edge.make_line((0, 0), (1, 0)) + e2a = Edge.make_line((1, 0), (2, 1)) + e3a = Edge.make_line((2, 1), (3, 0)) + w1 = Wire([e1a, e2a, e3a]) + + # Wire 2: (3,0) -> (0,0) - same path but opposite direction + e1b = Edge.make_line((1, 0), (0, 0)) + e2b = Edge.make_line((2, 1), (1, 0)) + e3b = Edge.make_line((3, 0), (2, 1)) + w2 = Wire([e3b, e2b, e1b]) + + assert not w1.geom_equal(w2) + + def test_open_wire_different_start(self): + """Open wires with same edges but different starting edge - should not match.""" + # For open wires, the start matters + e1 = Edge.make_line((0, 0), (1, 0)) + e2 = Edge.make_line((1, 0), (2, 1)) + e3 = Edge.make_line((2, 1), (3, 0)) + w1 = Wire([e1, e2, e3]) + + # Different edges entirely (shifted) + e4 = Edge.make_line((1, 0), (2, 0)) + e5 = Edge.make_line((2, 0), (3, 1)) + e6 = Edge.make_line((3, 1), (4, 0)) + w2 = Wire([e4, e5, e6]) + + assert not w1.geom_equal(w2) + + def test_wire_with_spline_edges(self): + """Wires containing spline edges.""" + pts1 = [(0, 0), (1, 1), (2, 0), (3, 1), (4, 0)] + pts2 = [(4, 0), (5, 1), (6, 0)] + + s1a = Spline(*[Vertex(p) for p in pts1]) + s2a = Spline(*[Vertex(p) for p in pts2]) + w1 = Wire([s1a.edge(), s2a.edge()]) + + s1b = Spline(*[Vertex(p) for p in pts1]) + s2b = Spline(*[Vertex(p) for p in pts2]) + w2 = Wire([s1b.edge(), s2b.edge()]) + + assert w1.geom_equal(w2) + + def test_single_edge_wire(self): + """Wires with single edge.""" + w1 = Wire([Edge.make_line((0, 0), (5, 5))]) + w2 = Wire([Edge.make_line((0, 0), (5, 5))]) + assert w1.geom_equal(w2) + + def test_single_edge_wire_reversed_not_equal(self): + """Single edge wire vs reversed single edge wire are not equal.""" + w1 = Wire([Edge.make_line((0, 0), (5, 5))]) + w2 = Wire([Edge.make_line((5, 5), (0, 0))]) + # Opposite direction means not equal + assert not w1.geom_equal(w2) class TestGeomEqualTypeMismatch: """Tests for type mismatch cases.""" - def test_edge_vs_wire(self): + def test_edge_vs_non_edge(self): e = Edge.make_line((0, 0), (1, 1)) w = Wire([e]) - assert not geom_equal(e, w) + # Edge.geom_equal should return False for non-Edge + assert not e.geom_equal(w) + + def test_wire_vs_non_wire(self): + e = Edge.make_line((0, 0), (1, 1)) + w = Wire([e]) + # Wire.geom_equal should return False for non-Wire + assert not w.geom_equal(e) def test_different_geom_types(self): line = Edge.make_line((0, 0, 0), (1, 1, 1)) circle = Circle(10).edge() - assert not geom_equal(line, circle) + assert not line.geom_equal(circle) From 216cbddecf78f3f87ab8707352c26bcc8b75d10a Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 11:10:13 +0100 Subject: [PATCH 27/42] add more tests for splines and bezier curves --- tests/test_direct_api/test_geom_equal.py | 213 +++++++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/tests/test_direct_api/test_geom_equal.py b/tests/test_direct_api/test_geom_equal.py index 0771c0e..1df96f1 100644 --- a/tests/test_direct_api/test_geom_equal.py +++ b/tests/test_direct_api/test_geom_equal.py @@ -259,6 +259,31 @@ class TestEdgeGeomEqualBezier: e2 = b2.edge() assert not e1.geom_equal(e2) + def test_different_degree(self): + """Bezier curves with different degrees (different number of control points).""" + # Quadratic (degree 2, 3 points) + b1 = Bezier((0, 0), (1, 1), (2, 0)) + # Cubic (degree 3, 4 points) - adjusted to have same endpoints + b2 = Bezier((0, 0), (0.5, 1), (1.5, 1), (2, 0)) + e1 = b1.edge() + e2 = b2.edge() + assert e1.geom_type == GeomType.BEZIER + assert e2.geom_type == GeomType.BEZIER + assert not e1.geom_equal(e2) + + def test_rational_bezier_different_weights(self): + """Rational Bezier curves with different weights.""" + pts = [(0, 0, 0), (1, 1, 0), (2, 0, 0)] + + # Create rational Bezier with weights [1, 2, 1] + e1 = Edge.make_bezier(*pts, weights=[1.0, 2.0, 1.0]) + + # Create rational Bezier with weights [1, 3, 1] + e2 = Edge.make_bezier(*pts, weights=[1.0, 3.0, 1.0]) + + assert e1.geom_type == GeomType.BEZIER + assert not e1.geom_equal(e2) + class TestEdgeGeomEqualBSpline: """Tests for Edge.geom_equal with BSPLINE type.""" @@ -304,6 +329,169 @@ class TestEdgeGeomEqualBSpline: e2 = s2.edge() assert e1.geom_equal(e2) + def test_different_periodicity(self): + """BSplines with different periodicity (periodic vs non-periodic).""" + # Same control points, different periodicity + pts = [(0, 0), (1, 1), (2, 0), (1, -1)] + + e1 = Edge.make_spline(pts, periodic=False) + e2 = Edge.make_spline(pts, periodic=True) + + assert e1.geom_type == GeomType.BSPLINE + assert e2.geom_type == GeomType.BSPLINE + # Different periodicity means not equal + assert not e1.geom_equal(e2) + + def test_different_pole_count(self): + """BSplines with different number of poles.""" + # 5 points + v1 = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1), (4, 0))] + # 6 points with same endpoints + v2 = [ + Vertex(p) + for p in ((0, 0), (0.8, 0.8), (1.6, 0.2), (2.4, 0.8), (3.2, 0.2), (4, 0)) + ] + s1 = Spline(*v1) + s2 = Spline(*v2) + e1 = s1.edge() + e2 = s2.edge() + assert e1.geom_type == GeomType.BSPLINE + assert e2.geom_type == GeomType.BSPLINE + assert not e1.geom_equal(e2) + + def test_different_knot_values(self): + """BSplines with different internal knot positions have different shapes.""" + from OCP.Geom import Geom_BSplineCurve + from OCP.TColgp import TColgp_Array1OfPnt + from OCP.TColStd import TColStd_Array1OfReal, TColStd_Array1OfInteger + from OCP.gp import gp_Pnt + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + + # 5 poles for degree 3 with one internal knot + poles = TColgp_Array1OfPnt(1, 5) + poles.SetValue(1, gp_Pnt(0, 0, 0)) + poles.SetValue(2, gp_Pnt(1, 2, 0)) + poles.SetValue(3, gp_Pnt(2, 2, 0)) + poles.SetValue(4, gp_Pnt(3, 2, 0)) + poles.SetValue(5, gp_Pnt(4, 0, 0)) + + mults = TColStd_Array1OfInteger(1, 3) + mults.SetValue(1, 4) + mults.SetValue(2, 1) # Internal knot + mults.SetValue(3, 4) + + # Internal knot at 0.5 + knots1 = TColStd_Array1OfReal(1, 3) + knots1.SetValue(1, 0.0) + knots1.SetValue(2, 0.5) + knots1.SetValue(3, 1.0) + curve1 = Geom_BSplineCurve(poles, knots1, mults, 3, False) + e1 = Edge(BRepBuilderAPI_MakeEdge(curve1).Edge()) + + # Internal knot at 0.3 - different position changes shape! + knots2 = TColStd_Array1OfReal(1, 3) + knots2.SetValue(1, 0.0) + knots2.SetValue(2, 0.3) + knots2.SetValue(3, 1.0) + curve2 = Geom_BSplineCurve(poles, knots2, mults, 3, False) + e2 = Edge(BRepBuilderAPI_MakeEdge(curve2).Edge()) + + assert e1.geom_type == GeomType.BSPLINE + # Different internal knot position = different geometric shape + assert (e1 @ 0.5) != (e2 @ 0.5) + assert not e1.geom_equal(e2) + + def test_different_multiplicities(self): + """BSplines with same poles/knots but different multiplicities have different shapes.""" + from OCP.Geom import Geom_BSplineCurve + from OCP.TColgp import TColgp_Array1OfPnt + from OCP.TColStd import TColStd_Array1OfReal, TColStd_Array1OfInteger + from OCP.gp import gp_Pnt + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + + # Same 7 poles for both curves + poles = TColgp_Array1OfPnt(1, 7) + poles.SetValue(1, gp_Pnt(0, 0, 0)) + poles.SetValue(2, gp_Pnt(1, 2, 0)) + poles.SetValue(3, gp_Pnt(2, 1, 0)) + poles.SetValue(4, gp_Pnt(3, 2, 0)) + poles.SetValue(5, gp_Pnt(4, 1, 0)) + poles.SetValue(6, gp_Pnt(5, 2, 0)) + poles.SetValue(7, gp_Pnt(6, 0, 0)) + + # Same 4 knots for both curves + knots = TColStd_Array1OfReal(1, 4) + knots.SetValue(1, 0.0) + knots.SetValue(2, 0.33) + knots.SetValue(3, 0.67) + knots.SetValue(4, 1.0) + + # Multiplicities [4, 1, 2, 4] - sum = 11 = 7 + 3 + 1 + mults1 = TColStd_Array1OfInteger(1, 4) + mults1.SetValue(1, 4) + mults1.SetValue(2, 1) + mults1.SetValue(3, 2) + mults1.SetValue(4, 4) + curve1 = Geom_BSplineCurve(poles, knots, mults1, 3, False) + e1 = Edge(BRepBuilderAPI_MakeEdge(curve1).Edge()) + + # Multiplicities [4, 2, 1, 4] - same sum, swapped internal mults + mults2 = TColStd_Array1OfInteger(1, 4) + mults2.SetValue(1, 4) + mults2.SetValue(2, 2) + mults2.SetValue(3, 1) + mults2.SetValue(4, 4) + curve2 = Geom_BSplineCurve(poles, knots, mults2, 3, False) + e2 = Edge(BRepBuilderAPI_MakeEdge(curve2).Edge()) + + assert e1.geom_type == GeomType.BSPLINE + assert e2.geom_type == GeomType.BSPLINE + # Same poles, same knots, different multiplicities = different shape + assert (e1 @ 0.5) != (e2 @ 0.5) + assert not e1.geom_equal(e2) + + def test_rational_bspline_different_weights(self): + """Rational BSplines with different weights.""" + from OCP.Geom import Geom_BSplineCurve + from OCP.TColgp import TColgp_Array1OfPnt + from OCP.TColStd import TColStd_Array1OfReal, TColStd_Array1OfInteger + from OCP.gp import gp_Pnt + from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge + + poles = TColgp_Array1OfPnt(1, 4) + poles.SetValue(1, gp_Pnt(0, 0, 0)) + poles.SetValue(2, gp_Pnt(1, 1, 0)) + poles.SetValue(3, gp_Pnt(2, 1, 0)) + poles.SetValue(4, gp_Pnt(3, 0, 0)) + + knots = TColStd_Array1OfReal(1, 2) + knots.SetValue(1, 0.0) + knots.SetValue(2, 1.0) + mults = TColStd_Array1OfInteger(1, 2) + mults.SetValue(1, 4) + mults.SetValue(2, 4) + + # Weights [1, 2, 2, 1] + weights1 = TColStd_Array1OfReal(1, 4) + weights1.SetValue(1, 1.0) + weights1.SetValue(2, 2.0) + weights1.SetValue(3, 2.0) + weights1.SetValue(4, 1.0) + curve1 = Geom_BSplineCurve(poles, weights1, knots, mults, 3, False) + e1 = Edge(BRepBuilderAPI_MakeEdge(curve1).Edge()) + + # Weights [1, 3, 3, 1] + weights2 = TColStd_Array1OfReal(1, 4) + weights2.SetValue(1, 1.0) + weights2.SetValue(2, 3.0) + weights2.SetValue(3, 3.0) + weights2.SetValue(4, 1.0) + curve2 = Geom_BSplineCurve(poles, weights2, knots, mults, 3, False) + e2 = Edge(BRepBuilderAPI_MakeEdge(curve2).Edge()) + + assert e1.geom_type == GeomType.BSPLINE + assert not e1.geom_equal(e2) + class TestEdgeGeomEqualOffset: """Tests for Edge.geom_equal with OFFSET type.""" @@ -341,6 +529,31 @@ class TestEdgeGeomEqualOffset: assert not offset_edges1[0].geom_equal(offset_edges2[0]) + def test_different_offset_direction(self): + """Offset curves with different offset directions (on different planes).""" + from build123d import Axis + + v = [Vertex(p) for p in ((0, 0), (1, 1), (2, 0), (3, 1))] + s = Spline(*v) + w = Wire([s.edge()]) + + # Offset on XY plane (Z direction) + offset_wire1 = w.offset_2d(0.1) + offset_edges1 = [ + e for e in offset_wire1.edges() if e.geom_type == GeomType.OFFSET + ] + + # Rotate wire 90 degrees around X axis to put it on XZ plane + w_rotated = w.rotate(Axis.X, 90) + offset_wire2 = w_rotated.offset_2d(0.1) + offset_edges2 = [ + e for e in offset_wire2.edges() if e.geom_type == GeomType.OFFSET + ] + + if len(offset_edges1) > 0 and len(offset_edges2) > 0: + # Different directions means not equal + assert not offset_edges1[0].geom_equal(offset_edges2[0]) + class TestEdgeGeomEqualTolerance: """Tests for tolerance behavior in Edge.geom_equal.""" From 143478886ae6a4aef7b321e51bdc4fc372bea122 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 11:38:50 +0100 Subject: [PATCH 28/42] improve test coverage --- tests/test_direct_api/test_intersection.py | 368 +++++++++++++++++++++ 1 file changed, 368 insertions(+) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 0712412..79c2d91 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -573,3 +573,371 @@ def make_exception_params(matrix): def test_exceptions(obj, target, expected): with pytest.raises(Exception): obj.intersect(target) + + +# Direct touch() method tests +class TestTouchMethod: + """Tests for direct touch() method calls to cover specific code paths.""" + + def test_solid_vertex_touch_on_face(self): + """Solid.touch(Vertex) where vertex is on a face of the solid.""" + solid = Box(2, 2, 2) # Box from -1 to 1 in all axes + # Vertex on the top face (z=1) + vertex = Vertex(0, 0, 1) + result = solid.touch(vertex) + assert len(result) == 1 + assert isinstance(result[0], Vertex) + + def test_solid_vertex_touch_on_edge(self): + """Solid.touch(Vertex) where vertex is on an edge of the solid.""" + solid = Box(2, 2, 2) + # Vertex on an edge (corner of top face) + vertex = Vertex(1, 0, 1) + result = solid.touch(vertex) + assert len(result) == 1 + assert isinstance(result[0], Vertex) + + def test_solid_vertex_touch_on_corner(self): + """Solid.touch(Vertex) where vertex is on a corner of the solid.""" + solid = Box(2, 2, 2) + # Vertex on a corner + vertex = Vertex(1, 1, 1) + result = solid.touch(vertex) + assert len(result) == 1 + assert isinstance(result[0], Vertex) + + def test_solid_vertex_touch_not_touching(self): + """Solid.touch(Vertex) where vertex is not on the solid boundary.""" + solid = Box(2, 2, 2) + vertex = Vertex(5, 5, 5) # Far away + result = solid.touch(vertex) + assert len(result) == 0 + + def test_solid_vertex_touch_inside(self): + """Solid.touch(Vertex) where vertex is inside the solid (not touch).""" + solid = Box(2, 2, 2) + vertex = Vertex(0, 0, 0) # Center of box + result = solid.touch(vertex) + # Inside is not a touch - touch is boundary contact only + assert len(result) == 0 + + def test_shell_tangent_touch(self): + """Shell.touch(Face) for tangent contact (sphere touching plane).""" + # Create a hemisphere shell + sphere = Sphere(1).faces()[0] + shell = Shell([sphere]) + + # Create a plane tangent to the sphere at bottom (z=-1) + tangent_face = Face(Plane.XY.offset(-1)) + + result = shell.touch(tangent_face) + # Should find tangent vertex contact at (0, 0, -1) + assert len(result) >= 1 + # Result should be vertex (tangent point) + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + +# ShapeList.expand() tests +class TestShapeListExpand: + """Tests for ShapeList.expand() method.""" + + def test_expand_with_vector(self): + """ShapeList containing Vector objects.""" + from build123d import Vector, ShapeList + + v1 = Vector(1, 2, 3) + v2 = Vector(4, 5, 6) + shapes = ShapeList([v1, v2]) + expanded = shapes.expand() + assert len(expanded) == 2 + assert v1 in expanded + assert v2 in expanded + + def test_expand_nested_compound(self): + """ShapeList with nested compounds.""" + # Create inner compound + inner = Compound([Box(1, 1, 1), Pos(3, 0, 0) * Box(1, 1, 1)]) + # Create outer compound containing inner compound + outer = Compound([inner, Pos(0, 3, 0) * Box(1, 1, 1)]) + + shapes = ShapeList([outer]) + expanded = shapes.expand() + + # Should have 3 solids after expanding nested compounds + solids = [s for s in expanded if isinstance(s, Solid)] + assert len(solids) == 3 + + def test_expand_shell_to_faces(self): + """ShapeList with Shell expands to faces.""" + shells = Box(1, 1, 1).shells() # Get shell from solid + if shells: + shell = shells[0] + shapes = ShapeList([shell]) + expanded = shapes.expand() + faces = [s for s in expanded if isinstance(s, Face)] + assert len(faces) == 6 # Box has 6 faces + + def test_expand_wire_to_edges(self): + """ShapeList with Wire expands to edges.""" + wire = Rectangle(2, 2).wire() + shapes = ShapeList([wire]) + expanded = shapes.expand() + edges = [s for s in expanded if isinstance(s, Edge)] + assert len(edges) == 4 # Rectangle has 4 edges + + def test_expand_mixed(self): + """ShapeList with mixed types.""" + from build123d import Vector + + v = Vector(1, 2, 3) + wire = Rectangle(2, 2).wire() + solid = Box(1, 1, 1) + compound = Compound([Pos(5, 0, 0) * Box(1, 1, 1)]) + + shapes = ShapeList([v, wire, solid, compound]) + expanded = shapes.expand() + + # Vector stays as vector + assert v in expanded + # Wire expands to 4 edges + edges = [s for s in expanded if isinstance(s, Edge)] + assert len(edges) == 4 + # Solid stays as solid + solids = [s for s in expanded if isinstance(s, Solid)] + assert len(solids) == 2 # Original + from compound + + +class TestShellTangentTouchCoverage: + """Tests for Shell tangent touch to cover two_d.py lines 467-491. + + These tests specifically target the Shell-specific code paths in Face.touch() + where we need to find which face in a Shell contains the contact point. + """ + + def test_shell_self_tangent_touch_multiple_faces(self): + """Shell.touch(Face) where Shell has multiple faces. + + Covers lines 472-476: finding face containing contact point in self Shell. + """ + # Create a shell with multiple faces (half-sphere has curved + flat faces) + half_sphere = Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2) + shell = Shell(half_sphere.faces()) + + # Create a plane tangent to the curved part at x=1 + tangent_face = Pos(1, 0, 0) * (Rot(0, 90, 0) * Rectangle(2, 2).face()) + + result = shell.touch(tangent_face) + # Should find tangent vertex at (1, 0, 0) + assert len(result) >= 1 + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + def test_face_shell_other_tangent_touch_multiple_faces(self): + """Face.touch(Shell) where Shell (other) has multiple faces. + + Covers lines 480-484: finding face containing contact point in other Shell. + """ + # Create a face + face = Pos(1, 0, 0) * (Rot(0, 90, 0) * Rectangle(2, 2).face()) + + # Create a shell with multiple faces (half-sphere) + half_sphere = Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2) + shell = Shell(half_sphere.faces()) + + result = face.touch(shell) + # Should find tangent vertex at (1, 0, 0) + assert len(result) >= 1 + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + def test_shell_shell_tangent_touch_multiple_faces(self): + """Shell.touch(Shell) where both Shells have multiple faces. + + Covers both line ranges 472-476 and 480-484. + """ + # Create two half-spheres touching at their curved surfaces + half_sphere1 = Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2) + shell1 = Shell(half_sphere1.faces()) + + half_sphere2 = Pos(2, 0, 0) * (Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2)) + shell2 = Shell(half_sphere2.faces()) + + result = shell1.touch(shell2) + # Should find tangent vertex at (1, 0, 0) + assert len(result) >= 1 + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + def test_interior_tangent_contact_shell_face(self): + """Shell.touch(Face) with interior tangent contact (not on any edges). + + Covers lines 467-491: the full interior tangent detection code path + including Shell face lookup and normal direction validation. + + Contact point must be: + - NOT on any edge of the shell (self) + - NOT on any edge of the face (other) + """ + import math + + # Create a sphere shell + sphere = Sphere(2) + shell = Shell(sphere.faces()) + + # Contact at (1, 1, sqrt(2)) - away from the y=0 seam plane of the sphere + # This point is in the interior of the spherical surface + x, y, z = 1.0, 1.0, math.sqrt(2) + + # Normal direction at this point on the sphere + normal = Vector(x, y, z).normalized() + + # Create a small face tangent to sphere at this point + # The face must be small enough that its edges don't reach the contact point + tangent_plane = Plane(origin=(x, y, z), z_dir=(normal.X, normal.Y, normal.Z)) + small_face = tangent_plane * Rectangle(0.1, 0.1).face() + + result = shell.touch(small_face) + # Should find interior tangent vertex near (1, 1, sqrt(2)) + assert len(result) >= 1 + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + def test_interior_tangent_contact_face_shell(self): + """Face.touch(Shell) with interior tangent contact. + + Same as above but with arguments swapped to test the 'other is Shell' path. + Covers lines 480-484: finding face in other Shell. + """ + import math + + # Create a sphere shell + sphere = Sphere(2) + shell = Shell(sphere.faces()) + + # Contact at (1, 1, sqrt(2)) + x, y, z = 1.0, 1.0, math.sqrt(2) + normal = Vector(x, y, z).normalized() + + # Create a small face tangent to sphere + tangent_plane = Plane(origin=(x, y, z), z_dir=(normal.X, normal.Y, normal.Z)) + small_face = tangent_plane * Rectangle(0.1, 0.1).face() + + # Call face.touch(shell) - 'other' is the Shell + result = small_face.touch(shell) + assert len(result) >= 1 + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + +class TestSolidEdgeTangentTouch: + """Tests for Solid.touch(Edge) tangent cases to cover three_d.py lines 891-906. + + These tests cover the BRepExtrema tangent detection for edges tangent to + solid surfaces (not penetrating). + """ + + def test_edge_tangent_to_cylinder(self): + """Edge tangent to cylinder surface returns touch vertex. + + Covers lines 902, 906: tangent contact detection via BRepExtrema. + """ + # Create a cylinder along Z axis + cylinder = Cylinder(1, 2) + + # Create an edge that is tangent to the cylinder at x=1 + # Edge runs along Y at x=1, z=1 (tangent to cylinder surface) + tangent_edge = Edge.make_line((1, -2, 1), (1, 2, 1)) + + result = cylinder.touch(tangent_edge) + # Should find tangent vertices where edge touches cylinder + # The edge at x=1 is tangent to the cylinder at radius=1 + vertices = [r for r in result if isinstance(r, Vertex)] + # Should have at least one tangent contact point + assert len(vertices) >= 1 + + def test_edge_tangent_to_sphere(self): + """Edge tangent to sphere surface returns touch vertex. + + Another test for lines 902, 906 with spherical geometry. + """ + # Create a sphere centered at origin + sphere = Sphere(1) + + # Create an edge that is tangent to the sphere at x=1 + # Edge runs along Z at x=1, y=0 + tangent_edge = Edge.make_line((1, 0, -2), (1, 0, 2)) + + result = sphere.touch(tangent_edge) + # Should find tangent vertex at (1, 0, 0) + vertices = [r for r in result if isinstance(r, Vertex)] + assert len(vertices) >= 1 + + +class TestConvertToShapes: + """Tests for helpers.convert_to_shapes() to cover helpers.py.""" + + def test_vector_intersection(self): + """Shape.intersect(Vector) converts Vector to Vertex.""" + box = Box(2, 2, 2) + # Vector inside the box + result = box.intersect(Vector(0, 0, 0)) + assert result is not None + assert len(result) == 1 + assert isinstance(result[0], Vertex) + + def test_location_intersection(self): + """Shape.intersect(Location) converts Location to Vertex.""" + box = Box(2, 2, 2) + # Location inside the box + result = box.intersect(Location((0, 0, 0))) + assert result is not None + assert len(result) == 1 + assert isinstance(result[0], Vertex) + + def test_location_intersection_with_rotation(self): + """Shape.intersect(Location with rotation) still uses position only.""" + box = Box(2, 2, 2) + # Location with rotation - position is still at origin + loc = Location((0, 0, 0), (45, 45, 45)) + result = box.intersect(loc) + assert result is not None + assert len(result) == 1 + assert isinstance(result[0], Vertex) + + +class TestEmptyCompoundIntersect: + """Tests for Compound._intersect() edge cases to cover composite.py line 741.""" + + def test_empty_compound_intersect(self): + """Empty Compound.intersect() returns None. + + Covers line 741: early return when compound has no elements. + """ + from OCP.TopoDS import TopoDS_Compound + from OCP.BRep import BRep_Builder + + # Create an actual empty OCCT compound (has wrapped but no children) + builder = BRep_Builder() + empty_occt = TopoDS_Compound() + builder.MakeCompound(empty_occt) + empty = Compound(empty_occt) + + box = Box(2, 2, 2) + result = empty.intersect(box) + assert result is None + + def test_empty_compound_intersect_with_face(self): + """Empty Compound.intersect(Face) returns None.""" + from OCP.TopoDS import TopoDS_Compound + from OCP.BRep import BRep_Builder + + # Create an actual empty OCCT compound + builder = BRep_Builder() + empty_occt = TopoDS_Compound() + builder.MakeCompound(empty_occt) + empty = Compound(empty_occt) + + face = Rectangle(2, 2).face() + result = empty.intersect(face) + assert result is None From 1a6929bc96e81554a549e8a4054107e0b59955d3 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 15:35:37 +0100 Subject: [PATCH 29/42] move infinite edge check from helpers to the _intersect methods to allow passing Edge(axis) --- src/build123d/topology/helpers.py | 19 +++----------- src/build123d/topology/one_d.py | 37 ++++++++++++++++++++++++++++ src/build123d/topology/shape_core.py | 2 +- src/build123d/topology/three_d.py | 5 ++++ src/build123d/topology/two_d.py | 5 ++++ 5 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py index 3f9505f..8b07fc8 100644 --- a/src/build123d/topology/helpers.py +++ b/src/build123d/topology/helpers.py @@ -10,13 +10,11 @@ from build123d.topology.two_d import Face def convert_to_shapes( - shape: Shape, objects: tuple[Shape | Vector | Location | Axis | Plane, ...], ) -> list[Shape]: """Convert geometry objects to shapes. Args: - shape: The shape context (used for bounding box when converting Axis) objects: Tuple of geometry objects to convert Returns: @@ -25,22 +23,11 @@ def convert_to_shapes( results = [] for obj in objects: if isinstance(obj, Vector): - results.append(Vertex(obj.X, obj.Y, obj.Z)) + results.append(Vertex(obj)) elif isinstance(obj, Location): - pos = obj.position - results.append(Vertex(pos.X, pos.Y, pos.Z)) + results.append(Vertex(obj.position)) elif isinstance(obj, Axis): - # Convert to finite edge based on bounding box - bbox = shape.bounding_box(optimal=False) - dist = shape.distance_to(obj.position) - # Be sure to avoid zero length edge for vertex on axis intersection - half_length = max(bbox.diagonal, 1) * max(dist, 1) - results.append( - Edge.make_line( - obj.position - obj.direction * half_length, - obj.position + obj.direction * half_length, - ) - ) + results.append(Edge(obj)) elif isinstance(obj, Plane): results.append(Face(obj)) elif isinstance(obj, Shape): diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 4ccb50c..b9ee591 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -734,6 +734,13 @@ class Mixin1D(Shape[TOPODS]): results: ShapeList = ShapeList() + # Trim infinite edges before OCCT operations + if isinstance(other, Edge) and other.is_infinite: + bbox = self.bounding_box(optimal=False) + other = other.trim_infinite( + bbox.diagonal + (other.center() - bbox.center()).length + ) + # 1D + 1D: Common (collinear overlap) + Section (crossing vertices) if isinstance(other, (Edge, Wire)): common = self._bool_op_list( @@ -3103,6 +3110,36 @@ class Edge(Mixin1D[TopoDS_Edge]): new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge() return Edge(new_edge) + @property + def is_infinite(self) -> bool: + """Check if edge is infinite (LINE with length > 1e100).""" + return self.geom_type == GeomType.LINE and self.length > 1e100 + + def trim_infinite(self, half_length: float) -> Edge: + """Trim an infinite line edge to a finite length. + + OCCT's boolean operations struggle with very long edges (length > 1e100). + This method trims such edges to a reasonable size centered at edge.center(). + + For non-infinite edges, returns self unchanged. + + Args: + half_length: Half-length of the resulting edge + + Returns: + Trimmed edge if infinite, otherwise self + """ + if not self.is_infinite: + return self + + origin = self.center() + direction = (self.end_point() - self.start_point()).normalized() + + return Edge.make_line( + origin - direction * half_length, + origin + direction * half_length, + ) + class Wire(Mixin1D[TopoDS_Wire]): """A Wire in build123d is a topological entity representing a connected sequence diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index d2414e6..3c06000 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1372,7 +1372,7 @@ class Shape(NodeMixin, Generic[TOPODS]): # Runtime import to avoid circular imports. Allows type safe actions in helpers from build123d.topology.helpers import convert_to_shapes - shapes = convert_to_shapes(self, to_intersect) + shapes = convert_to_shapes(to_intersect) # Chained iteration for AND semantics: c.intersect(s1, s2) = c ∩ s1 ∩ s2 common_set = ShapeList([self]) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 803043a..2503e7a 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -475,6 +475,11 @@ class Mixin3D(Shape[TOPODS]): results: ShapeList = ShapeList() + # Trim infinite edges before OCCT operations + if isinstance(other, Edge) and other.is_infinite: + bbox = self.bounding_box(optimal=False) + other = other.trim_infinite(bbox.diagonal + (other.center() - bbox.center()).length) + # Solid + Solid/Face/Shell/Edge/Wire: use Common if isinstance(other, (Solid, Face, Shell, Edge, Wire)): intersection = self._bool_op_list((self,), (other,), BRepAlgoAPI_Common()) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 040b991..69896e2 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -328,6 +328,11 @@ class Mixin2D(ABC, Shape[TOPODS]): results: ShapeList = ShapeList() + # Trim infinite edges before OCCT operations + if isinstance(other, Edge) and other.is_infinite: + bbox = self.bounding_box(optimal=False) + other = other.trim_infinite(bbox.diagonal + (other.center() - bbox.center()).length) + # 2D + 2D: Common (coplanar overlap) AND Section (crossing curves) if isinstance(other, (Face, Shell)): # Common for coplanar overlap From 1d2003ef089136d635363f3e6aa7a2f4893ce191 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 16:45:07 +0100 Subject: [PATCH 30/42] add a type validation step --- src/build123d/topology/shape_core.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 3c06000..19575b9 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -1369,10 +1369,10 @@ class Shape(NodeMixin, Generic[TOPODS]): if not to_intersect: return None - # Runtime import to avoid circular imports. Allows type safe actions in helpers - from build123d.topology.helpers import convert_to_shapes - - shapes = convert_to_shapes(to_intersect) + # Validate input types + for obj in to_intersect: + if not isinstance(obj, (Shape, Vector, Location, Axis, Plane)): + raise ValueError(f"Unsupported type for intersect: {type(obj)}") # Chained iteration for AND semantics: c.intersect(s1, s2) = c ∩ s1 ∩ s2 common_set = ShapeList([self]) From 340e8a16ff0ad091950b51ce5fa073478d9538e0 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 16:45:47 +0100 Subject: [PATCH 31/42] remove helpers.py and the runtime import by duplicating some conversion code --- src/build123d/topology/composite.py | 14 +++++++++-- src/build123d/topology/helpers.py | 37 ---------------------------- src/build123d/topology/one_d.py | 19 +++++++++++--- src/build123d/topology/shape_core.py | 9 +++---- src/build123d/topology/three_d.py | 13 ++++++++-- src/build123d/topology/two_d.py | 13 ++++++++-- src/build123d/topology/zero_d.py | 19 ++++++++++++-- 7 files changed, 71 insertions(+), 53 deletions(-) delete mode 100644 src/build123d/topology/helpers.py diff --git a/src/build123d/topology/composite.py b/src/build123d/topology/composite.py index 6856d05..b41ccfd 100644 --- a/src/build123d/topology/composite.py +++ b/src/build123d/topology/composite.py @@ -715,7 +715,7 @@ class Compound(Mixin3D[TopoDS_Compound]): def _intersect( self, - other: Shape, + other: Shape | Vector | Location | Axis | Plane, tolerance: float = 1e-6, include_touched: bool = False, ) -> ShapeList | None: @@ -729,11 +729,21 @@ class Compound(Mixin3D[TopoDS_Compound]): Nested Compounds are handled by recursion. Args: - other: Shape to intersect with + other: Shape or geometry object to intersect with tolerance: tolerance for intersection detection include_touched: if True, include boundary contacts (only relevant when Solids are involved) """ + # Convert geometry objects + if isinstance(other, Vector): + other = Vertex(other) + elif isinstance(other, Location): + other = Vertex(other.position) + elif isinstance(other, Axis): + other = Edge(other) + elif isinstance(other, Plane): + other = Face(other) + # Get self elements: assembly children or OCCT direct children self_elements = self.children if self.children else list(self) diff --git a/src/build123d/topology/helpers.py b/src/build123d/topology/helpers.py deleted file mode 100644 index 8b07fc8..0000000 --- a/src/build123d/topology/helpers.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Helper functions for topology operations.""" - -from __future__ import annotations - -from build123d.geometry import Axis, Location, Plane, Vector -from build123d.topology.shape_core import Shape -from build123d.topology.zero_d import Vertex -from build123d.topology.one_d import Edge -from build123d.topology.two_d import Face - - -def convert_to_shapes( - objects: tuple[Shape | Vector | Location | Axis | Plane, ...], -) -> list[Shape]: - """Convert geometry objects to shapes. - - Args: - objects: Tuple of geometry objects to convert - - Returns: - List of Shape objects - """ - results = [] - for obj in objects: - if isinstance(obj, Vector): - results.append(Vertex(obj)) - elif isinstance(obj, Location): - results.append(Vertex(obj.position)) - elif isinstance(obj, Axis): - results.append(Edge(obj)) - elif isinstance(obj, Plane): - results.append(Face(obj)) - elif isinstance(obj, Shape): - results.append(obj) - else: - raise ValueError(f"Unsupported type for intersect: {type(obj)}") - return results diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index b9ee591..725e354 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -715,7 +715,7 @@ class Mixin1D(Shape[TOPODS]): def _intersect( self, - other: Shape, + other: Shape | Vector | Location | Axis | Plane, tolerance: float = 1e-6, include_touched: bool = False, ) -> ShapeList | None: @@ -726,11 +726,18 @@ class Mixin1D(Shape[TOPODS]): - 1D + Face/Solid/Compound → delegates to other._intersect(self) Args: - other: Shape to intersect with + other: Shape or geometry object to intersect with tolerance: tolerance for intersection detection include_touched: if True, include boundary contacts (only relevant when Solids are involved) """ + # Convert geometry objects to shapes + if isinstance(other, Vector): + other = Vertex(other) + elif isinstance(other, Location): + other = Vertex(other.position) + elif isinstance(other, Axis): + other = Edge(other) results: ShapeList = ShapeList() @@ -741,8 +748,14 @@ class Mixin1D(Shape[TOPODS]): bbox.diagonal + (other.center() - bbox.center()).length ) + # 1D + Plane: run Section directly with OCP Face + if isinstance(other, Plane): + face: Shape = Shape(BRepBuilderAPI_MakeFace(other.wrapped).Face()) + section = self._bool_op_list((self,), (face,), BRepAlgoAPI_Section()) + results.extend(section.expand()) + # 1D + 1D: Common (collinear overlap) + Section (crossing vertices) - if isinstance(other, (Edge, Wire)): + elif isinstance(other, (Edge, Wire)): common = self._bool_op_list( (self,), (other,), BRepAlgoAPI_Common() ) diff --git a/src/build123d/topology/shape_core.py b/src/build123d/topology/shape_core.py index 19575b9..d975249 100644 --- a/src/build123d/topology/shape_core.py +++ b/src/build123d/topology/shape_core.py @@ -85,7 +85,6 @@ from OCP.BRepAlgoAPI import ( from OCP.BRepBuilderAPI import ( BRepBuilderAPI_Copy, BRepBuilderAPI_GTransform, - BRepBuilderAPI_MakeEdge, BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeVertex, BRepBuilderAPI_RightCorner, @@ -103,7 +102,6 @@ from OCP.BRepMesh import BRepMesh_IncrementalMesh from OCP.BRepPrimAPI import BRepPrimAPI_MakeHalfSpace from OCP.BRepTools import BRepTools, BRepTools_WireExplorer from OCP.gce import gce_MakeLin -from OCP.Geom import Geom_Line from OCP.GeomAPI import GeomAPI_ProjectPointOnSurf from OCP.GeomLib import GeomLib_IsPlanarSurface from OCP.gp import gp_Ax1, gp_Ax2, gp_Ax3, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec, gp_XYZ @@ -1375,8 +1373,9 @@ class Shape(NodeMixin, Generic[TOPODS]): raise ValueError(f"Unsupported type for intersect: {type(obj)}") # Chained iteration for AND semantics: c.intersect(s1, s2) = c ∩ s1 ∩ s2 + # Geometry objects (Vector, Location, Axis, Plane) are converted in _intersect common_set = ShapeList([self]) - for other in shapes: + for other in to_intersect: next_set: ShapeList = ShapeList() for obj in common_set: result = obj._intersect(other, tolerance, include_touched) @@ -1389,7 +1388,7 @@ class Shape(NodeMixin, Generic[TOPODS]): def _intersect( self, - other: Shape, + other: Shape | Vector | Location | Axis | Plane, tolerance: float = 1e-6, include_touched: bool = False, ) -> ShapeList | None: @@ -1399,7 +1398,7 @@ class Shape(NodeMixin, Generic[TOPODS]): Mixin3D, Compound) override this to provide actual intersection logic. Args: - other: Shape to intersect with + other: Shape or geometry object to intersect with tolerance: tolerance for intersection detection include_touched: if True, include boundary contacts diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 2503e7a..6123d37 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -429,7 +429,7 @@ class Mixin3D(Shape[TOPODS]): def _intersect( self, - other: Shape, + other: Shape | Vector | Location | Axis | Plane, tolerance: float = 1e-6, include_touched: bool = False, ) -> ShapeList | None: @@ -441,11 +441,20 @@ class Mixin3D(Shape[TOPODS]): - Solid + Edge → Edge (portion through solid) Args: - other: Shape to intersect with + other: Shape or geometry object to intersect with tolerance: tolerance for intersection detection include_touched: if True, include boundary contacts (shapes touching the solid's surface without penetrating) """ + # Convert geometry objects to shapes + if isinstance(other, Vector): + other = Vertex(other) + elif isinstance(other, Location): + other = Vertex(other.position) + elif isinstance(other, Axis): + other = Edge(other) + elif isinstance(other, Plane): + other = Face(other) def filter_redundant_touches(items: ShapeList) -> ShapeList: """Remove vertices/edges that lie on higher-dimensional results.""" diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 69896e2..d6e1daf 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -286,7 +286,7 @@ class Mixin2D(ABC, Shape[TOPODS]): def _intersect( self, - other: Shape, + other: Shape | Vector | Location | Axis | Plane, tolerance: float = 1e-6, include_touched: bool = False, ) -> ShapeList | None: @@ -298,11 +298,20 @@ class Mixin2D(ABC, Shape[TOPODS]): - 2D + Solid/Compound → delegates to other._intersect(self) Args: - other: Shape to intersect with + other: Shape or geometry object to intersect with tolerance: tolerance for intersection detection include_touched: if True, include boundary contacts (only relevant when Solids are involved) """ + # Convert geometry objects to shapes + if isinstance(other, Vector): + other = Vertex(other) + elif isinstance(other, Location): + other = Vertex(other.position) + elif isinstance(other, Axis): + other = Edge(other) + elif isinstance(other, Plane): + other = Face(other) def filter_edges( section_edges: ShapeList[Edge], common_edges: ShapeList[Edge] diff --git a/src/build123d/topology/zero_d.py b/src/build123d/topology/zero_d.py index 5176619..6e7c204 100644 --- a/src/build123d/topology/zero_d.py +++ b/src/build123d/topology/zero_d.py @@ -170,7 +170,7 @@ class Vertex(Shape[TopoDS_Vertex]): def _intersect( self, - other: Shape, + other: Shape | Vector | Location | Axis | Plane, tolerance: float = 1e-6, include_touched: bool = False, ) -> ShapeList | None: @@ -179,11 +179,26 @@ class Vertex(Shape[TopoDS_Vertex]): For a vertex (0D), intersection means the vertex lies on/in the other shape. Args: - other: Shape to intersect with + other: Shape or geometry object to intersect with tolerance: tolerance for intersection detection include_touched: if True, include boundary contacts (only relevant when Solids are involved) """ + # Convert geometry objects to Vertex + if isinstance(other, Vector): + other = Vertex(other) + elif isinstance(other, Location): + other = Vertex(other.position) + elif isinstance(other, Axis): + # Check if vertex lies on the axis + if other.intersect(self.center()): + return ShapeList([self]) + return None + elif isinstance(other, Plane): + # Check if vertex lies on the plane + if other.contains(self.center(), tolerance): + return ShapeList([self]) + return None if isinstance(other, Vertex): # Vertex + Vertex: check distance From 63cb049f6f5695e322f8a79281cad54e89a3c40b Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 17:40:40 +0100 Subject: [PATCH 32/42] remove default branch from test coverage, since in Pyhton it can't be reached --- src/build123d/topology/one_d.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/one_d.py b/src/build123d/topology/one_d.py index 725e354..9bfc075 100644 --- a/src/build123d/topology/one_d.py +++ b/src/build123d/topology/one_d.py @@ -2756,7 +2756,8 @@ class Edge(Mixin1D[TopoDS_Edge]): basis2 = Edge(BRepBuilderAPI_MakeEdge(oc2.BasisCurve()).Edge()) return basis1.geom_equal(basis2, tol) - case _: + case _: # pragma: no cover + # I don't think, GeomAbs_OtherCurve can be created in Python # OTHER/unknown: compare sample points for i in range(1, num_interpolation_points + 1): t = i / (num_interpolation_points + 1) From 36da0d0697555d7ebe8a02b7449c813af65b9a50 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Wed, 21 Jan 2026 17:47:46 +0100 Subject: [PATCH 33/42] remove lines coverd from comment --- tests/test_direct_api/test_intersection.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 79c2d91..822a19a 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -718,7 +718,7 @@ class TestShellTangentTouchCoverage: def test_shell_self_tangent_touch_multiple_faces(self): """Shell.touch(Face) where Shell has multiple faces. - Covers lines 472-476: finding face containing contact point in self Shell. + Finding face containing contact point in self Shell. """ # Create a shell with multiple faces (half-sphere has curved + flat faces) half_sphere = Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2) @@ -736,7 +736,7 @@ class TestShellTangentTouchCoverage: def test_face_shell_other_tangent_touch_multiple_faces(self): """Face.touch(Shell) where Shell (other) has multiple faces. - Covers lines 480-484: finding face containing contact point in other Shell. + Finding face containing contact point in other Shell. """ # Create a face face = Pos(1, 0, 0) * (Rot(0, 90, 0) * Rectangle(2, 2).face()) @@ -753,8 +753,6 @@ class TestShellTangentTouchCoverage: def test_shell_shell_tangent_touch_multiple_faces(self): """Shell.touch(Shell) where both Shells have multiple faces. - - Covers both line ranges 472-476 and 480-484. """ # Create two half-spheres touching at their curved surfaces half_sphere1 = Sphere(1) & Pos(0, 0, 0.5) * Box(3, 3, 2) @@ -772,8 +770,8 @@ class TestShellTangentTouchCoverage: def test_interior_tangent_contact_shell_face(self): """Shell.touch(Face) with interior tangent contact (not on any edges). - Covers lines 467-491: the full interior tangent detection code path - including Shell face lookup and normal direction validation. + Full interior tangent detection code path including Shell face + lookup and normal direction validation. Contact point must be: - NOT on any edge of the shell (self) @@ -807,7 +805,6 @@ class TestShellTangentTouchCoverage: """Face.touch(Shell) with interior tangent contact. Same as above but with arguments swapped to test the 'other is Shell' path. - Covers lines 480-484: finding face in other Shell. """ import math @@ -840,7 +837,7 @@ class TestSolidEdgeTangentTouch: def test_edge_tangent_to_cylinder(self): """Edge tangent to cylinder surface returns touch vertex. - Covers lines 902, 906: tangent contact detection via BRepExtrema. + Tangent contact detection via BRepExtrema. """ # Create a cylinder along Z axis cylinder = Cylinder(1, 2) @@ -859,7 +856,7 @@ class TestSolidEdgeTangentTouch: def test_edge_tangent_to_sphere(self): """Edge tangent to sphere surface returns touch vertex. - Another test for lines 902, 906 with spherical geometry. + Another test with spherical geometry. """ # Create a sphere centered at origin sphere = Sphere(1) @@ -912,7 +909,7 @@ class TestEmptyCompoundIntersect: def test_empty_compound_intersect(self): """Empty Compound.intersect() returns None. - Covers line 741: early return when compound has no elements. + Early return when compound has no elements. """ from OCP.TopoDS import TopoDS_Compound from OCP.BRep import BRep_Builder From f4b18dd89b3c3420c364c7565774c64d465fd4d0 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Thu, 22 Jan 2026 15:40:35 +0100 Subject: [PATCH 34/42] remove leading _ from variable names --- src/build123d/topology/two_d.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index d6e1daf..fd08c44 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -401,8 +401,8 @@ class Mixin2D(ABC, Shape[TOPODS]): self, other: Shape, tolerance: float = 1e-6, - _found_faces: ShapeList | None = None, - _found_edges: ShapeList | None = None, + found_faces: ShapeList | None = None, + found_edges: ShapeList | None = None, ) -> ShapeList: """Find boundary contacts between this 2D shape and another shape. @@ -416,12 +416,13 @@ class Mixin2D(ABC, Shape[TOPODS]): 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 + found_faces: pre-found faces to filter against (from Mixin3D.touch) + found_edges: pre-found edges to filter against (from Mixin3D.touch) Returns: ShapeList of contact shapes (Vertex only for 2D+2D) """ + # Helper functions for common geometric checks def vertex_on_edge(v: Vertex, e: Edge) -> bool: return v.distance_to(e) <= tolerance @@ -433,21 +434,18 @@ class Mixin2D(ABC, Shape[TOPODS]): 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( + 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: + found_faces = ShapeList() + found_edges = ShapeList() + if intersect_results: + for r in intersect_results: if isinstance(r, Face): - _found_faces.append(r) + found_faces.append(r) elif isinstance(r, Edge): - _found_edges.append(r) - - found_faces = _found_faces - found_edges = _found_edges + found_edges.append(r) # Use BRepExtrema to find all contact points (vertex-vertex, vertex-edge, vertex-face) found_vertices: ShapeList = ShapeList() From 89b16ef4dc9d7f82e655a4df90124e55648f5c7d Mon Sep 17 00:00:00 2001 From: Bernhard Date: Thu, 22 Jan 2026 15:43:48 +0100 Subject: [PATCH 35/42] improve filtering against vertices on edges --- src/build123d/topology/three_d.py | 9 ++++++- src/build123d/topology/two_d.py | 41 ++++--------------------------- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 6123d37..1f87e14 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -853,7 +853,14 @@ class Solid(Mixin3D[TopoDS_Solid]): 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) + # Include face-face intersection edges for filtering crossing vertices + sf_of_intersect = sf._intersect(of, tolerance, include_touched=False) + sf_of_edges = ShapeList( + e for e in (sf_of_intersect or []) if isinstance(e, Edge) + ) + tangent_vertices = sf.touch( + of, tolerance, found_faces, found_edges + sf_of_edges + ) for v in tangent_vertices: if not is_duplicate(v, found_vertices): results.append(v) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index fd08c44..8680306 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -457,7 +457,6 @@ class Mixin2D(ABC, Shape[TOPODS]): 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 @@ -472,46 +471,16 @@ class Mixin2D(ABC, Shape[TOPODS]): 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: - # Find the specific face containing the contact point - self_face: Face | None = None - other_face: Face | None = None - - if isinstance(self, Face): - self_face = self - else: # Shell - find face containing point - for f in self.faces(): - if f.distance_to(contact_pt) <= tolerance: - self_face = f - break - - if isinstance(other, Face): - other_face = other - else: # Shell - find face containing point - for f in other.faces(): - if f.distance_to(contact_pt) <= tolerance: - other_face = f - break - - if self_face and other_face: - normal1 = self_face.normal_at(contact_pt) - normal2 = other_face.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( - vertex_on_edge(new_vertex, e) for e in found_edges - ) - if not on_face and not on_edge and not is_duplicate_vertex( - new_vertex, found_vertices + on_edge = any(vertex_on_edge(new_vertex, e) for e in found_edges) + if ( + not on_face + and not on_edge + and not is_duplicate_vertex(new_vertex, found_vertices) ): results.append(new_vertex) found_vertices.append(new_vertex) From 79935583002059827ef1bf343bfc4558a1d3ac6e Mon Sep 17 00:00:00 2001 From: Bernhard Date: Thu, 22 Jan 2026 15:44:00 +0100 Subject: [PATCH 36/42] Use higher precision for BRepExtrema_DistShapeShape to avoid duplicates --- src/build123d/topology/two_d.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 8680306..a817f1d 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -449,7 +449,13 @@ class Mixin2D(ABC, Shape[TOPODS]): # Use BRepExtrema to find all contact points (vertex-vertex, vertex-edge, vertex-face) found_vertices: ShapeList = ShapeList() - extrema = BRepExtrema_DistShapeShape(self.wrapped, other.wrapped) + extrema = BRepExtrema_DistShapeShape() + extrema.SetDeflection( + tolerance * 1e-3 + ) # Higher precision to avoid duplicate solutions + extrema.LoadS1(self.wrapped) + extrema.LoadS2(other.wrapped) + extrema.Perform() if extrema.IsDone() and extrema.Value() <= tolerance: for i in range(1, extrema.NbSolution() + 1): pnt1 = extrema.PointOnShape1(i) From 4b5d43ee9befcc1c893d40d7e3c07b4a9364ba91 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Thu, 22 Jan 2026 15:44:47 +0100 Subject: [PATCH 37/42] filter empty faces --- src/build123d/topology/three_d.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 1f87e14..7f6b507 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -817,7 +817,10 @@ class Solid(Mixin3D[TopoDS_Solid]): if not sf_bb.overlaps(of_bb, tolerance): continue common = self._bool_op_list((sf,), (of,), BRepAlgoAPI_Common()) - found_faces.extend(s for s in common if not s.is_null) + # Filter out null and degenerate (zero-area) faces + found_faces.extend( + s for s in common if not s.is_null and s.area > tolerance + ) results.extend(found_faces) # Edge-Edge contacts (skip if on any found face) From 07f6c47237a7497d5f34534c9934701acd5e46e7 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 23 Jan 2026 15:41:03 +0100 Subject: [PATCH 38/42] streamline touch logic while fixing some missing touch edge cases --- src/build123d/topology/three_d.py | 264 ++++++++++++++++-------------- src/build123d/topology/two_d.py | 58 +++---- 2 files changed, 171 insertions(+), 151 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 7f6b507..02952ea 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -96,9 +96,9 @@ from OCP.TopoDS import ( TopoDS_Shell, TopoDS_Solid, TopoDS_Wire, - TopoDS_Compound, ) -from OCP.gp import gp_Ax2, gp_Pnt +from OCP.gp import gp_Ax2, gp_Pnt, gp_Vec +from OCP.BRepGProp import BRepGProp_Face from build123d.build_enums import CenterOf, GeomType, Keep, Kind, Transition, Until from build123d.geometry import ( DEG2RAD, @@ -499,8 +499,9 @@ class Mixin3D(Shape[TOPODS]): results.append(other) # Delegate to higher-order shapes (Compound) + # Don't pass include_touched - outer caller handles touches else: - result = other._intersect(self, tolerance, include_touched) + result = other._intersect(self, tolerance, include_touched=False) if result: results.extend(result) @@ -757,13 +758,16 @@ class Solid(Mixin3D[TopoDS_Solid]): # ---- Instance Methods ---- def touch( - self, other: Shape, tolerance: float = 1e-6, check_num_points: int = 5 + self, + other: Shape, + tolerance: float = 1e-6, + found_solids: ShapeList | None = None, ) -> 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 + Face/Shell → Face + Edge + Vertex (boundary contacts) - Solid + Edge/Wire → Vertex (edge endpoints on solid boundary) - Solid + Vertex → Vertex if on boundary - Solid + Compound → distributes over compound elements @@ -771,12 +775,14 @@ class Solid(Mixin3D[TopoDS_Solid]): Args: other: Shape to check boundary contacts with tolerance: tolerance for contact detection - check_num_points: number of interpolation points for edge-on-face check + found_solids: pre-found intersection solids to filter against Returns: ShapeList of boundary contact geometry (empty if no contact) """ - # Helper functions for common geometric checks + + # Helper functions for common geometric checks (for readability) + # Single shape versions for checking against one shapes def vertex_on_edge(v: Vertex, e: Edge) -> bool: return v.distance_to(e) <= tolerance @@ -784,122 +790,140 @@ class Solid(Mixin3D[TopoDS_Solid]): return v.distance_to(f) <= tolerance def edge_on_face(e: Edge, f: Face) -> bool: - # Check start, end, and interpolated points are all on the face - for i in range(check_num_points + 2): - t = i / (check_num_points + 1) - if f.distance_to(e @ t) > tolerance: - return False + # Can't use distance_to (e.g. normal vector would match), need Common + return bool(self._bool_op_list((e,), (f,), BRepAlgoAPI_Common())) + + # Multi shape versions for checking against multiple shapes + def vertex_on_edges(v: Vertex, edges: Iterable[Edge]) -> bool: + return any(vertex_on_edge(v, e) for e in edges) + + def vertex_on_faces(v: Vertex, faces: Iterable[Face]) -> bool: + return any(vertex_on_face(v, f) for f in faces) + + def edge_on_faces(e: Edge, faces: Iterable[Face]) -> bool: + return any(edge_on_face(e, f) for f in faces) + + def face_point_normal(face: Face, u: float, v: float) -> tuple[Vector, Vector]: + """Get both position and normal at UV coordinates. + Args + u (float): the horizontal coordinate in the parameter space of the Face, + between 0.0 and 1.0 + v (float): the vertical coordinate in the parameter space of the Face, + between 0.0 and 1.0 + Returns: + tuple[Vector, Vector]: [point on Face, normal at point] + """ + u0, u1, v0, v1 = face._uv_bounds() + u_val = u0 + u * (u1 - u0) + v_val = v0 + v * (v1 - v0) + gp_pnt = gp_Pnt() + gp_norm = gp_Vec() + BRepGProp_Face(face.wrapped).Normal(u_val, v_val, gp_pnt, gp_norm) + return Vector(gp_pnt), Vector(gp_norm) + + def faces_equal(f1: Face, f2: Face, grid_size: int = 4) -> bool: + """Check if two faces are geometrically equal. + + Face == uses topological equality (same OCC object), but we need + geometric equality. For performance reasons apply a heuristic + approach: Compare a grid of UV sample points, checking both position and + normal direction match within tolerance. + """ + # Early reject: bounding box check + bb1 = f1.bounding_box(optimal=False) + bb2 = f2.bounding_box(optimal=False) + if not bb1.overlaps(bb2, tolerance): + return False + + # Compare grid_size x grid_size grid of points in UV space + for i in range(grid_size): + u = i / (grid_size - 1) + for j in range(grid_size): + v = j / (grid_size - 1) + pos1, norm1 = face_point_normal(f1, u, v) + pos2, norm2 = face_point_normal(f2, u, v) + if (pos1 - pos2).length > tolerance: + return False + if abs(norm1.dot(norm2)) < 0.99: + return False return True - def is_duplicate(shape: Vertex | Edge, existing: Iterable[Shape]) -> bool: + def is_duplicate(shape: Shape, existing: Iterable[Shape]) -> bool: if isinstance(shape, Vertex): - return any(shape.distance_to(s) <= tolerance for s in existing) - # Edge: use geom_equal for full geometric comparison - return any( - isinstance(e, Edge) and shape.geom_equal(e, tolerance) - for e in existing - ) + return any( + isinstance(v, Vertex) and Vector(shape) == Vector(v) + for v in existing + ) + if isinstance(shape, Edge): + return any( + isinstance(e, Edge) and shape.geom_equal(e, tolerance) + for e in existing + ) + if isinstance(shape, Face): + # Heuristic approach + return any( + isinstance(f, Face) and faces_equal(shape, f) for f in existing + ) + return False 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) + if isinstance(other, (Solid, Face, Shell)): + # Unified handling: iterate over face pairs + # For Solid+Solid: get intersection solids to filter results that bound them + intersect_faces = [] + if isinstance(other, Solid): + if found_solids is None: + found_solids = ShapeList( + self._intersect(other, tolerance, include_touched=False) or [] + ) + intersect_faces = [f for s in found_solids for f in s.faces()] + + # Pre-calculate bounding boxes for early rejection 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() + # First pass: collect touch/intersect results from face pairs, + # filtering against intersection solid faces + raw_results: 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_list((sf,), (of,), BRepAlgoAPI_Common()) - # Filter out null and degenerate (zero-area) faces - found_faces.extend( - s for s in common if not s.is_null and s.area > tolerance + + # Process touch first (cheap), then intersect (expensive) + # Face touch gives tangent vertices + for r in sf.touch(of, tolerance=tolerance): + if not is_duplicate(r, raw_results) and not vertex_on_faces( + r, intersect_faces + ): + raw_results.append(r) + + # Face intersect gives shared faces/edges (touch handled above) + for r in sf.intersect(of, tolerance=tolerance) or []: + if not is_duplicate(r, raw_results) and not edge_on_faces( + r, intersect_faces + ): + raw_results.append(r) + + # Second pass: filter lower-dimensional results against higher-dimensional + all_faces = [f for f in raw_results if isinstance(f, Face)] + all_edges = [e for e in raw_results if isinstance(e, Edge)] + for r in raw_results: + if ( + isinstance(r, Face) + or (isinstance(r, Edge) and not edge_on_faces(r, all_faces)) + or ( + isinstance(r, Vertex) + and not vertex_on_faces(r, all_faces) + and not vertex_on_edges(r, all_edges) ) - 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_list((se,), (oe,), BRepAlgoAPI_Common()) - for s in common: - if s.is_null: - continue - # Skip if edge is on any found face - if not any(edge_on_face(s, f) for f in found_faces): - 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(vertex_on_face(sv, f) for f in found_faces) - on_edge = any(vertex_on_edge(sv, e) for e in found_edges) - if not on_face and not on_edge and not is_duplicate(sv, found_vertices): - 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 - # Include face-face intersection edges for filtering crossing vertices - sf_of_intersect = sf._intersect(of, tolerance, include_touched=False) - sf_of_edges = ShapeList( - e for e in (sf_of_intersect or []) if isinstance(e, Edge) - ) - tangent_vertices = sf.touch( - of, tolerance, found_faces, found_edges + sf_of_edges - ) - for v in tangent_vertices: - if not is_duplicate(v, found_vertices): - 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 - # Track found edges to avoid duplicates (edge may touch multiple adjacent faces) - touching_edges: list[Edge] = [] - 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_list((oe,), (sf,), BRepAlgoAPI_Common()) - for s in common: - if s.is_null or not isinstance(s, Edge): - continue - # Check if geometrically same edge already found - if not is_duplicate(s, touching_edges): - results.append(s) - touching_edges.append(s) - # Check face's vertices touching solid's edges (corner coincident) - for ov in other.vertices(): - for se in self.edges(): - if vertex_on_edge(ov, se): - results.append(ov) - break + ): + results.append(r) 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) + # Pre-calculate bounding boxes (optimal=False for speed, used for filtering) self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()] other_bb = other.bounding_box(optimal=False) @@ -908,24 +932,18 @@ class Solid(Mixin3D[TopoDS_Solid]): if vertex_on_face(ov, sf): 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_list((self,), (other,), BRepAlgoAPI_Common()) - 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 + + # Use BRepExtrema to find all tangent contacts (edge tangent to surface) + 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) + if pnt1.Distance(pnt2) <= tolerance: new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z()) - # Only add if not already covered by existing results if not is_duplicate(new_vertex, results): results.append(new_vertex) diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index a817f1d..3ace753 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -424,30 +424,37 @@ class Mixin2D(ABC, Shape[TOPODS]): """ # Helper functions for common geometric checks - def vertex_on_edge(v: Vertex, e: Edge) -> bool: - return v.distance_to(e) <= tolerance + def vertex_on_edges(v: Vertex, edges: Iterable[Edge]) -> bool: + return any(v.distance_to(e) <= tolerance for e in edges) - def is_duplicate_vertex(v: Vertex, existing: ShapeList) -> bool: - return any(v.distance_to(ev) <= tolerance for ev in existing) + def vertex_on_faces(v: Vertex, faces: Iterable[Face]) -> bool: + return any(v.distance_to(f) <= tolerance for f in faces) + + def is_duplicate(v: Vertex, vertices: Iterable[Vertex]) -> bool: + vec = Vector(v) + return any(vec == Vector(ov) for ov in vertices) 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: + # Get intersect results to filter against if not provided (direct call) + if found_faces is None: + found_faces = ShapeList() + found_edges = ShapeList() 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) + elif found_edges is None: # for mypy + found_edges = ShapeList() - # Use BRepExtrema to find all contact points (vertex-vertex, vertex-edge, vertex-face) + # Use BRepExtrema to find all contact points + # (vertex-vertex, vertex-edge, vertex-face) found_vertices: ShapeList = ShapeList() extrema = BRepExtrema_DistShapeShape() extrema.SetDeflection( @@ -465,29 +472,24 @@ class Mixin2D(ABC, Shape[TOPODS]): new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z()) - # Check if point is on edge boundary of either face - on_self_edge = any( - vertex_on_edge(new_vertex, e) for e in self.edges() - ) - on_other_edge = any( - vertex_on_edge(new_vertex, e) for e in other.edges() - ) + # Skip duplicates early (cheap check) + if is_duplicate(new_vertex, found_vertices): + continue - # Skip if point is on edges of both faces (edge-edge intersection) - if on_self_edge and on_other_edge: + # Skip edge-edge intersections, but allow corner touches + if ( + vertex_on_edges(new_vertex, self.edges()) + and vertex_on_edges(new_vertex, other.edges()) + and not is_duplicate(new_vertex, self.vertices()) + and not is_duplicate(new_vertex, other.vertices()) + ): 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(vertex_on_edge(new_vertex, e) for e in found_edges) - if ( - not on_face - and not on_edge - and not is_duplicate_vertex(new_vertex, found_vertices) - ): + # higher-dimensional contacts (faces or edges) + if not vertex_on_faces( + new_vertex, found_faces + ) and not vertex_on_edges(new_vertex, found_edges): results.append(new_vertex) found_vertices.append(new_vertex) From f8953737cc58bcdb05db9c2fb8226c04252cd1d4 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 23 Jan 2026 15:41:59 +0100 Subject: [PATCH 39/42] code foramtting --- src/build123d/topology/three_d.py | 4 +++- src/build123d/topology/two_d.py | 16 ++++++---------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index 02952ea..a6e5a33 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -487,7 +487,9 @@ class Mixin3D(Shape[TOPODS]): # Trim infinite edges before OCCT operations if isinstance(other, Edge) and other.is_infinite: bbox = self.bounding_box(optimal=False) - other = other.trim_infinite(bbox.diagonal + (other.center() - bbox.center()).length) + other = other.trim_infinite( + bbox.diagonal + (other.center() - bbox.center()).length + ) # Solid + Solid/Face/Shell/Edge/Wire: use Common if isinstance(other, (Solid, Face, Shell, Edge, Wire)): diff --git a/src/build123d/topology/two_d.py b/src/build123d/topology/two_d.py index 3ace753..0559486 100644 --- a/src/build123d/topology/two_d.py +++ b/src/build123d/topology/two_d.py @@ -340,22 +340,20 @@ class Mixin2D(ABC, Shape[TOPODS]): # Trim infinite edges before OCCT operations if isinstance(other, Edge) and other.is_infinite: bbox = self.bounding_box(optimal=False) - other = other.trim_infinite(bbox.diagonal + (other.center() - bbox.center()).length) + other = other.trim_infinite( + bbox.diagonal + (other.center() - bbox.center()).length + ) # 2D + 2D: Common (coplanar overlap) AND Section (crossing curves) if isinstance(other, (Face, Shell)): # Common for coplanar overlap - common = self._bool_op_list( - (self,), (other,), BRepAlgoAPI_Common() - ) + common = self._bool_op_list((self,), (other,), BRepAlgoAPI_Common()) common_faces = common.expand() results.extend(common_faces) # Section for crossing curves (only edges, not vertices) # Vertices from Section are boundary contacts (touch), not intersections - section = self._bool_op_list( - (self,), (other,), BRepAlgoAPI_Section() - ) + section = self._bool_op_list((self,), (other,), BRepAlgoAPI_Section()) section_edges = ShapeList( [s for s in section if isinstance(s, Edge)] ).expand() @@ -373,9 +371,7 @@ class Mixin2D(ABC, Shape[TOPODS]): # 2D + Edge: Section for intersection elif isinstance(other, (Edge, Wire)): - section = self._bool_op_list( - (self,), (other,), BRepAlgoAPI_Section() - ) + section = self._bool_op_list((self,), (other,), BRepAlgoAPI_Section()) results.extend(section) # 2D + Vertex: point containment on surface From 410bbd11d4682f4ad1766f97f744463a29fe297c Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 23 Jan 2026 15:42:43 +0100 Subject: [PATCH 40/42] add new test cases for the missing touch results --- tests/test_direct_api/test_intersection.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 822a19a..93d8b23 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -333,6 +333,14 @@ sl1 = Box(2, 2, 2).solid() sl2 = Pos(Z=5) * Box(2, 2, 2).solid() sl3 = Cylinder(2, 1).solid() - Cylinder(1.5, 1).solid() sl4 = Box(3, 1, 1) +# T-shaped solid (box + thin plate) for testing coplanar face touches +sl5 = Pos(0.5, 0, 1) * Box(1, 1, 1) + Pos(0.5, 0, 1) * Box(2, 0.1, 1) +sl6 = Pos(2, 0, 1.5) * Box(2, 2, 1) +# Overlapping boxes where coplanar face is part of intersection (not touch) +sl7 = Pos(0, 0.1, 0) * Box(2, 2, 2) +sl8 = Pos(1, 0, -1) * Box(4, 2, 1) +# Extended T-shaped solid for testing coplanar edge touches +sl9 = Box(2, 2, 2) + sl5 wi7 = Wire([l1 := sl3.faces().sort_by(Axis.Z)[-1].edges()[0].trim(.3, .4), l2 := l1.trim(2, 3), @@ -416,6 +424,18 @@ shape_3d_matrix = [ Case(Pos(0.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Face, Face, Solid, Solid], "multi to_intersect, intersecting", None, True), Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(Z=.5) * fc1], [Face], "multi to_intersect, intersecting", None), + + # T-shaped solid with coplanar face touches (edges should be filtered) + Case(sl5, sl6, [Solid], "coplanar face touch", None), + Case(sl5, sl6, [Solid, Face, Face], "coplanar face touch", None, True), + + # Overlapping boxes: coplanar face is part of intersection, not touch + Case(sl7, sl8, [Solid], "coplanar face filtered", None), + Case(sl7, sl8, [Solid], "coplanar face filtered", None, True), + + # Extended T-shaped solid with coplanar edge touches + Case(sl9, sl6, [Solid], "coplanar edge touch", None), + Case(sl9, sl6, [Solid, Face, Face, Edge, Edge], "coplanar edge touch", None, True), ] @pytest.mark.parametrize("obj, target, expected, include_touched", make_params(shape_3d_matrix)) From 8b5afa9a8cb643bf7ded2d52342a764866201b3e Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 23 Jan 2026 15:43:06 +0100 Subject: [PATCH 41/42] fix wrong test expectation --- tests/test_direct_api/test_intersection.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index 93d8b23..e129492 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -421,7 +421,7 @@ shape_3d_matrix = [ Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid], "multi to_intersect, intersecting", None), Case(Pos(0, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid, Solid], "multi to_intersect, intersecting", None), Case(Pos(0.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid, Solid], "multi to_intersect, intersecting", None), - Case(Pos(0.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Face, Face, Solid, Solid], "multi to_intersect, intersecting", None, True), + Case(Pos(0.5, 1.5) * sl1, [sl3, Pos(.5, .5) * sl1], [Solid, Solid], "multi to_intersect, intersecting", None, True), Case(Pos(1.5, 1.5) * sl1, [sl3, Pos(Z=.5) * fc1], [Face], "multi to_intersect, intersecting", None), From 0bb7bca91386f7f7c94f3ebcd68794ed52435fa7 Mon Sep 17 00:00:00 2001 From: Bernhard Date: Fri, 23 Jan 2026 16:47:03 +0100 Subject: [PATCH 42/42] Ensure that faces_equal gets called with a new test --- src/build123d/topology/three_d.py | 4 +--- tests/test_direct_api/test_intersection.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/build123d/topology/three_d.py b/src/build123d/topology/three_d.py index a6e5a33..3cf1d84 100644 --- a/src/build123d/topology/three_d.py +++ b/src/build123d/topology/three_d.py @@ -844,9 +844,7 @@ class Solid(Mixin3D[TopoDS_Solid]): v = j / (grid_size - 1) pos1, norm1 = face_point_normal(f1, u, v) pos2, norm2 = face_point_normal(f2, u, v) - if (pos1 - pos2).length > tolerance: - return False - if abs(norm1.dot(norm2)) < 0.99: + if (pos1 - pos2).length > tolerance or abs(norm1.dot(norm2)) < 0.99: return False return True diff --git a/tests/test_direct_api/test_intersection.py b/tests/test_direct_api/test_intersection.py index e129492..a9697a0 100644 --- a/tests/test_direct_api/test_intersection.py +++ b/tests/test_direct_api/test_intersection.py @@ -657,6 +657,21 @@ class TestTouchMethod: vertices = [r for r in result if isinstance(r, Vertex)] assert len(vertices) >= 1 + def test_solid_solid_touch_faces_equal(self): + """Solid.touch(Solid) exercises faces_equal for duplicate face detection.""" + b1 = Box(1, 1, 1, align=Align.MIN) + b2 = ( + Box(2, 2, 0.5, align=Align.MIN) + - Box(1, 1.2, 1, align=Align.MIN) + + Pos(1, 0, 0) * Box(1, 1, 1, align=Align.MIN) + + Box(1, 2, 0.5, align=Align.MIN) + ) + result = b1.touch(b2) + # Should find face contact + assert len(result) >= 1 + faces = [r for r in result if isinstance(r, Face)] + assert len(faces) >= 1 + # ShapeList.expand() tests class TestShapeListExpand: