feat: 完善 STEP 一级参数化编辑识别与关系式建模

This commit is contained in:
2026-08-14 18:42:39 +08:00
parent 70b59c1de6
commit a633b5a338
26 changed files with 4676 additions and 77 deletions
+631 -3
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections import Counter
import math
from pathlib import Path
import time
@@ -78,9 +79,12 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .export import ExportMixin
from .features import FeatureMixin
from .asitus_bridge import run_asitus_hole_recognition
from .operations import OperationMixin
from .polydata import PolydataMixin
from .recognition_graph import recognize_through_hole_regions
from .recognition_priority import (
external_relation_score_bonus,
feature_recognition_priority,
feature_recognition_priority_label,
feature_recognition_priority_reason,
@@ -119,6 +123,19 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache: dict[int, list[int]] = {}
self._edge_face_ids_cache: dict[int, list[int]] = {}
self._same_domain_face_ids_cache: dict[int, list[int]] = {}
self._asitus_hole_regions_attempted = False
self._asitus_hole_regions_loading = False
self._asitus_hole_region_cache: dict[int, list[int]] = {}
self._asitus_hole_recognition_info: dict[str, object] = {}
self._asitus_face_relation_cache: dict[int, dict[str, object]] = {}
self._asitus_adjacency_relation_cache: dict[tuple[int, int], dict[str, object]] = {}
self._asitus_geometric_relation_cache: dict[tuple[int, int], list[dict[str, object]]] = {}
self._internal_hole_regions_attempted = False
self._internal_hole_region_cache: dict[int, list[int]] = {}
self._internal_recognition_info: dict[str, object] = {}
self._recognition_graph_cache: dict[int, object] = {}
self._through_hole_regions_cache: dict[tuple[object, ...], list[object]] = {}
self._topology_refresh_generation = 0
self._face_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._cylindrical_first_level_topology_cache: dict[int, dict[str, object]] = {}
self._face_first_level_fact_cache: dict[tuple[int, str], dict[str, object]] = {}
@@ -210,6 +227,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
return info
def refresh_topology(self) -> None:
self._topology_refresh_generation = int(getattr(self, "_topology_refresh_generation", 0)) + 1
self.shape = _compound_from_shapes([p.shape for p in self.display_parts()])
self.faces.clear()
self.face_logical_ids.clear()
@@ -226,6 +244,18 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._face_edge_ids_cache.clear()
self._edge_face_ids_cache.clear()
self._same_domain_face_ids_cache.clear()
self._asitus_hole_regions_attempted = False
self._asitus_hole_regions_loading = False
self._asitus_hole_region_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
self._internal_hole_regions_attempted = False
self._internal_hole_region_cache.clear()
self._internal_recognition_info.clear()
self._recognition_graph_cache.clear()
self._through_hole_regions_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
@@ -359,8 +389,34 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
full_info = self._face_info_cache.get(face_id)
if full_info is not None:
info = dict(full_info)
if str(info.get("surface") or "") == "cylinder" and not str(info.get("feature_type") or ""):
info.update(self._cylindrical_feature_label_fields(info))
info.update(self._asitus_face_summary_fields(face_id))
if str(info.get("surface") or "") == "cylinder":
try:
hint = self._quick_cylindrical_feature_hint(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
info,
)
for key in (
"same_domain_face_ids",
"same_domain_face_count",
"feature_highlight_face_ids",
"same_domain_angular_span",
"same_domain_note",
"same_domain_v_range",
"same_domain_height_estimate",
"same_domain_range_source",
):
if key in hint:
info[key] = hint[key]
if bool(hint.get("is_full_cylinder")):
info["is_full_cylinder"] = True
if hint.get("angular_span") is not None:
info["angular_span"] = hint["angular_span"]
except Exception:
info.setdefault("feature_highlight_face_ids", (face_id,))
if not str(info.get("feature_type") or ""):
info.update(self._cylindrical_feature_label_fields(info))
info.update(self._recognition_summary_fields(info))
return info
cached = self._quick_face_info_cache.get(face_id)
@@ -514,6 +570,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
elif surface_label in FREEFORM_FACE_SURFACES:
info.update(self._freeform_face_limit_fields(surface_label))
info.update(self._asitus_face_summary_fields(face_id))
if surface_type == GeomAbs_Cylinder:
info.update(self._asitus_cylindrical_feature_hint_fields(face_id, info))
info.update(self._recognition_summary_fields(info))
self._quick_face_info_cache[face_id] = dict(info)
return dict(info)
@@ -558,6 +617,125 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"feature_edit_actions": "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认。",
}
def _asitus_cylindrical_feature_hint_fields(self, face_id: int, info: dict[str, object]) -> dict[str, object]:
if face_id < 0 or face_id >= len(self.faces):
return {}
if not self._asitus_adjacency_relation_cache and not self._asitus_geometric_relation_cache:
return {}
angle_types: Counter[str] = Counter()
relation_types: Counter[str] = Counter()
related_face_ids: set[int] = set()
for pair, relation in self._asitus_adjacency_relation_cache.items():
if int(face_id) not in pair:
continue
related_face_ids.update(int(item) for item in pair if int(item) != int(face_id))
angle_type = str(relation.get("angle_type") or "adjacent").strip().lower()
if angle_type:
angle_types[angle_type] += 1
for pair, relations in self._asitus_geometric_relation_cache.items():
if int(face_id) not in pair:
continue
related_face_ids.update(int(item) for item in pair if int(item) != int(face_id))
for relation in relations:
relation_type = str(relation.get("relation_type") or "").strip().lower()
if relation_type:
relation_types[relation_type] += 1
if not angle_types and not relation_types:
return {}
guess = str(info.get("feature_guess") or "")
orientation = str(info.get("orientation") or "")
angular_span = _float_or_none(info.get("same_domain_angular_span"))
if angular_span is None:
angular_span = _float_or_none(info.get("angular_span")) or 0.0
is_full = bool(info.get("is_full_cylinder")) or angular_span >= math.tau * 0.92
is_partial = 1.0e-6 < angular_span < math.tau * 0.92
radius = _float_or_none(info.get("radius")) or 0.0
boundary_edges = int(info.get("boundary_edges") or 0)
solid_id = self.face_solid_ids[face_id] if 0 <= face_id < len(self.face_solid_ids) else -1
solid_shape = self.solids[solid_id][1] if 0 <= solid_id < len(self.solids) else self.shape
solid_diagonal = max(_shape_diagonal(solid_shape), 1.0)
is_small_radius = radius > 0 and radius <= solid_diagonal * 0.04
is_fillet_radius = radius > 0 and radius <= solid_diagonal * 0.12
smooth_count = sum(value for key, value in angle_types.items() if "smooth" in key)
convex_count = sum(value for key, value in angle_types.items() if "convex" in key)
concave_count = sum(value for key, value in angle_types.items() if "concave" in key)
tangent_count = int(relation_types.get("tangent", 0))
coaxial_count = int(relation_types.get("coaxial", 0))
parallel_axis_count = int(relation_types.get("parallel_axis", 0))
planar_relation_count = sum(
int(relation_types.get(key, 0))
for key in ("coplanar", "parallel", "perpendicular")
)
slot_score = 0
if is_partial and guess == "hole/groove candidate":
slot_score += 4
slot_score += min((smooth_count + tangent_count) * 3, 6)
slot_score += min(concave_count * 2, 4)
slot_score += min(planar_relation_count, 3)
fillet_score = 0
if is_partial and (
guess == "round/fillet candidate"
or (is_fillet_radius and guess not in {"hole/groove candidate", "boss/outer-round candidate"})
):
fillet_score += 3
if is_small_radius:
fillet_score += 4
fillet_score += min((smooth_count + tangent_count) * 4, 8)
if boundary_edges >= 4:
fillet_score += 2
boss_score = 0
is_boss_like = (
guess == "boss/outer-round candidate"
or (
str(info.get("material_toward_axis") or "") == "inside"
and "outside" in str(info.get("material_away_axis") or "")
)
or (is_full and orientation == "forward")
)
if is_full and is_boss_like:
boss_score += 5
boss_score += min(convex_count * 2, 4)
boss_score += min(coaxial_count + parallel_axis_count, 4)
boss_score += min(planar_relation_count, 3)
scores = {
"slot": slot_score,
"boss": boss_score,
"fillet": fillet_score,
}
preferred, hint_score = max(scores.items(), key=lambda item: item[1])
if hint_score <= 0:
return {}
labels = {
"slot": "槽 / 半孔",
"boss": "凸台 / 外圆",
"fillet": "圆角 / 倒圆",
}
relation_summary = ", ".join(f"{key}:{value}" for key, value in sorted(relation_types.items()))
angle_summary = ", ".join(f"{key}:{value}" for key, value in sorted(angle_types.items()))
summary_parts = [f"{labels[preferred]} +{hint_score}"]
if relation_summary:
summary_parts.append(f"几何关系 {relation_summary}")
if angle_summary:
summary_parts.append(f"AAG角度 {angle_summary}")
return {
"analysis_situs_feature_hint_status": "ready",
"analysis_situs_feature_hint_preferred": preferred,
"analysis_situs_feature_hint_label": labels[preferred],
"analysis_situs_feature_hint_score": hint_score,
"analysis_situs_feature_hint_summary": "".join(summary_parts),
"analysis_situs_feature_hint_related_face_ids": tuple(sorted(related_face_ids)),
"analysis_situs_slot_hint_score": slot_score,
"analysis_situs_boss_hint_score": boss_score,
"analysis_situs_fillet_hint_score": fillet_score,
}
def _quick_cylindrical_feature_hint(
self,
face_id: int,
@@ -728,6 +906,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"v_range": (surf.FirstVParameter(), surf.LastVParameter()),
"boundary_edges": boundary_edges,
}
info.update(self._asitus_face_summary_fields(face_id))
info.update(_shape_bounds_info(face))
info.update(self._face_boundary_wire_info(face))
if surface_type == GeomAbs_Plane:
@@ -807,6 +986,9 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
info["minor_radius"] = torus.MinorRadius()
elif str(info.get("surface") or "") in FREEFORM_FACE_SURFACES:
info.update(self._freeform_face_limit_fields(str(info.get("surface") or "")))
info.update(self._asitus_face_summary_fields(face_id))
if surface_type == GeomAbs_Cylinder:
info.update(self._asitus_cylindrical_feature_hint_fields(face_id, info))
info.update(self._recognition_summary_fields(info))
self._face_info_cache[face_id] = dict(info)
return dict(info)
@@ -893,6 +1075,13 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
confidence = "high"
if surface in FREEFORM_FACE_SURFACES:
confidence = "low"
external_relation_bonus = external_relation_score_bonus(info)
if (
external_relation_bonus >= 8
and surface not in FREEFORM_FACE_SURFACES
and confidence in {"low", "unchecked", "none"}
):
confidence = "medium"
risk_rank = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
capability_specs = (
@@ -1024,6 +1213,21 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
add("first_level_topology", f"一级相邻Face={adjacent_count}")
if info.get("first_level_fact_summary") not in {None, ""}:
add("first_level_fact_graph", f"一级事实={info.get('first_level_fact_summary')}")
if info.get("asitus_adjacent_face_count") not in {None, ""}:
add(
"analysis_situs_aag",
f"Analysis Situs AAG adjacent Face={info.get('asitus_adjacent_face_count')}",
)
if info.get("asitus_geometric_relation_summary") not in {None, ""}:
add(
"analysis_situs_geometry",
f"Analysis Situs geometry={info.get('asitus_geometric_relation_summary')}",
)
if info.get("analysis_situs_feature_hint_summary") not in {None, ""}:
add(
"analysis_situs_feature_hint",
f"Analysis Situs hint={info.get('analysis_situs_feature_hint_summary')}",
)
if info.get("material_vote_summary") not in {None, ""}:
add("material_votes", f"材料采样={info.get('material_vote_summary')}")
if info.get("feature_end_face_ids") not in {None, ""}:
@@ -1127,6 +1331,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
risk_penalty = {"low": 0, "medium": 14, "high": 30, "blocked": 72}
score = confidence_points.get(confidence, 22)
score += min(len(evidence_keys) * 5, 24)
score += external_relation_bonus
score -= risk_penalty.get(risk, 14)
if blockers:
score -= 35
@@ -1183,6 +1388,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
"recognition_user_priority_reason": user_priority_reason,
"recognition_evidence": "".join(evidence),
"recognition_evidence_keys": tuple(evidence_keys),
"recognition_external_relation_score_bonus": external_relation_bonus,
"recognition_ready_actions": "".join(ready_actions),
"recognition_limited_actions": "".join(limited_actions),
"recognition_blockers": "".join(blockers),
@@ -2625,6 +2831,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
**fillet_info,
}
)
result.update(self._asitus_cylindrical_feature_hint_fields(face_id, result))
if angular_span >= math.tau * 0.92:
result.update(
{
@@ -4423,7 +4630,11 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
if surface_type == GeomAbs_Plane:
face_ids = self._connected_coplanar_planar_face_ids(face_id)
elif surface_type == GeomAbs_Cylinder:
face_ids = self._connected_cocylindrical_face_ids(face_id)
face_ids = (
self._asitus_hole_region_ids(face_id)
or self._internal_hole_region_ids(face_id)
or self._connected_cocylindrical_face_ids(face_id)
)
else:
face_ids = [face_id]
face_ids = sorted(set(face_ids or [face_id]))
@@ -4431,6 +4642,423 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
self._same_domain_face_ids_cache[item] = list(face_ids)
return list(face_ids)
def _asitus_hole_region_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
if not self._can_use_asitus_hole_recognition():
return []
if self._asitus_hole_regions_loading:
return []
if not self._asitus_hole_regions_attempted:
self._load_asitus_hole_regions()
return list(self._asitus_hole_region_cache.get(face_id, ()))
def _internal_hole_region_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []
if not self._can_use_internal_recognition_graph():
return []
if not self._internal_hole_regions_attempted:
self._load_internal_hole_regions()
return list(self._internal_hole_region_cache.get(face_id, ()))
def _can_use_internal_recognition_graph(self) -> bool:
return bool(self.faces)
def _load_internal_hole_regions(self) -> None:
self._internal_hole_regions_attempted = True
self._internal_hole_region_cache.clear()
self._internal_recognition_info.clear()
try:
regions = recognize_through_hole_regions(self)
mapped_groups: set[tuple[int, ...]] = set()
for region in regions:
group = tuple(sorted({int(item) for item in region.face_ids if 0 <= int(item) < len(self.faces)}))
if len(group) < 2:
continue
mapped_groups.add(group)
for face_id in group:
self._internal_hole_region_cache[face_id] = list(group)
self._internal_recognition_info.update(
{
"ok": True,
"source": "internal-recognition-graph",
"mapped_hole_count": len(mapped_groups),
"mapped_hole_groups": tuple(sorted(mapped_groups)),
"through_hole_region_count": len(regions),
}
)
except Exception as exc:
self._internal_hole_region_cache.clear()
self._internal_recognition_info.update(
{
"ok": False,
"source": "internal-recognition-graph",
"reason": "recognition-failed",
"message": str(exc),
}
)
def _can_use_asitus_hole_recognition(self) -> bool:
if int(getattr(self, "_topology_refresh_generation", 0) or 0) > 1:
return False
suffix = self.filename.suffix.lower()
return suffix in {".step", ".stp"} and self.filename.is_file()
def _load_asitus_hole_regions(self) -> None:
self.install_asitus_hole_recognition_result(run_asitus_hole_recognition(self.filename))
def begin_asitus_hole_region_load(self) -> bool:
if not self._can_use_asitus_hole_recognition():
return False
if self._asitus_hole_regions_attempted or self._asitus_hole_regions_loading:
return False
self._asitus_hole_regions_loading = True
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": "pending",
"message": "Analysis Situs hole recognition is running in the background.",
}
)
return True
def install_asitus_hole_recognition_result(self, result: object) -> list[tuple[int, ...]]:
self._asitus_hole_regions_loading = False
self._asitus_hole_regions_attempted = True
self._asitus_hole_region_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
if not self._can_use_asitus_hole_recognition():
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": "stale-topology",
"message": "Analysis Situs result ignored because the model topology has changed.",
}
)
return []
if not isinstance(result, dict):
result = {"ok": False, "reason": "bad-result", "message": "Analysis Situs returned an unexpected result."}
self._asitus_hole_recognition_info.update(
{
"ok": bool(result.get("ok")),
"reason": result.get("reason", ""),
"message": result.get("message", ""),
"cli": result.get("cli", ""),
}
)
if not result.get("ok"):
self._clear_same_domain_dependent_caches()
return []
self._install_asitus_relation_summary(result)
raw_groups = result.get("groups", ())
if not isinstance(raw_groups, (tuple, list)):
self._clear_same_domain_dependent_caches()
return []
mapped_groups = self._map_asitus_hole_groups(raw_groups)
if not mapped_groups:
self._clear_same_domain_dependent_caches()
return []
for group in mapped_groups:
for item in group:
self._asitus_hole_region_cache[int(item)] = list(group)
self._asitus_hole_recognition_info.update(
{
"mapped_hole_count": len(mapped_groups),
"mapped_hole_groups": tuple(tuple(group) for group in mapped_groups),
"source_face_id_base": "analysis-situs-aag-1-based",
}
)
self._clear_same_domain_dependent_caches()
return mapped_groups
def fail_asitus_hole_region_load(self, message: str) -> None:
self._asitus_hole_regions_loading = False
self._asitus_hole_regions_attempted = True
self._asitus_hole_region_cache.clear()
self._asitus_hole_recognition_info.clear()
self._asitus_face_relation_cache.clear()
self._asitus_adjacency_relation_cache.clear()
self._asitus_geometric_relation_cache.clear()
self._asitus_hole_recognition_info.update(
{
"ok": False,
"reason": "worker-failed",
"message": str(message or "Analysis Situs hole recognition failed."),
}
)
self._clear_same_domain_dependent_caches()
def _clear_same_domain_dependent_caches(self) -> None:
self._same_domain_face_ids_cache.clear()
self._internal_hole_regions_attempted = False
self._internal_hole_region_cache.clear()
self._internal_recognition_info.clear()
self._recognition_graph_cache.clear()
self._through_hole_regions_cache.clear()
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._editable_feature_candidates_cache.clear()
self._cylindrical_feature_candidates_cache.clear()
def _install_asitus_relation_summary(self, result: dict[str, object]) -> None:
faces = result.get("faces", ())
if isinstance(faces, (tuple, list)):
for item in faces:
if not isinstance(item, dict):
continue
raw_face_id = self._coerce_int(item.get("id"))
face_id = self._map_asitus_single_face_id(raw_face_id)
if face_id is None:
continue
neighbor_ids = tuple(
sorted(
{
mapped
for raw_neighbor in self._iter_int_values(item.get("neighbor_ids"))
for mapped in (self._map_asitus_single_face_id(raw_neighbor),)
if mapped is not None and mapped != face_id
}
)
)
self._asitus_face_relation_cache[face_id] = {
"source": "analysis-situs-aag",
"asitus_face_id": raw_face_id,
"surface": str(item.get("surface") or ""),
"neighbor_face_ids": neighbor_ids,
"neighbor_face_count": len(neighbor_ids),
}
adjacency = result.get("adjacency", ())
if isinstance(adjacency, (tuple, list)):
for item in adjacency:
if not isinstance(item, dict):
continue
mapped_pair = tuple(
sorted(
{
mapped
for raw_face_id in self._iter_int_values(item.get("face_ids"))
for mapped in (self._map_asitus_single_face_id(raw_face_id),)
if mapped is not None
}
)
)
if len(mapped_pair) != 2:
continue
self._asitus_adjacency_relation_cache[mapped_pair] = {
"source": "analysis-situs-aag",
"face_ids": mapped_pair,
"angle_type": str(item.get("angle_type") or ""),
"angle_rad": item.get("angle_rad"),
"edge_ids": tuple(self._iter_int_values(item.get("edge_ids"))),
}
geometric_relations = result.get("geometric_relations", ())
if isinstance(geometric_relations, (tuple, list)):
for item in geometric_relations:
if not isinstance(item, dict):
continue
mapped_pair = tuple(
sorted(
{
mapped
for raw_face_id in self._iter_int_values(item.get("face_ids"))
for mapped in (self._map_asitus_single_face_id(raw_face_id),)
if mapped is not None
}
)
)
if len(mapped_pair) != 2:
continue
relation = {
"source": str(item.get("source") or "analysis-situs-probe"),
"face_ids": mapped_pair,
"relation_type": str(item.get("relation_type") or ""),
"residual": item.get("residual"),
}
self._asitus_geometric_relation_cache.setdefault(mapped_pair, []).append(relation)
if (
self._asitus_face_relation_cache
or self._asitus_adjacency_relation_cache
or self._asitus_geometric_relation_cache
):
self._asitus_hole_recognition_info.update(
{
"aag_face_relation_count": len(self._asitus_face_relation_cache),
"aag_adjacency_relation_count": len(self._asitus_adjacency_relation_cache),
"aag_geometric_relation_pair_count": len(self._asitus_geometric_relation_cache),
"aag_geometric_relation_count": sum(
len(items) for items in self._asitus_geometric_relation_cache.values()
),
"aag_surface_summary": dict(result.get("surface_summary", {}) or {}),
"aag_angle_summary": dict(result.get("angle_summary", {}) or {}),
"aag_geometric_relation_summary": dict(result.get("geometric_relation_summary", {}) or {}),
"aag_geometric_relation_mode": str(result.get("geometric_relation_mode") or ""),
"aag_relation_source": "analysis-situs-aag",
}
)
def _asitus_face_summary_fields(self, face_id: int) -> dict[str, object]:
entry = self._asitus_face_relation_cache.get(int(face_id))
if not entry:
return {}
neighbor_ids = tuple(int(item) for item in entry.get("neighbor_face_ids", ()) or ())
relation_types: Counter[str] = Counter()
for neighbor_id in neighbor_ids:
pair = tuple(sorted((int(face_id), int(neighbor_id))))
relation = self._asitus_adjacency_relation_cache.get(pair)
if not relation:
continue
relation_type = str(relation.get("angle_type") or "adjacent")
relation_types[relation_type] += 1
relation_summary = ", ".join(f"{key}:{value}" for key, value in sorted(relation_types.items()))
if not relation_summary:
relation_summary = f"adjacent:{len(neighbor_ids)}"
geometric_relation_types: Counter[str] = Counter()
for pair, relations in self._asitus_geometric_relation_cache.items():
if int(face_id) not in pair:
continue
for relation in relations:
relation_type = str(relation.get("relation_type") or "")
if relation_type:
geometric_relation_types[relation_type] += 1
geometric_relation_summary = ", ".join(
f"{key}:{value}" for key, value in sorted(geometric_relation_types.items())
)
return {
"asitus_relation_status": "ready",
"asitus_relation_source": entry.get("source", "analysis-situs-aag"),
"asitus_face_id": entry.get("asitus_face_id"),
"asitus_surface": entry.get("surface", ""),
"asitus_adjacent_face_ids": neighbor_ids,
"asitus_adjacent_face_count": len(neighbor_ids),
"asitus_adjacent_relation_summary": relation_summary,
"asitus_geometric_relation_count": sum(geometric_relation_types.values()),
"asitus_geometric_relation_summary": geometric_relation_summary,
"asitus_geometric_relation_types": tuple(sorted(geometric_relation_types)),
}
def _map_asitus_single_face_id(self, raw_face_id: object) -> int | None:
value = self._coerce_int(raw_face_id)
if value is None:
return None
if 1 <= value <= len(self.faces):
return value - 1
if 0 <= value < len(self.faces):
return value
return None
@staticmethod
def _coerce_int(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
@staticmethod
def _iter_int_values(values: object) -> tuple[int, ...]:
if values is None or isinstance(values, (str, bytes)):
return ()
try:
iterator = iter(values) # type: ignore[arg-type]
except TypeError:
coerced = StepModel._coerce_int(values)
return () if coerced is None else (coerced,)
result: list[int] = []
for item in iterator:
coerced = StepModel._coerce_int(item)
if coerced is not None:
result.append(coerced)
return tuple(result)
def _map_asitus_hole_groups(self, groups: Iterable[object]) -> list[tuple[int, ...]]:
mapped: list[tuple[int, ...]] = []
seen: set[tuple[int, ...]] = set()
for raw_group in groups:
try:
values = tuple(sorted({int(item) for item in raw_group})) # type: ignore[arg-type]
except (TypeError, ValueError):
continue
group = self._map_asitus_hole_group(values)
if not group or group in seen:
continue
seen.add(group)
mapped.append(group)
return mapped
def _map_asitus_hole_group(self, raw_group: tuple[int, ...]) -> tuple[int, ...]:
best_group: tuple[int, ...] = ()
best_score = -1.0
for offset in (-1, 0):
candidate = tuple(sorted({item + offset for item in raw_group}))
score, cylinder_group = self._score_asitus_hole_group(candidate)
if score > best_score:
best_score = score
best_group = cylinder_group
return best_group if best_score >= 35.0 else ()
def _score_asitus_hole_group(self, candidate: tuple[int, ...]) -> tuple[float, tuple[int, ...]]:
if not candidate:
return -1.0, ()
if any(face_id < 0 or face_id >= len(self.faces) for face_id in candidate):
return -1.0, ()
cylinder_face_ids: list[int] = []
cylinder_surfaces: list[BRepAdaptor_Surface] = []
for face_id in candidate:
try:
surf = BRepAdaptor_Surface(self.faces[face_id])
except Exception:
continue
if surf.GetType() != GeomAbs_Cylinder:
continue
cylinder_face_ids.append(face_id)
cylinder_surfaces.append(surf)
if not cylinder_face_ids:
return -1.0, ()
score = 20.0 * len(cylinder_face_ids)
if len(cylinder_face_ids) < len(candidate):
score -= 5.0 * (len(candidate) - len(cylinder_face_ids))
part_ids = {self.face_part_ids[item] for item in cylinder_face_ids}
solid_ids = {self.face_solid_ids[item] for item in cylinder_face_ids}
if len(part_ids) == 1:
score += 10.0
if len(solid_ids) == 1:
score += 10.0
if len(cylinder_surfaces) > 1:
diagonal = _shape_diagonal(self.shape)
tolerance = min(max(diagonal * 1e-7, 1e-6), 1e-3)
source = cylinder_surfaces[0]
if all(_surfaces_are_cocylindrical(source, other, tolerance) for other in cylinder_surfaces[1:]):
score += 40.0
else:
score -= 45.0
angular_span = 0.0
for surf in cylinder_surfaces:
try:
angular_span += abs(float(surf.LastUParameter()) - float(surf.FirstUParameter()))
except Exception:
pass
if angular_span >= math.tau * 0.85:
score += 25.0
elif angular_span >= math.pi * 0.9:
score += 8.0
return score, tuple(sorted(set(cylinder_face_ids)))
def _connected_cocylindrical_face_ids(self, face_id: int) -> list[int]:
if face_id < 0 or face_id >= len(self.faces):
return []