Merge pull request #1209 from bernhard-42/intersect-optimized
Some checks are pending
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-14, 3.10) (push) Waiting to run
tests / tests (macos-14, 3.14) (push) Waiting to run
tests / tests (macos-15-intel, 3.10) (push) Waiting to run
tests / tests (macos-15-intel, 3.14) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.14) (push) Waiting to run
tests / tests (windows-latest, 3.10) (push) Waiting to run
tests / tests (windows-latest, 3.14) (push) Waiting to run
Run type checking / typecheck (3.10) (push) Waiting to run
Run type checking / typecheck (3.14) (push) Waiting to run

Intersect optimized
This commit is contained in:
Roger Maitland 2026-02-23 10:45:44 -05:00 committed by GitHub
commit 2d8775e414
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2500 additions and 624 deletions

View file

@ -1182,6 +1182,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)

View file

@ -230,16 +230,14 @@ def extrude(
)
)
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:
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())

View file

@ -713,149 +713,88 @@ 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 | Vector | Location | Axis | Plane,
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 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)
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

@ -713,126 +713,73 @@ 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 | Vector | Location | Axis | Plane,
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 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)
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])
# 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
)
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 + 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())
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)])
)
# 1D + 1D: Common (collinear overlap) + Section (crossing vertices)
elif isinstance(other, (Edge, Wire)):
common = self._bool_op_list(
(self,), (other,), BRepAlgoAPI_Common()
)
results.extend(common.expand())
section = self._bool_op_list(
(self,), (other,), BRepAlgoAPI_Section()
)
# 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)
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)
)
)
# 1D + Vertex: point containment on edge
elif isinstance(other, Vertex):
if other.distance_to(self) <= tolerance:
results.append(other)
return filtered_shapes
# Delegate to higher-order shapes (Face, Solid, etc.)
else:
result = other._intersect(self, tolerance, include_touched)
if result:
results.extend(result)
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,
@ -2692,6 +2639,132 @@ 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 _: # 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)
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]:
@ -3051,6 +3124,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
@ -3733,6 +3836,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.

View file

@ -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
@ -1341,67 +1339,89 @@ 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()
)
def _to_face(plane: Plane) -> Face:
"""Helper method to convert plane to shape"""
return self.__class__.cast(BRepBuilderAPI_MakeFace(plane.wrapped).Face())
# Convert any geometry objects into their respective topology objects
objs = []
# Validate input types
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)
if not isinstance(obj, (Shape, Vector, Location, Axis, Plane)):
raise ValueError(f"Unsupported type for intersect: {type(obj)}")
# 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])
# 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 to_intersect:
next_set: ShapeList = 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
def _intersect(
self,
other: Shape | Vector | Location | Axis | Plane,
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 or geometry object 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.
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
TShape with the same Locations and Orientations. Also see
@ -2269,7 +2289,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
args: Iterable[Shape],
tools: Iterable[Shape],
operation: BRepAlgoAPI_BooleanOperation | BRepAlgoAPI_Splitter,
) -> Self | ShapeList[Self]:
) -> Self | ShapeList:
"""Generic boolean operation
Args:
@ -2279,6 +2299,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
BRepAlgoAPI_Splitter]:
Returns:
Shape or ShapeList depending on result
"""
args = list(args)
@ -2340,6 +2361,33 @@ class Shape(NodeMixin, Generic[TOPODS]):
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]]:
@ -2607,6 +2655,29 @@ 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 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:
"""The average of the center of objects within the ShapeList"""
if not self:

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
@ -96,7 +97,8 @@ from OCP.TopoDS import (
TopoDS_Solid,
TopoDS_Wire,
)
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,
@ -425,131 +427,92 @@ 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 | Vector | Location | Axis | Plane,
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 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 to_vector(objs: Iterable) -> ShapeList:
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
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_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()
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[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()
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()
)
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
and not (
isinstance(r, Edge)
and any(f.distance_to(r.center()) <= tolerance for f in faces)
)
)
return ShapeList(common_set)
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())
results.extend(intersection.expand())
# Solid + Vertex: point containment check
elif isinstance(other, Vertex):
if self.is_inside(Vector(other), tolerance):
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=False)
if result:
results.extend(result)
# Add boundary contacts if requested (only Solid has touch method)
if include_touched and isinstance(self, Solid):
results.extend(self.touch(other, tolerance))
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:
"""Returns whether or not the point is inside a solid or compound
@ -794,6 +757,210 @@ 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,
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 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
Args:
other: Shape to check boundary contacts with
tolerance: tolerance for contact detection
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 (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
def vertex_on_face(v: Vertex, f: Face) -> bool:
return v.distance_to(f) <= tolerance
def edge_on_face(e: Edge, f: Face) -> bool:
# 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 or abs(norm1.dot(norm2)) < 0.99:
return False
return True
def is_duplicate(shape: Shape, existing: Iterable[Shape]) -> bool:
if isinstance(shape, Vertex):
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, 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()]
# 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
# 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.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 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 vertex_on_face(ov, sf):
results.append(ov)
break
# 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())
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 vertex_on_face(other, sf):
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,
@ -118,7 +119,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
@ -277,127 +284,217 @@ 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 | Vector | Location | Axis | Plane,
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
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]
) -> 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
]
# 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
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
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_edges = ShapeList(
[s for s in section if isinstance(s, Edge)]
).expand()
if not common_faces:
# No coplanar overlap - all section edges are valid crossings
results.extend(section_edges)
else:
# 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))
# 2D + Edge: Section for intersection
elif isinstance(other, (Edge, Wire)):
section = self._bool_op_list((self,), (other,), BRepAlgoAPI_Section())
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: pre-found faces to filter against (from Mixin3D.touch)
found_edges: pre-found edges to filter against (from Mixin3D.touch)
Returns:
ShapeList[Vertex | Edge | Face] | None: ShapeList of vertices, edges, and/or
faces.
ShapeList of contact shapes (Vertex only for 2D+2D)
"""
def to_vector(objs: Iterable) -> ShapeList:
return ShapeList([Vector(v) if isinstance(v, Vertex) else v for v in objs])
# Helper functions for common geometric checks
def vertex_on_edges(v: Vertex, edges: Iterable[Edge]) -> bool:
return any(v.distance_to(e) <= tolerance for e in edges)
def to_vertex(objs: Iterable) -> ShapeList:
return ShapeList([Vertex(v) if isinstance(v, Vector) else v for v in objs])
def vertex_on_faces(v: Vertex, faces: Iterable[Face]) -> bool:
return any(v.distance_to(f) <= tolerance for f in faces)
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 is_duplicate(v: Vertex, vertices: Iterable[Vertex]) -> bool:
vec = Vector(v)
return any(vec == Vector(ov) for ov in vertices)
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)])
results: ShapeList = ShapeList()
if isinstance(other, (Face, Shell)):
# 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
)
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()
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)
)
)
# Use BRepExtrema to find all contact points
# (vertex-vertex, vertex-edge, vertex-face)
found_vertices: ShapeList = ShapeList()
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)
pnt2 = extrema.PointOnShape2(i)
if pnt1.Distance(pnt2) > tolerance:
continue
return filtered_shapes
new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z())
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)}")
# Skip duplicates early (cheap check)
if is_duplicate(new_vertex, found_vertices):
continue
# 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))
# 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
case _ if issubclass(type(target), Shape):
result = target.intersect(obj)
# Filter: only keep vertices that are not boundaries of
# 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)
if result:
common.extend(result)
# 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))
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])
else:
return None
return ShapeList(common_set)
return results
@abstractmethod
def location_at(self, *args: Any, **kwargs: Any) -> Location:

View file

@ -168,44 +168,46 @@ 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 | Vector | Location | Axis | Plane,
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 or geometry object 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)}")
# 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(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 ----

View file

@ -0,0 +1,968 @@
"""Tests for Edge.geom_equal and Wire.geom_equal methods."""
import pytest
from build123d import (
Vertex,
Edge,
Wire,
Spline,
Rectangle,
Circle,
Ellipse,
Bezier,
GeomType,
Location,
Plane,
)
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 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 e1.geom_equal(e2)
class TestEdgeGeomEqualCircle:
"""Tests for Edge.geom_equal with CIRCLE type."""
def test_same_circle(self):
c1 = Circle(10)
c2 = Circle(10)
e1 = c1.edge()
e2 = c2.edge()
assert e1.geom_type == GeomType.CIRCLE
assert e1.geom_equal(e2)
def test_different_radius(self):
c1 = Circle(10)
c2 = Circle(11)
e1 = c1.edge()
e2 = c2.edge()
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 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 e1.geom_equal(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 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 TestEdgeGeomEqualEllipse:
"""Tests for Edge.geom_equal with ELLIPSE type."""
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 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 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 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 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)]
b1 = Bezier(*pts)
b2 = Bezier(*pts)
e1 = b1.edge()
e2 = b2.edge()
assert e1.geom_type == GeomType.BEZIER
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 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."""
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 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))]
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 e1.geom_equal(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 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."""
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 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))]
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 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."""
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 r1.wire().geom_equal(r2.wire())
def test_different_rectangle_wire(self):
r1 = Rectangle(10, 5)
r2 = Rectangle(10, 6)
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))]
s1 = Spline(*v)
s2 = Spline(*v)
w1 = Wire([s1.edge()])
w2 = Wire([s2.edge()])
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 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_non_edge(self):
e = Edge.make_line((0, 0), (1, 1))
w = Wire([e])
# 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 line.geom_equal(circle)

View file

@ -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
@ -229,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)))
@ -237,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),
@ -272,7 +298,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),
@ -281,20 +309,38 @@ 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),
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()
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),
@ -316,6 +362,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),
@ -323,42 +370,85 @@ 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.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(3.5, 0, 1) * sl1, fc1, [Edge], "edge collinear", None),
Case(Pos(3.5, 3.5) * sl1, fc1, [Vertex], "corner coincident", 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),
# 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(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),
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 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),
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(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], [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", 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())
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]
@ -367,48 +457,61 @@ 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], "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),
Case(pt1, cp6, [Face, Face], "intersecting", None),
Case(pt1, cp6, [Face, Face, Edge, Edge], "intersecting", None, True),
]
@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 +540,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 +563,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 +592,384 @@ 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)
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
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:
"""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.
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.
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.
"""
# 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).
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.
"""
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.
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 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.
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

View file

@ -405,14 +405,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))