feat: 完善 STEP 一级参数化编辑识别与关系式建模
This commit is contained in:
+102
-1
@@ -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("参数化建模")
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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 []
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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}"
|
||||
@@ -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": "识别限制",
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
+994
-53
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user