mirror of
https://github.com/gumyr/build123d.git
synced 2026-03-10 08:42:06 -07:00
streamline touch logic while fixing some missing touch edge cases
This commit is contained in:
parent
4b5d43ee9b
commit
07f6c47237
2 changed files with 171 additions and 151 deletions
|
|
@ -96,9 +96,9 @@ from OCP.TopoDS import (
|
|||
TopoDS_Shell,
|
||||
TopoDS_Solid,
|
||||
TopoDS_Wire,
|
||||
TopoDS_Compound,
|
||||
)
|
||||
from OCP.gp import gp_Ax2, gp_Pnt
|
||||
from OCP.gp import gp_Ax2, gp_Pnt, gp_Vec
|
||||
from OCP.BRepGProp import BRepGProp_Face
|
||||
from build123d.build_enums import CenterOf, GeomType, Keep, Kind, Transition, Until
|
||||
from build123d.geometry import (
|
||||
DEG2RAD,
|
||||
|
|
@ -499,8 +499,9 @@ class Mixin3D(Shape[TOPODS]):
|
|||
results.append(other)
|
||||
|
||||
# Delegate to higher-order shapes (Compound)
|
||||
# Don't pass include_touched - outer caller handles touches
|
||||
else:
|
||||
result = other._intersect(self, tolerance, include_touched)
|
||||
result = other._intersect(self, tolerance, include_touched=False)
|
||||
if result:
|
||||
results.extend(result)
|
||||
|
||||
|
|
@ -757,13 +758,16 @@ class Solid(Mixin3D[TopoDS_Solid]):
|
|||
# ---- Instance Methods ----
|
||||
|
||||
def touch(
|
||||
self, other: Shape, tolerance: float = 1e-6, check_num_points: int = 5
|
||||
self,
|
||||
other: Shape,
|
||||
tolerance: float = 1e-6,
|
||||
found_solids: ShapeList | None = None,
|
||||
) -> ShapeList[Vertex | Edge | Face]:
|
||||
"""Find where this Solid's boundary contacts another shape.
|
||||
|
||||
Returns geometry where boundaries contact without interior overlap:
|
||||
- Solid + Solid → Face + Edge + Vertex (all boundary contacts)
|
||||
- Solid + Face/Shell → Edge + Vertex (face boundary on solid boundary)
|
||||
- Solid + Face/Shell → Face + Edge + Vertex (boundary contacts)
|
||||
- Solid + Edge/Wire → Vertex (edge endpoints on solid boundary)
|
||||
- Solid + Vertex → Vertex if on boundary
|
||||
- Solid + Compound → distributes over compound elements
|
||||
|
|
@ -771,12 +775,14 @@ class Solid(Mixin3D[TopoDS_Solid]):
|
|||
Args:
|
||||
other: Shape to check boundary contacts with
|
||||
tolerance: tolerance for contact detection
|
||||
check_num_points: number of interpolation points for edge-on-face check
|
||||
found_solids: pre-found intersection solids to filter against
|
||||
|
||||
Returns:
|
||||
ShapeList of boundary contact geometry (empty if no contact)
|
||||
"""
|
||||
# Helper functions for common geometric checks
|
||||
|
||||
# Helper functions for common geometric checks (for readability)
|
||||
# Single shape versions for checking against one shapes
|
||||
def vertex_on_edge(v: Vertex, e: Edge) -> bool:
|
||||
return v.distance_to(e) <= tolerance
|
||||
|
||||
|
|
@ -784,122 +790,140 @@ class Solid(Mixin3D[TopoDS_Solid]):
|
|||
return v.distance_to(f) <= tolerance
|
||||
|
||||
def edge_on_face(e: Edge, f: Face) -> bool:
|
||||
# Check start, end, and interpolated points are all on the face
|
||||
for i in range(check_num_points + 2):
|
||||
t = i / (check_num_points + 1)
|
||||
if f.distance_to(e @ t) > tolerance:
|
||||
return False
|
||||
# Can't use distance_to (e.g. normal vector would match), need Common
|
||||
return bool(self._bool_op_list((e,), (f,), BRepAlgoAPI_Common()))
|
||||
|
||||
# Multi shape versions for checking against multiple shapes
|
||||
def vertex_on_edges(v: Vertex, edges: Iterable[Edge]) -> bool:
|
||||
return any(vertex_on_edge(v, e) for e in edges)
|
||||
|
||||
def vertex_on_faces(v: Vertex, faces: Iterable[Face]) -> bool:
|
||||
return any(vertex_on_face(v, f) for f in faces)
|
||||
|
||||
def edge_on_faces(e: Edge, faces: Iterable[Face]) -> bool:
|
||||
return any(edge_on_face(e, f) for f in faces)
|
||||
|
||||
def face_point_normal(face: Face, u: float, v: float) -> tuple[Vector, Vector]:
|
||||
"""Get both position and normal at UV coordinates.
|
||||
Args
|
||||
u (float): the horizontal coordinate in the parameter space of the Face,
|
||||
between 0.0 and 1.0
|
||||
v (float): the vertical coordinate in the parameter space of the Face,
|
||||
between 0.0 and 1.0
|
||||
Returns:
|
||||
tuple[Vector, Vector]: [point on Face, normal at point]
|
||||
"""
|
||||
u0, u1, v0, v1 = face._uv_bounds()
|
||||
u_val = u0 + u * (u1 - u0)
|
||||
v_val = v0 + v * (v1 - v0)
|
||||
gp_pnt = gp_Pnt()
|
||||
gp_norm = gp_Vec()
|
||||
BRepGProp_Face(face.wrapped).Normal(u_val, v_val, gp_pnt, gp_norm)
|
||||
return Vector(gp_pnt), Vector(gp_norm)
|
||||
|
||||
def faces_equal(f1: Face, f2: Face, grid_size: int = 4) -> bool:
|
||||
"""Check if two faces are geometrically equal.
|
||||
|
||||
Face == uses topological equality (same OCC object), but we need
|
||||
geometric equality. For performance reasons apply a heuristic
|
||||
approach: Compare a grid of UV sample points, checking both position and
|
||||
normal direction match within tolerance.
|
||||
"""
|
||||
# Early reject: bounding box check
|
||||
bb1 = f1.bounding_box(optimal=False)
|
||||
bb2 = f2.bounding_box(optimal=False)
|
||||
if not bb1.overlaps(bb2, tolerance):
|
||||
return False
|
||||
|
||||
# Compare grid_size x grid_size grid of points in UV space
|
||||
for i in range(grid_size):
|
||||
u = i / (grid_size - 1)
|
||||
for j in range(grid_size):
|
||||
v = j / (grid_size - 1)
|
||||
pos1, norm1 = face_point_normal(f1, u, v)
|
||||
pos2, norm2 = face_point_normal(f2, u, v)
|
||||
if (pos1 - pos2).length > tolerance:
|
||||
return False
|
||||
if abs(norm1.dot(norm2)) < 0.99:
|
||||
return False
|
||||
return True
|
||||
|
||||
def is_duplicate(shape: Vertex | Edge, existing: Iterable[Shape]) -> bool:
|
||||
def is_duplicate(shape: Shape, existing: Iterable[Shape]) -> bool:
|
||||
if isinstance(shape, Vertex):
|
||||
return any(shape.distance_to(s) <= tolerance for s in existing)
|
||||
# Edge: use geom_equal for full geometric comparison
|
||||
return any(
|
||||
isinstance(e, Edge) and shape.geom_equal(e, tolerance)
|
||||
for e in existing
|
||||
)
|
||||
return any(
|
||||
isinstance(v, Vertex) and Vector(shape) == Vector(v)
|
||||
for v in existing
|
||||
)
|
||||
if isinstance(shape, Edge):
|
||||
return any(
|
||||
isinstance(e, Edge) and shape.geom_equal(e, tolerance)
|
||||
for e in existing
|
||||
)
|
||||
if isinstance(shape, Face):
|
||||
# Heuristic approach
|
||||
return any(
|
||||
isinstance(f, Face) and faces_equal(shape, f) for f in existing
|
||||
)
|
||||
return False
|
||||
|
||||
results: ShapeList = ShapeList()
|
||||
|
||||
if isinstance(other, Solid):
|
||||
# Solid + Solid: find all boundary contacts (faces, edges, vertices)
|
||||
# Pre-calculate bounding boxes (optimal=False for speed, used only for filtering)
|
||||
if isinstance(other, (Solid, Face, Shell)):
|
||||
# Unified handling: iterate over face pairs
|
||||
# For Solid+Solid: get intersection solids to filter results that bound them
|
||||
intersect_faces = []
|
||||
if isinstance(other, Solid):
|
||||
if found_solids is None:
|
||||
found_solids = ShapeList(
|
||||
self._intersect(other, tolerance, include_touched=False) or []
|
||||
)
|
||||
intersect_faces = [f for s in found_solids for f in s.faces()]
|
||||
|
||||
# Pre-calculate bounding boxes for early rejection
|
||||
self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()]
|
||||
other_faces = [(f, f.bounding_box(optimal=False)) for f in other.faces()]
|
||||
self_edges = [(e, e.bounding_box(optimal=False)) for e in self.edges()]
|
||||
other_edges = [(e, e.bounding_box(optimal=False)) for e in other.edges()]
|
||||
|
||||
# Face-Face contacts (collect first)
|
||||
found_faces: ShapeList = ShapeList()
|
||||
# First pass: collect touch/intersect results from face pairs,
|
||||
# filtering against intersection solid faces
|
||||
raw_results: ShapeList = ShapeList()
|
||||
for sf, sf_bb in self_faces:
|
||||
for of, of_bb in other_faces:
|
||||
if not sf_bb.overlaps(of_bb, tolerance):
|
||||
continue
|
||||
common = self._bool_op_list((sf,), (of,), BRepAlgoAPI_Common())
|
||||
# Filter out null and degenerate (zero-area) faces
|
||||
found_faces.extend(
|
||||
s for s in common if not s.is_null and s.area > tolerance
|
||||
|
||||
# Process touch first (cheap), then intersect (expensive)
|
||||
# Face touch gives tangent vertices
|
||||
for r in sf.touch(of, tolerance=tolerance):
|
||||
if not is_duplicate(r, raw_results) and not vertex_on_faces(
|
||||
r, intersect_faces
|
||||
):
|
||||
raw_results.append(r)
|
||||
|
||||
# Face intersect gives shared faces/edges (touch handled above)
|
||||
for r in sf.intersect(of, tolerance=tolerance) or []:
|
||||
if not is_duplicate(r, raw_results) and not edge_on_faces(
|
||||
r, intersect_faces
|
||||
):
|
||||
raw_results.append(r)
|
||||
|
||||
# Second pass: filter lower-dimensional results against higher-dimensional
|
||||
all_faces = [f for f in raw_results if isinstance(f, Face)]
|
||||
all_edges = [e for e in raw_results if isinstance(e, Edge)]
|
||||
for r in raw_results:
|
||||
if (
|
||||
isinstance(r, Face)
|
||||
or (isinstance(r, Edge) and not edge_on_faces(r, all_faces))
|
||||
or (
|
||||
isinstance(r, Vertex)
|
||||
and not vertex_on_faces(r, all_faces)
|
||||
and not vertex_on_edges(r, all_edges)
|
||||
)
|
||||
results.extend(found_faces)
|
||||
|
||||
# Edge-Edge contacts (skip if on any found face)
|
||||
found_edges: ShapeList = ShapeList()
|
||||
for se, se_bb in self_edges:
|
||||
for oe, oe_bb in other_edges:
|
||||
if not se_bb.overlaps(oe_bb, tolerance):
|
||||
continue
|
||||
common = self._bool_op_list((se,), (oe,), BRepAlgoAPI_Common())
|
||||
for s in common:
|
||||
if s.is_null:
|
||||
continue
|
||||
# Skip if edge is on any found face
|
||||
if not any(edge_on_face(s, f) for f in found_faces):
|
||||
found_edges.append(s)
|
||||
results.extend(found_edges)
|
||||
|
||||
# Vertex-Vertex contacts (skip if on any found face or edge)
|
||||
found_vertices: ShapeList = ShapeList()
|
||||
for sv in self.vertices():
|
||||
for ov in other.vertices():
|
||||
if sv.distance_to(ov) <= tolerance:
|
||||
on_face = any(vertex_on_face(sv, f) for f in found_faces)
|
||||
on_edge = any(vertex_on_edge(sv, e) for e in found_edges)
|
||||
if not on_face and not on_edge and not is_duplicate(sv, found_vertices):
|
||||
results.append(sv)
|
||||
found_vertices.append(sv)
|
||||
break
|
||||
|
||||
# Tangent contacts (skip if on any found face or edge)
|
||||
# Use Mixin2D.touch() on face pairs to find tangent contacts (e.g., sphere touching box)
|
||||
for sf, sf_bb in self_faces:
|
||||
for of, of_bb in other_faces:
|
||||
if not sf_bb.overlaps(of_bb, tolerance):
|
||||
continue
|
||||
# Include face-face intersection edges for filtering crossing vertices
|
||||
sf_of_intersect = sf._intersect(of, tolerance, include_touched=False)
|
||||
sf_of_edges = ShapeList(
|
||||
e for e in (sf_of_intersect or []) if isinstance(e, Edge)
|
||||
)
|
||||
tangent_vertices = sf.touch(
|
||||
of, tolerance, found_faces, found_edges + sf_of_edges
|
||||
)
|
||||
for v in tangent_vertices:
|
||||
if not is_duplicate(v, found_vertices):
|
||||
results.append(v)
|
||||
found_vertices.append(v)
|
||||
|
||||
elif isinstance(other, (Face, Shell)):
|
||||
# Solid + Face: find where face boundary meets solid boundary
|
||||
# Pre-calculate bounding boxes (optimal=False for speed, used only for filtering)
|
||||
self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()]
|
||||
other_edges = [(e, e.bounding_box(optimal=False)) for e in other.edges()]
|
||||
|
||||
# Check face's edges touching solid's faces
|
||||
# Track found edges to avoid duplicates (edge may touch multiple adjacent faces)
|
||||
touching_edges: list[Edge] = []
|
||||
for oe, oe_bb in other_edges:
|
||||
for sf, sf_bb in self_faces:
|
||||
if not oe_bb.overlaps(sf_bb, tolerance):
|
||||
continue
|
||||
common = self._bool_op_list((oe,), (sf,), BRepAlgoAPI_Common())
|
||||
for s in common:
|
||||
if s.is_null or not isinstance(s, Edge):
|
||||
continue
|
||||
# Check if geometrically same edge already found
|
||||
if not is_duplicate(s, touching_edges):
|
||||
results.append(s)
|
||||
touching_edges.append(s)
|
||||
# Check face's vertices touching solid's edges (corner coincident)
|
||||
for ov in other.vertices():
|
||||
for se in self.edges():
|
||||
if vertex_on_edge(ov, se):
|
||||
results.append(ov)
|
||||
break
|
||||
):
|
||||
results.append(r)
|
||||
|
||||
elif isinstance(other, (Edge, Wire)):
|
||||
# Solid + Edge: find where edge endpoints touch solid boundary
|
||||
# Pre-calculate bounding boxes (optimal=False for speed, used only for filtering)
|
||||
# Pre-calculate bounding boxes (optimal=False for speed, used for filtering)
|
||||
self_faces = [(f, f.bounding_box(optimal=False)) for f in self.faces()]
|
||||
other_bb = other.bounding_box(optimal=False)
|
||||
|
||||
|
|
@ -908,24 +932,18 @@ class Solid(Mixin3D[TopoDS_Solid]):
|
|||
if vertex_on_face(ov, sf):
|
||||
results.append(ov)
|
||||
break
|
||||
# Use BRepExtrema to find tangent contacts (edge tangent to surface)
|
||||
# Only valid if edge doesn't penetrate solid (Common returns nothing)
|
||||
# If Common returns something, contact points are entry/exit (intersect, not touches)
|
||||
common_result = self._bool_op_list((self,), (other,), BRepAlgoAPI_Common())
|
||||
if not common_result: # No penetration - could be tangent
|
||||
for sf, sf_bb in self_faces:
|
||||
if not sf_bb.overlaps(other_bb, tolerance):
|
||||
continue
|
||||
extrema = BRepExtrema_DistShapeShape(sf.wrapped, other.wrapped)
|
||||
if extrema.IsDone() and extrema.Value() <= tolerance:
|
||||
for i in range(1, extrema.NbSolution() + 1):
|
||||
pnt1 = extrema.PointOnShape1(i)
|
||||
pnt2 = extrema.PointOnShape2(i)
|
||||
# Verify points are actually close
|
||||
if pnt1.Distance(pnt2) > tolerance:
|
||||
continue
|
||||
|
||||
# Use BRepExtrema to find all tangent contacts (edge tangent to surface)
|
||||
for sf, sf_bb in self_faces:
|
||||
if not sf_bb.overlaps(other_bb, tolerance):
|
||||
continue
|
||||
extrema = BRepExtrema_DistShapeShape(sf.wrapped, other.wrapped)
|
||||
if extrema.IsDone() and extrema.Value() <= tolerance:
|
||||
for i in range(1, extrema.NbSolution() + 1):
|
||||
pnt1 = extrema.PointOnShape1(i)
|
||||
pnt2 = extrema.PointOnShape2(i)
|
||||
if pnt1.Distance(pnt2) <= tolerance:
|
||||
new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z())
|
||||
# Only add if not already covered by existing results
|
||||
if not is_duplicate(new_vertex, results):
|
||||
results.append(new_vertex)
|
||||
|
||||
|
|
|
|||
|
|
@ -424,30 +424,37 @@ class Mixin2D(ABC, Shape[TOPODS]):
|
|||
"""
|
||||
|
||||
# Helper functions for common geometric checks
|
||||
def vertex_on_edge(v: Vertex, e: Edge) -> bool:
|
||||
return v.distance_to(e) <= tolerance
|
||||
def vertex_on_edges(v: Vertex, edges: Iterable[Edge]) -> bool:
|
||||
return any(v.distance_to(e) <= tolerance for e in edges)
|
||||
|
||||
def is_duplicate_vertex(v: Vertex, existing: ShapeList) -> bool:
|
||||
return any(v.distance_to(ev) <= tolerance for ev in existing)
|
||||
def vertex_on_faces(v: Vertex, faces: Iterable[Face]) -> bool:
|
||||
return any(v.distance_to(f) <= tolerance for f in faces)
|
||||
|
||||
def is_duplicate(v: Vertex, vertices: Iterable[Vertex]) -> bool:
|
||||
vec = Vector(v)
|
||||
return any(vec == Vector(ov) for ov in vertices)
|
||||
|
||||
results: ShapeList = ShapeList()
|
||||
|
||||
if isinstance(other, (Face, Shell)):
|
||||
# Get intersect results to filter against if not provided
|
||||
if found_faces is None or found_edges is None:
|
||||
# Get intersect results to filter against if not provided (direct call)
|
||||
if found_faces is None:
|
||||
found_faces = ShapeList()
|
||||
found_edges = ShapeList()
|
||||
intersect_results = self._intersect(
|
||||
other, tolerance, include_touched=False
|
||||
)
|
||||
found_faces = ShapeList()
|
||||
found_edges = ShapeList()
|
||||
if intersect_results:
|
||||
for r in intersect_results:
|
||||
if isinstance(r, Face):
|
||||
found_faces.append(r)
|
||||
elif isinstance(r, Edge):
|
||||
found_edges.append(r)
|
||||
elif found_edges is None: # for mypy
|
||||
found_edges = ShapeList()
|
||||
|
||||
# Use BRepExtrema to find all contact points (vertex-vertex, vertex-edge, vertex-face)
|
||||
# Use BRepExtrema to find all contact points
|
||||
# (vertex-vertex, vertex-edge, vertex-face)
|
||||
found_vertices: ShapeList = ShapeList()
|
||||
extrema = BRepExtrema_DistShapeShape()
|
||||
extrema.SetDeflection(
|
||||
|
|
@ -465,29 +472,24 @@ class Mixin2D(ABC, Shape[TOPODS]):
|
|||
|
||||
new_vertex = Vertex(pnt1.X(), pnt1.Y(), pnt1.Z())
|
||||
|
||||
# Check if point is on edge boundary of either face
|
||||
on_self_edge = any(
|
||||
vertex_on_edge(new_vertex, e) for e in self.edges()
|
||||
)
|
||||
on_other_edge = any(
|
||||
vertex_on_edge(new_vertex, e) for e in other.edges()
|
||||
)
|
||||
# Skip duplicates early (cheap check)
|
||||
if is_duplicate(new_vertex, found_vertices):
|
||||
continue
|
||||
|
||||
# Skip if point is on edges of both faces (edge-edge intersection)
|
||||
if on_self_edge and on_other_edge:
|
||||
# Skip edge-edge intersections, but allow corner touches
|
||||
if (
|
||||
vertex_on_edges(new_vertex, self.edges())
|
||||
and vertex_on_edges(new_vertex, other.edges())
|
||||
and not is_duplicate(new_vertex, self.vertices())
|
||||
and not is_duplicate(new_vertex, other.vertices())
|
||||
):
|
||||
continue
|
||||
|
||||
# Filter: only keep vertices that are not boundaries of
|
||||
# higher-dimensional contacts (faces or edges) and not duplicates
|
||||
on_face = any(
|
||||
new_vertex.distance_to(f) <= tolerance for f in found_faces
|
||||
)
|
||||
on_edge = any(vertex_on_edge(new_vertex, e) for e in found_edges)
|
||||
if (
|
||||
not on_face
|
||||
and not on_edge
|
||||
and not is_duplicate_vertex(new_vertex, found_vertices)
|
||||
):
|
||||
# higher-dimensional contacts (faces or edges)
|
||||
if not vertex_on_faces(
|
||||
new_vertex, found_faces
|
||||
) and not vertex_on_edges(new_vertex, found_edges):
|
||||
results.append(new_vertex)
|
||||
found_vertices.append(new_vertex)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue