from __future__ import annotations import math from collections import Counter from dataclasses import dataclass from typing import Iterable from OCC.Core.BRepAdaptor import BRepAdaptor_Surface from OCC.Core.BRepGProp import brepgprop from OCC.Core.GeomAbs import GeomAbs_Cylinder, GeomAbs_Plane from OCC.Core.GProp import GProp_GProps from OCC.Core.TopAbs import TopAbs_EDGE from OCC.Core.TopExp import topexp from OCC.Core.TopTools import TopTools_IndexedMapOfShape from OCC.Core.TopoDS import TopoDS_Shape from .geometry_utils import ( _axis_parameter, _direction_dot, _point_axis_distance, _shape_axis_interval, _shape_diagonal, _surface_center, ) ANGULAR_TOLERANCE = 1.0e-7 COVERAGE_TOLERANCE = 0.82 EXTERNAL_COAXIAL_CONFIDENCE_BOOST = 0.08 EXTERNAL_TANGENT_CONFIDENCE_BOOST = 0.03 EXTERNAL_OPENING_PLANE_CONFIDENCE_BOOST = 0.04 @dataclass(frozen=True) class RecognitionFace: face_id: int solid_id: int surface_type: str area: float centroid: tuple[float, float, float] boundary_edge_ids: tuple[int, ...] adjacent_face_ids: tuple[int, ...] axis_point: object | None = None axis_direction: object | None = None radius: float | None = None axis_interval: tuple[float, float] | None = None angular_span: float | None = None plane_parameter: float | None = None @dataclass(frozen=True) class RecognitionRelation: relation_type: str face_ids: tuple[int, ...] residual: float @dataclass(frozen=True) class RecognitionGraph: solid_id: int face_ids: tuple[int, ...] faces: tuple[RecognitionFace, ...] relation_counts: dict[str, int] relations: tuple[RecognitionRelation, ...] def face(self, face_id: int) -> RecognitionFace | None: for item in self.faces: if item.face_id == int(face_id): return item return None @dataclass(frozen=True) class ThroughHoleRegion: face_ids: tuple[int, ...] solid_id: int diameter: float axis_interval: tuple[float, float] angular_coverage: float opening_face_ids: tuple[int, ...] confidence: float def build_recognition_graph(model: object, solid_id: int) -> RecognitionGraph: face_ids = tuple( face_id for face_id, item in enumerate(getattr(model, "face_solid_ids", ())) if int(item) == int(solid_id) ) faces: list[RecognitionFace] = [] for face_id in face_ids: face = getattr(model, "faces")[face_id] boundary_edge_ids = tuple(_face_boundary_edge_ids(model, face_id)) adjacent_face_ids = tuple(sorted(_adjacent_face_ids(model, boundary_edge_ids, face_id))) surf = BRepAdaptor_Surface(face) surface_type = "other" axis_point = None axis_direction = None radius: float | None = None axis_interval: tuple[float, float] | None = None angular_span: float | None = None plane_parameter: float | None = None if surf.GetType() == GeomAbs_Cylinder: surface_type = "cylinder" cylinder = surf.Cylinder() axis = cylinder.Axis() axis_point = axis.Location() axis_direction = axis.Direction() radius = float(cylinder.Radius()) axis_interval = _shape_axis_interval(face, axis_point, axis_direction) angular_span = abs(float(surf.LastUParameter()) - float(surf.FirstUParameter())) elif surf.GetType() == GeomAbs_Plane: surface_type = "plane" plane = surf.Plane() axis_point = plane.Location() axis_direction = plane.Axis().Direction() plane_parameter = _axis_parameter(axis_point, axis_direction, plane.Location()) area, centroid = _surface_metrics(face) faces.append( RecognitionFace( face_id=face_id, solid_id=int(solid_id), surface_type=surface_type, area=area, centroid=centroid, boundary_edge_ids=boundary_edge_ids, adjacent_face_ids=adjacent_face_ids, axis_point=axis_point, axis_direction=axis_direction, radius=radius, axis_interval=axis_interval, angular_span=angular_span, plane_parameter=plane_parameter, ) ) relations = infer_recognition_relations(faces, _recognition_tolerance(model)) relations.extend(_external_recognition_relations(model, face_ids)) relation_counts = dict(Counter(item.relation_type for item in relations)) return RecognitionGraph( solid_id=int(solid_id), face_ids=face_ids, faces=tuple(faces), relation_counts=relation_counts, relations=tuple(relations), ) def infer_recognition_relations( faces: Iterable[RecognitionFace], tolerance: float, ) -> list[RecognitionRelation]: items = list(faces) relations: list[RecognitionRelation] = [] for face in items: for adjacent_id in face.adjacent_face_ids: if face.face_id < adjacent_id: relations.append(RecognitionRelation("adjacent", (face.face_id, adjacent_id), 0.0)) for index, left in enumerate(items): for right in items[index + 1 :]: if left.surface_type == "plane" and right.surface_type == "plane": relation = _plane_relation(left, right, tolerance) if relation is not None: relations.append(relation) if left.surface_type == "cylinder" and right.surface_type == "cylinder": relation = _cylinder_relation(left, right, tolerance) if relation is not None: relations.append(relation) return relations def recognize_through_hole_regions(model: object, solid_id: int | None = None) -> list[ThroughHoleRegion]: solid_ids = _solid_ids(model, solid_id) cache_key = ("all", solid_ids) if solid_id is None else ("solid", int(solid_id)) cache = getattr(model, "_through_hole_regions_cache", None) if isinstance(cache, dict) and cache_key in cache: return list(cache[cache_key]) regions: list[ThroughHoleRegion] = [] for current_solid_id in solid_ids: graph = _cached_recognition_graph(model, current_solid_id) regions.extend(_recognize_graph_through_hole_regions(model, graph)) result = _dedupe_regions(regions) if isinstance(cache, dict): cache[cache_key] = list(result) return result def recognition_summary(model: object) -> dict[str, object]: solid_ids = _solid_ids(model, None) relation_counts: Counter[str] = Counter() hole_count = 0 face_count = 0 for solid_id in solid_ids: graph = _cached_recognition_graph(model, solid_id) relation_counts.update(graph.relation_counts) face_count += len(graph.face_ids) hole_count += len(recognize_through_hole_regions(model, solid_id)) return { "source": "internal-recognition-graph", "solid_count": len(solid_ids), "face_count": face_count, "relation_counts": dict(relation_counts), "through_hole_region_count": hole_count, } def _cached_recognition_graph(model: object, solid_id: int) -> RecognitionGraph: cache = getattr(model, "_recognition_graph_cache", None) if isinstance(cache, dict) and int(solid_id) in cache: return cache[int(solid_id)] graph = build_recognition_graph(model, int(solid_id)) if isinstance(cache, dict): cache[int(solid_id)] = graph return graph def _recognize_graph_through_hole_regions(model: object, graph: RecognitionGraph) -> list[ThroughHoleRegion]: cylinders = [face for face in graph.faces if face.surface_type == "cylinder" and face.radius and face.radius > 0] if not cylinders: return [] tolerance = _recognition_tolerance(model) visited: set[int] = set() regions: list[ThroughHoleRegion] = [] for source in cylinders: if source.face_id in visited: continue group = _cocylindrical_interval_group(model, cylinders, source, tolerance) visited.update(face.face_id for face in group) if not group: continue coverage = sum(min(abs(float(face.angular_span or 0.0)), math.tau) for face in group) if coverage < math.tau * COVERAGE_TOLERANCE: continue intervals = [face.axis_interval for face in group if face.axis_interval is not None] if not intervals: continue v_min = min(float(item[0]) for item in intervals) v_max = max(float(item[1]) for item in intervals) opening_face_ids = _opening_plane_face_ids(graph, group, tolerance) confidence = 0.72 if coverage >= math.tau * 0.98: confidence += 0.12 if len(opening_face_ids) >= 2: confidence += 0.12 if len(group) > 1: confidence += 0.04 if _has_external_relation(model, (face.face_id for face in group), {"coaxial"}): confidence += EXTERNAL_COAXIAL_CONFIDENCE_BOOST if _has_external_relation(model, (face.face_id for face in group), {"tangent"}): confidence += EXTERNAL_TANGENT_CONFIDENCE_BOOST if len(opening_face_ids) >= 2 and _has_external_relation( model, opening_face_ids, {"coplanar", "parallel"}, ): confidence += EXTERNAL_OPENING_PLANE_CONFIDENCE_BOOST regions.append( ThroughHoleRegion( face_ids=tuple(sorted(face.face_id for face in group)), solid_id=graph.solid_id, diameter=float(group[0].radius or 0.0) * 2.0, axis_interval=(v_min, v_max), angular_coverage=coverage, opening_face_ids=tuple(sorted(opening_face_ids)), confidence=min(confidence, 0.99), ) ) return regions def _external_recognition_relations(model: object, face_ids: Iterable[int]) -> list[RecognitionRelation]: cache = getattr(model, "_asitus_geometric_relation_cache", None) if not isinstance(cache, dict): return [] valid_face_ids = {int(item) for item in face_ids} relations: list[RecognitionRelation] = [] for pair, items in cache.items(): try: face_pair = tuple(sorted(int(item) for item in pair)) except (TypeError, ValueError): continue if len(face_pair) != 2 or face_pair[0] not in valid_face_ids or face_pair[1] not in valid_face_ids: continue if not isinstance(items, (tuple, list)): continue for item in items: if not isinstance(item, dict): continue relation_type = str(item.get("relation_type") or "").strip() if not relation_type: continue relations.append( RecognitionRelation( f"external_{relation_type}", face_pair, _float_or_zero(item.get("residual")), ) ) return relations def _has_external_relation(model: object, face_ids: Iterable[int], relation_types: set[str]) -> bool: return _external_relation(model, face_ids, relation_types) is not None def _external_relation( model: object, face_ids: Iterable[int], relation_types: set[str], ) -> dict[str, object] | None: cache = getattr(model, "_asitus_geometric_relation_cache", None) if not isinstance(cache, dict): return None face_id_set = {int(item) for item in face_ids} if len(face_id_set) < 2: return None for pair, items in cache.items(): try: face_pair = tuple(sorted(int(item) for item in pair)) except (TypeError, ValueError): continue if len(face_pair) != 2 or face_pair[0] not in face_id_set or face_pair[1] not in face_id_set: continue if not isinstance(items, (tuple, list)): continue for item in items: if isinstance(item, dict) and str(item.get("relation_type") or "").strip() in relation_types: return item return None def _float_or_zero(value: object) -> float: try: return float(value) except (TypeError, ValueError): return 0.0 def _cocylindrical_interval_group( model: object, cylinders: list[RecognitionFace], source: RecognitionFace, tolerance: float, ) -> list[RecognitionFace]: pending = [source] visited = {source.face_id} result: list[RecognitionFace] = [] while pending: current = pending.pop(0) result.append(current) for candidate in cylinders: if candidate.face_id in visited: continue if candidate.solid_id != source.solid_id: continue if not _recognition_faces_are_cocylindrical(source, candidate, tolerance) and not ( _external_cocylindrical_hint(model, source, candidate, tolerance) ): continue if not _intervals_overlap_or_touch(current.axis_interval, candidate.axis_interval, tolerance * 50.0): continue visited.add(candidate.face_id) pending.append(candidate) return result def _external_cocylindrical_hint( model: object, left: RecognitionFace, right: RecognitionFace, tolerance: float, ) -> bool: relation = _external_relation(model, (left.face_id, right.face_id), {"coaxial"}) if relation is None: return False if left.radius is None or right.radius is None: return False radius_tolerance = max(tolerance, max(left.radius, right.radius) * 1e-6) radius_delta = abs(float(left.radius) - float(right.radius)) residual = _float_or_zero(relation.get("residual")) return radius_delta <= radius_tolerance or residual <= radius_tolerance def _recognition_faces_are_cocylindrical(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> bool: if left.axis_point is None or left.axis_direction is None or right.axis_point is None or right.axis_direction is None: return False if left.radius is None or right.radius is None: return False radius_tolerance = max(tolerance, max(left.radius, right.radius) * 1e-6) if abs(left.radius - right.radius) > radius_tolerance: return False if abs(_direction_dot(left.axis_direction, right.axis_direction)) < 1.0 - 1e-6: return False return _point_axis_distance(left.axis_point, left.axis_direction, right.axis_point) <= max(tolerance, radius_tolerance) def _opening_plane_face_ids( graph: RecognitionGraph, group: list[RecognitionFace], tolerance: float, ) -> set[int]: if not group or group[0].axis_point is None or group[0].axis_direction is None: return set() axis_point = group[0].axis_point axis_direction = group[0].axis_direction intervals = [face.axis_interval for face in group if face.axis_interval is not None] if not intervals: return set() v_min = min(float(item[0]) for item in intervals) v_max = max(float(item[1]) for item in intervals) end_tolerance = max(tolerance * 80.0, abs(v_max - v_min) * 1e-4, 1e-4) side_ids = {face.face_id for face in group} adjacent_ids: set[int] = set() for face in group: adjacent_ids.update(face.adjacent_face_ids) openings: set[int] = set() by_id = {face.face_id: face for face in graph.faces} for adjacent_id in adjacent_ids - side_ids: adjacent = by_id.get(adjacent_id) if adjacent is None or adjacent.surface_type != "plane" or adjacent.axis_direction is None: continue if abs(_direction_dot(adjacent.axis_direction, axis_direction)) < 1.0 - ANGULAR_TOLERANCE: continue try: parameter = _axis_parameter(axis_point, axis_direction, _gp_point(adjacent.centroid)) except Exception: continue if abs(parameter - v_min) <= end_tolerance or abs(parameter - v_max) <= end_tolerance: openings.add(adjacent_id) return openings def _plane_relation(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> RecognitionRelation | None: if left.axis_direction is None or right.axis_direction is None: return None dot = abs(_direction_dot(left.axis_direction, right.axis_direction)) if dot >= 1.0 - ANGULAR_TOLERANCE: residual = abs(_plane_offset(left, right)) if residual <= tolerance: return RecognitionRelation("coplanar", (left.face_id, right.face_id), residual) return RecognitionRelation("parallel", (left.face_id, right.face_id), residual) if dot <= ANGULAR_TOLERANCE: return RecognitionRelation("perpendicular", (left.face_id, right.face_id), dot) return None def _cylinder_relation(left: RecognitionFace, right: RecognitionFace, tolerance: float) -> RecognitionRelation | None: if not _recognition_faces_are_cocylindrical(left, right, tolerance): if left.axis_point is not None and left.axis_direction is not None and right.axis_direction is not None: if abs(_direction_dot(left.axis_direction, right.axis_direction)) >= 1.0 - ANGULAR_TOLERANCE: return RecognitionRelation("parallel_axis", (left.face_id, right.face_id), 0.0) return None residual = 0.0 if left.axis_point is not None and left.axis_direction is not None and right.axis_point is not None: residual = _point_axis_distance(left.axis_point, left.axis_direction, right.axis_point) return RecognitionRelation("coaxial", (left.face_id, right.face_id), residual) def _plane_offset(left: RecognitionFace, right: RecognitionFace) -> float: if left.axis_point is None or left.axis_direction is None or right.axis_point is None: return math.inf return float(_axis_parameter(left.axis_point, left.axis_direction, right.axis_point)) def _surface_metrics(shape: TopoDS_Shape) -> tuple[float, tuple[float, float, float]]: props = GProp_GProps() try: brepgprop.SurfaceProperties(shape, props) center = props.CentreOfMass() return float(props.Mass()), (float(center.X()), float(center.Y()), float(center.Z())) except Exception: center = _surface_center(shape) return 0.0, (float(center.X()), float(center.Y()), float(center.Z())) def _face_boundary_edge_ids(model: object, face_id: int) -> list[int]: if hasattr(model, "_face_boundary_edge_ids"): return list(model._face_boundary_edge_ids(face_id)) # noqa: SLF001 edges = TopTools_IndexedMapOfShape() topexp.MapShapes(getattr(model, "faces")[face_id], TopAbs_EDGE, edges) return list(range(edges.Size())) def _adjacent_face_ids(model: object, edge_ids: Iterable[int], face_id: int) -> set[int]: adjacent: set[int] = set() if hasattr(model, "_adjacent_face_ids_for_edges"): adjacent.update(model._adjacent_face_ids_for_edges(edge_ids, face_id)) # noqa: SLF001 else: edge_face_ids = getattr(model, "_edge_face_ids_cache", {}) for edge_id in edge_ids: adjacent.update(int(item) for item in edge_face_ids.get(int(edge_id), ()) if int(item) != int(face_id)) return adjacent def _recognition_tolerance(model: object) -> float: try: diagonal = _shape_diagonal(getattr(model, "shape")) except Exception: diagonal = 1.0 return min(max(float(diagonal) * 1e-7, 1e-6), 1e-3) def _solid_ids(model: object, solid_id: int | None) -> tuple[int, ...]: if solid_id is not None: return (int(solid_id),) face_solid_ids = sorted({int(item) for item in getattr(model, "face_solid_ids", ()) if int(item) >= 0}) if face_solid_ids: return tuple(face_solid_ids) return tuple(range(len(getattr(model, "solids", ()) or ()))) def _intervals_overlap_or_touch( left: tuple[float, float] | None, right: tuple[float, float] | None, tolerance: float, ) -> bool: if left is None or right is None: return True left_min, left_max = min(left), max(left) right_min, right_max = min(right), max(right) return max(left_min, right_min) <= min(left_max, right_max) + max(tolerance, 0.0) def _dedupe_regions(regions: Iterable[ThroughHoleRegion]) -> list[ThroughHoleRegion]: result: list[ThroughHoleRegion] = [] seen: set[tuple[int, ...]] = set() for region in sorted(regions, key=lambda item: (item.solid_id, item.face_ids)): if region.face_ids in seen: continue seen.add(region.face_ids) result.append(region) return result def _gp_point(values: tuple[float, float, float]): from OCC.Core.gp import gp_Pnt return gp_Pnt(float(values[0]), float(values[1]), float(values[2]))