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
+102 -1
View File
@@ -12,13 +12,14 @@ import vtkmodules.vtkInteractionWidgets # noqa: F401
import vtkmodules.vtkInteractionStyle # noqa: F401
import vtkmodules.vtkRenderingFreeType # noqa: F401
import vtkmodules.vtkRenderingOpenGL2 # noqa: F401
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot
from PySide6.QtCore import Qt, QThread, QTimer, Signal, Slot, QStringListModel
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QCompleter,
QFileDialog,
QFrame,
QGridLayout,
@@ -161,6 +162,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.selected_face_id: int | None = None
self.selected_edge_id: int | None = None
self.selected_pick_position: tuple[float, float, float] | None = None
self.multi_selected_feature_face_ids: list[int] = []
self.multi_selected_hole_entries: list[dict[str, object]] = []
self.multi_selection_active = False
self.model_actor = None
self.edge_actor = None
@@ -253,6 +257,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.pending_scan_kind: str | None = None
self.pending_scan_context: dict[str, object] | None = None
self.scan_wait_cursor_active = False
self.asitus_thread: QThread | None = None
self.asitus_worker: ScanWorker | None = None
self.pending_asitus_context: dict[str, object] | None = None
self.load_in_progress = False
self.load_thread: QThread | None = None
self.load_worker: LoadWorker | None = None
@@ -278,6 +285,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.property_editor_selected_row: int | None = None
self.property_command_active_key = ""
self.property_command_buttons: dict[str, QPushButton] = {}
self.relation_formula_items: list[dict[str, object]] = []
self.relation_formula_next_id = 1
self._build_ui()
self._build_vtk()
@@ -879,6 +888,47 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
QLineEdit#propertyCardTargetEditor:focus {
border-color: #2563eb;
}
QGroupBox#relationFormulaBox {
margin-top: 2px;
}
QLineEdit#relationFormulaInput {
background: #ffffff;
border: 1px solid #b7c6d9;
border-radius: 5px;
color: #172033;
min-height: 24px;
padding: 3px 6px;
}
QLineEdit#relationFormulaInput:focus {
border: 1px solid #2563eb;
}
QPushButton#relationFormulaAddButton,
QPushButton#relationFormulaRemoveButton {
background: #f8fafc;
border: 1px solid #94a3b8;
border-radius: 5px;
color: #1f2937;
font-weight: 700;
min-height: 26px;
padding: 3px 8px;
}
QPushButton#relationFormulaAddButton:hover,
QPushButton#relationFormulaRemoveButton:hover {
background: #eef6ff;
border-color: #2563eb;
color: #1e3a8a;
}
QPushButton#relationFormulaAddButton:disabled,
QPushButton#relationFormulaRemoveButton:disabled {
background: #eef2f6;
border: 1px dashed #bcc7d4;
color: #8f99a8;
}
QListWidget#relationFormulaList {
background: #ffffff;
border: 1px solid #d8e0eb;
border-radius: 5px;
}
QTabWidget::pane {
border: 1px solid #d8e0eb;
border-radius: 6px;
@@ -1237,6 +1287,57 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
self.property_expand_button.setMaximumHeight(22)
self.property_expand_button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
object_edit_layout.addWidget(self.property_expand_button)
self.relation_formula_box = QGroupBox("关系式")
self.relation_formula_box.setObjectName("relationFormulaBox")
help_tip(
self.relation_formula_box,
"用 FaceID.参数 = 表达式 的形式建立关系式。第一版会先计算公式并回填当前参数表目标值,再执行参数化建模。",
)
relation_layout = QVBoxLayout(self.relation_formula_box)
relation_layout.setContentsMargins(6, 8, 6, 6)
relation_layout.setSpacing(5)
relation_input_row = QHBoxLayout()
relation_input_row.setContentsMargins(0, 0, 0, 0)
relation_input_row.setSpacing(5)
self.relation_formula_input = QLineEdit("")
self.relation_formula_input.setObjectName("relationFormulaInput")
self.relation_formula_input.setPlaceholderText("Face87.直径 = Face85.直径")
help_tip(
self.relation_formula_input,
"示例:Face87.直径 = Face85.直径,或 Face87.位置 = Face85.位置 + (0, 0, -3.5)。输入 Face87. 后会提示当前可用参数。",
)
self.relation_formula_completer_model = QStringListModel(self)
self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self)
self.relation_formula_completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
self.relation_formula_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
self.relation_formula_input.setCompleter(self.relation_formula_completer)
self.relation_formula_input.installEventFilter(self)
self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed)
self.relation_formula_input.returnPressed.connect(self.add_relation_formula)
self.add_relation_formula_button = QPushButton("添加公式")
self.add_relation_formula_button.setObjectName("relationFormulaAddButton")
self.add_relation_formula_button.clicked.connect(self.add_relation_formula)
relation_input_row.addWidget(self.relation_formula_input, stretch=1)
relation_input_row.addWidget(self.add_relation_formula_button)
relation_layout.addLayout(relation_input_row)
self.relation_formula_list = QListWidget()
self.relation_formula_list.setObjectName("relationFormulaList")
self.relation_formula_list.setMinimumHeight(54)
self.relation_formula_list.setMaximumHeight(86)
self.relation_formula_list.itemSelectionChanged.connect(self._update_relation_formula_buttons)
help_tip(self.relation_formula_list, "已建立的关系式。失效或暂不能映射的公式会在这里标出原因。")
relation_layout.addWidget(self.relation_formula_list)
relation_button_row = QHBoxLayout()
relation_button_row.setContentsMargins(0, 0, 0, 0)
relation_button_row.addStretch(1)
self.remove_relation_formula_button = QPushButton("删除公式")
self.remove_relation_formula_button.setObjectName("relationFormulaRemoveButton")
self.remove_relation_formula_button.clicked.connect(self.remove_selected_relation_formula)
relation_button_row.addWidget(self.remove_relation_formula_button)
relation_layout.addLayout(relation_button_row)
object_edit_layout.addWidget(self.relation_formula_box)
property_action_row = QHBoxLayout()
property_action_row.setContentsMargins(0, 0, 0, 0)
self.apply_property_button = QPushButton("参数化建模")
+308
View File
@@ -0,0 +1,308 @@
from __future__ import annotations
import json
import os
import subprocess
from pathlib import Path
from typing import Iterable
ASITUS_RECOGNIZE_HOLES_ENV = "STEP_EDITOR_ASITUS_RECOGNIZE_HOLES"
ASITUS_DISABLE_ENV = "STEP_EDITOR_DISABLE_ASITUS"
ASITUS_TIMEOUT_ENV = "STEP_EDITOR_ASITUS_TIMEOUT"
def _project_root(project_root: Path | None = None) -> Path:
return project_root or Path(__file__).resolve().parent.parent
def default_asitus_recognize_holes_path(project_root: Path | None = None) -> Path | None:
env_path = os.environ.get(ASITUS_RECOGNIZE_HOLES_ENV, "").strip()
if env_path:
path = Path(env_path).expanduser()
return path if path.is_file() else None
if os.environ.get(ASITUS_DISABLE_ENV, "").strip().lower() in {"1", "true", "yes", "on"}:
return None
root = _project_root(project_root)
candidates = (
root / "third_party" / "asitus_probe_tools_build" / "Release" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_tools_build" / "RelWithDebInfo" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_tools_build" / "Debug" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_build" / "Release" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_build" / "RelWithDebInfo" / "recognize_holes.exe",
root / "third_party" / "asitus_probe_build" / "Debug" / "recognize_holes.exe",
)
for candidate in candidates:
if candidate.is_file():
return candidate
return None
def asitus_runtime_path_entries(project_root: Path | None = None) -> list[Path]:
root = _project_root(project_root)
third_party = root / "third_party" / "3rdparty"
candidates = (
root / "third_party" / "AnalysisSitus_build_algo_occt77" / "win64" / "vc14" / "bin",
third_party / "OCCT" / "win64" / "vc14" / "bin",
third_party / "freeimage-3.17.0-vc14-64" / "bin",
third_party / "freetype-2.5.5-vc14-64" / "bin",
third_party / "tbb_2021.5-vc14-64" / "bin",
third_party / "tcltk-86-64" / "bin",
third_party / "ffmpeg-3.3.4-64" / "bin",
third_party / "openvr-1.14.15-64" / "bin" / "win64",
third_party / "3rdparty-vc14-64" / "freeimage-3.18.0-x64" / "bin",
third_party / "3rdparty-vc14-64" / "freetype-2.13.3-x64" / "bin",
third_party / "3rdparty-vc14-64" / "tbb-2021.13.0-x64" / "bin",
third_party / "3rdparty-vc14-64" / "tcltk-8.6.15-x64" / "bin",
third_party / "3rdparty-vc14-64" / "ffmpeg-3.3.4-64" / "bin",
third_party / "3rdparty-vc14-64" / "openvr-1.14.15-64" / "bin" / "win64",
)
return [path for path in candidates if path.is_dir()]
def parse_asitus_hole_groups(payload: object) -> list[tuple[int, ...]]:
payload = _json_payload(payload)
if not isinstance(payload, dict):
return []
groups: list[tuple[int, ...]] = []
holes = payload.get("holes")
if isinstance(holes, list):
for item in holes:
if not isinstance(item, dict):
continue
group = _int_tuple(item.get("faceIds"))
if group:
groups.append(group)
if groups:
return _dedupe_groups(groups)
flat_ids = _int_tuple(payload.get("holeFaceIds"))
return [flat_ids] if flat_ids else []
def parse_asitus_probe_payload(payload: object) -> dict[str, object]:
data = _json_payload(payload)
if not isinstance(data, dict):
return {
"groups": (),
"faces": (),
"adjacency": (),
"geometric_relations": (),
"surface_summary": {},
"angle_summary": {},
"geometric_relation_summary": {},
"geometric_relation_mode": "",
}
faces: list[dict[str, object]] = []
raw_faces = data.get("faces")
if isinstance(raw_faces, list):
for item in raw_faces:
if not isinstance(item, dict):
continue
face_id = _int_or_none(item.get("id"))
if face_id is None:
continue
faces.append(
{
"id": face_id,
"surface": str(item.get("surface") or ""),
"neighbor_ids": _int_tuple(item.get("neighbors")),
}
)
adjacency: list[dict[str, object]] = []
raw_adjacency = data.get("adjacency")
if isinstance(raw_adjacency, list):
for item in raw_adjacency:
if not isinstance(item, dict):
continue
face_ids = _int_tuple(item.get("faceIds"))
if len(face_ids) != 2:
continue
adjacency.append(
{
"face_ids": face_ids,
"angle_type": str(item.get("angleType") or item.get("type") or ""),
"angle_rad": _float_or_none(item.get("angleRad")),
"edge_ids": _int_tuple(item.get("edgeIds")),
}
)
geometric_relations: list[dict[str, object]] = []
raw_geometric_relations = data.get("geometricRelations")
if isinstance(raw_geometric_relations, list):
for item in raw_geometric_relations:
if not isinstance(item, dict):
continue
face_ids = _int_tuple(item.get("faceIds"))
if len(face_ids) != 2:
continue
geometric_relations.append(
{
"face_ids": face_ids,
"relation_type": str(item.get("type") or item.get("relationType") or ""),
"residual": _float_or_none(item.get("residual")),
"source": str(item.get("source") or "analysis-situs-probe"),
}
)
return {
"groups": tuple(parse_asitus_hole_groups(data)),
"valid_brep": data.get("validBreP"),
"face_count": _int_or_none(data.get("faceCount")),
"aag_node_count": _int_or_none(data.get("aagNodeCount")),
"faces": tuple(faces),
"adjacency": tuple(adjacency),
"geometric_relations": tuple(geometric_relations),
"surface_summary": _str_int_dict(data.get("surfaceSummary")),
"angle_summary": _str_int_dict(data.get("angleSummary")),
"geometric_relation_summary": _str_int_dict(data.get("geometricRelationSummary")),
"geometric_relation_mode": str(data.get("geometricRelationMode") or ""),
}
def _json_payload(payload: object) -> object:
if not isinstance(payload, str):
return payload
text = payload.strip()
json_start = text.find("{")
if json_start > 0:
text = text[json_start:]
return json.loads(text)
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _float_or_none(value: object) -> float | None:
try:
return float(value)
except (TypeError, ValueError):
return None
def _str_int_dict(value: object) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for key, item in value.items():
try:
result[str(key)] = int(item)
except (TypeError, ValueError):
continue
return result
def _int_tuple(values: object) -> tuple[int, ...]:
if values is None:
return ()
if isinstance(values, (str, bytes)):
return ()
try:
items = list(values) # type: ignore[arg-type]
except TypeError:
return ()
result: list[int] = []
for item in items:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return tuple(sorted(set(result)))
def _dedupe_groups(groups: Iterable[tuple[int, ...]]) -> list[tuple[int, ...]]:
result: list[tuple[int, ...]] = []
seen: set[tuple[int, ...]] = set()
for group in groups:
if not group or group in seen:
continue
seen.add(group)
result.append(group)
return result
def run_asitus_hole_recognition(
step_path: str | Path,
*,
cli_path: str | Path | None = None,
project_root: Path | None = None,
timeout_seconds: float | None = None,
) -> dict[str, object]:
source = Path(step_path).expanduser()
if not source.is_file():
return {"ok": False, "reason": "missing-step", "groups": (), "message": f"STEP file not found: {source}"}
cli = Path(cli_path).expanduser() if cli_path else default_asitus_recognize_holes_path(project_root)
if cli is None or not cli.is_file():
return {"ok": False, "reason": "missing-cli", "groups": (), "message": "Analysis Situs recognize_holes CLI is not available."}
if timeout_seconds is None:
try:
timeout_seconds = float(os.environ.get(ASITUS_TIMEOUT_ENV, "") or 3.0)
except ValueError:
timeout_seconds = 3.0
env = os.environ.copy()
path_entries = [str(path) for path in asitus_runtime_path_entries(project_root)]
env["PATH"] = os.pathsep.join([*path_entries, env.get("PATH", "")])
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
completed = subprocess.run(
[str(cli), str(source)],
cwd=str(_project_root(project_root)),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=max(float(timeout_seconds), 0.1),
creationflags=creationflags,
check=False,
)
except subprocess.TimeoutExpired:
return {"ok": False, "reason": "timeout", "groups": (), "message": "Analysis Situs hole recognition timed out."}
except OSError as exc:
return {"ok": False, "reason": "launch-failed", "groups": (), "message": str(exc)}
if completed.returncode != 0:
message = (completed.stderr or completed.stdout or "").strip()
return {
"ok": False,
"reason": "recognizer-failed",
"returncode": completed.returncode,
"groups": (),
"message": message,
}
try:
parsed = parse_asitus_probe_payload(completed.stdout)
except (json.JSONDecodeError, TypeError, ValueError) as exc:
return {"ok": False, "reason": "bad-json", "groups": (), "message": str(exc), "stdout": completed.stdout}
groups = tuple(parsed.get("groups", ()))
return {
"ok": True,
"reason": "ok",
"groups": groups,
"hole_count": len(groups),
"valid_brep": parsed.get("valid_brep"),
"face_count": parsed.get("face_count"),
"aag_node_count": parsed.get("aag_node_count"),
"faces": tuple(parsed.get("faces", ())),
"adjacency": tuple(parsed.get("adjacency", ())),
"geometric_relations": tuple(parsed.get("geometric_relations", ())),
"surface_summary": dict(parsed.get("surface_summary", {}) or {}),
"angle_summary": dict(parsed.get("angle_summary", {}) or {}),
"geometric_relation_summary": dict(parsed.get("geometric_relation_summary", {}) or {}),
"geometric_relation_mode": str(parsed.get("geometric_relation_mode") or ""),
"cli": str(cli),
"message": f"Analysis Situs recognized {len(groups)} hole groups.",
}
+92 -4
View File
@@ -61,10 +61,78 @@ from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
from .geometry_utils import * # noqa: F403
from .recognition_priority import feature_recognition_sort_key
from .recognition_priority import external_relation_score_bonus, feature_recognition_sort_key
class FeatureMixin:
def _external_candidate_relation_fields(self, candidate: dict[str, object]) -> dict[str, object]:
face_ids = (
_int_values(candidate.get("feature_highlight_face_ids"))
or _int_values(candidate.get("feature_face_ids"))
or _int_values(candidate.get("face_region_ids"))
or _int_values(candidate.get("face_id"))
)
if not face_ids and str(candidate.get("target_kind") or "") == "face":
face_ids = _int_values(candidate.get("target_id"))
relation_summary_by_type: dict[str, int] = {}
relation_face_count = 0
summary_getter = getattr(self, "_asitus_face_summary_fields", None)
if not callable(summary_getter):
return {}
for face_id in sorted(set(face_ids)):
try:
fields = summary_getter(face_id)
except Exception:
continue
if not isinstance(fields, dict):
continue
relation_count = int(fields.get("asitus_geometric_relation_count") or 0)
if relation_count <= 0:
continue
relation_face_count += 1
for relation_type in fields.get("asitus_geometric_relation_types", ()) or ():
relation_key = str(relation_type or "").strip()
if relation_key:
relation_summary_by_type[relation_key] = relation_summary_by_type.get(relation_key, 0) + 1
if not relation_summary_by_type:
return {}
relation_summary = ", ".join(
f"{key}:{value}" for key, value in sorted(relation_summary_by_type.items())
)
result = {
"external_recognition_relation_source": "analysis-situs",
"external_recognition_relation_summary": relation_summary,
"external_recognition_relation_types": tuple(sorted(relation_summary_by_type)),
"external_recognition_relation_count": sum(relation_summary_by_type.values()),
"external_recognition_relation_face_count": relation_face_count,
}
hint_getter = getattr(self, "_asitus_cylindrical_feature_hint_fields", None)
if callable(hint_getter) and len(set(face_ids)) == 1:
try:
hint_face_id = int(face_ids[0])
hint_context = {**candidate, **result}
cached_feature_getter = getattr(self, "cached_feature_info", None)
cached_feature = cached_feature_getter(hint_face_id) if callable(cached_feature_getter) else None
if isinstance(cached_feature, dict):
hint_context = {**cached_feature, **hint_context}
result.update(hint_getter(hint_face_id, hint_context))
except Exception:
pass
result["recognition_external_relation_score_bonus"] = external_relation_score_bonus(result)
return result
def _with_external_candidate_relation_support(self, candidate: dict[str, object]) -> dict[str, object]:
result = dict(candidate)
fields = self._external_candidate_relation_fields(result)
if not fields:
return result
result.update(fields)
bonus = int(result.get("recognition_external_relation_score_bonus") or 0)
confidence = str(result.get("confidence") or "")
if bonus >= 8 and confidence in {"", "pending", "unchecked", "none", "low"}:
result["confidence"] = "medium"
return result
def _first_level_fact_plan_fields(self, face_id: int, scope: str) -> dict[str, object]:
try:
return self.face_first_level_facts(face_id, scope=scope)
@@ -1005,6 +1073,7 @@ class FeatureMixin:
)
ellipse_edge_minor_radius_count += 1
candidates = [self._with_external_candidate_relation_support(candidate) for candidate in candidates]
for candidate in candidates:
candidate["recognition_user_priority"] = feature_recognition_sort_key(candidate)[0]
candidates.sort(key=feature_recognition_sort_key)
@@ -1094,12 +1163,21 @@ class FeatureMixin:
"boss_resize_note",
"recognition_risk",
"recognition_blockers",
"analysis_situs_feature_hint_status",
"analysis_situs_feature_hint_preferred",
"analysis_situs_feature_hint_label",
"analysis_situs_feature_hint_score",
"analysis_situs_feature_hint_summary",
"analysis_situs_feature_hint_related_face_ids",
"analysis_situs_slot_hint_score",
"analysis_situs_boss_hint_score",
"analysis_situs_fillet_hint_score",
):
if feature.get(key) not in {None, ""}:
candidate[key] = feature.get(key)
except Exception:
pass
candidates.append(candidate)
candidates.append(self._with_external_candidate_relation_support(candidate))
if len(candidates) >= limit:
break
result = [dict(item) for item in candidates]
@@ -1248,7 +1326,13 @@ class FeatureMixin:
blockers.append("Target cylinder axis center must be three numeric coordinates.")
current_diameter = _float_or_none(info.get("diameter"))
angular_span = _float_or_none(info.get("angular_span"))
selected_angular_span = _float_or_none(info.get("angular_span"))
angular_span = (
_float_or_none(feature.get("same_domain_angular_span"))
or _float_or_none(info.get("same_domain_angular_span"))
or _float_or_none(feature.get("angular_span"))
or selected_angular_span
)
feature_guess = str(info.get("feature_guess", ""))
confidence = str(info.get("confidence", "low"))
surf = BRepAdaptor_Surface(self.faces[face_id])
@@ -1357,13 +1441,17 @@ class FeatureMixin:
"axis_move_radial_distance": radial_distance,
"axis": axis_direction,
"angular_span": angular_span,
"selected_angular_span": selected_angular_span,
"same_domain_angular_span": feature.get("same_domain_angular_span") or info.get("same_domain_angular_span"),
"is_full_cylinder": feature.get("is_full_cylinder", info.get("is_full_cylinder")),
"same_domain_face_ids": axis_range.get("same_domain_face_ids", ()),
"same_domain_face_count": axis_range.get("same_domain_face_count", 0),
"same_domain_v_range": (axis_range.get("v_min"), axis_range.get("v_max")),
"same_domain_range_source": axis_range.get("range_source", ""),
"supports_isolation": True,
"resize_strategy": "fill-old-cylinder-and-cut-moved-cylinder",
"edit_strategy_label": "填旧孔并切新孔",
"edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标轴心切出新孔;这会改变孔的位置,不会整体平移零件。",
"edit_semantics": "先填补当前完整圆柱孔,再按同直径在目标位置切出新孔;这会改变孔的位置,不会整体平移零件。",
}
def cylindrical_slot_resize_plan(
+14 -1
View File
@@ -15,7 +15,10 @@ def _optional_int(value: object) -> int | None:
def _point3(value: object, operation: str) -> tuple[float, float, float]:
point = list(value) if isinstance(value, (list, tuple)) else []
if isinstance(value, str):
point = [chunk.strip() for chunk in value.strip().strip("()[]").replace(";", ",").split(",") if chunk.strip()]
else:
point = list(value) if isinstance(value, (list, tuple)) else []
if len(point) != 3:
raise ValueError(f"{operation} requires a 3D target center.")
return (float(point[0]), float(point[1]), float(point[2]))
@@ -78,6 +81,16 @@ def _execute(model: StepModel, operation: str, args: list[object]) -> str:
return model.resize_toroidal_radius(int(args[0]), float(args[1]), str(args[2]))
if operation == "resize_cylindrical_hole":
return model.resize_cylindrical_hole(int(args[0]), float(args[1]))
if operation == "edit_cylindrical_holes_by_refs":
offset = _point3(args[2], operation) if len(args) > 2 and args[2] is not None and args[2] != "" else None
diameter = None if len(args) <= 1 or args[1] in {None, ""} else float(args[1])
return model.edit_cylindrical_holes_by_refs(list(args[0]), target_diameter=diameter, offset=offset)
if operation == "resize_cylindrical_holes_by_refs":
return model.resize_cylindrical_holes_by_refs(list(args[0]), float(args[1]))
if operation == "move_cylindrical_holes_by_offset":
return model.move_cylindrical_holes_by_offset(list(args[0]), _point3(args[1], operation))
if operation == "suppress_cylindrical_holes_by_refs":
return model.suppress_cylindrical_holes_by_refs(list(args[0]))
if operation == "resize_cylindrical_owning_scale":
return model.resize_cylindrical_owning_scale(int(args[0]), float(args[1]))
if operation == "move_cylindrical_hole_axis":
+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 []
+163
View File
@@ -13506,6 +13506,169 @@ class OperationMixin:
f"verified_face={verification.get('face_id', '')}."
)
def _cylindrical_hole_batch_entry(self, face_id: int) -> dict[str, object] | None:
if face_id < 0 or face_id >= len(self.faces):
return None
try:
feature = self.feature_info(face_id)
except Exception:
return None
if feature.get("surface") != "cylinder" or str(feature.get("feature_guess") or "") != "hole/groove candidate":
return None
if not _is_effectively_full_cylinder(feature):
return None
diameter = _float_or_none(feature.get("diameter"))
if diameter is None or diameter <= 1e-9:
return None
axis_data = self._cylindrical_face_axis_mid_center(face_id, feature)
center = _tuple_or_none((axis_data or {}).get("current_axis_center"))
if center is None:
center = _tuple_or_none(feature.get("axis_center"))
if center is None:
return None
try:
logical_id = self.face_region_logical_id(face_id)
except Exception:
logical_id = face_id
return {
"face_id": int(face_id),
"logical_id": int(logical_id),
"diameter": float(diameter),
"axis_center": center,
"same_domain_face_ids": tuple(_int_values(feature.get("same_domain_face_ids")) or [face_id]),
}
def _resolve_cylindrical_hole_batch_ref(self, ref: dict[str, object]) -> int | None:
reference_center = _tuple_or_none(ref.get("axis_center") or ref.get("center"))
reference_diameter = _float_or_none(ref.get("diameter"))
if reference_center is None:
return None
preferred_face_id = _int_or_none(ref.get("face_id"))
candidates: list[tuple[float, int]] = []
seen_logical_ids: set[int] = set()
face_order: list[int] = []
if preferred_face_id is not None and 0 <= preferred_face_id < len(self.faces):
face_order.append(preferred_face_id)
face_order.extend(face_id for face_id in range(len(self.faces)) if face_id != preferred_face_id)
for face_id in face_order:
entry = self._cylindrical_hole_batch_entry(face_id)
if entry is None:
continue
logical_id = int(entry.get("logical_id", face_id))
if logical_id in seen_logical_ids:
continue
seen_logical_ids.add(logical_id)
center = _tuple_or_none(entry.get("axis_center"))
diameter = _float_or_none(entry.get("diameter"))
if center is None:
continue
center_distance = _vector_length(_tuple_sub(center, reference_center))
diameter_delta = 0.0
if reference_diameter is not None and diameter is not None:
diameter_delta = abs(diameter - reference_diameter)
distance_limit = max(float(reference_diameter or diameter or 1.0) * 3.0, 1e-3)
if center_distance > distance_limit:
continue
score = center_distance + diameter_delta * 0.1 + (0.0 if face_id == preferred_face_id else 1e-6)
candidates.append((score, int(entry["face_id"])))
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
return candidates[0][1]
def edit_cylindrical_holes_by_refs(
self,
refs: Iterable[dict[str, object]],
target_diameter: float | None = None,
offset: tuple[float, float, float] | None = None,
) -> str:
entries = [dict(item) for item in refs if isinstance(item, dict)]
if len(entries) < 2:
raise ValueError("Batch hole edit requires at least two cylindrical hole references.")
diameter = _float_or_none(target_diameter)
move_offset = _tuple_or_none(offset)
if diameter is None and move_offset is None:
raise ValueError("Batch hole edit requires a target diameter or a position offset.")
if diameter is not None and diameter <= 0:
raise ValueError("Target diameter must be greater than 0.")
if move_offset is not None and _vector_length(move_offset) <= 1e-9:
move_offset = None
if diameter is None and move_offset is None:
raise ValueError("Position offset is zero; no batch hole edit is required.")
snapshot = self.snapshot()
resized = 0
moved = 0
try:
if diameter is not None:
for entry in entries:
face_id = self._resolve_cylindrical_hole_batch_ref(entry)
if face_id is None:
raise RuntimeError(f"Could not resolve cylindrical hole near {entry.get('axis_center')}.")
self.resize_cylindrical_hole(face_id, diameter)
resized += 1
if move_offset is not None:
for entry in entries:
face_id = self._resolve_cylindrical_hole_batch_ref(entry)
if face_id is None:
raise RuntimeError(f"Could not resolve cylindrical hole near {entry.get('axis_center')}.")
current = self._cylindrical_hole_batch_entry(face_id)
center = _tuple_or_none((current or {}).get("axis_center"))
if center is None:
raise RuntimeError(f"Could not read current axis center for cylindrical hole face {face_id}.")
self.move_cylindrical_hole_axis(face_id, _tuple_add(center, move_offset))
moved += 1
except Exception:
self.restore_snapshot(snapshot)
raise
summary_parts: list[str] = []
if resized:
summary_parts.append(f"diameter -> {diameter:g} on {resized} holes")
if moved and move_offset is not None:
summary_parts.append(f"offset {move_offset} on {moved} holes")
return "Multi-hole edit completed: " + "; ".join(summary_parts) + "."
def resize_cylindrical_holes_by_refs(
self,
refs: Iterable[dict[str, object]],
target_diameter: float,
) -> str:
return self.edit_cylindrical_holes_by_refs(refs, target_diameter=target_diameter)
def move_cylindrical_holes_by_offset(
self,
refs: Iterable[dict[str, object]],
offset: tuple[float, float, float],
) -> str:
return self.edit_cylindrical_holes_by_refs(refs, offset=offset)
def suppress_cylindrical_holes_by_refs(
self,
refs: Iterable[dict[str, object]],
) -> str:
entries = [dict(item) for item in refs if isinstance(item, dict)]
if len(entries) < 2:
raise ValueError("Batch hole suppress requires at least two cylindrical hole references.")
snapshot = self.snapshot()
suppressed = 0
try:
for entry in entries:
face_id = self._resolve_cylindrical_hole_batch_ref(entry)
if face_id is None:
raise RuntimeError(f"Could not resolve cylindrical hole near {entry.get('axis_center')}.")
self.suppress_cylindrical_hole(face_id)
suppressed += 1
except Exception:
self.restore_snapshot(snapshot)
raise
return f"Multi-hole suppress completed: {suppressed} holes."
def resize_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
plan = self.cylindrical_resize_plan(face_id, new_diameter)
if plan["status"] == "blocked":
+39
View File
@@ -29,6 +29,9 @@ _ACTION_OPERATION_MAP = {
"resize_shell_thickness": "resize_shell_thickness",
"resize_shell_thickness_owning_scale": "resize_shell_thickness_owning_scale",
"resize_hole": "resize_cylindrical_hole",
"resize_multi_selected_holes": "resize_cylindrical_holes_by_refs",
"move_multi_selected_holes_by_offset": "move_cylindrical_holes_by_offset",
"suppress_multi_selected_holes": "suppress_cylindrical_holes_by_refs",
"resize_cylindrical_owning_scale": "resize_cylindrical_owning_scale",
"resize_hole_depth": "resize_cylindrical_depth",
"resize_hole_depth_owning_scale": "resize_cylindrical_depth_owning_scale",
@@ -203,6 +206,42 @@ def component_edit_config_from_spec(
) -> dict[str, object] | None:
action = str(spec.get("action") or "")
operation = operation_for_action(action)
if action in {"resize_multi_selected_holes", "move_multi_selected_holes_by_offset"}:
refs = [dict(item) for item in (spec.get("multi_hole_refs") or []) if isinstance(item, dict)]
if not operation or len(refs) < 2:
return None
value_type = str(spec.get("value_type", "number"))
default_value = parameter_row.get("default", "")
target_value: object
if value_type == "vector3":
target_value = _as_list3(default_value) or _as_list3(spec.get("current_raw")) or default_value
elif value_type in {"number", "positive", "integer", "integer_or_empty"}:
target_value = _float_or_text(default_value)
else:
target_value = default_value
target_arg: object = {"param": parameter_row["name"]}
transform = str(spec.get("target_transform") or "")
if transform:
target_arg = {
"param": parameter_row["name"],
"transform": transform,
"context": spec.get("transform_context", {}),
}
return {
"parameter": parameter_row["name"],
"displayName": parameter_row.get("displayName", parameter_row["name"]),
"targetKind": "multi_feature",
"targetId": -1,
"uiAction": action,
"operation": operation,
"args": [refs, target_arg],
"default": target_value,
"valueType": value_type,
"scope": spec.get("scope_key", spec.get("scope_default", "")),
"scopeLabel": spec.get("scope_label", spec.get("scope_text", "")),
"sourceStep": str(step_path or ""),
"parameterKey": spec.get("key", ""),
}
target_id = _target_object_id(spec, selected_kind, selected_face_id, selected_edge_id)
if not operation or target_id is None:
return None
+539
View File
@@ -0,0 +1,539 @@
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]))
+77 -1
View File
@@ -38,6 +38,17 @@ USER_PRIORITY_BUCKETS: tuple[tuple[int, str, str], ...] = (
(90, "只读/诊断", "暂未稳定归类为可修改特征。"),
)
EXTERNAL_RELATION_SCORE_WEIGHTS: dict[str, int] = {
"coaxial": 8,
"tangent": 5,
"coplanar": 4,
"parallel": 3,
"perpendicular": 3,
"parallel_axis": 3,
}
EXTERNAL_RELATION_SCORE_LIMIT = 18
EXTERNAL_FEATURE_HINT_SCORE_LIMIT = 12
def _text(value: object) -> str:
return str(value or "").strip()
@@ -50,6 +61,69 @@ def _float_or_none(value: object) -> float | None:
return None
def _int_or_zero(value: object) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _text_values(value: object) -> tuple[str, ...]:
if value is None or value == "":
return ()
if isinstance(value, str):
return (value.strip(),) if value.strip() else ()
if isinstance(value, (list, tuple, set)):
return tuple(str(item).strip() for item in value if str(item).strip())
return ()
def _relation_types_from_summary(value: object) -> tuple[str, ...]:
text = _text(value)
if not text:
return ()
result: list[str] = []
for chunk in text.replace(";", ",").split(","):
relation_type = chunk.split(":", 1)[0].strip()
if relation_type:
result.append(relation_type)
return tuple(result)
def external_relation_score_bonus(info: Mapping[str, object]) -> int:
relation_types = _text_values(info.get("external_recognition_relation_types")) or _text_values(
info.get("asitus_geometric_relation_types")
)
if not relation_types:
relation_types = _relation_types_from_summary(
info.get("external_recognition_relation_summary")
or info.get("asitus_geometric_relation_summary")
)
relation_count = _int_or_zero(
info.get("external_recognition_relation_count")
or info.get("asitus_geometric_relation_count")
)
score = 0
for relation_type in relation_types:
score += EXTERNAL_RELATION_SCORE_WEIGHTS.get(relation_type, 1)
if relation_count and not relation_types:
score = min(relation_count * 2, EXTERNAL_RELATION_SCORE_LIMIT)
score = max(0, min(score, EXTERNAL_RELATION_SCORE_LIMIT))
hint_score = min(_int_or_zero(info.get("analysis_situs_feature_hint_score")), EXTERNAL_FEATURE_HINT_SCORE_LIMIT)
return max(0, min(score + hint_score, EXTERNAL_RELATION_SCORE_LIMIT + EXTERNAL_FEATURE_HINT_SCORE_LIMIT))
def _confidence_sort_rank(value: object) -> int:
return {
"high": 0,
"medium": 1,
"low": 2,
"pending": 3,
"unchecked": 3,
"none": 4,
}.get(_text(value), 5)
def _is_effectively_full_cylinder(info: Mapping[str, object]) -> bool:
if bool(info.get("is_full_cylinder")):
return True
@@ -146,7 +220,7 @@ def feature_recognition_priority_reason(info: Mapping[str, object]) -> str:
return reason
def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int]:
def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int, int, int, int, int]:
status_order = {"ready": 0, "candidate": 0, "caution": 1, "blocked": 2}
risk_order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
target_id = info.get("target_id", info.get("face_id", info.get("edge_id", -1)))
@@ -158,5 +232,7 @@ def feature_recognition_sort_key(info: Mapping[str, object]) -> tuple[int, int,
feature_recognition_priority(info),
status_order.get(_text(info.get("status")), 9),
risk_order.get(_text(info.get("risk")), 9),
_confidence_sort_rank(info.get("confidence") or info.get("recognition_confidence")),
-external_relation_score_bonus(info),
numeric_target,
)
+244
View File
@@ -0,0 +1,244 @@
from __future__ import annotations
from dataclasses import dataclass
import ast
import math
import re
from typing import Callable, Iterable
RELATION_REF_PATTERN = re.compile(
r"\b(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[A-Za-z0-9_\u4e00-\u9fff]+)\b"
)
class RelationFormulaError(ValueError):
pass
@dataclass(frozen=True)
class ObjectParameterRef:
kind: str
object_id: int
parameter: str
@property
def token(self) -> str:
return f"{self.kind}{self.object_id}.{self.parameter}"
@dataclass(frozen=True)
class RelationFormula:
text: str
target: ObjectParameterRef
expression: str
safe_expression: str
references: tuple[ObjectParameterRef, ...]
class Vector3:
__slots__ = ("values",)
def __init__(self, values: Iterable[object]) -> None:
items = tuple(values)
if len(items) != 3:
raise TypeError("Vector expression must contain exactly 3 values.")
try:
self.values = (float(items[0]), float(items[1]), float(items[2]))
except (TypeError, ValueError) as exc:
raise TypeError("Vector expression values must be numbers.") from exc
def __iter__(self):
return iter(self.values)
def __len__(self) -> int:
return 3
def __getitem__(self, index: int) -> float:
return self.values[index]
def __repr__(self) -> str:
return f"Vector3({self.values!r})"
def __add__(self, other: object) -> "Vector3":
right = _coerce_vector(other)
return Vector3((self.values[0] + right[0], self.values[1] + right[1], self.values[2] + right[2]))
def __radd__(self, other: object) -> "Vector3":
return self.__add__(other)
def __sub__(self, other: object) -> "Vector3":
right = _coerce_vector(other)
return Vector3((self.values[0] - right[0], self.values[1] - right[1], self.values[2] - right[2]))
def __rsub__(self, other: object) -> "Vector3":
left = _coerce_vector(other)
return Vector3((left[0] - self.values[0], left[1] - self.values[1], left[2] - self.values[2]))
def __mul__(self, other: object) -> "Vector3":
scalar = _coerce_number(other)
return Vector3((self.values[0] * scalar, self.values[1] * scalar, self.values[2] * scalar))
def __rmul__(self, other: object) -> "Vector3":
return self.__mul__(other)
def __truediv__(self, other: object) -> "Vector3":
scalar = _coerce_number(other)
if abs(scalar) <= 1e-15:
raise ZeroDivisionError("Vector division by zero.")
return Vector3((self.values[0] / scalar, self.values[1] / scalar, self.values[2] / scalar))
def __neg__(self) -> "Vector3":
return Vector3((-self.values[0], -self.values[1], -self.values[2]))
def parse_relation_formula(text: str) -> RelationFormula:
normalized = " ".join(str(text or "").strip().split())
if not normalized:
raise RelationFormulaError("请输入关系式。")
if normalized.count("=") != 1:
raise RelationFormulaError("关系式必须且只能包含一个等号,例如 Face87.直径 = Face85.直径。")
left, expression = (part.strip() for part in normalized.split("=", 1))
if not left or not expression:
raise RelationFormulaError("关系式左侧和右侧都不能为空。")
target_match = RELATION_REF_PATTERN.fullmatch(left)
if target_match is None:
raise RelationFormulaError("关系式左侧必须是 FaceID.参数 或 EdgeID.参数,例如 Face87.直径。")
target = _ref_from_match(target_match)
references: list[ObjectParameterRef] = []
def replace_ref(match: re.Match[str]) -> str:
references.append(_ref_from_match(match))
return f"__ref{len(references) - 1}"
safe_expression = RELATION_REF_PATTERN.sub(replace_ref, expression)
try:
tree = ast.parse(safe_expression, mode="eval")
except SyntaxError as exc:
raise RelationFormulaError(f"关系式右侧语法错误:{exc.msg}") from exc
_validate_expression_tree(tree, len(references))
return RelationFormula(
text=f"{target.token} = {expression}",
target=target,
expression=expression,
safe_expression=safe_expression,
references=tuple(references),
)
def evaluate_relation_formula(
formula: RelationFormula,
value_resolver: Callable[[ObjectParameterRef], object],
) -> float | Vector3:
namespace: dict[str, object] = {}
for index, ref in enumerate(formula.references):
namespace[f"__ref{index}"] = _coerce_formula_value(value_resolver(ref))
code = compile(formula.safe_expression, "<relation-formula>", "eval")
try:
value = eval(code, {"__builtins__": {}}, namespace)
except ZeroDivisionError as exc:
raise RelationFormulaError("关系式中出现除以 0。") from exc
except Exception as exc:
raise RelationFormulaError(f"关系式计算失败:{exc}") from exc
return _coerce_formula_value(value)
def relation_value_to_text(value: object) -> str:
value = _coerce_formula_value(value)
if isinstance(value, Vector3):
return ", ".join(_format_number(item) for item in value.values)
return _format_number(float(value))
def rewrite_relation_formula_ids(text: str, face_id_map: dict[int, int], edge_id_map: dict[int, int] | None = None) -> str:
edge_id_map = dict(edge_id_map or {})
def replace(match: re.Match[str]) -> str:
kind = str(match.group("kind"))
object_id = int(match.group("object_id"))
parameter = str(match.group("parameter"))
if kind == "Face" and object_id in face_id_map:
object_id = int(face_id_map[object_id])
elif kind == "Edge" and object_id in edge_id_map:
object_id = int(edge_id_map[object_id])
return f"{kind}{object_id}.{parameter}"
return RELATION_REF_PATTERN.sub(replace, text)
def _ref_from_match(match: re.Match[str]) -> ObjectParameterRef:
return ObjectParameterRef(
kind=str(match.group("kind")),
object_id=int(match.group("object_id")),
parameter=str(match.group("parameter")),
)
def _validate_expression_tree(tree: ast.AST, ref_count: int) -> None:
allowed = (
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Name,
ast.Load,
ast.Constant,
ast.Tuple,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.UAdd,
ast.USub,
)
for node in ast.walk(tree):
if not isinstance(node, allowed):
raise RelationFormulaError("关系式只支持数字、对象参数、括号、向量和 + - * / 运算。")
if isinstance(node, ast.Name):
if not re.fullmatch(r"__ref\d+", node.id):
raise RelationFormulaError(f"未知参数引用:{node.id}")
index = int(node.id.replace("__ref", ""))
if index < 0 or index >= ref_count:
raise RelationFormulaError(f"未知参数引用:{node.id}")
elif isinstance(node, ast.Constant):
if not isinstance(node.value, (int, float)):
raise RelationFormulaError("关系式常量只支持数字。")
if isinstance(node.value, float) and not math.isfinite(node.value):
raise RelationFormulaError("关系式数字不能是 NaN 或无穷大。")
elif isinstance(node, ast.Tuple):
if len(node.elts) != 3:
raise RelationFormulaError("向量必须是 3 个数字,例如 (0, 0, -3.5)。")
def _coerce_formula_value(value: object) -> float | Vector3:
if isinstance(value, Vector3):
return value
if isinstance(value, (tuple, list)):
return Vector3(value)
return _coerce_number(value)
def _coerce_vector(value: object) -> tuple[float, float, float]:
if isinstance(value, Vector3):
return value.values
if isinstance(value, (tuple, list)):
return Vector3(value).values
raise TypeError("Vector operation requires another 3D vector.")
def _coerce_number(value: object) -> float:
if isinstance(value, bool):
raise TypeError("Boolean is not a valid numeric formula value.")
if isinstance(value, (int, float)):
number = float(value)
else:
raise TypeError(f"{value!r} is not a valid numeric formula value.")
if not math.isfinite(number):
raise TypeError("Formula value must be finite.")
return number
def _format_number(value: float) -> str:
if abs(value) < 5e-13:
value = 0.0
return f"{value:.12g}"
+15
View File
@@ -540,6 +540,21 @@ INFO_LABELS = {
"recognition_user_priority_reason": "优先级说明",
"recognition_evidence": "识别依据",
"recognition_evidence_keys": "识别依据项",
"recognition_external_relation_score_bonus": "Analysis Situs 关系加权",
"external_recognition_relation_summary": "Analysis Situs 关系摘要",
"external_recognition_relation_types": "Analysis Situs 关系类型",
"external_recognition_relation_count": "Analysis Situs 关系数",
"external_recognition_relation_face_count": "Analysis Situs 关系 Face 数",
"external_recognition_relation_source": "Analysis Situs 关系来源",
"analysis_situs_feature_hint_status": "Analysis Situs 特征提示状态",
"analysis_situs_feature_hint_preferred": "Analysis Situs 推荐语义",
"analysis_situs_feature_hint_label": "Analysis Situs 推荐语义名称",
"analysis_situs_feature_hint_score": "Analysis Situs 特征提示分",
"analysis_situs_feature_hint_summary": "Analysis Situs 特征提示",
"analysis_situs_feature_hint_related_face_ids": "Analysis Situs 相关 Face",
"analysis_situs_slot_hint_score": "Analysis Situs 槽提示分",
"analysis_situs_boss_hint_score": "Analysis Situs 凸台提示分",
"analysis_situs_fillet_hint_score": "Analysis Situs 圆角提示分",
"recognition_ready_actions": "当前可改",
"recognition_limited_actions": "当前受限修改",
"recognition_blockers": "识别限制",
+213 -2
View File
@@ -74,6 +74,13 @@ def _unit_triple_or_none(value: object) -> tuple[float, float, float] | None:
return (triple[0] / length, triple[1] / length, triple[2] / length)
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
def _compact_plan_value(value: object) -> str:
text = _format_value(value)
return text if len(text) <= 120 else text[:117] + "..."
@@ -1592,8 +1599,9 @@ class WindowActionMixin:
if plan.get("status") == "blocked":
self._show_blocked_plan_message(title, plan, blocked_status)
return False
supports_isolation = bool(plan.get("supports_isolation")) or self._quick_edit_title_supports_isolation(title)
if (
not self._quick_edit_title_supports_isolation(title)
not supports_isolation
and self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan)
):
return False
@@ -1620,7 +1628,7 @@ class WindowActionMixin:
+ f"\n{plan.get('message', '')}\n\n"
+ (
"本次会在隔离子进程里执行高风险 OCC 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if str(plan.get("risk")) == "high" and self._quick_edit_title_supports_isolation(title)
if str(plan.get("risk")) == "high" and supports_isolation
else ""
)
+ "为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;"
@@ -1687,6 +1695,10 @@ class WindowActionMixin:
"resize_sphere_radius",
"resize_torus_radius",
"resize_cylindrical_hole",
"edit_cylindrical_holes_by_refs",
"resize_cylindrical_holes_by_refs",
"move_cylindrical_holes_by_offset",
"suppress_cylindrical_holes_by_refs",
"resize_cylindrical_owning_scale",
"move_cylindrical_hole_axis",
"suppress_cylindrical_hole",
@@ -2351,6 +2363,200 @@ class WindowActionMixin:
isolation=isolation,
)
def _multi_selected_hole_refs(self) -> list[dict[str, object]]:
refs: list[dict[str, object]] = []
for item in getattr(self, "multi_selected_hole_entries", []) or []:
if not isinstance(item, dict):
continue
center = _triple_or_none(item.get("axis_center"))
diameter = _float_or_none(item.get("diameter"))
face_id = _int_or_none(item.get("face_id"))
logical_id = _int_or_none(item.get("logical_id"))
if center is None or diameter is None or diameter <= 0:
continue
refs.append(
{
"face_id": face_id,
"logical_id": logical_id,
"diameter": float(diameter),
"axis_center": [float(center[0]), float(center[1]), float(center[2])],
"part_id": item.get("part_id"),
"solid_id": item.get("solid_id"),
}
)
return refs
def _run_multi_selected_hole_edit(
self,
*,
target_diameter: float | None = None,
offset: tuple[float, float, float] | None = None,
) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再批量修改孔。"):
return
refs = self._multi_selected_hole_refs()
if len(refs) < 2:
QMessageBox.information(self, "不能修改", "请先按 Ctrl 选择至少两个完整圆柱孔。")
return
if target_diameter is None and offset is None:
QMessageBox.information(self, "不能修改", "请先输入孔径目标值或位置偏移量。")
return
if target_diameter is not None and target_diameter <= 0:
QMessageBox.information(self, "不能修改", "孔径必须大于 0。")
return
if offset is not None and _vector_length(offset) <= 1e-9:
offset = None
if target_diameter is None and offset is None:
QMessageBox.information(self, "不能修改", "位置偏移量为 0,不需要修改。")
return
operation_label_parts: list[str] = []
if target_diameter is not None:
operation_label_parts.append("改孔径")
if offset is not None:
operation_label_parts.append("移动位置")
operation_name = "批量孔" + " + ".join(operation_label_parts)
logical_ids = [item.get("logical_id") for item in refs if item.get("logical_id") is not None]
refs_arg = [dict(item) for item in refs]
offset_arg = list(offset) if offset is not None else None
isolation = {
"operation": "edit_cylindrical_holes_by_refs",
"args": [refs_arg, target_diameter, offset_arg],
"timeout_seconds": 300.0,
"reason": "multi-hole-isolated-occ-edit",
}
self.clear_edit_preview(render=False)
def action():
return self.model.edit_cylindrical_holes_by_refs(
refs_arg,
target_diameter=target_diameter,
offset=offset,
)
self._run_edit_action(
action,
operation_name=operation_name,
target=f"{len(refs)} holes",
parameters={
"surface": "cylinder",
"feature_type": "multi cylindrical hole",
"feature_guess": "hole/groove candidate",
"multi_selected_count": len(refs),
"multi_selected_logical_ids": tuple(logical_ids),
"target_diameter": target_diameter,
"axis_move_vector": offset,
"resize_strategy": "multi-hole-fill-and-recut",
"edit_strategy_label": "批量圆柱孔参数化",
"edit_semantics": "按当前多选孔引用逐个重新定位孔组,统一修改孔径或按相同偏移移动位置;失败时整体回滚。",
"multi_hole_status": "ready",
"multi_hole_risk": "medium",
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind=None,
target_id=None,
isolation=isolation,
)
def resize_multi_selected_holes(self) -> None:
try:
target_diameter = float(self.hole_diameter_input.text())
except (AttributeError, ValueError):
QMessageBox.information(self, "不能修改", "请输入数字形式的目标孔径。")
return
self._run_multi_selected_hole_edit(target_diameter=target_diameter)
def move_multi_selected_holes_by_offset(self) -> None:
try:
offset = (
float(self.translate_x_input.text()),
float(self.translate_y_input.text()),
float(self.translate_z_input.text()),
)
except (AttributeError, ValueError):
QMessageBox.information(self, "不能修改", "请输入 X/Y/Z 三个数字形式的位置偏移量。")
return
self._run_multi_selected_hole_edit(offset=offset)
def suppress_multi_selected_holes(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再批量封堵孔。"):
return
refs = self._multi_selected_hole_refs()
if len(refs) < 2:
QMessageBox.information(self, "不能修改", "请先按 Ctrl 选择至少两个完整圆柱孔。")
return
refs_arg = [dict(item) for item in refs]
logical_ids = [item.get("logical_id") for item in refs if item.get("logical_id") is not None]
isolation = {
"operation": "suppress_cylindrical_holes_by_refs",
"args": [refs_arg],
"timeout_seconds": 300.0,
"reason": "multi-hole-suppress-isolated-occ-edit",
}
self.clear_edit_preview(render=False)
def action():
return self.model.suppress_cylindrical_holes_by_refs(refs_arg)
self._run_edit_action(
action,
operation_name="批量封堵孔",
target=f"{len(refs)} holes",
parameters={
"surface": "cylinder",
"feature_type": "multi cylindrical hole",
"feature_guess": "hole/groove candidate",
"multi_selected_count": len(refs),
"multi_selected_logical_ids": tuple(logical_ids),
"suppress_strategy": "multi-hole-fill",
"edit_strategy_label": "批量圆柱孔封堵",
"edit_semantics": "按当前多选孔引用逐个封堵;任意孔失败时整次批量操作会回滚。",
"multi_hole_status": "ready",
"multi_hole_risk": "medium",
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind=None,
target_id=None,
isolation=isolation,
)
def apply_multi_selected_hole_property_edit(self, changed: list[tuple[int, dict[str, object], str]]) -> None:
target_diameter: float | None = None
offset: tuple[float, float, float] | None = None
for _row, spec, text in changed:
validation_error = self._property_target_validation_error(spec, text)
if validation_error:
QMessageBox.information(self, "目标值无效", validation_error)
return
key = str(spec.get("key") or "")
try:
if key == "multi_hole_diameter":
diameter_value = float(text)
if target_diameter is not None and abs(target_diameter - diameter_value) > 1e-9:
QMessageBox.information(self, "目标值无效", "孔径和半径换算后的目标孔径不一致,请只修改其中一个。")
return
target_diameter = diameter_value
elif key == "multi_hole_radius":
diameter_value = float(text) * 2.0
if target_diameter is not None and abs(target_diameter - diameter_value) > 1e-9:
QMessageBox.information(self, "目标值无效", "孔径和半径换算后的目标孔径不一致,请只修改其中一个。")
return
target_diameter = diameter_value
elif key == "multi_hole_position_delta":
offset = self._parse_property_vector3(text)
except ValueError as exc:
QMessageBox.information(self, "目标值无效", str(exc))
return
self._run_multi_selected_hole_edit(target_diameter=target_diameter, offset=offset)
def move_cylindrical_slot_axis(self) -> None:
if self.model is None:
return
@@ -8979,6 +9185,11 @@ class WindowActionMixin:
if isinstance(timings, dict):
timings["finish_ui"] = time.perf_counter() - finish_started
locator_note = self._locate_operation_record(record)
relation_note = ""
if hasattr(self, "_refresh_relation_formulas_after_model_edit"):
relation_note = self._refresh_relation_formulas_after_model_edit()
if relation_note:
locator_note = f"{locator_note}\n{relation_note}" if locator_note else relation_note
except Exception as exc:
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
self._end_edit_task(clear_preview=True)
+197 -4
View File
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import (
)
from .model import StepModel
from .asitus_bridge import run_asitus_hole_recognition
from .records import OperationRecord
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
@@ -178,6 +179,10 @@ class WindowCoreMixin:
elif watched is getattr(self, "property_table", None):
if event.type() == QEvent.Type.Resize and hasattr(self, "_resize_property_table_columns"):
QTimer.singleShot(0, self._resize_property_table_columns)
elif watched is getattr(self, "relation_formula_input", None):
if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab:
if hasattr(self, "_accept_relation_formula_completion") and self._accept_relation_formula_completion():
return True
return super().eventFilter(watched, event)
def _should_suppress_transient_tooltip(self, watched) -> bool:
@@ -959,6 +964,11 @@ class WindowCoreMixin:
self.statusBar().showMessage("扫描正在进行,请等待扫描完成后再关闭窗口。")
event.ignore()
return
asitus_thread_running = bool(self.asitus_thread is not None and self.asitus_thread.isRunning())
if asitus_thread_running:
self.statusBar().showMessage("孔组识别正在后台预热,请稍后再关闭窗口。")
event.ignore()
return
super().closeEvent(event)
def _request_thread_quit(self, thread: QThread | None) -> None:
@@ -1174,6 +1184,85 @@ class WindowCoreMixin:
self._update_action_states()
if edge_deferred:
QTimer.singleShot(80, self._rebuild_deferred_edge_display)
QTimer.singleShot(160, self._start_asitus_hole_recognition_preload)
def _start_asitus_hole_recognition_preload(self) -> None:
if self.model is None or self.step_path is None:
return
if self.asitus_thread is not None and self.asitus_thread.isRunning():
return
if not hasattr(self.model, "begin_asitus_hole_region_load"):
return
if not self.model.begin_asitus_hole_region_load():
return
step_path = Path(self.step_path)
context = {
"path": step_path,
"model_id": id(self.model),
}
self.pending_asitus_context = dict(context)
def action(path=step_path):
return run_asitus_hole_recognition(path)
thread = QThread(self)
worker = ScanWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_asitus_hole_recognition, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_asitus_hole_recognition, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_asitus_thread)
self.asitus_thread = thread
self.asitus_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
@Slot(object)
def _finish_asitus_hole_recognition(self, result: object) -> None:
try:
context = dict(self.pending_asitus_context or {})
if self.model is None or id(self.model) != context.get("model_id"):
return
if self.step_path is None or Path(context.get("path", "")) != Path(self.step_path):
return
mapped_groups = self.model.install_asitus_hole_recognition_result(result)
if mapped_groups:
self._refresh_selection_after_asitus_holes(mapped_groups)
finally:
self._request_thread_quit(self.asitus_thread)
@Slot(str)
def _fail_asitus_hole_recognition(self, message: str) -> None:
try:
context = dict(self.pending_asitus_context or {})
if self.model is not None and id(self.model) == context.get("model_id"):
self.model.fail_asitus_hole_region_load(message)
finally:
self._request_thread_quit(self.asitus_thread)
def _refresh_selection_after_asitus_holes(self, mapped_groups: list[tuple[int, ...]]) -> None:
if self.model is None or self.selected_face_id is None:
return
selected_id = int(self.selected_face_id)
if not any(selected_id in group for group in mapped_groups):
return
if getattr(self, "operation_in_progress", False):
return
pick_position = self.selected_pick_position
if self.selected_kind == "feature":
self.select_feature(selected_id, pick_position=pick_position)
elif self.selected_kind == "face":
self.select_face(selected_id, pick_position=pick_position)
@Slot()
def _forget_asitus_thread(self) -> None:
self.asitus_thread = None
self.asitus_worker = None
self.pending_asitus_context = None
@Slot(object)
def _finish_initial_load(self, result: object) -> None:
@@ -1567,6 +1656,12 @@ class WindowCoreMixin:
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
label = f"特征 Face {self.selected_face_id}"
elif self.selected_kind == "multi_feature":
info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {}
face_ids = _int_values(info.get("feature_highlight_face_ids"))
if not face_ids:
face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", []))
label = f"多选孔 {len(getattr(self, 'multi_selected_hole_entries', []) or [])}"
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = [self.selected_face_id]
edge_ids = self.model.face_boundary_edge_ids(self.selected_face_id)
@@ -1639,6 +1734,13 @@ class WindowCoreMixin:
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
if edge_ids:
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
elif self.selected_kind == "multi_feature":
info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {}
face_ids = _int_values(info.get("feature_highlight_face_ids"))
if not face_ids:
face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", []))
if face_ids:
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
face_polydata = self._cached_face_overlay_polydata(face_ids=face_ids, smooth=False)
@@ -1675,6 +1777,13 @@ class WindowCoreMixin:
info = {}
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "multi_feature":
info = self._multi_hole_selection_info() if hasattr(self, "_multi_hole_selection_info") else {}
face_ids = _int_values(info.get("feature_highlight_face_ids"))
if not face_ids:
face_ids = _int_values(getattr(self, "multi_selected_feature_face_ids", []))
if face_ids:
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = self._selection_same_domain_face_ids(self.selected_face_id) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
@@ -2077,8 +2186,94 @@ class WindowCoreMixin:
return
self._clear_hover(render=False)
if self._is_multi_feature_toggle_request(target):
self._toggle_multi_feature_selection(target, pick_position=target.get("pick_position"))
return
self._select_pick_target(target)
def _is_multi_feature_toggle_request(self, target: dict[str, object]) -> bool:
if str(target.get("kind") or "") not in {"feature", "face"}:
return False
try:
return bool(QApplication.keyboardModifiers() & Qt.KeyboardModifier.ControlModifier)
except Exception:
return False
def _toggle_multi_feature_selection(
self,
target: dict[str, object],
*,
pick_position: tuple[float, float, float] | None = None,
) -> None:
if self.model is None:
return
face_id = _safe_int_or_none(target.get("target_id"))
if face_id is None:
return
entry = self._hole_multi_select_entry(face_id) if hasattr(self, "_hole_multi_select_entry") else None
if entry is None:
self.statusBar().showMessage("多选第一版只支持完整圆柱孔;请按 Ctrl 选择孔壁。")
return
entries = [dict(item) for item in (getattr(self, "multi_selected_hole_entries", []) or [])]
if not entries and self.selected_kind == "feature" and self.selected_face_id is not None:
current_entry = self._hole_multi_select_entry(int(self.selected_face_id))
if current_entry is not None:
entries.append(dict(current_entry))
logical_id = int(entry.get("logical_id", face_id))
existing_index = next(
(
index
for index, item in enumerate(entries)
if int(item.get("logical_id", -1)) == logical_id
),
None,
)
if existing_index is None:
entries.append(dict(entry))
else:
entries.pop(existing_index)
if len(entries) < 2:
if entries:
self.select_feature(int(entries[0]["face_id"]), pick_position=pick_position)
else:
self._reset_selection()
return
part_ids = {
int(item["part_id"])
for item in entries
if item.get("part_id") not in {None, ""}
}
solid_ids = {
int(item["solid_id"])
for item in entries
if item.get("solid_id") not in {None, ""}
}
self._reset_selection(clear_highlight=False, clear_info=False)
self.multi_selected_hole_entries = entries
self.multi_selected_feature_face_ids = [int(item["face_id"]) for item in entries]
self.multi_selection_active = True
self.selected_kind = "multi_feature"
self.selected_face_id = int(entries[-1]["face_id"])
self.selected_edge_id = None
self.selected_part_id = next(iter(part_ids)) if len(part_ids) == 1 else None
self.selected_solid_id = next(iter(solid_ids)) if len(solid_ids) == 1 else None
self.selected_pick_position = pick_position
info = self._multi_hole_selection_info()
if hasattr(self, "_set_selection_mode"):
self._set_selection_mode("Feature")
if hasattr(self, "_sync_id_picker"):
self._sync_id_picker("Feature", int(entries[-1].get("logical_id", entries[-1]["face_id"])))
self._highlight_faces(face_ids=_int_values(info.get("feature_highlight_face_ids")) or self.multi_selected_feature_face_ids)
self._show_pick_marker(pick_position)
self.set_info(self._with_pick_info(info, pick_position))
ids_text = ", ".join(str(item.get("logical_id")) for item in entries if item.get("logical_id") is not None)
self.statusBar().showMessage(f"已多选 {len(entries)} 个孔:{ids_text}。再次 Ctrl 点击可移除。")
def on_pointer_button_press(self, _obj, _event) -> None:
if not self._is_ui_thread():
self._invoke_on_ui_thread(self._handle_pointer_button_press)
@@ -2642,8 +2837,7 @@ class WindowCoreMixin:
self._sync_id_picker("Feature" if feature_mode else "Face", logical_id)
self.set_info(self._with_pick_info(input_info, pick_position))
self._update_action_states()
raw_note = f"(当前拓扑 Face {face_id}" if logical_id != face_id else ""
message = f"已选择Face {logical_id}{raw_note}" if not feature_mode else f"已选择特征来源 Face {logical_id}{raw_note}"
message = f"已选择Face {logical_id}" if not feature_mode else f"已选择特征来源 Face {logical_id}"
self.statusBar().showMessage(self._selection_status(message, pick_position))
def _selection_same_domain_face_ids(self, face_id: int) -> list[int]:
@@ -2687,8 +2881,7 @@ class WindowCoreMixin:
if hasattr(self, "_feature_display_label"):
feature_type = self._feature_display_label(feature_type)
self._update_action_states()
raw_note = f"(当前拓扑 Face {face_id}" if logical_id != face_id else ""
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}{raw_note}", pick_position))
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 Face {logical_id}", pick_position))
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
fillet_radius_suggestion: str | None = None
File diff suppressed because it is too large Load Diff