forma-3d-review-api / src /comparison /gap_overlap.py
lomit's picture
Sync from forma-3d-review@b6d4687f5d0f2e5303758c97095ea7e38e740723
182efca verified
"""Gap and overlap detection on boundary edges."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
import numpy as np
import open3d as o3d
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_UniformAbscissa
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS, TopoDS_Shape
logger = logging.getLogger(__name__)
NUM_EDGE_SAMPLES = 50
@dataclass
class GapOverlapResult:
"""Gap and overlap detection results."""
gap_distances: list[float] = field(default_factory=list)
overlap_distances: list[float] = field(default_factory=list)
gap_locations: list[np.ndarray] = field(default_factory=list)
overlap_locations: list[np.ndarray] = field(default_factory=list)
@property
def gap_count(self) -> int:
return len(self.gap_distances)
@property
def overlap_count(self) -> int:
return len(self.overlap_distances)
@property
def max_gap(self) -> float:
return max(self.gap_distances) if self.gap_distances else 0.0
@property
def max_overlap(self) -> float:
return max(self.overlap_distances) if self.overlap_distances else 0.0
def _sample_boundary_edges(shape: TopoDS_Shape) -> np.ndarray:
"""Sample points along boundary edges of a shape.
Boundary edges are edges that belong to only one face.
"""
# Collect edge-to-face counts
edge_face_count: dict[int, int] = {}
edge_map: dict[int, object] = {}
face_exp = TopExp_Explorer(shape, TopAbs_FACE)
while face_exp.More():
face = TopoDS.Face_s(face_exp.Current())
edge_exp = TopExp_Explorer(face, TopAbs_EDGE)
while edge_exp.More():
edge = TopoDS.Edge_s(edge_exp.Current())
h = hash(edge)
edge_face_count[h] = edge_face_count.get(h, 0) + 1
edge_map[h] = edge
edge_exp.Next()
face_exp.Next()
# Boundary edges: shared by only one face
boundary_edges = [edge_map[h] for h, count in edge_face_count.items() if count == 1]
logger.info("Found %d boundary edges out of %d total", len(boundary_edges), len(edge_map))
if not boundary_edges:
return np.empty((0, 3), dtype=np.float64)
points = []
for edge in boundary_edges:
try:
curve = BRepAdaptor_Curve(edge)
u_first = curve.FirstParameter()
u_last = curve.LastParameter()
distributor = GCPnts_UniformAbscissa(curve, NUM_EDGE_SAMPLES, u_first, u_last)
if not distributor.IsDone():
continue
for i in range(1, distributor.NbPoints() + 1):
param = distributor.Parameter(i)
pnt = curve.Value(param)
points.append([pnt.X(), pnt.Y(), pnt.Z()])
except Exception:
continue
if not points:
return np.empty((0, 3), dtype=np.float64)
return np.array(points, dtype=np.float64)
def detect_gaps_and_overlaps(
shape_a: TopoDS_Shape,
shape_b: TopoDS_Shape,
vertices_b: np.ndarray,
gap_tolerance_mm: float = 0.05,
overlap_tolerance_mm: float = 0.05,
) -> GapOverlapResult:
"""Detect gaps and overlaps by projecting boundary edge points.
Samples boundary edges of shape_a and measures distance to
the closest point on shape_b's mesh surface.
Args:
shape_a: First shape (will sample boundary edges from).
shape_b: Second shape (reference).
vertices_b: Tessellated vertices of shape_b.
gap_tolerance_mm: Gap detection threshold.
overlap_tolerance_mm: Overlap detection threshold.
Returns:
GapOverlapResult with detected gaps and overlaps.
"""
boundary_points = _sample_boundary_edges(shape_a)
if len(boundary_points) == 0:
logger.info("No boundary edges found -- skipping gap/overlap detection")
return GapOverlapResult()
if len(vertices_b) == 0:
logger.warning("Target mesh has no vertices -- skipping gap/overlap detection")
return GapOverlapResult()
# Build KDTree on target mesh
target_pcd = o3d.geometry.PointCloud()
target_pcd.points = o3d.utility.Vector3dVector(vertices_b)
kdtree = o3d.geometry.KDTreeFlann(target_pcd)
result = GapOverlapResult()
for point in boundary_points:
_, idx, dist_sq = kdtree.search_knn_vector_3d(point, 1)
dist = np.sqrt(dist_sq[0])
if dist > gap_tolerance_mm:
result.gap_distances.append(float(dist))
result.gap_locations.append(point)
elif dist < overlap_tolerance_mm * 0.1:
# Very close = potential overlap (surface interpenetration)
result.overlap_distances.append(float(dist))
result.overlap_locations.append(point)
logger.info(
"Gap/overlap detection: %d gaps (max=%.4f mm), %d overlaps (max=%.4f mm)",
result.gap_count, result.max_gap,
result.overlap_count, result.max_overlap,
)
return result