From 722256a41fbf82700e99ac6eb06a4d0151692cc7 Mon Sep 17 00:00:00 2001 From: laluo <2135665716@qq.com> Date: Mon, 17 Aug 2026 18:53:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E5=8C=96=E5=85=B3=E7=B3=BB=E5=BC=8F=E4=B8=8ESCDM=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E4=BF=AE=E6=94=B9=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ify_icepak_cylindrical_region_selection.py | 58 + scripts/verify_property_card_editor_ui.py | 712 +++++++++++- step_editor/app.py | 23 +- step_editor/model.py | 18 + step_editor/window_actions.py | 18 +- step_editor/window_core.py | 44 +- step_editor/window_state.py | 1008 +++++++++++++++-- 7 files changed, 1798 insertions(+), 83 deletions(-) diff --git a/scripts/verify_icepak_cylindrical_region_selection.py b/scripts/verify_icepak_cylindrical_region_selection.py index dd724e3..edf6ddc 100644 --- a/scripts/verify_icepak_cylindrical_region_selection.py +++ b/scripts/verify_icepak_cylindrical_region_selection.py @@ -80,6 +80,63 @@ def _isolated_hole_resize(model: StepModel, face_id: int, diameter: float, root: return str(response.get("message") or "") +def _assert_isolated_hole_resize_disables_stale_external_regions( + model: StepModel, + face_id: int, + diameter: float, + root: Path, +) -> None: + input_path = root / "face87_stale_input.brep" + output_path = root / "face87_stale_output.brep" + request_path = root / "face87_stale_resize_request.json" + model.export_internal_brep(input_path) + request_path.write_text( + json.dumps( + { + "input_path": str(input_path), + "output_path": str(output_path), + "input_format": "brep", + "output_format": "brep", + "operation": "resize_cylindrical_hole", + "args": [face_id, diameter], + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + code = run_request(request_path) + response = json.loads(request_path.with_suffix(".response.json").read_text(encoding="utf-8")) + _assert(code == 0 and response.get("ok") is True, f"isolated stale-region resize should pass: {response}") + message = str(response.get("message") or "") + marker = "verified_face=" + marker_index = message.find(marker) + _assert(marker_index >= 0, f"isolated resize should report verified_face: {message}") + verified_text = message[marker_index + len(marker):].split(".", 1)[0].strip() + verified_face = int(float(verified_text)) + + result_model = StepModel.load_internal_brep(output_path) + result_model.filename = MODEL_PATH + stale_region = result_model.face_region_ids(verified_face) + _assert( + len(stale_region) > 1, + f"test fixture should expose the stale external-region bug before marking: {stale_region}", + ) + + result_model.mark_external_recognition_stale("isolated-edit-result") + current_region = result_model.face_region_ids(verified_face) + _assert( + current_region == [verified_face], + f"isolated edit result should not reuse original STEP Analysis Situs hole groups: {current_region}", + ) + result_model.assign_logical_face_region_exclusive(face_id, current_region) + _assert( + result_model.face_ids_for_logical_id(face_id) == current_region, + f"logical Face {face_id} should only attach to the edited hole, not a neighboring hole: " + f"{result_model.face_ids_for_logical_id(face_id)}", + ) + + def _isolated_hole_axis_move( model: StepModel, face_id: int, @@ -331,6 +388,7 @@ def main() -> int: with tempfile.TemporaryDirectory(prefix="icepak_face87_isolated_") as temp_dir: worker_message = _isolated_hole_resize(model, 87, 0.3, Path(temp_dir)) _assert("diameter 0.5 -> 0.3" in worker_message, "Face 87 isolated diameter shrink should complete") + _assert_isolated_hole_resize_disables_stale_external_regions(model, 87, 0.3, Path(temp_dir)) with tempfile.TemporaryDirectory(prefix="icepak_face85_axis_isolated_") as temp_dir: worker_message = _isolated_hole_axis_move(model, 85, axis_move_target, Path(temp_dir)) diff --git a/scripts/verify_property_card_editor_ui.py b/scripts/verify_property_card_editor_ui.py index 8a6e603..3e2d5e0 100644 --- a/scripts/verify_property_card_editor_ui.py +++ b/scripts/verify_property_card_editor_ui.py @@ -9,7 +9,9 @@ import tempfile os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from PySide6.QtCore import QEvent, QObject, QStringListModel +from PySide6.QtCore import QEvent, QObject, Qt, QStringListModel +from PySide6.QtGui import QKeyEvent +from PySide6.QtTest import QTest from PySide6.QtWidgets import ( QApplication, QCheckBox, @@ -22,6 +24,7 @@ from PySide6.QtWidgets import ( QPushButton, QScrollArea, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -31,6 +34,7 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from step_editor.widgets import NoWheelComboBox +from step_editor.relation_formulas import ObjectParameterRef, parse_relation_formula from step_editor.window_actions import WindowActionMixin from step_editor.window_core import WindowCoreMixin from step_editor.window_state import ( @@ -40,6 +44,7 @@ from step_editor.window_state import ( PROPERTY_SCOPE_COLUMN, PROPERTY_TABLE_HEADERS, PROPERTY_TARGET_COLUMN, + RELATION_FORMULA_OBJECT_COMPLETION_LIMIT, WindowStateMixin, ) @@ -71,6 +76,7 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.selected_solid_id = None self.selected_face_id = 0 self.selected_edge_id = None + self.selected_pick_position = None self.step_path = PROJECT_ROOT / "assets" / "models" / "probe.step" self.current_info_values = self._plane_info() self.executed_property_actions: list[tuple[str, str]] = [] @@ -100,7 +106,14 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): self.relation_formula_input = QLineEdit() self.relation_formula_completer_model = QStringListModel(self) self.relation_formula_completer = QCompleter(self.relation_formula_completer_model, self) - self.relation_formula_input.setCompleter(self.relation_formula_completer) + self.relation_formula_completer.setMaxVisibleItems(12) + self.relation_formula_completer.activated[str].connect(self._on_relation_formula_completion_activated) + relation_popup = self.relation_formula_completer.popup() + if relation_popup is not None: + relation_popup.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.relation_formula_completer.setWidget(self.relation_formula_input) + self.relation_formula_input.textChanged.connect(self._on_relation_formula_input_changed) + layout.addWidget(self.relation_formula_input) self.add_relation_formula_button = QPushButton() self.remove_relation_formula_button = QPushButton() self.relation_formula_list = QListWidget() @@ -136,6 +149,9 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): def _set_control_state(self, widget, enabled: bool, _enabled_tip: str, _disabled_tip: str) -> None: widget.setEnabled(enabled) + def setTitle(self, title: str) -> None: + self.object_edit_title = title + def resize_face_width_local(self) -> None: self.executed_property_actions.append(("resize_face_width_local", self.face_width_input.text())) self._after_property_edit_finished(success=True) @@ -148,6 +164,81 @@ class _PropertyTableProbe(QWidget, WindowStateMixin): return _StatusBar() +class _RelationFormulaEventProbe(WindowCoreMixin, _PropertyTableProbe): + pass + + +class _GlobalRelationCompletionModel: + def __init__(self) -> None: + self.faces = [object() for _index in range(100)] + self.edges = [object() for _index in range(12)] + self.face_logical_ids = list(range(len(self.faces))) + self.face_region_call_count = 0 + + def face_logical_id(self, face_id: int) -> int: + return int(self.face_logical_ids[int(face_id)]) + + def face_region_logical_id(self, face_id: int) -> int: + self.face_region_call_count += 1 + return int(face_id) + + def resolve_face_selection_id(self, object_id: int) -> int | None: + if 0 <= int(object_id) < len(self.faces): + return int(object_id) + return None + + def quick_face_info(self, face_id: int) -> dict[str, object]: + return {"surface": "cylinder" if int(face_id) in {85, 87} else "plane"} + + +class _LargeRelationCompletionModel(_GlobalRelationCompletionModel): + def __init__(self) -> None: + self.faces = [object() for _index in range(10000)] + self.edges = [object() for _index in range(1000)] + self.face_logical_ids = list(range(len(self.faces))) + self.face_region_call_count = 0 + + def face_region_logical_id(self, face_id: int) -> int: + self.face_region_call_count += 1 + raise AssertionError("relation formula completion should not call face_region_logical_id") + + +class _RelationFormulaRemapModel: + def __init__(self, face_infos: dict[int, dict[str, object]], face_count: int = 120) -> None: + self.faces = [object() for _index in range(face_count)] + self.edges = [] + self.face_logical_ids = list(range(face_count)) + self.face_part_ids = [0 for _index in range(face_count)] + self.face_solid_ids = [0 for _index in range(face_count)] + self.face_infos = dict(face_infos) + + def face_logical_id(self, face_id: int) -> int: + return int(self.face_logical_ids[int(face_id)]) + + def face_region_logical_id(self, face_id: int) -> int: + return self.face_logical_id(int(face_id)) + + def face_ids_for_logical_id(self, logical_id: int) -> list[int]: + return [face_id for face_id, item in enumerate(self.face_logical_ids) if int(item) == int(logical_id)] + + def resolve_face_selection_id(self, object_id: int) -> int | None: + matches = self.face_ids_for_logical_id(int(object_id)) + if matches: + return int(matches[0]) + if 0 <= int(object_id) < len(self.faces): + return int(object_id) + return None + + def quick_face_info(self, face_id: int) -> dict[str, object]: + info = dict(self.face_infos.get(int(face_id), {})) + if not info: + info = {"surface": "plane", "area_center": (1000.0 + int(face_id), 0.0, 0.0), "area": 1.0} + info.setdefault("part_id", 0) + info.setdefault("solid_id", 0) + info.setdefault("axis", (0.0, 0.0, 1.0)) + return info + + class _ActionMessageProbe(WindowActionMixin): pass @@ -463,26 +554,627 @@ def _assert_relation_formula_editor() -> None: completions = set(probe.relation_formula_completer_model.stringList()) _assert("Face0.面内长度" in completions, f"relation formula completion missing face length: {completions}") _assert("Face0.面内宽度" in completions, f"relation formula completion missing face width: {completions}") + probe.relation_formula_input.setText("F") + probe.relation_formula_input.setCursorPosition(1) + probe._update_relation_formula_completions() + f_completions = set(probe.relation_formula_completer_model.stringList()) + _assert( + "Face" in f_completions or "Face0" in f_completions, + f"relation formula should suggest face object tokens after 'F': {f_completions}", + ) + _assert(probe.relation_formula_completer.completionCount() > 0, "relation formula completer should have matches after 'F'") + _assert(probe._accept_relation_formula_completion(), "Tab should accept relation formula completion after 'F'") + _assert( + probe.relation_formula_input.text() in {"Face", "Face0"}, + f"Tab should replace 'F' with a face completion: {probe.relation_formula_input.text()}", + ) + if probe.relation_formula_input.text() == "Face": + probe._update_relation_formula_completions() + face_object_completions = set(probe.relation_formula_completer_model.stringList()) + _assert( + "Face0" in face_object_completions, + f"relation formula should suggest concrete Face IDs after completing Face: {face_object_completions}", + ) + _assert(probe._accept_relation_formula_completion(), "second Tab should accept a concrete Face ID completion") + _assert( + probe.relation_formula_input.text() == "Face0", + f"second Tab should complete Face to Face0: {probe.relation_formula_input.text()}", + ) + probe._update_relation_formula_completions() + exact_object_completions = set(probe.relation_formula_completer_model.stringList()) + _assert( + any(str(item).startswith("Face0.") for item in exact_object_completions), + f"concrete object completion should suggest editable parameters: {exact_object_completions}", + ) + event_probe = _RelationFormulaEventProbe() + event_probe.show() + event_probe._refresh_property_editor() + event_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason) + QApplication.processEvents() + event_probe.relation_formula_input.setText("F") + event_probe.relation_formula_input.setCursorPosition(1) + tab_event = QKeyEvent(QEvent.Type.ShortcutOverride, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier) + _assert( + event_probe.eventFilter(event_probe.relation_formula_input, tab_event), + "relation formula input should consume Tab before Qt moves focus", + ) + _assert( + event_probe.relation_formula_input.text() in {"Face", "Face0"}, + f"ShortcutOverride Tab should accept the face completion: {event_probe.relation_formula_input.text()}", + ) + QApplication.processEvents() + _assert(event_probe.relation_formula_input.hasFocus(), "relation formula input should keep focus after Tab completion") + popup = event_probe.relation_formula_completer.popup() + if popup is not None: + popup.installEventFilter(event_probe) + before_backspace = event_probe.relation_formula_input.text() + popup_backspace = QKeyEvent(QEvent.Type.KeyPress, Qt.Key.Key_Backspace, Qt.KeyboardModifier.NoModifier) + _assert( + event_probe.eventFilter(popup, popup_backspace), + "relation formula completer popup should forward Backspace to the input", + ) + _assert( + event_probe.relation_formula_input.text() == before_backspace[:-1], + f"Backspace should edit the input even if popup receives it: {event_probe.relation_formula_input.text()}", + ) + _assert(event_probe.relation_formula_input.hasFocus(), "relation formula input should keep focus after popup Backspace") + global_probe = _PropertyTableProbe() + global_probe.model = _GlobalRelationCompletionModel() + global_probe.selected_kind = "face" + global_probe.selected_face_id = 0 + global_probe.show() + global_probe._refresh_property_editor() + global_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason) + QApplication.processEvents() + global_probe.model.face_region_call_count = 0 + global_probe.relation_formula_input.setText("Face") + global_probe.relation_formula_input.setCursorPosition(len("Face")) + global_probe._update_relation_formula_completions() + global_probe._show_relation_formula_completion_popup() + QApplication.processEvents() + global_face_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + global_face_completions = set(global_face_completion_list) + _assert("Face85" in global_face_completions, f"global Face completion should include Face85: {global_face_completions}") + _assert("Face85." not in global_face_completions, f"object completion should not show trailing dot: {global_face_completions}") + _assert( + global_face_completion_list.index("Face2") < global_face_completion_list.index("Face10") < global_face_completion_list.index("Face85"), + f"Face completions should be sorted by numeric ID: {global_face_completion_list[:20]}", + ) + global_probe.relation_formula_input.setText("Face8") + global_probe.relation_formula_input.setCursorPosition(len("Face8")) + global_probe._update_relation_formula_completions() + face8_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + _assert("Face87" in face8_completion_list, f"Face8 should still suggest longer Face IDs: {face8_completion_list[:20]}") + _assert( + face8_completion_list[0] != "Face8", + f"Face8 completion should prefer longer IDs instead of no-op self completion: {face8_completion_list[:20]}", + ) + _assert( + face8_completion_list.index("Face80") < face8_completion_list.index("Face87"), + f"Face8 completions should keep numeric order: {face8_completion_list[:20]}", + ) + face8_tab_probe = _PropertyTableProbe() + face8_tab_probe.model = _GlobalRelationCompletionModel() + face8_tab_probe.selected_kind = "face" + face8_tab_probe.selected_face_id = 0 + face8_tab_probe._refresh_property_editor() + face8_tab_probe.relation_formula_input.setText("Face8") + face8_tab_probe.relation_formula_input.setCursorPosition(len("Face8")) + _assert(face8_tab_probe._accept_relation_formula_completion(), "Tab should accept a longer Face8 completion") + _assert( + face8_tab_probe.relation_formula_input.text().startswith("Face8") + and face8_tab_probe.relation_formula_input.text() != "Face8", + f"Tab after Face8 should complete to a longer ID, not stay unchanged: {face8_tab_probe.relation_formula_input.text()}", + ) + face_tab_probe = _RelationFormulaEventProbe() + face_tab_probe.model = _GlobalRelationCompletionModel() + face_tab_probe.selected_kind = "face" + face_tab_probe.selected_face_id = 0 + face_tab_probe.show() + face_tab_probe._refresh_property_editor() + face_tab_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason) + QApplication.processEvents() + face_tab_probe.relation_formula_input.setText("F") + face_tab_probe.relation_formula_input.setCursorPosition(len("F")) + face_tab_event = QKeyEvent(QEvent.Type.ShortcutOverride, Qt.Key.Key_Tab, Qt.KeyboardModifier.NoModifier) + _assert(face_tab_probe.eventFilter(face_tab_probe.relation_formula_input, face_tab_event), "F + Tab should be consumed") + for _index in range(4): + QApplication.processEvents() + _assert(face_tab_probe.relation_formula_input.text() == "Face", f"F + Tab should complete to Face: {face_tab_probe.relation_formula_input.text()}") + _assert("Face87" in set(face_tab_probe.relation_formula_completer_model.stringList()), "Face layer should suggest Face87 after F + Tab") + _assert(face_tab_probe.relation_formula_completer.popup().isVisible(), "Face layer popup should open after F + Tab") + _assert( + face_tab_probe.relation_formula_completer.popup().minimumWidth() + >= max(face_tab_probe.relation_formula_input.width(), 320), + "Face completion popup should be wide enough to show text", + ) + face_manual_probe = _RelationFormulaEventProbe() + face_manual_probe.model = _GlobalRelationCompletionModel() + face_manual_probe.selected_kind = "face" + face_manual_probe.selected_face_id = 0 + face_manual_probe.show() + face_manual_probe._refresh_property_editor() + face_manual_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason) + QApplication.processEvents() + face_manual_probe.relation_formula_input.setText("Face") + face_manual_probe.relation_formula_input.setCursorPosition(len("Face")) + for _index in range(4): + QApplication.processEvents() + _assert("Face87" in set(face_manual_probe.relation_formula_completer_model.stringList()), "manual Face input should suggest Face87") + _assert(face_manual_probe.relation_formula_completer.popup().isVisible(), "manual Face input should open the next completion layer") + _assert( + face_manual_probe.relation_formula_completer.popup().minimumWidth() + >= max(face_manual_probe.relation_formula_input.width(), 320), + "manual Face popup should not collapse to cursor width", + ) + global_probe.relation_formula_input.setText("Face87") + global_probe.relation_formula_input.setCursorPosition(len("Face87")) + global_probe._update_relation_formula_completions() + face87_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + face87_ref = next((item for item in face87_completion_list if str(item).startswith("Face87.")), "") + _assert(face87_ref, f"exact Face ID should suggest editable parameters: {face87_completion_list}") + face87_radius_ref = next((item for item in face87_completion_list if str(item).endswith("半径")), "") + _assert(face87_radius_ref, f"exact Face ID should suggest radius: {face87_completion_list}") + radius_probe = _PropertyTableProbe() + radius_probe.model = _GlobalRelationCompletionModel() + radius_probe.selected_kind = "face" + radius_probe.selected_face_id = 0 + radius_probe.show() + radius_probe._refresh_property_editor() + radius_probe.relation_formula_input.setFocus(Qt.FocusReason.OtherFocusReason) + QApplication.processEvents() + radius_probe.relation_formula_input.setText("Face87") + radius_probe.relation_formula_input.setCursorPosition(len("Face87")) + radius_probe._update_relation_formula_completions() + radius_probe._show_relation_formula_completion_popup() + QApplication.processEvents() + radius_completion_list = list(radius_probe.relation_formula_completer_model.stringList()) + radius_row = radius_completion_list.index(face87_radius_ref) + radius_model = radius_probe.relation_formula_completer.model() + radius_popup = radius_probe.relation_formula_completer.popup() + radius_probe.relation_formula_completer.setCurrentRow(radius_row) + radius_popup.setCurrentIndex(radius_model.index(radius_row, 0)) + _assert(radius_probe._accept_relation_formula_completion(), "Tab should accept the popup-selected radius completion") + _assert( + radius_probe.relation_formula_input.text() == face87_radius_ref, + f"Tab should keep the popup-selected completion, not reset to default: {radius_probe.relation_formula_input.text()}", + ) + global_probe.relation_formula_input.setText(face87_ref) + global_probe.relation_formula_input.setCursorPosition(len(face87_ref)) + global_probe._update_relation_formula_completions() + _assert(" = " in set(global_probe.relation_formula_completer_model.stringList()), "complete Face parameter should suggest '='") + global_probe._on_relation_formula_completion_activated(" = ") + _assert( + global_probe.relation_formula_input.text() == f"{face87_ref} = ", + f"mouse activation should insert '=' without replacing the whole formula: {global_probe.relation_formula_input.text()}", + ) + global_probe.relation_formula_input.setText(face87_ref) + global_probe.relation_formula_input.setCursorPosition(len(face87_ref)) + global_probe._update_relation_formula_completions() + global_probe.relation_formula_input.setText(f"{face87_ref} = F") + global_probe.relation_formula_input.setCursorPosition(len(f"{face87_ref} = F")) + global_probe._update_relation_formula_completions() + rhs_f_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + _assert( + "Face85" in rhs_f_completion_list and "Face87" in rhs_f_completion_list, + f"right-hand expression should suggest global Face IDs after F: {rhs_f_completion_list[:20]}", + ) + global_probe.relation_formula_input.setText(f"{face87_ref} = Face8") + global_probe.relation_formula_input.setCursorPosition(len(f"{face87_ref} = Face8")) + global_probe._update_relation_formula_completions() + rhs_face8_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + _assert("Face87" in rhs_face8_completion_list, f"right-hand Face8 should suggest Face87: {rhs_face8_completion_list[:20]}") + global_probe.relation_formula_input.setText(f"{face87_ref} = Face87") + global_probe.relation_formula_input.setCursorPosition(len(f"{face87_ref} = Face87")) + global_probe._update_relation_formula_completions() + rhs_face87_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + _assert( + any(str(item).startswith("Face87.") for item in rhs_face87_completion_list), + f"right-hand exact Face ID should suggest parameters: {rhs_face87_completion_list}", + ) + global_probe.relation_formula_input.setText("Edge") + global_probe.relation_formula_input.setCursorPosition(len("Edge")) + global_probe._update_relation_formula_completions() + global_edge_completion_list = list(global_probe.relation_formula_completer_model.stringList()) + global_edge_completions = set(global_edge_completion_list) + _assert("Edge0" in global_edge_completions, f"Edge completion should be available even in Face selection mode: {global_edge_completions}") + _assert( + global_edge_completion_list.index("Edge2") < global_edge_completion_list.index("Edge10"), + f"Edge completions should be sorted by numeric ID: {global_edge_completion_list}", + ) + _assert( + global_probe.model.face_region_call_count == 0, + f"relation formula typing should not call heavy face_region_logical_id: {global_probe.model.face_region_call_count}", + ) + large_probe = _PropertyTableProbe() + large_probe.model = _LargeRelationCompletionModel() + large_probe.selected_kind = "face" + large_probe.selected_face_id = 0 + large_probe._refresh_property_editor() + large_probe.model.face_region_call_count = 0 + large_probe.relation_formula_input.setText("F") + large_probe.relation_formula_input.setCursorPosition(len("F")) + large_probe._update_relation_formula_completions() + large_completions = list(large_probe.relation_formula_completer_model.stringList()) + _assert("Face87" in large_completions, f"large model F completion should still include common Face IDs: {large_completions[:20]}") + _assert( + len(large_completions) <= RELATION_FORMULA_OBJECT_COMPLETION_LIMIT + 2, + f"large model broad completion should be capped: {len(large_completions)}", + ) + _assert( + large_probe.model.face_region_call_count == 0, + f"large model completion should not call heavy face_region_logical_id: {large_probe.model.face_region_call_count}", + ) + probe.relation_formula_input.setText("Face0.") + probe.relation_formula_input.setCursorPosition(len("Face0.")) + probe._update_relation_formula_completions() + face_completions = set(probe.relation_formula_completer_model.stringList()) + _assert("Face0.面内长度" in face_completions, f"relation formula should suggest parameters after Face0.: {face_completions}") + probe.relation_formula_input.setText("Face0.面内宽度") + probe.relation_formula_input.setCursorPosition(len("Face0.面内宽度")) + probe._update_relation_formula_completions() + _assert(" = " in set(probe.relation_formula_completer_model.stringList()), "relation formula should suggest '=' after a target parameter") probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2") probe.add_relation_formula() _assert(len(probe.relation_formula_items) == 1, f"formula was not stored: {probe.relation_formula_items}") _assert(probe.relation_formula_list.count() == 1, "formula list should display the stored formula") - probe._update_property_apply_state() - _assert(probe.apply_property_button.isEnabled(), "formula targeting current table should enable parametric modeling") - probe.apply_current_property_edit() for _index in range(6): QApplication.processEvents() - if not getattr(probe, "property_batch_active", False): + if not getattr(probe, "relation_formula_replay_active", False): break _assert( probe.executed_property_actions == [("resize_face_height_local", "12")], - f"formula should fill target value and execute the existing row action: {probe.executed_property_actions}", + f"formula should immediately fill target value and execute the existing row action: {probe.executed_property_actions}", ) width_row = _row_by_label(probe, "面内宽度") width_widget = probe.property_table.cellWidget(width_row, PROPERTY_TARGET_COLUMN) _assert(isinstance(width_widget, QLineEdit), "formula target row should still have a target editor") _assert(width_widget.text().strip() == "12", "formula result should be written back to the target value cell") _assert(str(probe.relation_formula_items[0].get("status")) == "applied", "formula should be marked as applied") + for _index in range(4): + QApplication.processEvents() + _assert(probe.relation_formula_input.isEnabled(), "relation formula input should stay enabled after one formula is applied") + probe.relation_formula_input.setText("F") + _assert(probe.add_relation_formula_button.isEnabled(), "existing formulas should not disable adding another formula") + + +def _assert_relation_radius_formula_proxy() -> None: + probe = _PropertyTableProbe() + probe.model = _GlobalRelationCompletionModel() + probe.selected_kind = "face" + probe.selected_face_id = 87 + probe.property_table.setRowCount(1) + probe.property_editor_specs = [ + { + "key": "diameter", + "label": "直径", + "current_raw": 1.0, + "current_text": "1", + "target_text": "1", + "editable": True, + "enabled": True, + "action": "resize_hole", + "value_type": "number", + "source_face_id": 87, + } + ] + probe.property_table.setItem(0, PROPERTY_LABEL_COLUMN, QTableWidgetItem("直径")) + target = QLineEdit("1") + probe.property_table.setCellWidget(0, PROPERTY_TARGET_COLUMN, target) + + ref = ObjectParameterRef("Face", 87, "半径") + visible = probe._relation_visible_spec_for_ref(ref) + _assert(visible is not None, "Face radius formula should fall back to an executable diameter row") + _assert( + abs(float(probe._relation_value_for_ref(ref)) - 0.5) <= 1e-9, + f"radius formula should read half of the diameter: {probe._relation_value_for_ref(ref)}", + ) + row = probe._set_relation_target_value(ref, 0.6) + _assert(row == 0, f"radius proxy should write to the diameter row: {row}") + _assert(target.text() == "1.2", f"radius proxy should convert target radius to diameter: {target.text()}") + + +def _assert_relation_formula_input_remains_editable_while_replaying() -> None: + probe = _PropertyTableProbe() + probe.model = _GlobalRelationCompletionModel() + probe.selected_kind = "face" + probe.selected_face_id = 87 + probe._refresh_property_editor() + probe.operation_in_progress = True + probe.relation_formula_replay_active = True + probe.relation_formula_replay_queue = [] + probe.relation_formula_replay_total = 1 + probe.relation_formula_input.setEnabled(False) + probe._update_relation_formula_buttons() + _assert(probe.relation_formula_input.isEnabled(), "relation formula input should stay editable while formulas replay") + + probe.relation_formula_input.setText("Face87.半径 = Face85.半径") + probe.add_relation_formula() + _assert(len(probe.relation_formula_items) == 1, f"formula should be stored while replaying: {probe.relation_formula_items}") + _assert(len(probe.relation_formula_replay_queue) == 1, f"formula should be queued while replaying: {probe.relation_formula_replay_queue}") + _assert(probe.relation_formula_input.text() == "", "queued formula should clear the input for the next formula") + _assert(probe.relation_formula_input.isEnabled(), "relation formula input should remain enabled after queuing") + _assert(probe.relation_formula_replay_active, "adding a queued formula should not stop the current replay") + + +def _assert_relation_formula_input_clickable_after_existing_formula() -> None: + probe = _RelationFormulaEventProbe() + probe.relation_formula_input.installEventFilter(probe) + probe.show() + probe._refresh_property_editor() + probe.relation_formula_input.setText("Face0.面内宽度 = Face0.面内长度 * 1.2") + probe.add_relation_formula() + for _index in range(12): + QApplication.processEvents() + if not getattr(probe, "relation_formula_replay_active", False): + break + _assert(len(probe.relation_formula_items) == 1, f"formula should exist before click test: {probe.relation_formula_items}") + _assert(str(probe.relation_formula_items[0].get("status")) == "applied", "formula should be applied before click test") + probe.activateWindow() + probe.raise_() + QApplication.processEvents() + QTest.mouseClick(probe.relation_formula_input, Qt.MouseButton.LeftButton) + QApplication.processEvents() + _assert(probe.relation_formula_input.isEnabled(), "relation formula input should stay enabled with an existing formula") + _assert(probe.relation_formula_input.hasFocus(), "clicking the relation formula input should focus it with an existing formula") + probe.relation_formula_input.setText("F") + _assert(probe.add_relation_formula_button.isEnabled(), "clickable input should allow adding another formula") + + +def _assert_relation_formula_input_is_selection_independent() -> None: + probe = _RelationFormulaEventProbe() + probe.relation_formula_input.installEventFilter(probe) + probe.model = _GlobalRelationCompletionModel() + probe.selected_kind = None + probe.selected_face_id = None + probe.selected_edge_id = None + probe.relation_formula_items = [] + probe.relation_formula_input.setEnabled(False) + probe._refresh_relation_formula_list() + _assert(probe.relation_formula_input.isEnabled(), "relation formula input should be editable without a selected face") + _assert(not probe.relation_formula_input.isReadOnly(), "relation formula input should not become read-only without a selected face") + _assert(not probe.add_relation_formula_button.isEnabled(), "empty relation formula should not enable the add button") + + probe.show() + probe.activateWindow() + probe.raise_() + QApplication.processEvents() + QTest.mouseClick(probe.relation_formula_input, Qt.MouseButton.LeftButton) + QApplication.processEvents() + + probe.relation_formula_input.setText("Face8") + probe.relation_formula_input.setCursorPosition(len("Face8")) + probe._update_relation_formula_completions() + object_completions = list(probe.relation_formula_completer_model.stringList()) + _assert("Face85" in object_completions and "Face87" in object_completions, f"global face completions should not require selection: {object_completions[:20]}") + + probe.relation_formula_input.setText("Face85") + probe.relation_formula_input.setCursorPosition(len("Face85")) + probe._update_relation_formula_completions() + parameter_completions = list(probe.relation_formula_completer_model.stringList()) + _assert(any(str(item).startswith("Face85.") for item in parameter_completions), f"Face parameter completions should not require selection: {parameter_completions}") + + +def _assert_relation_formula_input_recovers_after_loading() -> None: + probe = _RelationFormulaEventProbe() + probe.model = _GlobalRelationCompletionModel() + probe.mode_combo = NoWheelComboBox() + probe.mode_combo.addItem("Face", "Face") + probe.load_thread = None + probe.load_refine_thread = None + probe.pending_load_path = None + probe.load_in_progress = True + probe._update_relation_formula_buttons() + _assert(not probe.relation_formula_input.isEnabled(), "relation formula input should be disabled while loading") + + probe._end_load_task() + _assert(probe.relation_formula_input.isEnabled(), "relation formula input should recover after loading without selecting a face") + + +def _assert_relation_formula_ids_follow_model_remap() -> None: + old_model = _RelationFormulaRemapModel( + { + 85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + 87: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + } + ) + new_model = _RelationFormulaRemapModel( + { + 90: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + 92: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.15, "area": 5.0}, + } + ) + probe = _PropertyTableProbe() + formula_text = "Face87.直径 = Face85.半径" + probe.model = old_model + formula = parse_relation_formula(formula_text) + probe.relation_formula_items = [ + { + "id": 1, + "text": formula_text, + "enabled": True, + "status": "applied", + "message": "已修改模型。", + "signatures": probe._relation_signatures_for_formula(formula), + } + ] + probe.model = new_model + note = probe._refresh_relation_formulas_after_model_edit() + _assert( + probe.relation_formula_items[0]["text"] == "Face92.直径 = Face90.半径", + f"relation formula should follow remapped visible Face IDs: {probe.relation_formula_items}", + ) + _assert("Face ID" in note, f"relation remap note should mention updated Face IDs: {note}") + + old_model = _RelationFormulaRemapModel( + { + 85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + 87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0}, + } + ) + new_model = _RelationFormulaRemapModel( + { + 85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.125, "area": 4.0}, + 11: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0}, + } + ) + probe = _PropertyTableProbe() + formula_text = "Face85.直径 = Face87.半径" + probe.model = old_model + formula = parse_relation_formula(formula_text) + probe.relation_formula_items = [ + { + "id": 1, + "text": formula_text, + "enabled": True, + "status": "applied", + "message": "已修改模型。", + "signatures": probe._relation_signatures_for_formula(formula), + } + ] + probe.model = new_model + probe._refresh_relation_formulas_after_model_edit() + _assert( + probe.relation_formula_items[0]["text"] == "Face85.直径 = Face11.半径", + f"relation formula should remap a changed reference ID even when the target ID is preserved: {probe.relation_formula_items}", + ) + + old_model = _RelationFormulaRemapModel( + { + 85: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + 87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0}, + } + ) + new_model = _RelationFormulaRemapModel( + { + 85: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.5, "area": 16.0}, + 89: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.125, "area": 4.0}, + 91: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.25, "area": 8.0}, + } + ) + new_model.face_logical_ids[89] = 85 + probe = _PropertyTableProbe() + formula_text = "Face85.直径 = Face87.半径" + probe.model = old_model + formula = parse_relation_formula(formula_text) + probe.relation_formula_items = [ + { + "id": 1, + "text": formula_text, + "enabled": True, + "status": "applied", + "message": "已修改模型。", + "signatures": probe._relation_signatures_for_formula(formula), + } + ] + probe.model = new_model + probe._refresh_relation_formulas_after_model_edit() + _assert( + probe.relation_formula_items[0]["text"] == "Face85.直径 = Face91.半径", + f"relation remap should prefer the old reference radius over a same-center changed target: {probe.relation_formula_items}", + ) + + old_model = _RelationFormulaRemapModel( + { + 9: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + 87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.15, "area": 4.8}, + } + ) + new_model = _RelationFormulaRemapModel( + { + 14: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.4, "area": 12.8}, + 87: {"surface": "cylinder", "area_center": (0.5, 1.0, 0.5), "bbox_center": (0.5, 1.0, 0.5), "radius": 0.15, "area": 4.8}, + } + ) + new_model.face_logical_ids[14] = 9 + probe = _PropertyTableProbe() + formula_text = "Face87.直径 = Face9.半径" + probe.model = old_model + formula = parse_relation_formula(formula_text) + probe.relation_formula_items = [ + { + "id": 1, + "text": formula_text, + "enabled": True, + "status": "applied", + "message": "已修改模型。", + "signatures": probe._relation_signatures_for_formula(formula), + } + ] + probe.model = new_model + probe._refresh_relation_formulas_after_model_edit( + context={"target_kind": "face", "target_id": 9, "target_logical_id": 9} + ) + _assert( + probe.relation_formula_items[0]["text"] == "Face87.直径 = Face9.半径", + f"edited right-hand dependency should keep its logical Face ID instead of becoming invalid: {probe.relation_formula_items}", + ) + _assert( + str(probe.relation_formula_items[0].get("status")) != "invalid", + f"edited right-hand dependency should remain valid: {probe.relation_formula_items}", + ) + _assert( + getattr(probe, "_last_relation_formula_refresh_affected_ids", []) == [1], + f"edited right-hand dependency should mark the formula for reapply: {probe.relation_formula_items}", + ) + + old_model = _RelationFormulaRemapModel( + { + 9: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.25, "area": 8.0}, + 87: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.125, "area": 4.0}, + } + ) + new_model = _RelationFormulaRemapModel( + { + 1: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.5, "area": 16.0}, + 9: {"surface": "cylinder", "area_center": (0.5, 1.0, 9.5), "bbox_center": (0.5, 1.0, 9.5), "radius": 0.4, "area": 12.8}, + 11: {"surface": "cylinder", "area_center": (2.0, 1.0, 9.5), "bbox_center": (2.0, 1.0, 9.5), "radius": 0.125, "area": 4.0}, + } + ) + new_model.face_logical_ids[9] = 9 + probe = _PropertyTableProbe() + formula_text = "Face87.\u76f4\u5f84 = Face9.\u534a\u5f84" + probe.model = old_model + formula = parse_relation_formula(formula_text) + probe.relation_formula_items = [ + { + "id": 1, + "text": formula_text, + "enabled": True, + "status": "applied", + "message": "applied", + "signatures": probe._relation_signatures_for_formula(formula), + } + ] + probe.model = new_model + probe._refresh_relation_formulas_after_model_edit( + context={"target_kind": "face", "target_id": 9, "target_logical_id": 9} + ) + _assert( + probe.relation_formula_items[0]["text"] == "Face11.\u76f4\u5f84 = Face9.\u534a\u5f84", + f"unchanged formula target should stay strict while a dependency changes: {probe.relation_formula_items}", + ) + _assert( + str(probe.relation_formula_items[0].get("status")) != "invalid", + f"strict target remap should keep the dependency formula valid: {probe.relation_formula_items}", + ) + _assert( + getattr(probe, "_last_relation_formula_refresh_affected_ids", []) == [1], + f"edited dependency should still queue the formula for reapply after target remap: {probe.relation_formula_items}", + ) + + def _unexpected_restore() -> bool: + raise AssertionError("dependency relation replay must not restore the formula base snapshot") + + probe.relation_formula_base_snapshot = {"sentinel": object()} + probe._restore_relation_formula_base_snapshot = _unexpected_restore + probe._start_relation_formula_reapply(reason="dependency", restore_base=False, formula_ids=[1]) + _assert( + [int(item.get("id", -1)) for item in probe.relation_formula_replay_queue] == [1], + f"dependency replay should queue only the affected formula: {probe.relation_formula_replay_queue}", + ) + probe.relation_formula_replay_active = False + probe.relation_formula_replay_queue = [] def _assert_mouse_selection_guards() -> None: @@ -724,6 +1416,12 @@ def main() -> int: _assert_diagnostics_stay_out_of_parameter_table(probe) _assert_relation_formula_editor() + _assert_relation_radius_formula_proxy() + _assert_relation_formula_input_remains_editable_while_replaying() + _assert_relation_formula_input_clickable_after_existing_formula() + _assert_relation_formula_input_is_selection_independent() + _assert_relation_formula_input_recovers_after_loading() + _assert_relation_formula_ids_follow_model_remap() _assert_mouse_selection_guards() _assert_quick_blind_depth_spec() _assert_user_facing_failure_messages() diff --git a/step_editor/app.py b/step_editor/app.py index 96b5a23..e862b34 100644 --- a/step_editor/app.py +++ b/step_editor/app.py @@ -287,6 +287,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.property_command_buttons: dict[str, QPushButton] = {} self.relation_formula_items: list[dict[str, object]] = [] self.relation_formula_next_id = 1 + self.relation_formula_base_snapshot: dict[object, object] | None = None + self.relation_formula_replay_queue: list[dict[str, object]] = [] + self.relation_formula_replay_total = 0 + self.relation_formula_replay_done = 0 + self.relation_formula_replay_active = False + self.relation_formula_replay_current_id: int | None = None + self.relation_formula_replay_callback_seen = False + self._relation_formula_replay_running_action = False + self._relation_formula_object_label_cache_key: object = None + self._relation_formula_object_label_cache: dict[str, object] = {} self._build_ui() self._build_vtk() @@ -1292,7 +1302,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.relation_formula_box.setObjectName("relationFormulaBox") help_tip( self.relation_formula_box, - "用 FaceID.参数 = 表达式 的形式建立关系式。第一版会先计算公式并回填当前参数表目标值,再执行参数化建模。", + "用 FaceID.参数 = 表达式 的形式建立关系式。添加后会立即按当前公式组重新计算并修改模型。", ) relation_layout = QVBoxLayout(self.relation_formula_box) relation_layout.setContentsMargins(6, 8, 6, 6) @@ -1305,13 +1315,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf self.relation_formula_input.setPlaceholderText("Face87.直径 = Face85.直径") help_tip( self.relation_formula_input, - "示例:Face87.直径 = Face85.直径,或 Face87.位置 = Face85.位置 + (0, 0, -3.5)。输入 Face87. 后会提示当前可用参数。", + "示例: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.setFilterMode(Qt.MatchFlag.MatchStartsWith) self.relation_formula_completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion) - self.relation_formula_input.setCompleter(self.relation_formula_completer) + self.relation_formula_completer.setMaxVisibleItems(12) + self.relation_formula_completer.activated[str].connect(self._on_relation_formula_completion_activated) + relation_popup = self.relation_formula_completer.popup() + if relation_popup is not None: + relation_popup.setFocusPolicy(Qt.FocusPolicy.NoFocus) + relation_popup.installEventFilter(self) + self.relation_formula_completer.setWidget(self.relation_formula_input) 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) diff --git a/step_editor/model.py b/step_editor/model.py index ce39923..8108477 100644 --- a/step_editor/model.py +++ b/step_editor/model.py @@ -4808,6 +4808,24 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd self._editable_feature_candidates_cache.clear() self._cylindrical_feature_candidates_cache.clear() + def mark_external_recognition_stale(self, reason: str = "modified-topology") -> None: + self._topology_refresh_generation = max(int(getattr(self, "_topology_refresh_generation", 0) or 0), 2) + self._asitus_hole_regions_attempted = False + self._asitus_hole_regions_loading = False + self._asitus_hole_region_cache.clear() + self._asitus_face_relation_cache.clear() + self._asitus_adjacency_relation_cache.clear() + self._asitus_geometric_relation_cache.clear() + self._asitus_hole_recognition_info.clear() + self._asitus_hole_recognition_info.update( + { + "ok": False, + "reason": str(reason or "modified-topology"), + "message": "Analysis Situs result ignored because the model topology has changed.", + } + ) + self._clear_same_domain_dependent_caches() + def _install_asitus_relation_summary(self, result: dict[str, object]) -> None: faces = result.get("faces", ()) if isinstance(faces, (tuple, list)): diff --git a/step_editor/window_actions.py b/step_editor/window_actions.py index b29727b..e88c5ec 100644 --- a/step_editor/window_actions.py +++ b/step_editor/window_actions.py @@ -8257,6 +8257,10 @@ class WindowActionMixin: new_model.filename = self.step_path except Exception: pass + try: + new_model.mark_external_recognition_stale("isolated-edit-result") + except Exception: + pass child_message = str(response.get("message") or "隔离子进程编辑完成。") self._preserve_isolated_face_logical_id(new_model, context, child_message) started = time.perf_counter() @@ -9186,8 +9190,11 @@ class WindowActionMixin: timings["finish_ui"] = time.perf_counter() - finish_started locator_note = self._locate_operation_record(record) relation_note = "" + relation_dependency_ids: list[int] = [] + relation_replay_active = bool(getattr(self, "relation_formula_replay_active", False)) if hasattr(self, "_refresh_relation_formulas_after_model_edit"): - relation_note = self._refresh_relation_formulas_after_model_edit() + relation_note = self._refresh_relation_formulas_after_model_edit(context=context) + relation_dependency_ids = list(getattr(self, "_last_relation_formula_refresh_affected_ids", []) or []) if relation_note: locator_note = f"{locator_note}\n{relation_note}" if locator_note else relation_note except Exception as exc: @@ -9220,6 +9227,15 @@ class WindowActionMixin: self.set_plain_info(f"{record.detail}{timing_detail}\n\n{locator_note}") if hasattr(self, "_after_property_edit_finished"): self._after_property_edit_finished(success=True) + if ( + relation_dependency_ids + and not relation_replay_active + and hasattr(self, "_queue_relation_formula_dependency_reapply") + and hasattr(self, "_run_pending_relation_formula_dependency_reapply") + ): + self._queue_relation_formula_dependency_reapply(relation_dependency_ids) + if not bool(getattr(self, "property_batch_active", False)): + self._run_pending_relation_formula_dependency_reapply() @Slot(str) def _fail_edit_action(self, message: str) -> None: diff --git a/step_editor/window_core.py b/step_editor/window_core.py index edf5c7b..9f164ea 100644 --- a/step_editor/window_core.py +++ b/step_editor/window_core.py @@ -179,12 +179,48 @@ 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 self._relation_formula_popup_widget(): + if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Backspace: + editor = getattr(self, "relation_formula_input", None) + popup = self._relation_formula_popup_widget() + if editor is not None and hasattr(editor, "backspace"): + if popup is not None and hasattr(popup, "hide"): + popup.hide() + if hasattr(editor, "setFocus"): + editor.setFocus(Qt.FocusReason.OtherFocusReason) + editor.backspace() + if hasattr(self, "_update_relation_formula_completions"): + QTimer.singleShot(0, self._update_relation_formula_completions) + event.accept() + return True elif watched is getattr(self, "relation_formula_input", None): - if event.type() == QEvent.Type.KeyPress and event.key() == Qt.Key.Key_Tab: + if event.type() == QEvent.Type.MouseButtonPress: + if hasattr(self, "_hide_relation_formula_completion_popup"): + self._hide_relation_formula_completion_popup() + if hasattr(watched, "setFocus"): + watched.setFocus(Qt.FocusReason.MouseFocusReason) + if event.type() in {QEvent.Type.ShortcutOverride, QEvent.Type.KeyPress} and event.key() == Qt.Key.Key_Tab: + if bool(getattr(self, "_relation_formula_tab_completion_accepted", False)): + self._relation_formula_tab_completion_accepted = False + event.accept() + return True if hasattr(self, "_accept_relation_formula_completion") and self._accept_relation_formula_completion(): + self._relation_formula_tab_completion_accepted = event.type() == QEvent.Type.ShortcutOverride + if self._relation_formula_tab_completion_accepted: + QTimer.singleShot(0, lambda: setattr(self, "_relation_formula_tab_completion_accepted", False)) + event.accept() return True return super().eventFilter(watched, event) + def _relation_formula_popup_widget(self): + completer = getattr(self, "relation_formula_completer", None) + if completer is None or not hasattr(completer, "popup"): + return None + try: + return completer.popup() + except RuntimeError: + return None + def _should_suppress_transient_tooltip(self, watched) -> bool: if not isinstance(watched, QWidget): return False @@ -1274,6 +1310,8 @@ class WindowCoreMixin: self.load_in_progress = False self.pending_load_path = None self._update_action_states() + if hasattr(self, "_update_relation_formula_buttons"): + self._update_relation_formula_buttons() if float(getattr(self, "preview_load_deflection", 0.0) or 0.0) > float( getattr(self, "initial_load_deflection", 0.0) or 0.0 ): @@ -1337,6 +1375,8 @@ class WindowCoreMixin: self.load_in_progress = False self.pending_load_path = None self._update_action_states() + if hasattr(self, "_update_relation_formula_buttons"): + self._update_relation_formula_buttons() self._request_thread_quit(self.load_thread) self._request_thread_quit(self.load_refine_thread) @@ -1387,6 +1427,8 @@ class WindowCoreMixin: self.redo_stack.clear() self.operation_history.clear() self.redo_history.clear() + if hasattr(self, "_clear_relation_formula_runtime_state"): + self._clear_relation_formula_runtime_state(clear_items=True) self.clear_diff_preview(render=False) if hasattr(self, "history_list"): self.history_list.clear() diff --git a/step_editor/window_state.py b/step_editor/window_state.py index e40a714..c2f4b1e 100644 --- a/step_editor/window_state.py +++ b/step_editor/window_state.py @@ -3,7 +3,9 @@ from __future__ import annotations from datetime import datetime import math from pathlib import Path +import re import time +from typing import Iterable from PySide6.QtCore import Qt, QThread, QTimer, Slot, QStringListModel from PySide6.QtWidgets import ( QApplication, @@ -31,6 +33,7 @@ from .model import StepModel from .parametric_component import component_edit_config_from_spec from .relation_formulas import ( ObjectParameterRef, + RELATION_REF_PATTERN, RelationFormulaError, Vector3, evaluate_relation_formula, @@ -52,6 +55,7 @@ PROPERTY_SCOPE_COLUMN = 3 PROPERTY_INPUT_COLUMN = 4 PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "目标值", "建模意图", "输入参数") PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 62, 66, 50) +RELATION_FORMULA_OBJECT_COMPLETION_LIMIT = 300 PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 82, 88, 66) PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS = { "face_center_position", @@ -1537,6 +1541,7 @@ class WindowStateMixin: "当前没有可重做的编辑。", ) self._update_property_apply_state(has_model) + self._update_relation_formula_buttons() def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None: tip = enabled_tip if enabled else disabled_tip @@ -2240,12 +2245,325 @@ class WindowStateMixin: completer = getattr(self, "relation_formula_completer", None) if not isinstance(completer, QCompleter): return - completions = sorted(set(self._relation_formula_completion_tokens()), key=lambda item: item.lower()) + editor = getattr(self, "relation_formula_input", None) + completion_context_key = ( + (editor.text(), editor.cursorPosition()) if isinstance(editor, QLineEdit) else None + ) + completions, prefix = self._relation_formula_context_completions() model = getattr(self, "relation_formula_completer_model", None) if isinstance(model, QStringListModel): model.setStringList(completions) else: completer.setModel(QStringListModel(completions, completer)) + completer.setCompletionPrefix(prefix) + if isinstance(editor, QLineEdit): + completer.setWidget(editor) + self._relation_formula_completion_context_key = completion_context_key + if completions: + try: + completer.setCurrentRow(0) + except Exception: + pass + else: + self._hide_relation_formula_completion_popup() + if completions and isinstance(editor, QLineEdit) and editor.hasFocus(): + QTimer.singleShot(0, self._show_relation_formula_completion_popup) + + def _show_relation_formula_completion_popup(self) -> None: + completer = getattr(self, "relation_formula_completer", None) + editor = getattr(self, "relation_formula_input", None) + if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit): + return + if not editor.hasFocus(): + return + popup = completer.popup() + if popup is not None: + popup.setFocusPolicy(Qt.FocusPolicy.NoFocus) + if completer.completionCount() <= 0: + return + popup_width = max(editor.width(), 320) + if popup is not None: + popup.setMinimumWidth(popup_width) + popup_rect = editor.cursorRect() + popup_rect.moveLeft(0) + popup_rect.setWidth(popup_width) + completer.complete(popup_rect) + editor.setFocus(Qt.FocusReason.OtherFocusReason) + QTimer.singleShot(0, self._restore_relation_formula_input_focus) + + def _restore_relation_formula_input_focus(self) -> None: + editor = getattr(self, "relation_formula_input", None) + if isinstance(editor, QLineEdit): + editor.setFocus(Qt.FocusReason.OtherFocusReason) + + def _refresh_relation_formula_completion_popup(self) -> None: + editor = getattr(self, "relation_formula_input", None) + if isinstance(editor, QLineEdit): + editor.setFocus(Qt.FocusReason.OtherFocusReason) + self._update_relation_formula_completions() + self._show_relation_formula_completion_popup() + + def _hide_relation_formula_completion_popup(self) -> None: + completer = getattr(self, "relation_formula_completer", None) + if not isinstance(completer, QCompleter): + return + self._relation_formula_completion_context_key = None + popup = completer.popup() + if popup is not None and hasattr(popup, "hide"): + popup.hide() + + def _relation_formula_popup_current_completion(self) -> str: + completer = getattr(self, "relation_formula_completer", None) + editor = getattr(self, "relation_formula_input", None) + if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit): + return "" + if getattr(self, "_relation_formula_completion_context_key", None) != (editor.text(), editor.cursorPosition()): + return "" + popup = completer.popup() + if popup is None: + return "" + index = popup.currentIndex() + if not index.isValid(): + return "" + return str(index.data() or "") + + def _on_relation_formula_completion_activated(self, completion: str) -> None: + self._insert_relation_formula_completion(str(completion or "")) + + def _relation_formula_context_completions(self) -> tuple[list[str], str]: + editor = getattr(self, "relation_formula_input", None) + text = editor.text() if isinstance(editor, QLineEdit) else "" + cursor = editor.cursorPosition() if isinstance(editor, QLineEdit) else len(text) + before = text[:cursor] + fragment_start = self._relation_formula_fragment_start(before) + fragment = before[fragment_start:] + stripped_fragment = fragment.strip() + self._relation_formula_completion_prefix_start = fragment_start + + if "=" not in before: + if stripped_fragment and RELATION_REF_PATTERN.fullmatch(stripped_fragment): + return [" = "], "" + if self._relation_formula_fragment_is_object_prefix(stripped_fragment): + object_context = self._relation_formula_object_context_completions(stripped_fragment) + if object_context is not None: + return object_context + if self._relation_formula_fragment_is_existing_object(stripped_fragment): + return self._relation_formula_exact_object_ref_completion_tokens(stripped_fragment), stripped_fragment + return ( + self._sort_relation_formula_completions(self._relation_formula_ref_completion_tokens(fragment)), + fragment, + ) + + if not stripped_fragment: + return ( + self._sort_relation_formula_completions(self._relation_formula_ref_completion_tokens(fragment)), + "", + ) + if self._relation_formula_fragment_is_object_prefix(stripped_fragment): + object_context = self._relation_formula_object_context_completions(stripped_fragment) + if object_context is not None: + return object_context + if self._relation_formula_fragment_is_existing_object(stripped_fragment): + return self._relation_formula_exact_object_ref_completion_tokens(stripped_fragment), stripped_fragment + if RELATION_REF_PATTERN.fullmatch(stripped_fragment) or self._relation_formula_fragment_is_number(stripped_fragment): + return [" + ", " - ", " * ", " / "], "" + return ( + self._sort_relation_formula_completions(self._relation_formula_ref_completion_tokens(fragment)), + fragment, + ) + + def _relation_formula_object_context_completions(self, fragment: str) -> tuple[list[str], str] | None: + object_tokens = self._sort_relation_formula_completions(self._relation_formula_object_completion_tokens(fragment)) + if not object_tokens: + return None + lowered = str(fragment or "").strip().lower() + exact_object = self._relation_formula_fragment_is_existing_object(fragment) + has_longer_match = any(token.lower() != lowered and token.lower().startswith(lowered) for token in object_tokens) + if exact_object and not has_longer_match: + ref_tokens = self._relation_formula_exact_object_ref_completion_tokens(fragment) + if ref_tokens: + return ref_tokens, fragment + if exact_object and has_longer_match: + object_tokens = [token for token in object_tokens if token.lower() != lowered] + return object_tokens, fragment + + def _relation_formula_exact_object_ref_completion_tokens(self, fragment: str) -> list[str]: + match = re.fullmatch(r"(?PFace|Edge)(?P\d+)", str(fragment or ""), flags=re.IGNORECASE) + if match is None: + return ["."] + kind = "Face" if match.group("kind").lower() == "face" else "Edge" + object_id = int(match.group("object_id")) + names = self._relation_known_parameter_names(kind, object_id) + tokens = [f"{kind}{object_id}.{name}" for name in names if self._relation_parameter_name_is_safe(str(name))] + return self._sort_relation_formula_completions(tokens) or ["."] + + @staticmethod + def _sort_relation_formula_completions(tokens: list[str]) -> list[str]: + unique = list(dict.fromkeys(str(item) for item in tokens if str(item))) + + def sort_key(token: str) -> tuple[int, int, int, str]: + stripped = token.strip() + if stripped == "Face": + return (0, -1, -1, "") + if stripped == "Edge": + return (1, -1, -1, "") + match = re.match(r"^(Face|Edge)(\d+)(?:\.|$)", stripped, flags=re.IGNORECASE) + if match is not None: + kind = 0 if match.group(1).lower() == "face" else 1 + return (kind, int(match.group(2)), 0 if "." not in stripped else 1, stripped.lower()) + return (9, 0, 0, stripped.lower()) + + return sorted(unique, key=sort_key) + + @staticmethod + def _relation_formula_fragment_start(text: str) -> int: + separators = " \t\r\n=+-*/()," + return max((text.rfind(char) for char in separators), default=-1) + 1 + + @staticmethod + def _relation_formula_fragment_is_number(fragment: str) -> bool: + try: + float(fragment) + return True + except ValueError: + return False + + @staticmethod + def _relation_formula_fragment_is_object_prefix(fragment: str) -> bool: + if not fragment: + return False + lowered = fragment.lower() + return "face".startswith(lowered) or "edge".startswith(lowered) or bool( + re.fullmatch(r"(Face|Edge)\d*", fragment, flags=re.IGNORECASE) + ) + + def _relation_formula_object_completion_tokens(self, fragment: str = "") -> list[str]: + normalized = str(fragment or "").strip() + lowered = normalized.lower() + kind_only = lowered in {"face", "edge"} + prefix_kinds: list[str] = [] + if lowered and "face".startswith(lowered): + prefix_kinds.append("Face") + if lowered and "edge".startswith(lowered): + prefix_kinds.append("Edge") + tokens = [] if kind_only else list(prefix_kinds) + labels = {object_label for object_label, _row, _spec in self._relation_visible_parameter_specs() if object_label} + if self.selected_kind == "multi_feature": + for entry in getattr(self, "multi_selected_hole_entries", []) or []: + if not isinstance(entry, dict): + continue + logical_id = _int_or_none(entry.get("logical_id")) + if logical_id is not None: + labels.add(f"Face{logical_id}") + if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None and self.model is not None: + try: + labels.add(f"Face{int(self.model.face_logical_id(int(self.selected_face_id)))}") + except Exception: + labels.add(f"Face{int(self.selected_face_id)}") + if self.selected_kind == "edge" and self.selected_edge_id is not None: + labels.add(f"Edge{int(self.selected_edge_id)}") + for label in labels: + if not lowered or label.lower().startswith(lowered): + tokens.append(label) + + match = re.fullmatch(r"(?PFace|Edge)(?P\d*)", normalized, flags=re.IGNORECASE) + if match is not None: + tokens.extend( + self._relation_matching_object_labels( + match.group("kind"), + match.group("prefix"), + limit=RELATION_FORMULA_OBJECT_COMPLETION_LIMIT, + ) + ) + elif prefix_kinds: + for kind in prefix_kinds: + tokens.extend(self._relation_matching_object_labels(kind, "", limit=RELATION_FORMULA_OBJECT_COMPLETION_LIMIT)) + if kind_only and not tokens: + tokens.append(normalized) + return tokens + + def _relation_formula_object_label_cache_data(self) -> dict[str, object]: + model = self.model + if model is None: + return {"Face": [], "Edge": [], "FaceSet": set(), "EdgeSet": set()} + faces = getattr(model, "faces", []) or [] + edges = getattr(model, "edges", []) or [] + logical_ids = tuple(int(item) for item in (getattr(model, "face_logical_ids", []) or [])) + cache_key = (id(model), id(getattr(model, "shape", None)), len(faces), len(edges), logical_ids) + if ( + getattr(self, "_relation_formula_object_label_cache_key", None) == cache_key + and isinstance(getattr(self, "_relation_formula_object_label_cache", None), dict) + ): + return self._relation_formula_object_label_cache + + if len(logical_ids) >= len(faces): + face_ids = {int(logical_ids[index]) for index in range(len(faces))} + else: + face_ids = set(range(len(faces))) + edge_ids = set(range(len(edges))) + face_labels = self._sort_relation_formula_completions([f"Face{face_id}" for face_id in face_ids]) + edge_labels = self._sort_relation_formula_completions([f"Edge{edge_id}" for edge_id in edge_ids]) + cache = { + "Face": face_labels, + "Edge": edge_labels, + "FaceSet": set(face_labels), + "EdgeSet": set(edge_labels), + } + self._relation_formula_object_label_cache_key = cache_key + self._relation_formula_object_label_cache = cache + return cache + + def _relation_matching_object_labels(self, kind: str, id_prefix: str, *, limit: int | None = None) -> list[str]: + normalized_kind = "Face" if str(kind).lower() == "face" else "Edge" if str(kind).lower() == "edge" else "" + if not normalized_kind: + return [] + labels = list(self._relation_formula_object_label_cache_data().get(normalized_kind, []) or []) + id_text = str(id_prefix or "") + if id_text: + prefix = f"{normalized_kind}{id_text}".lower() + labels = [label for label in labels if str(label).lower().startswith(prefix)] + if limit is not None: + return labels[: max(0, int(limit))] + return labels + + def _relation_formula_fragment_is_existing_object(self, fragment: str) -> bool: + match = re.fullmatch(r"(?PFace|Edge)(?P\d+)", str(fragment or ""), flags=re.IGNORECASE) + if match is None: + return False + kind = match.group("kind").lower() + object_id = int(match.group("object_id")) + visible_labels = {label.lower() for label, _row, _spec in self._relation_visible_parameter_specs() if label} + if str(fragment).lower() in visible_labels: + return True + normalized_kind = "Face" if kind == "face" else "Edge" + cached = self._relation_formula_object_label_cache_data() + cached_set = cached.get(f"{normalized_kind}Set") + if isinstance(cached_set, set) and f"{normalized_kind}{object_id}" in cached_set: + return True + if self.model is None: + return False + if kind == "face": + if hasattr(self.model, "resolve_face_selection_id"): + try: + face_id = self.model.resolve_face_selection_id(object_id) + except Exception: + face_id = None + return face_id is not None + return 0 <= object_id < len(getattr(self.model, "faces", []) or []) + if kind == "edge": + return 0 <= object_id < len(getattr(self.model, "edges", []) or []) + return False + + def _relation_formula_ref_completion_tokens(self, fragment: str = "") -> list[str]: + tokens = self._relation_formula_completion_tokens() + match = re.match( + r"^\s*(?PFace|Edge)(?P\d+)\.(?P[A-Za-z0-9_\u4e00-\u9fff]*)$", + str(fragment or ""), + ) + if match is not None: + prefix = f"{match.group('kind')}{match.group('object_id')}." + tokens.extend(f"{prefix}{name}" for name in self._relation_known_parameter_names(match.group("kind"), int(match.group("object_id")))) + return tokens def _relation_formula_completion_tokens(self) -> list[str]: tokens: list[str] = [] @@ -2264,6 +2582,30 @@ class WindowStateMixin: tokens.append(f"Face{logical_id}.{name}") return tokens + def _relation_known_parameter_names(self, kind: str, object_id: int) -> tuple[str, ...]: + if kind == "Edge": + return ("长度", "直径", "半径", "位置", "位置X", "位置Y", "位置Z") + if kind != "Face": + return () + names = ["直径", "半径", "位置", "位置X", "位置Y", "位置Z", "偏移", "面内长度", "面内宽度", "高度", "深度", "槽宽", "宽度"] + if self.model is not None and hasattr(self.model, "resolve_face_selection_id"): + face_id = self.model.resolve_face_selection_id(int(object_id)) + if face_id is not None: + try: + info = self.model.quick_face_info(face_id) + except Exception: + info = {} + surface = str(info.get("surface") or "") + if surface == "plane": + names = ["偏移", "面内长度", "面内宽度", "位置", "位置X", "位置Y", "位置Z"] + elif surface == "cylinder": + names = ["直径", "半径", "位置", "位置X", "位置Y", "位置Z", "高度", "深度", "槽宽", "宽度"] + compact: list[str] = [] + for name in names: + if name not in compact: + compact.append(name) + return tuple(compact) + def _relation_visible_parameter_specs(self) -> list[tuple[str, int, dict[str, object]]]: if not hasattr(self, "property_table"): return [] @@ -2285,7 +2627,7 @@ class WindowStateMixin: model_faces = getattr(self.model, "faces", None) if self.model is not None else None if source_face_id is not None and model_faces is not None and 0 <= source_face_id < len(model_faces): try: - return f"Face{int(self.model.face_region_logical_id(source_face_id))}" + return f"Face{int(self.model.face_logical_id(source_face_id))}" except Exception: return f"Face{int(source_face_id)}" if source_face_id is not None: @@ -2319,16 +2661,29 @@ class WindowStateMixin: return bool(name) and all(char == "_" or char.isalnum() for char in name) def _update_relation_formula_buttons(self) -> None: - has_model = self.model is not None and not ( - self.operation_in_progress or self.scan_in_progress or self.load_in_progress + formula_backend_ready = self.model is not None and not (self.scan_in_progress or self.load_in_progress) + can_type_formula = not (self.scan_in_progress or self.load_in_progress) + can_modify_formula_set = formula_backend_ready and not ( + self.operation_in_progress or bool(getattr(self, "relation_formula_replay_active", False)) ) + formula_input = getattr(self, "relation_formula_input", None) + if isinstance(formula_input, QLineEdit): + formula_input.setEnabled(bool(can_type_formula)) + formula_input.setReadOnly(False) + if hasattr(self, "_set_help_tip"): + self._set_help_tip( + formula_input, + "输入关系式;如果当前已有公式正在计算,新公式会先加入队列。", + ) + else: + formula_input.setToolTip("输入关系式;如果当前已有公式正在计算,新公式会先加入队列。") add_button = getattr(self, "add_relation_formula_button", None) if isinstance(add_button, QPushButton): text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else "" self._set_control_state( add_button, - bool(has_model and text), - "添加当前关系式。第一版会在参数化建模前计算并回填当前参数表目标值。", + bool(formula_backend_ready and text), + "添加当前关系式,并立即按当前公式组重新计算模型。", "请先导入模型,并输入类似 Face87.直径 = Face85.直径 的关系式。", ) remove_button = getattr(self, "remove_relation_formula_button", None) @@ -2337,22 +2692,27 @@ class WindowStateMixin: has_selected = bool(formula_list is not None and formula_list.selectedItems()) self._set_control_state( remove_button, - has_selected, - "删除选中的关系式。", - "请先在已有关系式列表里选择一条公式。", + bool(can_modify_formula_set and has_selected), + "删除选中的关系式,并按剩余公式重新计算模型。", + "请先在已有关系式列表里选择一条公式;公式计算中暂不能删除。", ) def add_relation_formula(self) -> None: if self.model is None: self.statusBar().showMessage("请先导入模型后再添加关系式。") return + replay_active = bool(getattr(self, "relation_formula_replay_active", False)) + if not replay_active and self._edit_busy("请等待当前几何计算完成后再添加公式。"): + return text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else "" try: formula = parse_relation_formula(text) - self._validate_relation_formula_references(formula) + if not replay_active: + self._validate_relation_formula_references(formula) except RelationFormulaError as exc: QMessageBox.information(self, "关系式无效", str(exc)) return + self._ensure_relation_formula_base_snapshot() formula_id = int(getattr(self, "relation_formula_next_id", 1) or 1) self.relation_formula_next_id = formula_id + 1 item = { @@ -2360,19 +2720,34 @@ class WindowStateMixin: "text": formula.text, "enabled": True, "status": "ready", - "message": "等待参数化建模时计算。", - "signatures": self._relation_signatures_for_formula(formula), + "message": "等待当前公式完成后计算。" if replay_active else "等待立即计算。", + "signatures": {}, } + try: + item["signatures"] = self._relation_signatures_for_formula(formula) + except RelationFormulaError: + item["signatures"] = {} self.relation_formula_items = [*list(getattr(self, "relation_formula_items", []) or []), item] self.relation_formula_input.clear() self._refresh_relation_formula_list() self._update_property_apply_state() - self.statusBar().showMessage(f"已添加关系式:{formula.text}") + QTimer.singleShot(0, self._restore_relation_formula_input_focus) + if replay_active: + queue = list(getattr(self, "relation_formula_replay_queue", []) or []) + queue.append(dict(item)) + self.relation_formula_replay_queue = queue + self.relation_formula_replay_total = int(getattr(self, "relation_formula_replay_total", 0) or 0) + 1 + self.statusBar().showMessage(f"已添加关系式:{formula.text},当前公式完成后继续计算。") + return + self.statusBar().showMessage(f"已添加关系式:{formula.text},正在更新模型...") + self._start_relation_formula_reapply(reason="add") def remove_selected_relation_formula(self) -> None: formula_list = getattr(self, "relation_formula_list", None) if formula_list is None: return + if self._edit_busy("请等待当前几何计算完成后再删除公式。"): + return ids = { int(item.data(Qt.ItemDataRole.UserRole)) for item in formula_list.selectedItems() @@ -2387,7 +2762,262 @@ class WindowStateMixin: ] self._refresh_relation_formula_list() self._update_property_apply_state() - self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式。") + if self.relation_formula_items: + self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,正在按剩余公式更新模型...") + self._start_relation_formula_reapply(reason="remove") + else: + restored = self._restore_relation_formula_base_snapshot() + self._clear_relation_formula_runtime_state(clear_items=False) + message = "模型已恢复到添加公式前状态。" if restored else "已删除关系式。" + self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}") + + def _clear_relation_formula_runtime_state(self, *, clear_items: bool) -> None: + if clear_items: + self.relation_formula_items = [] + self.relation_formula_next_id = 1 + self.relation_formula_base_snapshot = None + self.relation_formula_replay_queue = [] + self.relation_formula_replay_total = 0 + self.relation_formula_replay_done = 0 + self.relation_formula_replay_active = False + self.relation_formula_replay_current_id = None + self.relation_formula_replay_callback_seen = False + self._relation_formula_replay_running_action = False + self._refresh_relation_formula_list() + self._update_property_apply_state() + + def _ensure_relation_formula_base_snapshot(self) -> None: + if self.model is None or getattr(self, "relation_formula_base_snapshot", None) is not None: + return + if not hasattr(self.model, "snapshot"): + return + try: + self.relation_formula_base_snapshot = self.model.snapshot() + except Exception: + self.relation_formula_base_snapshot = None + + def _restore_relation_formula_base_snapshot(self) -> bool: + snapshot = getattr(self, "relation_formula_base_snapshot", None) + if self.model is None or not isinstance(snapshot, dict): + return False + try: + if hasattr(self, "_restore_snapshot"): + self._restore_snapshot(snapshot) + else: + self.model.restore_snapshot(snapshot) + except Exception as exc: + QMessageBox.information(self, "关系式恢复失败", f"恢复到公式基准状态失败:{exc}") + return False + return True + + def _queue_relation_formula_dependency_reapply(self, formula_ids: Iterable[int]) -> None: + ids = {int(item) for item in formula_ids if int(item) >= 0} + if not ids: + return + pending = set(getattr(self, "_pending_relation_formula_dependency_ids", []) or []) + pending.update(ids) + self._pending_relation_formula_dependency_ids = sorted(pending) + + def _run_pending_relation_formula_dependency_reapply(self) -> None: + ids = list(getattr(self, "_pending_relation_formula_dependency_ids", []) or []) + self._pending_relation_formula_dependency_ids = [] + if not ids: + return + if bool(getattr(self, "operation_in_progress", False)) or bool(getattr(self, "relation_formula_replay_active", False)): + self._queue_relation_formula_dependency_reapply(ids) + return + self._start_relation_formula_reapply( + reason="dependency", + restore_base=False, + formula_ids=ids, + ) + + def _start_relation_formula_reapply( + self, + *, + reason: str, + restore_base: bool = True, + formula_ids: Iterable[int] | None = None, + ) -> None: + if self.model is None: + return + if self._edit_busy("请等待当前几何计算完成后再更新公式。"): + return + if restore_base: + self._ensure_relation_formula_base_snapshot() + if restore_base and getattr(self, "relation_formula_base_snapshot", None) is not None: + if not self._restore_relation_formula_base_snapshot(): + return + selected_ids = {int(item) for item in formula_ids} if formula_ids is not None else None + self.relation_formula_replay_queue = [ + dict(item) + for item in getattr(self, "relation_formula_items", []) or [] + if bool(item.get("enabled", True)) + and (selected_ids is None or int(item.get("id", -1)) in selected_ids) + and str(item.get("status") or "") != "invalid" + ] + self.relation_formula_replay_total = len(self.relation_formula_replay_queue) + self.relation_formula_replay_done = 0 + self.relation_formula_replay_active = bool(self.relation_formula_replay_queue) + self.relation_formula_replay_current_id = None + self.relation_formula_replay_callback_seen = False + if not self.relation_formula_replay_active: + self._refresh_relation_formula_list() + self._update_property_apply_state() + return + if reason == "add": + label = "添加" + elif reason == "dependency": + label = "依赖参数变化" + else: + label = "删除" + self.statusBar().showMessage(f"{label}公式后正在重新计算:共 {self.relation_formula_replay_total} 条。") + QTimer.singleShot(0, self._run_next_relation_formula) + + def _run_next_relation_formula(self) -> None: + if not bool(getattr(self, "relation_formula_replay_active", False)): + return + if self.model is None: + self._finish_relation_formula_replay("关系式计算停止:当前没有模型。") + return + queue = list(getattr(self, "relation_formula_replay_queue", []) or []) + while queue: + item = dict(queue.pop(0)) + self.relation_formula_replay_queue = queue + formula_id = int(item.get("id", -1)) + self.relation_formula_replay_current_id = formula_id + try: + row = self._prepare_relation_formula_item_for_edit(item) + except RelationFormulaError as exc: + self._update_relation_formula_item(formula_id, status="invalid", message=str(exc)) + self._finish_relation_formula_replay(f"关系式计算已停止:{exc}") + return + if row is None: + self.relation_formula_replay_done = int(getattr(self, "relation_formula_replay_done", 0) or 0) + 1 + continue + self._update_relation_formula_item(formula_id, status="running", message="正在修改模型。") + done = int(getattr(self, "relation_formula_replay_done", 0) or 0) + total = int(getattr(self, "relation_formula_replay_total", 0) or 0) + self.statusBar().showMessage(f"关系式正在修改模型:{done + 1}/{total} - {item.get('text', '')}") + self._relation_formula_replay_running_action = True + self.relation_formula_replay_callback_seen = False + try: + self.apply_property_row_edit(row) + finally: + self._relation_formula_replay_running_action = False + if bool(getattr(self, "operation_in_progress", False)): + return + if bool(getattr(self, "relation_formula_replay_callback_seen", False)): + return + self._update_relation_formula_item(formula_id, status="invalid", message="公式没有启动可执行的参数化操作。") + self._finish_relation_formula_replay("关系式没有启动可执行的参数化操作。") + return + self._finish_relation_formula_replay("关系式已全部生效。") + + def _prepare_relation_formula_item_for_edit(self, item: dict[str, object]) -> int | None: + formula = parse_relation_formula(str(item.get("text") or "")) + self._ensure_relation_target_visible(formula.target) + value = evaluate_relation_formula(formula, self._relation_value_for_ref) + row = self._set_relation_target_value(formula.target, value) + spec = self._effective_property_spec(self.property_editor_specs[row], row=row) + text = self._property_target_text(row) + if not self._property_target_changed(spec, text): + self._update_relation_formula_item( + int(item.get("id", -1)), + status="applied", + message=f"{relation_value_to_text(value)},无需变化", + signatures=self._relation_signatures_for_formula(formula), + ) + return None + self._update_relation_formula_item( + int(item.get("id", -1)), + status="ready", + message=relation_value_to_text(value), + signatures=self._relation_signatures_for_formula(formula), + ) + return row + + def _ensure_relation_target_visible(self, ref: ObjectParameterRef) -> None: + if self._relation_visible_spec_for_ref(ref) is not None: + return + if self.model is None: + raise RelationFormulaError("当前没有已导入模型。") + if ref.kind == "Face": + face_id = self.model.resolve_face_selection_id(int(ref.object_id)) if hasattr(self.model, "resolve_face_selection_id") else int(ref.object_id) + if face_id is None or not (0 <= int(face_id) < len(getattr(self.model, "faces", []))): + raise RelationFormulaError(f"找不到 {ref.kind}{ref.object_id}。") + if hasattr(self, "_set_selection_mode"): + self._set_selection_mode("Feature") + try: + self.select_feature(int(face_id)) + except Exception: + self.select_face(int(face_id), feature_mode=True) + elif ref.kind == "Edge": + edge_id = int(ref.object_id) + if edge_id < 0 or edge_id >= len(getattr(self.model, "edges", [])): + raise RelationFormulaError(f"找不到 Edge{edge_id}。") + if hasattr(self, "_set_selection_mode"): + self._set_selection_mode("Edge") + self.select_edge(edge_id) + else: + raise RelationFormulaError(f"不支持的对象类型:{ref.kind}") + QApplication.processEvents() + if self._relation_visible_spec_for_ref(ref) is None: + raise RelationFormulaError(f"{ref.token} 当前没有可执行的参数化操作。") + + def _update_relation_formula_item(self, formula_id: int, **updates: object) -> None: + changed: list[dict[str, object]] = [] + for item in getattr(self, "relation_formula_items", []) or []: + next_item = dict(item) + if int(next_item.get("id", -1)) == int(formula_id): + next_item.update(updates) + changed.append(next_item) + self.relation_formula_items = changed + self._refresh_relation_formula_list() + self._update_property_apply_state() + + def _after_relation_formula_edit_finished(self, *, success: bool) -> None: + self.relation_formula_replay_callback_seen = True + formula_id = getattr(self, "relation_formula_replay_current_id", None) + if formula_id is not None: + self._update_relation_formula_item( + int(formula_id), + status="applied" if success else "invalid", + message="已修改模型。" if success else "模型修改失败,已停止后续公式。", + ) + if not success: + self._finish_relation_formula_replay("关系式修改失败,模型已保持在失败前状态。") + return + self.relation_formula_replay_done = int(getattr(self, "relation_formula_replay_done", 0) or 0) + 1 + self._refresh_relation_formula_replay_queue_from_current_items() + QTimer.singleShot(0, self._run_next_relation_formula) + + def _refresh_relation_formula_replay_queue_from_current_items(self) -> None: + remaining_ids = [int(item.get("id", -1)) for item in getattr(self, "relation_formula_replay_queue", []) or []] + if not remaining_ids: + return + current_by_id = { + int(item.get("id", -1)): dict(item) + for item in getattr(self, "relation_formula_items", []) or [] + } + self.relation_formula_replay_queue = [ + dict(current_by_id[item_id]) + for item_id in remaining_ids + if item_id in current_by_id + ] + + def _finish_relation_formula_replay(self, message: str) -> None: + self.relation_formula_replay_queue = [] + self.relation_formula_replay_total = 0 + self.relation_formula_replay_done = 0 + self.relation_formula_replay_active = False + self.relation_formula_replay_current_id = None + self.relation_formula_replay_callback_seen = False + self._relation_formula_replay_running_action = False + self._refresh_relation_formula_list() + self._update_property_apply_state() + self.statusBar().showMessage(message) + QTimer.singleShot(0, self._restore_relation_formula_input_focus) def _refresh_relation_formula_list(self) -> None: formula_list = getattr(self, "relation_formula_list", None) @@ -2403,6 +3033,8 @@ class WindowStateMixin: suffix = f" [失效:{message or '需要重新选择'}]" elif status == "pending": suffix = f" [待选择:{message or '目标不在当前参数表'}]" + elif status == "running": + suffix = f" [正在修改]" elif status == "applied": suffix = f" [已计算:{message}]" row_item = QListWidgetItem(f"{text}{suffix}") @@ -2412,10 +3044,16 @@ class WindowStateMixin: row_item.setForeground(QColor("#b91c1c")) elif status == "pending": row_item.setForeground(QColor("#92400e")) + elif status == "running": + row_item.setForeground(QColor("#1d4ed8")) elif status == "applied": row_item.setForeground(QColor("#166534")) formula_list.addItem(row_item) self._update_relation_formula_buttons() + editor = getattr(self, "relation_formula_input", None) + if isinstance(editor, QLineEdit): + editor.setEnabled(not (self.scan_in_progress or self.load_in_progress)) + editor.setReadOnly(False) def _validate_relation_formula_references(self, formula) -> None: self._relation_parameter_supported(formula.target, target=True) @@ -2491,6 +3129,18 @@ class WindowStateMixin: names = self._relation_parameter_names_for_spec(spec) if base_name in names or ref.parameter in names: return row, spec + if base_name == "半径": + for _object_label, row, spec in self._relation_visible_parameter_specs(): + if not bool(spec.get("enabled")): + continue + if not self._relation_ref_matches_spec_object(ref, spec): + continue + names = self._relation_parameter_names_for_spec(spec) + if "直径" not in names and "参考直径" not in names: + continue + proxy_spec = dict(spec) + proxy_spec["_relation_proxy_kind"] = "radius_via_diameter" + return row, proxy_spec return None def _relation_ref_matches_spec_object(self, ref: ObjectParameterRef, spec: dict[str, object]) -> bool: @@ -2555,6 +3205,11 @@ class WindowStateMixin: value = float(text) else: value = spec.get("current_raw") + if spec.get("_relation_proxy_kind") == "radius_via_diameter": + diameter = _float_or_none(value) + if diameter is None: + raise RelationFormulaError(f"{ref.token} 无法从当前直径参数换算半径。") + value = diameter * 0.5 _base_name, component = self._relation_base_parameter(ref.parameter) if component is not None: vector = _triple_or_none(value) @@ -2643,7 +3298,7 @@ class WindowStateMixin: return float(vector[component]) return value - def _set_relation_target_value(self, ref: ObjectParameterRef, value: object) -> None: + def _set_relation_target_value(self, ref: ObjectParameterRef, value: object) -> int: visible = self._relation_visible_spec_for_ref(ref) if visible is None: raise RelationFormulaError(f"{ref.token} 当前没有显示在特征参数表里。") @@ -2661,6 +3316,11 @@ class WindowStateMixin: else: if isinstance(value, Vector3) or isinstance(value, (tuple, list)): raise RelationFormulaError(f"{ref.token} 需要数字结果,不能写入三维坐标。") + if spec.get("_relation_proxy_kind") == "radius_via_diameter": + radius = _float_or_none(value) + if radius is None: + raise RelationFormulaError(f"{ref.token} 的公式结果不能换算为目标直径。") + value = radius * 2.0 text = relation_value_to_text(value) validation_error = self._property_target_validation_error(spec, text) if validation_error: @@ -2672,6 +3332,7 @@ class WindowStateMixin: item = self.property_table.item(row, PROPERTY_TARGET_COLUMN) if item is not None: item.setText(text) + return row def _relation_current_vector_target(self, row: int, spec: dict[str, object]) -> tuple[float, float, float]: text = self._property_target_text(row) @@ -2702,6 +3363,7 @@ class WindowStateMixin: info = self.model.quick_face_info(face_id) except Exception: info = {} + axis_center = self._axis_center_from_feature_info(info) return { "kind": "Face", "object_id": int(ref.object_id), @@ -2714,7 +3376,9 @@ class WindowStateMixin: "surface": info.get("surface"), "area": info.get("area"), "center": _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center")), + "axis_center": axis_center, "radius": info.get("radius"), + "diameter": info.get("diameter"), "axis": _triple_or_none(info.get("axis")), } @@ -2729,29 +3393,47 @@ class WindowStateMixin: except Exception: return int(face_id) - def _refresh_relation_formulas_after_model_edit(self) -> str: + def _refresh_relation_formulas_after_model_edit(self, context: dict[str, object] | None = None) -> str: + self._last_relation_formula_refresh_affected_ids = [] if self.model is None or not getattr(self, "relation_formula_items", None): return "" updated_items: list[dict[str, object]] = [] changed_count = 0 invalid_count = 0 + affected_ids: set[int] = set() for raw_item in self.relation_formula_items: item = dict(raw_item) + try: + parsed_formula = parse_relation_formula(str(item.get("text") or "")) + except RelationFormulaError: + parsed_formula = None signatures = item.get("signatures") if not isinstance(signatures, dict): try: - signatures = self._relation_signatures_for_formula(parse_relation_formula(str(item.get("text") or ""))) + if parsed_formula is None: + parsed_formula = parse_relation_formula(str(item.get("text") or "")) + signatures = self._relation_signatures_for_formula(parsed_formula) except RelationFormulaError: signatures = {} face_id_map: dict[int, int] = {} item_invalid = False - for signature in signatures.values(): + target_token = parsed_formula.target.token if parsed_formula is not None else "" + for token, signature in signatures.items(): if not isinstance(signature, dict) or signature.get("kind") != "Face": continue old_id = _int_or_none(signature.get("object_id")) if old_id is None: continue - new_id = self._relation_current_face_id_from_signature(signature) + edited_token = self._relation_signature_matches_edit_context(signature, context) + if edited_token: + affected_ids.add(int(item.get("id", -1))) + allow_changed_geometry = bool(edited_token) or ( + context is None and str(token) == target_token + ) + new_id = self._relation_current_face_id_from_signature( + signature, + allow_changed_geometry=allow_changed_geometry, + ) if new_id is None: item_invalid = True continue @@ -2778,6 +3460,9 @@ class WindowStateMixin: invalid_count += 1 updated_items.append(item) self.relation_formula_items = updated_items + self._last_relation_formula_refresh_affected_ids = sorted( + item_id for item_id in affected_ids if item_id >= 0 + ) self._refresh_relation_formula_list() parts: list[str] = [] if changed_count: @@ -2786,7 +3471,39 @@ class WindowStateMixin: parts.append(f"{invalid_count} 条关系式需要重新选择") return ";".join(parts) - def _relation_current_face_id_from_signature(self, signature: dict[str, object]) -> int | None: + def _relation_signature_matches_edit_context( + self, + signature: dict[str, object], + context: dict[str, object] | None, + ) -> bool: + if not isinstance(context, dict): + return False + if str(context.get("target_kind") or "") not in {"face", "feature"}: + return False + target_logical_id = _int_or_none(context.get("target_logical_id")) + target_id = _int_or_none(context.get("target_id")) + signature_ids = { + item + for item in ( + _int_or_none(signature.get("object_id")), + _int_or_none(signature.get("logical_id")), + _int_or_none(signature.get("current_logical_id")), + ) + if item is not None + } + if target_logical_id is not None and target_logical_id in signature_ids: + return True + if target_id is not None and target_id in signature_ids: + return True + face_id = _int_or_none(signature.get("face_id")) + return target_id is not None and face_id is not None and int(target_id) == int(face_id) + + def _relation_current_face_id_from_signature( + self, + signature: dict[str, object], + *, + allow_changed_geometry: bool = False, + ) -> int | None: if self.model is None: return None old_logical = _int_or_none(signature.get("logical_id", signature.get("object_id"))) @@ -2795,82 +3512,227 @@ class WindowStateMixin: matches = self.model.face_ids_for_logical_id(old_logical) except Exception: matches = [] - if len(matches) == 1: - return self._relation_face_display_id(matches[0]) - if len(matches) > 1: - display_ids = {self._relation_face_display_id(item) for item in matches} + compatible_matches = [ + int(item) + for item in matches + if self._relation_face_signature_is_compatible( + signature, + int(item), + allow_changed_geometry=allow_changed_geometry, + ) + ] + if len(compatible_matches) == 1: + return self._relation_face_display_id(compatible_matches[0]) + if len(compatible_matches) > 1: + display_ids = {self._relation_face_display_id(item) for item in compatible_matches} if len(display_ids) == 1: return next(iter(display_ids)) - matched = self._relation_match_face_signature(signature) + matched = self._relation_match_face_signature(signature, allow_changed_geometry=allow_changed_geometry) if matched is None: - return None - return self._relation_face_display_id(matched) + matched = self._relation_match_face_signature( + signature, + allow_part_mismatch=True, + allow_changed_geometry=allow_changed_geometry, + ) + if matched is not None: + return self._relation_face_display_id(matched) + return None - def _relation_match_face_signature(self, signature: dict[str, object]) -> int | None: + def _relation_match_face_signature( + self, + signature: dict[str, object], + *, + allow_part_mismatch: bool = False, + allow_changed_geometry: bool = False, + ) -> int | None: if self.model is None: return None - surface = str(signature.get("surface") or "") - center = _triple_or_none(signature.get("center")) - radius = _float_or_none(signature.get("radius")) - area = _float_or_none(signature.get("area")) - part_id = _int_or_none(signature.get("part_id")) candidates: list[tuple[float, int]] = [] for face_id in range(len(self.model.faces)): - if part_id is not None and 0 <= part_id != int(self.model.face_part_ids[face_id]): - continue - try: - info = self.model.quick_face_info(face_id) - except Exception: - continue - if surface and str(info.get("surface") or "") != surface: - continue - score = 0.0 - candidate_center = _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center")) - if center is not None and candidate_center is not None: - score += math.sqrt(sum((candidate_center[index] - center[index]) ** 2 for index in range(3))) - elif center is not None: - score += 1e9 - candidate_radius = _float_or_none(info.get("radius")) - if radius is not None: - if candidate_radius is None: - score += 1e9 - else: - score += abs(candidate_radius - radius) * 10.0 - candidate_area = _float_or_none(info.get("area")) - if area is not None and area > 0 and candidate_area is not None: - score += abs(candidate_area - area) / max(abs(area), 1.0) - candidates.append((score, face_id)) + score = self._relation_face_signature_score( + signature, + face_id, + allow_part_mismatch=allow_part_mismatch, + allow_changed_geometry=allow_changed_geometry, + ) + if score is not None: + candidates.append((score, face_id)) if not candidates: return None candidates.sort(key=lambda item: item[0]) best_score, best_face_id = candidates[0] + if best_score > self._relation_face_signature_score_limit( + signature, + allow_changed_geometry=allow_changed_geometry, + ): + return None if len(candidates) > 1 and abs(candidates[1][0] - best_score) <= max(best_score * 0.05, 1e-6): + close_face_ids = [ + face_id + for score, face_id in candidates + if abs(score - best_score) <= max(best_score * 0.05, 1e-6) + ] + display_ids = {self._relation_face_display_id(face_id) for face_id in close_face_ids} + if len(display_ids) == 1: + return close_face_ids[0] return None return best_face_id + def _relation_face_signature_is_compatible( + self, + signature: dict[str, object], + face_id: int, + *, + allow_changed_geometry: bool, + ) -> bool: + score = self._relation_face_signature_score( + signature, + face_id, + allow_part_mismatch=True, + allow_changed_geometry=allow_changed_geometry, + ) + if score is None: + return False + return score <= self._relation_face_signature_score_limit( + signature, + allow_changed_geometry=allow_changed_geometry, + ) + + def _relation_face_signature_score( + self, + signature: dict[str, object], + face_id: int, + *, + allow_part_mismatch: bool, + allow_changed_geometry: bool, + ) -> float | None: + if self.model is None or face_id < 0 or face_id >= len(getattr(self.model, "faces", [])): + return None + part_id = _int_or_none(signature.get("part_id")) + if part_id is not None and hasattr(self.model, "face_part_ids") and 0 <= face_id < len(self.model.face_part_ids): + same_part = int(self.model.face_part_ids[face_id]) == int(part_id) + if not same_part and not allow_part_mismatch: + return None + else: + same_part = True + try: + info = self.model.quick_face_info(face_id) + except Exception: + return None + surface = str(signature.get("surface") or "") + if surface and str(info.get("surface") or "") != surface: + return None + score = 0.0 if same_part else 0.05 + center = _triple_or_none(signature.get("axis_center")) or _triple_or_none(signature.get("center")) + candidate_center = ( + self._axis_center_from_feature_info(info) + or _triple_or_none(info.get("area_center")) + or _triple_or_none(info.get("bbox_center")) + ) + if center is not None and candidate_center is not None: + score += math.sqrt(sum((candidate_center[index] - center[index]) ** 2 for index in range(3))) + elif center is not None: + return None + axis = _triple_or_none(signature.get("axis")) + candidate_axis = _triple_or_none(info.get("axis")) + if axis is not None and candidate_axis is not None: + axis_dot = abs(sum(axis[index] * candidate_axis[index] for index in range(3))) + score += max(0.0, 1.0 - min(axis_dot, 1.0)) * 0.25 + radius = _float_or_none(signature.get("radius")) + candidate_radius = _float_or_none(info.get("radius")) + if radius is not None and candidate_radius is not None and not allow_changed_geometry: + score += abs(candidate_radius - radius) * 4.0 + elif radius is not None and candidate_radius is None and not allow_changed_geometry: + return None + area = _float_or_none(signature.get("area")) + candidate_area = _float_or_none(info.get("area")) + if area is not None and area > 0 and candidate_area is not None and not allow_changed_geometry: + score += abs(candidate_area - area) / max(abs(area), 1.0) * max(math.sqrt(abs(area)) * 0.05, 0.02) + return score + + @staticmethod + def _relation_face_signature_score_limit( + signature: dict[str, object], + *, + allow_changed_geometry: bool, + ) -> float: + radius = _float_or_none(signature.get("radius")) + area = _float_or_none(signature.get("area")) + size_terms = [1e-4] + if radius is not None and radius > 0: + size_terms.append(radius * (4.0 if allow_changed_geometry else 1.5)) + if area is not None and area > 0: + size_terms.append(math.sqrt(area) * (1.5 if allow_changed_geometry else 0.3)) + return max(size_terms) + def _accept_relation_formula_completion(self) -> bool: completer = getattr(self, "relation_formula_completer", None) editor = getattr(self, "relation_formula_input", None) if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit): return False - self._update_relation_formula_completions() - completion = completer.currentCompletion() + completion = self._relation_formula_popup_current_completion() if not completion: + self._update_relation_formula_completions() + completion = completer.currentCompletion() model = completer.model() + completion_prefix = str(completer.completionPrefix() or "") if model is not None and model.rowCount() > 0: - completion = str(model.index(0, 0).data() or "") + if ( + not completion + or (completion_prefix and not str(completion).lower().startswith(completion_prefix.lower())) + or str(completion) == completion_prefix + ): + for row in range(model.rowCount()): + candidate = str(model.index(row, 0).data() or "") + if ( + (not completion_prefix or candidate.lower().startswith(completion_prefix.lower())) + and candidate.lower() != completion_prefix.lower() + ): + completion = candidate + break + if not completion: + completion = str(model.index(0, 0).data() or "") if not completion: return False + return self._insert_relation_formula_completion(str(completion)) + + def _insert_relation_formula_completion(self, completion: str) -> bool: + editor = getattr(self, "relation_formula_input", None) + if not isinstance(editor, QLineEdit): + return False text = editor.text() cursor = editor.cursorPosition() - prefix_start = max(text.rfind(" ", 0, cursor), text.rfind("=", 0, cursor)) + 1 + if completion.strip() in {"=", "+", "-", "*", "/", "."}: + new_text = f"{text[:cursor]}{completion}{text[cursor:]}" + editor.setText(new_text) + editor.setCursorPosition(cursor + len(completion)) + self._hide_relation_formula_completion_popup() + editor.setFocus(Qt.FocusReason.OtherFocusReason) + QTimer.singleShot(0, self._restore_relation_formula_input_focus) + if completion.strip() == ".": + QTimer.singleShot(0, self._update_relation_formula_completions) + return True + prefix_start = int(getattr(self, "_relation_formula_completion_prefix_start", -1)) + if prefix_start < 0 or prefix_start > cursor: + prefix_start = self._relation_formula_fragment_start(text[:cursor]) prefix = text[prefix_start:cursor] - if "." in prefix and completion.lower().startswith(prefix.lower()): + if prefix and completion.lower().startswith(prefix.lower()): new_text = f"{text[:prefix_start]}{completion}{text[cursor:]}" + new_cursor = prefix_start + len(completion) else: new_text = f"{text[:cursor]}{completion}{text[cursor:]}" + new_cursor = cursor + len(completion) editor.setText(new_text) - editor.setCursorPosition(prefix_start + len(completion)) + editor.setCursorPosition(new_cursor) + self._hide_relation_formula_completion_popup() + editor.setFocus(Qt.FocusReason.OtherFocusReason) + QTimer.singleShot(0, self._restore_relation_formula_input_focus) + if ( + completion in {"Face", "Edge"} + or re.fullmatch(r"(Face|Edge)\d+", str(completion), flags=re.IGNORECASE) + or RELATION_REF_PATTERN.fullmatch(str(completion)) + ): + QTimer.singleShot(0, self._refresh_relation_formula_completion_popup) return True def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]: @@ -6721,7 +7583,10 @@ class WindowStateMixin: return if has_model is None: has_model = self.model is not None and not ( - self.operation_in_progress or self.scan_in_progress or self.load_in_progress + self.operation_in_progress + or self.scan_in_progress + or self.load_in_progress + or bool(getattr(self, "relation_formula_replay_active", False)) ) for row, spec in enumerate(getattr(self, "property_editor_specs", [])): effective_spec = self._effective_property_spec(spec, row=row) @@ -6825,9 +7690,8 @@ class WindowStateMixin: setattr(widget, "_geom_param_button_state", button_state) if hasattr(self, "apply_property_button"): changed = self._changed_property_rows() - formula_ready = self._relation_formulas_have_current_targets() - enabled = bool(has_model and (changed or formula_ready)) - disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值,或添加能作用于当前参数表的关系式。" + enabled = bool(has_model and changed) + disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。关系式会在点击“添加公式”后立即执行。" if changed: count = len(changed) enabled_tip = ( @@ -6835,8 +7699,6 @@ class WindowStateMixin: if count == 1 else f"按表格顺序依次应用当前被修改的 {count} 个参数;失败时会停止后续修改。" ) - elif formula_ready: - enabled_tip = "先计算关系式并回填目标值,再执行参数化建模。" else: enabled_tip = "应用当前被修改的参数。" self._set_control_state( @@ -7190,10 +8052,9 @@ class WindowStateMixin: return abs(value - current) > PROPERTY_VALUE_TOLERANCE def apply_current_property_edit(self) -> None: - self._apply_relation_formulas_to_property_targets() changed = self._changed_property_rows() if not changed: - self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值,或添加能回填当前参数表的关系式。") + self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值;关系式会在添加后自动执行。") return if self.selected_kind == "multi_feature" and hasattr(self, "apply_multi_selected_hole_property_edit"): self.apply_multi_selected_hole_property_edit(changed) @@ -7222,6 +8083,8 @@ class WindowStateMixin: self._clear_property_batch_state() self.statusBar().showMessage(f"批量参数化建模完成:已应用 {total} 个参数。") self._update_property_apply_state() + if hasattr(self, "_run_pending_relation_formula_dependency_reapply"): + self._run_pending_relation_formula_dependency_reapply() return item = queue.pop(0) self.property_batch_queue = queue @@ -7280,6 +8143,9 @@ class WindowStateMixin: self._property_batch_item_callback_seen = False def _after_property_edit_finished(self, *, success: bool) -> None: + if bool(getattr(self, "relation_formula_replay_active", False)): + self._after_relation_formula_edit_finished(success=success) + return if not bool(getattr(self, "property_batch_active", False)): return self._property_batch_item_callback_seen = True