reimplement intersect as intersect+touch top-down to avoid expensive filtering

This commit is contained in:
Bernhard 2026-01-16 12:48:31 +01:00
parent 2f92b2e03e
commit d3ab5b24fa
6 changed files with 600 additions and 556 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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