Files

540 lines
20 KiB
Python

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]))