feat: 完善一级关系编辑 UI 与视图体验
This commit is contained in:
+187
-19
@@ -8,6 +8,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
import vtk
|
||||
import vtkmodules.vtkInteractionWidgets # noqa: F401
|
||||
import vtkmodules.vtkInteractionStyle # noqa: F401
|
||||
import vtkmodules.vtkRenderingFreeType # noqa: F401
|
||||
import vtkmodules.vtkRenderingOpenGL2 # noqa: F401
|
||||
@@ -57,6 +58,7 @@ from .window_state import WindowStateMixin
|
||||
|
||||
|
||||
_CRASH_LOG_HANDLE = None
|
||||
_VTK_OUTPUT_WINDOW = None
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
APP_USER_MODEL_ID = "GeometryParametric.StepEditor"
|
||||
ISOLATED_EDIT_WORKER_ARG = "--isolated-edit-worker"
|
||||
@@ -119,6 +121,18 @@ def _set_windows_taskbar_identity() -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _suppress_vtk_output_window() -> None:
|
||||
"""Keep VTK warnings from opening transient native output windows."""
|
||||
global _VTK_OUTPUT_WINDOW
|
||||
try:
|
||||
vtk.vtkObject.GlobalWarningDisplayOff()
|
||||
output_window = vtk.vtkStringOutputWindow()
|
||||
vtk.vtkOutputWindow.SetInstance(output_window)
|
||||
_VTK_OUTPUT_WINDOW = output_window
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _isolated_edit_worker_request(argv: list[str]) -> Path | None:
|
||||
if ISOLATED_EDIT_WORKER_ARG not in argv:
|
||||
return None
|
||||
@@ -132,6 +146,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
ui_task_requested = Signal(object)
|
||||
|
||||
def __init__(self, step_path: str | Path, *, background_load: bool = False):
|
||||
_suppress_vtk_output_window()
|
||||
super().__init__()
|
||||
self.ui_task_requested.connect(self._run_ui_task, Qt.ConnectionType.QueuedConnection)
|
||||
self.setWindowTitle("几何参数化")
|
||||
@@ -149,6 +164,16 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
|
||||
self.model_actor = None
|
||||
self.edge_actor = None
|
||||
self.scene_axes_renderer = None
|
||||
self.orientation_marker_widget = None
|
||||
self.orientation_marker_prop = None
|
||||
self.step_coordinate_axes_actor = None
|
||||
self.hide_edges_during_camera_interaction = False
|
||||
self.edge_visibility_before_camera_interaction: int | None = None
|
||||
self.prefer_fxaa_antialiasing = True
|
||||
self.fallback_multi_samples = 2
|
||||
self.interactive_multi_samples = 0
|
||||
self.still_multi_samples = 0
|
||||
self.highlight_actor = None
|
||||
self.edge_highlight_actor = None
|
||||
self.highlight_signature: tuple[object, ...] | None = None
|
||||
@@ -160,6 +185,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.pending_hover_position: tuple[int, int] | None = None
|
||||
self.last_hover_pick_position: tuple[int, int] | None = None
|
||||
self.camera_interaction_active = False
|
||||
self.camera_interaction_visual_changed = False
|
||||
self.last_camera_interaction_ended_at: datetime | None = None
|
||||
self.hover_after_camera_cooldown_ms = 420
|
||||
self.pointer_button_down = False
|
||||
@@ -233,9 +259,9 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.auto_load_on_show = bool(background_load)
|
||||
self.vtk_interactor_started = False
|
||||
self.first_show_handled = False
|
||||
self.preview_load_deflection = 1.0
|
||||
self.initial_load_deflection = 0.035
|
||||
self.edit_result_deflection = 0.035
|
||||
self.preview_load_deflection = 0.35
|
||||
self.initial_load_deflection = 0.35
|
||||
self.edit_result_deflection = 0.35
|
||||
self.last_id_kind = "Feature"
|
||||
self.feature_detection_level = "current-only"
|
||||
self.property_editor_updating = False
|
||||
@@ -244,7 +270,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self._selected_action_info_cache_value: dict[str, object] | None = None
|
||||
self.property_editor_specs: list[dict[str, object]] = []
|
||||
self.property_table_expanded = False
|
||||
self.property_table_collapsed_rows = 6
|
||||
self.property_table_collapsed_rows = 8
|
||||
self.property_editor_selected_row: int | None = None
|
||||
self.property_command_active_key = ""
|
||||
self.property_command_buttons: dict[str, QPushButton] = {}
|
||||
|
||||
self._build_ui()
|
||||
self._build_vtk()
|
||||
@@ -487,8 +516,8 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
border-radius: 5px;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
min-height: 18px;
|
||||
padding: 1px 3px;
|
||||
min-height: 26px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
QPushButton#propertyRowEditButton:hover {
|
||||
background: #f97316;
|
||||
@@ -646,6 +675,109 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
padding-left: 1px;
|
||||
padding-right: 1px;
|
||||
}
|
||||
QLabel#propertyCommandSummary {
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
padding: 0 2px 1px 2px;
|
||||
}
|
||||
QLabel#propertyCommandHelp {
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 6px;
|
||||
color: #7c2d12;
|
||||
font-size: 11px;
|
||||
padding: 5px 7px;
|
||||
}
|
||||
QFrame#propertyCommandBar {
|
||||
background: #eef2f7;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QPushButton#propertyCommandButton {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
min-height: 34px;
|
||||
padding: 3px 6px;
|
||||
}
|
||||
QPushButton#propertyCommandButton:hover {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
QPushButton#propertyCommandButton[active="true"] {
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
border: 1px solid #f59e0b;
|
||||
}
|
||||
QScrollArea#propertyCardScroll {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
QWidget#propertyCardContainer {
|
||||
background: transparent;
|
||||
}
|
||||
QFrame#propertyCard {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
QFrame#propertyCard[compact="true"] {
|
||||
background: #ffffff;
|
||||
}
|
||||
QFrame#propertyCard[editable="true"] {
|
||||
background: #fffaf2;
|
||||
border-color: #f4c58a;
|
||||
}
|
||||
QFrame#propertyCard[selected="true"] {
|
||||
background: #fffaf2;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
QFrame#propertyCard[changed="true"] {
|
||||
background: #f0fdf4;
|
||||
border-color: #16a34a;
|
||||
}
|
||||
QFrame#propertyCard[invalid="true"] {
|
||||
background: #fff1f2;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
QFrame#propertyCard[pinTop="true"] {
|
||||
background: #eef6ff;
|
||||
border-color: #b7d3f5;
|
||||
}
|
||||
QLabel#propertyCardTitle {
|
||||
color: #172033;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#propertyCardMetaLabel {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
QLabel#propertyCardValue {
|
||||
color: #172033;
|
||||
}
|
||||
QLabel#propertyCardCompactValue {
|
||||
color: #172033;
|
||||
font-weight: 600;
|
||||
}
|
||||
QLabel#propertyCardHint {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
}
|
||||
QLabel#propertyCardCompactMeta {
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
}
|
||||
QLineEdit#propertyCardTargetEditor {
|
||||
background: #ffffff;
|
||||
border: 1px solid #c8d2df;
|
||||
border-radius: 4px;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
QLineEdit#propertyCardTargetEditor:focus {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #d8e0eb;
|
||||
border-radius: 6px;
|
||||
@@ -920,20 +1052,20 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.object_edit_box.setObjectName("editSection")
|
||||
help_tip(
|
||||
self.object_edit_box,
|
||||
"选择特征后,这里只列出可可靠修改的语义尺寸;面积、中心和拓扑数据位于下方诊断信息。",
|
||||
"选择对象后,这里以参数卡片显示当前值、建模意图、目标值和应用按钮;诊断信息默认折叠在后面。",
|
||||
)
|
||||
object_edit_layout = QVBoxLayout(self.object_edit_box)
|
||||
object_edit_layout.setContentsMargins(0, 8, 0, 4)
|
||||
object_edit_layout.setSpacing(4)
|
||||
self.property_table = QTableWidget(0, 5)
|
||||
self.property_table = QTableWidget(0, 5, self.object_edit_box)
|
||||
self.property_table.setObjectName("propertyTable")
|
||||
self.property_table.setHorizontalHeaderLabels(["尺寸参数", "当前值", "建模意图", "目标值", "操作"])
|
||||
self.property_table.setHorizontalHeaderLabels(["参数", "当前值", "建模意图", "目标值", "操作"])
|
||||
self.property_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.property_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.property_table.setAlternatingRowColors(True)
|
||||
self.property_table.setMinimumHeight(180)
|
||||
self.property_table.setWordWrap(False)
|
||||
self.property_table.setTextElideMode(Qt.TextElideMode.ElideMiddle)
|
||||
self.property_table.setTextElideMode(Qt.TextElideMode.ElideRight)
|
||||
self.property_table.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.property_table.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.property_table.verticalHeader().setVisible(False)
|
||||
@@ -946,21 +1078,51 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
property_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
property_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
|
||||
property_header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
|
||||
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
|
||||
self.property_table.setColumnWidth(0, 148)
|
||||
self.property_table.setColumnWidth(1, 68)
|
||||
self.property_table.setColumnWidth(2, 96)
|
||||
self.property_table.setColumnWidth(3, 72)
|
||||
self.property_table.setColumnWidth(4, 54)
|
||||
property_header.setSectionResizeMode(4, QHeaderView.ResizeMode.Fixed)
|
||||
self._resize_property_table_columns()
|
||||
self.property_table.installEventFilter(self)
|
||||
help_tip(
|
||||
self.property_table,
|
||||
"特征模式显示当前特征及局部关联特征的可变尺寸;建模意图决定这次修改是局部重建、拉伸/切除、端面移动还是整体缩放。",
|
||||
)
|
||||
self.property_table.itemChanged.connect(self._on_property_table_item_changed)
|
||||
object_edit_layout.addWidget(self.property_table)
|
||||
self.property_expand_button = QPushButton("展开全部参数")
|
||||
self.property_table.setVisible(False)
|
||||
self.property_table.setMaximumHeight(0)
|
||||
self.property_command_summary_label = QLabel("未选择可编辑对象")
|
||||
self.property_command_summary_label.setObjectName("propertyCommandSummary")
|
||||
self.property_command_summary_label.setWordWrap(True)
|
||||
object_edit_layout.addWidget(self.property_command_summary_label)
|
||||
self.property_command_bar = QFrame()
|
||||
self.property_command_bar.setObjectName("propertyCommandBar")
|
||||
self.property_command_layout = QHBoxLayout(self.property_command_bar)
|
||||
self.property_command_layout.setContentsMargins(3, 3, 3, 3)
|
||||
self.property_command_layout.setSpacing(2)
|
||||
object_edit_layout.addWidget(self.property_command_bar)
|
||||
self.property_command_help_label = QLabel("")
|
||||
self.property_command_help_label.setObjectName("propertyCommandHelp")
|
||||
self.property_command_help_label.setWordWrap(True)
|
||||
self.property_command_help_label.setVisible(False)
|
||||
object_edit_layout.addWidget(self.property_command_help_label)
|
||||
self.property_card_scroll = QScrollArea()
|
||||
self.property_card_scroll.setObjectName("propertyCardScroll")
|
||||
self.property_card_scroll.setWidgetResizable(True)
|
||||
self.property_card_scroll.setFrameShape(QFrame.Shape.NoFrame)
|
||||
self.property_card_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.property_card_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
|
||||
self.property_card_container = QWidget()
|
||||
self.property_card_container.setObjectName("propertyCardContainer")
|
||||
self.property_card_layout = QVBoxLayout(self.property_card_container)
|
||||
self.property_card_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.property_card_layout.setSpacing(6)
|
||||
self.property_card_scroll.setWidget(self.property_card_container)
|
||||
help_tip(
|
||||
self.property_card_scroll,
|
||||
"每张卡片对应一个参数;修改目标值后,点击卡片右上角的按钮只应用这一项。",
|
||||
)
|
||||
object_edit_layout.addWidget(self.property_card_scroll)
|
||||
self.property_expand_button = QPushButton("更多诊断信息")
|
||||
self.property_expand_button.setObjectName("propertyExpandBar")
|
||||
help_tip(self.property_expand_button, "参数较多时默认只显示前 6 行;点击这里展开或收起完整参数列表。")
|
||||
help_tip(self.property_expand_button, "默认优先显示可编辑参数和关键说明;点击这里展开或收起完整诊断信息。")
|
||||
self.property_expand_button.clicked.connect(self.toggle_property_table_expanded)
|
||||
self.property_expand_button.setVisible(False)
|
||||
self.property_expand_button.setMaximumHeight(22)
|
||||
@@ -969,6 +1131,7 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
property_action_row = QHBoxLayout()
|
||||
property_action_row.setContentsMargins(0, 0, 0, 0)
|
||||
self.apply_property_button = QPushButton("参数化建模")
|
||||
self.apply_property_button.setVisible(False)
|
||||
help_tip(self.apply_property_button, "按当前属性表里修改过的目标值执行参数化建模;一次只执行一个几何修改,成功后可撤销。")
|
||||
self.apply_property_button.clicked.connect(self.apply_current_property_edit)
|
||||
self.quick_export_all_button = QPushButton("导出当前完整STEP")
|
||||
@@ -1465,6 +1628,10 @@ class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, Inf
|
||||
self.vtk_widget.setMouseTracking(True)
|
||||
self.vtk_widget.installEventFilter(self)
|
||||
root_layout.addWidget(self.vtk_widget, stretch=1)
|
||||
app = QApplication.instance()
|
||||
if app is not None and not getattr(self, "_global_tooltip_filter_installed", False):
|
||||
app.installEventFilter(self)
|
||||
self._global_tooltip_filter_installed = True
|
||||
|
||||
self.statusBar().showMessage("请选择 STEP 文件,或点击“读取模型”加载到可视化区域。")
|
||||
|
||||
@@ -1495,6 +1662,7 @@ def main() -> int:
|
||||
_enable_crash_log()
|
||||
path, smoke_test = _parse_args(sys.argv)
|
||||
_set_windows_taskbar_identity()
|
||||
_suppress_vtk_output_window()
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("几何参数化")
|
||||
app.setApplicationDisplayName("几何参数化")
|
||||
|
||||
@@ -2193,6 +2193,15 @@ class FeatureMixin:
|
||||
scoped_info["height_estimate"] = axis_range["span"]
|
||||
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
|
||||
readiness = _cylinder_boss_resize_readiness(scoped_info, new_diameter)
|
||||
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
|
||||
readiness,
|
||||
topology_fields,
|
||||
status_key="boss_resize_status",
|
||||
risk_key="boss_resize_risk",
|
||||
note_key="boss_resize_note",
|
||||
warnings_key="boss_resize_warnings",
|
||||
blockers_key="boss_resize_blockers",
|
||||
)
|
||||
resize_mode = _resize_mode(current_diameter, new_diameter)
|
||||
delta_diameter = new_diameter - current_diameter
|
||||
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
|
||||
@@ -2213,6 +2222,7 @@ class FeatureMixin:
|
||||
"face_id": face_id,
|
||||
"part_id": info["part_id"],
|
||||
"solid_id": info["solid_id"],
|
||||
**topology_fields,
|
||||
"current_diameter": current_diameter,
|
||||
"target_diameter": new_diameter,
|
||||
"delta_diameter": delta_diameter,
|
||||
@@ -2252,12 +2262,14 @@ class FeatureMixin:
|
||||
feature = self.feature_info(face_id)
|
||||
except Exception:
|
||||
feature = {}
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
|
||||
blockers: list[str] = []
|
||||
warnings: list[str] = [
|
||||
"Cylindrical boss axis move removes the old boss envelope, then fuses a same-diameter boss on the target axis."
|
||||
]
|
||||
risk = "medium"
|
||||
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
|
||||
|
||||
try:
|
||||
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
|
||||
@@ -2360,6 +2372,7 @@ class FeatureMixin:
|
||||
"face_id": face_id,
|
||||
"part_id": info.get("part_id"),
|
||||
"solid_id": info.get("solid_id"),
|
||||
**topology_fields,
|
||||
"surface": info.get("surface"),
|
||||
"feature_type": feature.get("feature_type"),
|
||||
"feature_guess": feature_guess,
|
||||
|
||||
@@ -127,6 +127,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
self._edge_polydata_cache: dict[tuple[object, ...], object] = {}
|
||||
self._polydata_cache_limit = 96
|
||||
self._mesh_deflection: float | None = None
|
||||
self._face_mesh_deflections: dict[int, float] = {}
|
||||
self.refresh_topology()
|
||||
|
||||
@classmethod
|
||||
@@ -214,6 +215,7 @@ class StepModel(FeatureMixin, ExportMixin, TransformMixin, OperationMixin, Polyd
|
||||
self._face_polydata_cache.clear()
|
||||
self._edge_polydata_cache.clear()
|
||||
self._mesh_deflection = None
|
||||
self._face_mesh_deflections.clear()
|
||||
|
||||
solid_id = 0
|
||||
for part in self.display_parts():
|
||||
|
||||
+521
-107
@@ -1678,6 +1678,23 @@ class OperationMixin:
|
||||
notes.append(f"相邻圆柱Face {face_id} 的{mode_label}不可用:{axis_plan.get('message', '')}")
|
||||
continue
|
||||
|
||||
axis_topology_fields = {
|
||||
f"move_axis_{key}": axis_plan.get(key)
|
||||
for key in (
|
||||
"topology_relation_depth",
|
||||
"topology_relation_status",
|
||||
"topology_relation_boundary",
|
||||
"topology_ignored_relation_depths",
|
||||
"first_level_topology_status",
|
||||
"first_level_topology_guard_note",
|
||||
"first_level_boundary_edge_count",
|
||||
"first_level_adjacent_face_count",
|
||||
"cylindrical_feature_side_face_count",
|
||||
"cylindrical_feature_boundary_edge_count",
|
||||
"cylindrical_feature_adjacent_face_count",
|
||||
)
|
||||
if key in axis_plan
|
||||
}
|
||||
candidate = {
|
||||
"circular_edge_cylinder_face_id": face_id,
|
||||
"circular_edge_cylinder_mode": mode,
|
||||
@@ -1703,6 +1720,7 @@ class OperationMixin:
|
||||
"move_axis_blockers": axis_plan.get("blockers", ""),
|
||||
"move_axis_feature_guess": axis_plan.get("feature_guess", feature_guess),
|
||||
"move_axis_confidence": axis_plan.get("confidence", face_info.get("confidence", "")),
|
||||
**axis_topology_fields,
|
||||
"resize_strategy": "move-adjacent-cylinder-from-circular-edge-center",
|
||||
"edit_strategy_label": f"通过{mode_label}移动圆Edge",
|
||||
"edit_semantics": (
|
||||
@@ -2347,11 +2365,53 @@ class OperationMixin:
|
||||
feature_guess = str(info.get("feature_guess", ""))
|
||||
current_radius = float(feature.get("existing_fillet_radius_estimate", info["radius"]))
|
||||
support_face_ids = tuple(feature.get("feature_existing_fillet_support_face_ids", ()))
|
||||
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
|
||||
warnings: list[str] = []
|
||||
blockers: list[str] = []
|
||||
risk = "medium"
|
||||
status = "caution"
|
||||
|
||||
topology_status = str(topology_fields.get("topology_relation_status") or "")
|
||||
side_count = int(topology_fields.get("cylindrical_feature_side_face_count", 0) or 0)
|
||||
boundary_edge_count = int(topology_fields.get("cylindrical_feature_boundary_edge_count", 0) or 0)
|
||||
adjacent_face_ids = tuple(_int_values(topology_fields.get("cylindrical_feature_adjacent_face_ids")))
|
||||
adjacent_count = int(topology_fields.get("cylindrical_feature_adjacent_face_count", 0) or 0)
|
||||
missing_support_faces = tuple(sorted(set(support_face_ids) - set(adjacent_face_ids)))
|
||||
topology_blockers: list[str] = []
|
||||
topology_warnings: list[str] = []
|
||||
topology_risk = "low"
|
||||
if topology_status != "ready":
|
||||
topology_blockers.append("当前圆角的一级拓扑关系无法确认,不能稳定执行移除旧圆角并重建。")
|
||||
if side_count <= 0:
|
||||
topology_blockers.append("没有识别到当前圆角面区域。")
|
||||
if boundary_edge_count <= 0:
|
||||
topology_blockers.append("没有识别到当前圆角面的边界 Edge,不能确认重建影响范围。")
|
||||
if adjacent_count < 2:
|
||||
topology_blockers.append("没有识别到当前圆角直接连接的两个支撑 Face。")
|
||||
if missing_support_faces:
|
||||
topology_blockers.append(f"已识别支撑 Face {missing_support_faces} 不在当前圆角一级相邻 Face 中。")
|
||||
if adjacent_count > 8 or boundary_edge_count > 16:
|
||||
topology_risk = _max_risk(topology_risk, "high")
|
||||
topology_warnings.append("当前圆角一级邻域较复杂,重建后需要重点检查相邻面。")
|
||||
elif adjacent_count > 4 or boundary_edge_count > 8:
|
||||
topology_risk = _max_risk(topology_risk, "medium")
|
||||
topology_warnings.append("当前圆角一级邻域包含较多相邻面或边界边。")
|
||||
topology_fields.update(
|
||||
{
|
||||
"first_level_topology_status": "blocked" if topology_blockers else "ready",
|
||||
"first_level_topology_risk": "blocked" if topology_blockers else topology_risk,
|
||||
"first_level_topology_blockers": ";".join(topology_blockers),
|
||||
"first_level_topology_warnings": ";".join(topology_warnings),
|
||||
"first_level_topology_guard_note": (
|
||||
f"圆角一级关系检查:圆角 Face {side_count} 个,边界 Edge {boundary_edge_count} 条,"
|
||||
f"直接相邻 Face {adjacent_count} 个,支撑 Face {len(support_face_ids)} 个。"
|
||||
),
|
||||
}
|
||||
)
|
||||
blockers.extend(topology_blockers)
|
||||
warnings.extend(topology_warnings)
|
||||
risk = _max_risk(risk, str(topology_fields.get("first_level_topology_risk") or "low"))
|
||||
|
||||
if feature_guess != "round/fillet candidate":
|
||||
blockers.append("当前圆柱面没有被识别为已有圆角/倒圆候选。")
|
||||
if target_radius <= 0:
|
||||
@@ -2412,6 +2472,7 @@ class OperationMixin:
|
||||
"face_id": face_id,
|
||||
"part_id": info.get("part_id"),
|
||||
"solid_id": info.get("solid_id"),
|
||||
**topology_fields,
|
||||
"feature_type": feature.get("feature_type"),
|
||||
"feature_guess": feature_guess,
|
||||
"confidence": info.get("confidence"),
|
||||
@@ -4105,6 +4166,7 @@ class OperationMixin:
|
||||
blockers.append(f"当前选中 Face 不是圆柱面,不能调整{edit_label}。")
|
||||
if require_boss and str(info.get("feature_guess", "")) != "boss/outer-round candidate":
|
||||
blockers.append("凸台高度调整当前版本只支持明确的圆柱凸台候选。")
|
||||
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
|
||||
cylinder_identity = {**info, **feature}
|
||||
angular_span = _effective_cylinder_angular_span(cylinder_identity) or 0.0
|
||||
if not _is_effectively_full_cylinder(cylinder_identity):
|
||||
@@ -4239,6 +4301,7 @@ class OperationMixin:
|
||||
"face_id": face_id,
|
||||
"part_id": info.get("part_id"),
|
||||
"solid_id": info.get("solid_id"),
|
||||
**topology_fields,
|
||||
"feature_type": feature.get("feature_type"),
|
||||
"feature_guess": info.get("feature_guess"),
|
||||
"confidence": info.get("confidence"),
|
||||
@@ -4320,7 +4383,8 @@ class OperationMixin:
|
||||
f"target_height={float(plan['target_height']):g}, "
|
||||
f"delta={float(plan['delta_height']):g}, "
|
||||
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
|
||||
f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}. {push_result}"
|
||||
f"risk={plan['risk']}, verified_face={verification.get('face_id', '')}, "
|
||||
f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}. {push_result}"
|
||||
)
|
||||
|
||||
def shell_thickness_preview_polydata(
|
||||
@@ -7664,6 +7728,7 @@ class OperationMixin:
|
||||
check = self._edge_length_result_check(plan)
|
||||
if check is None:
|
||||
return "Result check unavailable."
|
||||
topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=False)
|
||||
return (
|
||||
"Result check: "
|
||||
f"match={check['match_method']}, "
|
||||
@@ -7672,7 +7737,7 @@ class OperationMixin:
|
||||
f"target_error={float(check['target_error']):g}, "
|
||||
f"relative_error={float(check['relative_error']):g}, "
|
||||
f"endpoint_error={float(check['endpoint_error']):g}, "
|
||||
f"scope={check['scope']}."
|
||||
f"scope={check['scope']}. {topology_check}"
|
||||
)
|
||||
|
||||
def _edge_length_result_summary_or_raise(self, plan: dict[str, object]) -> str:
|
||||
@@ -7699,6 +7764,7 @@ class OperationMixin:
|
||||
f"允许误差 {endpoint_tolerance:g}。"
|
||||
)
|
||||
|
||||
topology_check = self._edge_first_level_result_summary(plan, int(check["edge_id"]), enforce=True)
|
||||
return (
|
||||
"Result check: "
|
||||
f"match={check['match_method']}, "
|
||||
@@ -7707,7 +7773,173 @@ class OperationMixin:
|
||||
f"target_error={float(check['target_error']):g}, "
|
||||
f"relative_error={float(check['relative_error']):g}, "
|
||||
f"endpoint_error={float(check['endpoint_error']):g}, "
|
||||
f"scope={check['scope']}."
|
||||
f"scope={check['scope']}. {topology_check}"
|
||||
)
|
||||
|
||||
def _edge_first_level_result_summary(
|
||||
self,
|
||||
plan: dict[str, object],
|
||||
result_edge_id: int,
|
||||
*,
|
||||
enforce: bool,
|
||||
) -> str:
|
||||
strategy = str(plan.get("resize_strategy") or "")
|
||||
enforce_strategy = strategy in {
|
||||
"local-edge-only-deform",
|
||||
"local-edge-endpoint-deform",
|
||||
"local-edge-center-deform",
|
||||
"move-edge-end-plane-by-push-pull",
|
||||
}
|
||||
expected_vertices = _int_or_none(plan.get("first_level_fact_boundary_vertex_count"))
|
||||
if expected_vertices is None:
|
||||
expected_vertices = _int_or_none(plan.get("first_level_vertex_count"))
|
||||
expected_adjacent_edges = _int_or_none(plan.get("first_level_fact_adjacent_edge_count"))
|
||||
if expected_adjacent_edges is None:
|
||||
expected_adjacent_edges = _int_or_none(plan.get("first_level_adjacent_edge_count"))
|
||||
expected_adjacent_faces = _int_or_none(plan.get("first_level_fact_adjacent_face_count"))
|
||||
if expected_adjacent_faces is None:
|
||||
expected_adjacent_faces = _int_or_none(plan.get("first_level_adjacent_face_count"))
|
||||
expected_included_edges = _int_or_none(plan.get("first_level_fact_included_edge_count"))
|
||||
if expected_included_edges is None:
|
||||
expected_included_edges = _int_or_none(plan.get("first_level_edge_count"))
|
||||
|
||||
if not any(
|
||||
value is not None and value > 0
|
||||
for value in (expected_vertices, expected_adjacent_edges, expected_adjacent_faces, expected_included_edges)
|
||||
):
|
||||
return "First-level topology check: unavailable."
|
||||
|
||||
try:
|
||||
topology = self.edge_first_level_topology(int(result_edge_id))
|
||||
except Exception as exc:
|
||||
if enforce and enforce_strategy:
|
||||
raise RuntimeError(
|
||||
"Edge编辑后无法重新识别目标Edge的一级关系,模型已恢复到修改前状态。"
|
||||
f"目标Edge {result_edge_id},原因:{exc}"
|
||||
) from exc
|
||||
return f"First-level topology check: unavailable ({exc})."
|
||||
|
||||
actual_vertices = int(topology.get("first_level_vertex_count", 0) or 0)
|
||||
actual_adjacent_edges = int(topology.get("first_level_adjacent_edge_count", 0) or 0)
|
||||
actual_adjacent_faces = int(topology.get("first_level_adjacent_face_count", 0) or 0)
|
||||
actual_included_edges = int(topology.get("first_level_edge_count", 0) or 0)
|
||||
|
||||
failures: list[str] = []
|
||||
if expected_vertices is not None and expected_vertices > 0 and actual_vertices != expected_vertices:
|
||||
failures.append(f"端点 Vertex {actual_vertices}/{expected_vertices}")
|
||||
if (
|
||||
expected_adjacent_edges is not None
|
||||
and expected_adjacent_edges > 0
|
||||
and actual_adjacent_edges < expected_adjacent_edges
|
||||
):
|
||||
failures.append(f"共享端点相邻 Edge {actual_adjacent_edges}/{expected_adjacent_edges}")
|
||||
if (
|
||||
expected_adjacent_faces is not None
|
||||
and expected_adjacent_faces > 0
|
||||
and actual_adjacent_faces < expected_adjacent_faces
|
||||
):
|
||||
failures.append(f"直接相邻 Face {actual_adjacent_faces}/{expected_adjacent_faces}")
|
||||
if (
|
||||
expected_included_edges is not None
|
||||
and expected_included_edges > 0
|
||||
and actual_included_edges < expected_included_edges
|
||||
):
|
||||
failures.append(f"一级范围 Edge {actual_included_edges}/{expected_included_edges}")
|
||||
|
||||
if failures and enforce and enforce_strategy:
|
||||
raise RuntimeError(
|
||||
"Edge编辑后一级关系不完整,模型已恢复到修改前状态。"
|
||||
f"目标Edge {result_edge_id},未通过:{'; '.join(failures)}。"
|
||||
)
|
||||
|
||||
status = "ok" if not failures else f"warning({'; '.join(failures)})"
|
||||
return (
|
||||
"First-level topology check: "
|
||||
f"{status}, edge={result_edge_id}, endpoint_vertices={actual_vertices}, "
|
||||
f"adjacent_edges={actual_adjacent_edges}, adjacent_faces={actual_adjacent_faces}, "
|
||||
f"included_edges={actual_included_edges}."
|
||||
)
|
||||
|
||||
def _edge_blend_result_summary_or_raise(
|
||||
self,
|
||||
plan: dict[str, object],
|
||||
before_stats,
|
||||
*,
|
||||
mode: str,
|
||||
) -> str:
|
||||
part_id = _int_or_none(plan.get("part_id"))
|
||||
if part_id is None:
|
||||
raise RuntimeError("Edge blend result check failed: the source part could not be resolved.")
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise RuntimeError(f"Edge blend result check failed: unknown part id {part_id}.")
|
||||
|
||||
_ensure_valid_shape(part.shape)
|
||||
after_stats = self.part_topology_stats(part_id)
|
||||
before_solids = int(getattr(before_stats, "solids", 0))
|
||||
after_solids = int(getattr(after_stats, "solids", 0))
|
||||
if before_solids > 0 and after_solids != before_solids:
|
||||
raise RuntimeError(
|
||||
"Edge blend result changed the solid count; the model has been restored to the previous state. "
|
||||
f"Solids {before_solids}->{after_solids}."
|
||||
)
|
||||
|
||||
face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0))
|
||||
edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0))
|
||||
if mode == "fillet":
|
||||
target_radius = _float_or_none(plan.get("target_radius"))
|
||||
if target_radius is None or target_radius <= 0.0:
|
||||
raise RuntimeError("Edge fillet result check failed: target radius is invalid.")
|
||||
tolerance = max(_shape_diagonal(part.shape) * 1e-5, target_radius * 1e-3, 1e-5)
|
||||
matched_faces: list[int] = []
|
||||
for face_id in range(len(self.faces)):
|
||||
if int(self.face_part_ids[face_id]) != part_id:
|
||||
continue
|
||||
try:
|
||||
info = self.face_info(face_id)
|
||||
except Exception:
|
||||
continue
|
||||
if info.get("surface") != "cylinder":
|
||||
continue
|
||||
radius = _float_or_none(info.get("radius"))
|
||||
if radius is None:
|
||||
continue
|
||||
if abs(radius - target_radius) <= tolerance:
|
||||
matched_faces.append(face_id)
|
||||
if not matched_faces:
|
||||
raise RuntimeError(
|
||||
"Edge fillet result did not contain a cylindrical face near the target radius; "
|
||||
"the model has been restored to the previous state. "
|
||||
f"Target radius {target_radius:g}, tolerance {tolerance:g}."
|
||||
)
|
||||
if face_delta < 0:
|
||||
raise RuntimeError(
|
||||
"Edge fillet result lost faces unexpectedly; the model has been restored to the previous state. "
|
||||
f"Face delta {face_delta}."
|
||||
)
|
||||
return (
|
||||
"Edge blend result check: "
|
||||
f"mode=fillet, solids={before_solids}->{after_solids}, "
|
||||
f"face_delta={face_delta}, edge_delta={edge_delta}, "
|
||||
f"target_radius={target_radius:g}, matched_faces={tuple(matched_faces[:8])}."
|
||||
)
|
||||
|
||||
if face_delta <= 0:
|
||||
raise RuntimeError(
|
||||
"Edge chamfer result did not add the expected chamfer face; "
|
||||
"the model has been restored to the previous state. "
|
||||
f"Face delta {face_delta}."
|
||||
)
|
||||
if edge_delta <= 0:
|
||||
raise RuntimeError(
|
||||
"Edge chamfer result did not add the expected boundary edges; "
|
||||
"the model has been restored to the previous state. "
|
||||
f"Edge delta {edge_delta}."
|
||||
)
|
||||
return (
|
||||
"Edge blend result check: "
|
||||
f"mode={mode}, solids={before_solids}->{after_solids}, "
|
||||
f"face_delta={face_delta}, edge_delta={edge_delta}."
|
||||
)
|
||||
|
||||
def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None:
|
||||
@@ -10166,6 +10398,119 @@ class OperationMixin:
|
||||
return False
|
||||
return bool(saw_outer_radius and saw_inner_radius)
|
||||
|
||||
def _existing_fillet_result_summary_or_raise(
|
||||
self,
|
||||
plan: dict[str, object],
|
||||
before_stats,
|
||||
) -> str:
|
||||
part_id = _int_or_none(plan.get("part_id"))
|
||||
if part_id is None:
|
||||
raise RuntimeError("Existing fillet result check failed: the source part could not be resolved.")
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise RuntimeError(f"Existing fillet result check failed: unknown part id {part_id}.")
|
||||
|
||||
_ensure_valid_shape(part.shape)
|
||||
after_stats = self.part_topology_stats(part_id)
|
||||
before_solids = int(getattr(before_stats, "solids", 0))
|
||||
after_solids = int(getattr(after_stats, "solids", 0))
|
||||
if before_solids > 0 and after_solids != before_solids:
|
||||
raise RuntimeError(
|
||||
"Existing fillet result changed the solid count; the model has been restored to the previous state. "
|
||||
f"Solids {before_solids}->{after_solids}."
|
||||
)
|
||||
|
||||
target_radius = _float_or_none(plan.get("target_radius"))
|
||||
if target_radius is None or target_radius <= 0.0:
|
||||
raise RuntimeError("Existing fillet result check failed: target radius is invalid.")
|
||||
diagonal = max(_shape_diagonal(part.shape), target_radius, 1.0)
|
||||
radius_tolerance = max(target_radius * 0.01, diagonal * 1e-5, 1e-5)
|
||||
required_adjacent = max(2, min(int(plan.get("cylindrical_feature_adjacent_face_count", 0) or 0), 4))
|
||||
required_boundary = max(1, min(int(plan.get("cylindrical_feature_boundary_edge_count", 0) or 0), 4))
|
||||
matched: list[dict[str, object]] = []
|
||||
closest: dict[str, object] | None = None
|
||||
|
||||
for candidate_face_id in range(len(self.faces)):
|
||||
if int(self.face_part_ids[candidate_face_id]) != part_id:
|
||||
continue
|
||||
try:
|
||||
info = self.face_info(candidate_face_id)
|
||||
except Exception:
|
||||
continue
|
||||
if info.get("surface") != "cylinder":
|
||||
continue
|
||||
radius = _float_or_none(info.get("radius"))
|
||||
if radius is None:
|
||||
continue
|
||||
radius_error = abs(radius - target_radius)
|
||||
candidate = {
|
||||
"face_id": candidate_face_id,
|
||||
"radius": radius,
|
||||
"radius_error": radius_error,
|
||||
"feature_guess": info.get("feature_guess", ""),
|
||||
"angular_span": info.get("angular_span", ""),
|
||||
}
|
||||
if closest is None or radius_error < float(closest["radius_error"]):
|
||||
closest = candidate
|
||||
if radius_error > radius_tolerance:
|
||||
continue
|
||||
try:
|
||||
topology = self.cylindrical_feature_first_level_topology(candidate_face_id)
|
||||
side_count = int(topology.get("cylindrical_feature_side_face_count", 0) or 0)
|
||||
boundary_count = int(topology.get("cylindrical_feature_boundary_edge_count", 0) or 0)
|
||||
adjacent_count = int(topology.get("cylindrical_feature_adjacent_face_count", 0) or 0)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Existing fillet result matched the target radius but first-level topology failed; "
|
||||
f"Face {candidate_face_id}, error={exc}."
|
||||
) from exc
|
||||
if side_count <= 0 or boundary_count < required_boundary or adjacent_count < required_adjacent:
|
||||
raise RuntimeError(
|
||||
"Existing fillet result matched the target radius but lost first-level topology; "
|
||||
f"Face {candidate_face_id}, side={side_count}, boundary_edges={boundary_count}, "
|
||||
f"adjacent_faces={adjacent_count}, required_boundary={required_boundary}, "
|
||||
f"required_adjacent={required_adjacent}."
|
||||
)
|
||||
matched.append(
|
||||
{
|
||||
**candidate,
|
||||
"side_face_count": side_count,
|
||||
"boundary_edge_count": boundary_count,
|
||||
"adjacent_face_count": adjacent_count,
|
||||
}
|
||||
)
|
||||
|
||||
if not matched:
|
||||
if closest is None:
|
||||
detail = "no cylindrical Face remained in the source part"
|
||||
else:
|
||||
detail = (
|
||||
f"closest Face {closest['face_id']} radius {float(closest['radius']):.6g}, "
|
||||
f"target {target_radius:.6g}, error {float(closest['radius_error']):.6g}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Existing fillet result did not contain a target-radius cylindrical round Face; "
|
||||
f"the model has been restored to the previous state. {detail}."
|
||||
)
|
||||
|
||||
face_delta = int(getattr(after_stats, "faces", 0)) - int(getattr(before_stats, "faces", 0))
|
||||
edge_delta = int(getattr(after_stats, "edges", 0)) - int(getattr(before_stats, "edges", 0))
|
||||
matched_face_ids = tuple(int(item["face_id"]) for item in matched[:8])
|
||||
topology_summaries = tuple(
|
||||
{
|
||||
"face_id": int(item["face_id"]),
|
||||
"boundary_edge_count": int(item["boundary_edge_count"]),
|
||||
"adjacent_face_count": int(item["adjacent_face_count"]),
|
||||
}
|
||||
for item in matched[:8]
|
||||
)
|
||||
return (
|
||||
"Existing fillet result check: "
|
||||
f"solids={before_solids}->{after_solids}, face_delta={face_delta}, edge_delta={edge_delta}, "
|
||||
f"target_radius={target_radius:g}, matched_faces={matched_face_ids}, "
|
||||
f"first_level_topology_matched=True, topology={topology_summaries}."
|
||||
)
|
||||
|
||||
def resize_existing_fillet(self, face_id: int, target_radius: float) -> str:
|
||||
plan = self.existing_fillet_resize_plan(face_id, target_radius)
|
||||
if plan["status"] == "blocked":
|
||||
@@ -10176,49 +10521,58 @@ class OperationMixin:
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
source_face = topods.Face(self.faces[face_id])
|
||||
defeatured = _defeature_faces(part.shape, [source_face])
|
||||
axis_point = gp_Pnt(*plan["axis_point"])
|
||||
axis_dir = gp_Dir(*plan["axis"])
|
||||
root_edges = _axis_aligned_edge_candidates(
|
||||
defeatured,
|
||||
axis_point,
|
||||
axis_dir,
|
||||
expected_length=float(plan.get("height_estimate") or 0.0),
|
||||
reference_radius=float(plan["current_radius"]),
|
||||
)
|
||||
if not root_edges:
|
||||
raise RuntimeError(
|
||||
"已尝试移除已有圆角面,但没有找到可重新倒圆的轴向锐边;"
|
||||
"该圆角可能不是简单直线边圆角。"
|
||||
old_part_shape = part.shape
|
||||
before_stats = self.part_topology_stats(part_id)
|
||||
try:
|
||||
source_face = topods.Face(self.faces[face_id])
|
||||
defeatured = _defeature_faces(part.shape, [source_face])
|
||||
axis_point = gp_Pnt(*plan["axis_point"])
|
||||
axis_dir = gp_Dir(*plan["axis"])
|
||||
root_edges = _axis_aligned_edge_candidates(
|
||||
defeatured,
|
||||
axis_point,
|
||||
axis_dir,
|
||||
expected_length=float(plan.get("height_estimate") or 0.0),
|
||||
reference_radius=float(plan["current_radius"]),
|
||||
)
|
||||
if not root_edges:
|
||||
raise RuntimeError(
|
||||
"Existing fillet face was removed, but no axis-aligned sharp Edge was found for refillet; "
|
||||
"the selected round is probably not a simple straight-edge fillet."
|
||||
)
|
||||
|
||||
result = None
|
||||
failures: list[str] = []
|
||||
for index, root_edge in enumerate(root_edges[:16], start=1):
|
||||
try:
|
||||
maker = BRepFilletAPI_MakeFillet(defeatured)
|
||||
maker.Add(float(target_radius), topods.Edge(root_edge))
|
||||
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
|
||||
break
|
||||
except Exception as exc:
|
||||
failures.append(str(exc))
|
||||
if result is None:
|
||||
detail = failures[-1] if failures else "没有可用的候选边。"
|
||||
raise RuntimeError(
|
||||
"已移除已有圆角面,但所有候选锐边都无法重新倒圆;"
|
||||
f"该圆角可能是复杂 blend 或支撑面不适合重建。最后错误:{detail}"
|
||||
)
|
||||
result = None
|
||||
failures: list[str] = []
|
||||
for index, root_edge in enumerate(root_edges[:16], start=1):
|
||||
try:
|
||||
maker = BRepFilletAPI_MakeFillet(defeatured)
|
||||
maker.Add(float(target_radius), topods.Edge(root_edge))
|
||||
result = _finalize_builder_result(maker, f"existing fillet resize candidate {index}")
|
||||
break
|
||||
except Exception as exc:
|
||||
failures.append(str(exc))
|
||||
if result is None:
|
||||
detail = failures[-1] if failures else "no candidate Edge was available"
|
||||
raise RuntimeError(
|
||||
"Existing fillet face was removed, but every candidate sharp Edge failed to refillet; "
|
||||
f"the selected round may be a complex blend. Last error: {detail}"
|
||||
)
|
||||
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
result_check = self._existing_fillet_result_summary_or_raise(plan, before_stats)
|
||||
except Exception:
|
||||
part.shape = old_part_shape
|
||||
self.refresh_topology()
|
||||
raise
|
||||
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
return (
|
||||
"Existing fillet radius resize completed: "
|
||||
f"face {face_id}, current_radius={float(plan['current_radius']):g}, "
|
||||
f"target_radius={target_radius:g}, "
|
||||
f"delta_radius={float(plan['delta_radius']):g}, "
|
||||
f"support_faces={plan.get('feature_existing_fillet_support_face_ids')}, "
|
||||
f"risk={plan['risk']}."
|
||||
f"risk={plan['risk']}. {result_check}"
|
||||
)
|
||||
|
||||
def fillet_edge(self, edge_id: int, radius: float) -> str:
|
||||
@@ -10231,16 +10585,24 @@ class OperationMixin:
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
maker = BRepFilletAPI_MakeFillet(part.shape)
|
||||
maker.Add(radius, topods.Edge(self.edges[edge_id]))
|
||||
result = _finalize_builder_result(maker, "edge fillet")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
old_part_shape = part.shape
|
||||
before_stats = self.part_topology_stats(part_id)
|
||||
try:
|
||||
maker = BRepFilletAPI_MakeFillet(part.shape)
|
||||
maker.Add(radius, topods.Edge(self.edges[edge_id]))
|
||||
result = _finalize_builder_result(maker, "edge fillet")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="fillet")
|
||||
except Exception:
|
||||
part.shape = old_part_shape
|
||||
self.refresh_topology()
|
||||
raise
|
||||
return (
|
||||
f"Edge fillet completed: edge {edge_id}, radius={radius:g}, "
|
||||
f"edge_length={float(plan['edge_length']):g}, "
|
||||
f"radius_to_length_ratio={float(plan['radius_to_length_ratio']):g}, "
|
||||
f"risk={plan['risk']}."
|
||||
f"risk={plan['risk']}. {result_check}"
|
||||
)
|
||||
|
||||
def chamfer_edge(self, edge_id: int, distance: float) -> str:
|
||||
@@ -10253,16 +10615,24 @@ class OperationMixin:
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||||
maker.Add(distance, topods.Edge(self.edges[edge_id]))
|
||||
result = _finalize_builder_result(maker, "edge chamfer")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
old_part_shape = part.shape
|
||||
before_stats = self.part_topology_stats(part_id)
|
||||
try:
|
||||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||||
maker.Add(distance, topods.Edge(self.edges[edge_id]))
|
||||
result = _finalize_builder_result(maker, "edge chamfer")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="chamfer")
|
||||
except Exception:
|
||||
part.shape = old_part_shape
|
||||
self.refresh_topology()
|
||||
raise
|
||||
return (
|
||||
f"Edge chamfer completed: edge {edge_id}, distance={distance:g}, "
|
||||
f"edge_length={float(plan['edge_length']):g}, "
|
||||
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
|
||||
f"risk={plan['risk']}."
|
||||
f"risk={plan['risk']}. {result_check}"
|
||||
)
|
||||
|
||||
def chamfer_edge_asymmetric(
|
||||
@@ -10282,19 +10652,29 @@ class OperationMixin:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
resolved_reference_face_id = int(plan["reference_face_id"])
|
||||
|
||||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||||
old_part_shape = part.shape
|
||||
before_stats = self.part_topology_stats(part_id)
|
||||
try:
|
||||
maker.Add(
|
||||
float(distance1),
|
||||
float(distance2),
|
||||
topods.Edge(self.edges[edge_id]),
|
||||
topods.Face(self.faces[resolved_reference_face_id]),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RuntimeError("The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face).") from exc
|
||||
result = _finalize_builder_result(maker, "asymmetric edge chamfer")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||||
try:
|
||||
maker.Add(
|
||||
float(distance1),
|
||||
float(distance2),
|
||||
topods.Edge(self.edges[edge_id]),
|
||||
topods.Face(self.faces[resolved_reference_face_id]),
|
||||
)
|
||||
except TypeError as exc:
|
||||
raise RuntimeError(
|
||||
"The current OCCT binding does not support asymmetric chamfer Add(D1, D2, Edge, Face)."
|
||||
) from exc
|
||||
result = _finalize_builder_result(maker, "asymmetric edge chamfer")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="asymmetric_chamfer")
|
||||
except Exception:
|
||||
part.shape = old_part_shape
|
||||
self.refresh_topology()
|
||||
raise
|
||||
return (
|
||||
f"Asymmetric Edge chamfer completed: edge {edge_id}, "
|
||||
f"distance1={float(distance1):g}, distance2={float(distance2):g}, "
|
||||
@@ -10302,7 +10682,7 @@ class OperationMixin:
|
||||
f"edge_length={float(plan['edge_length']):g}, "
|
||||
f"distance1_to_length_ratio={float(plan['distance1_to_length_ratio']):g}, "
|
||||
f"distance2_to_length_ratio={float(plan['distance2_to_length_ratio']):g}, "
|
||||
f"risk={plan['risk']}."
|
||||
f"risk={plan['risk']}. {result_check}"
|
||||
)
|
||||
|
||||
def chamfer_edge_distance_angle(
|
||||
@@ -10322,28 +10702,40 @@ class OperationMixin:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
resolved_reference_face_id = int(plan["reference_face_id"])
|
||||
|
||||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||||
old_part_shape = part.shape
|
||||
before_stats = self.part_topology_stats(part_id)
|
||||
try:
|
||||
maker.AddDA(
|
||||
float(distance),
|
||||
float(plan["target_angle_radians"]),
|
||||
topods.Edge(self.edges[edge_id]),
|
||||
topods.Face(self.faces[resolved_reference_face_id]),
|
||||
)
|
||||
except AttributeError as exc:
|
||||
raise RuntimeError("The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
|
||||
except TypeError as exc:
|
||||
raise RuntimeError("The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face).") from exc
|
||||
result = _finalize_builder_result(maker, "distance-angle edge chamfer")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
maker = BRepFilletAPI_MakeChamfer(part.shape)
|
||||
try:
|
||||
maker.AddDA(
|
||||
float(distance),
|
||||
float(plan["target_angle_radians"]),
|
||||
topods.Edge(self.edges[edge_id]),
|
||||
topods.Face(self.faces[resolved_reference_face_id]),
|
||||
)
|
||||
except AttributeError as exc:
|
||||
raise RuntimeError(
|
||||
"The current OCCT binding does not support distance-angle chamfer AddDA(D, Angle, Edge, Face)."
|
||||
) from exc
|
||||
except TypeError as exc:
|
||||
raise RuntimeError(
|
||||
"The current OCCT binding rejected distance-angle chamfer AddDA(D, Angle, Edge, Face)."
|
||||
) from exc
|
||||
result = _finalize_builder_result(maker, "distance-angle edge chamfer")
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
result_check = self._edge_blend_result_summary_or_raise(plan, before_stats, mode="distance_angle_chamfer")
|
||||
except Exception:
|
||||
part.shape = old_part_shape
|
||||
self.refresh_topology()
|
||||
raise
|
||||
return (
|
||||
f"Distance-angle Edge chamfer completed: edge {edge_id}, "
|
||||
f"distance={float(distance):g}, angle_degrees={float(angle_degrees):g}, "
|
||||
f"reference_face={resolved_reference_face_id}, "
|
||||
f"edge_length={float(plan['edge_length']):g}, "
|
||||
f"distance_to_length_ratio={float(plan['distance_to_length_ratio']):g}, "
|
||||
f"risk={plan['risk']}."
|
||||
f"risk={plan['risk']}. {result_check}"
|
||||
)
|
||||
|
||||
def enlarge_cylindrical_hole(self, face_id: int, new_diameter: float) -> str:
|
||||
@@ -11892,41 +12284,62 @@ class OperationMixin:
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
start = gp_Pnt(*plan["boss_tool_start_point"])
|
||||
direction = gp_Dir(*plan["boss_tool_axis_direction"])
|
||||
axis = gp_Ax2(start, direction)
|
||||
height = float(plan["boss_tool_height"])
|
||||
if plan["resize_mode"] == "enlarge":
|
||||
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
|
||||
op = BRepAlgoAPI_Fuse(part.shape, tool)
|
||||
result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False)
|
||||
action = "enlarged by bounded fuse"
|
||||
else:
|
||||
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
|
||||
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
|
||||
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
|
||||
removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False)
|
||||
if _topology_shape_count(removed, TopAbs_FACE) == 0:
|
||||
exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"])
|
||||
exact_axis = gp_Ax2(exact_start, direction)
|
||||
exact_replacement = BRepPrimAPI_MakeCylinder(
|
||||
exact_axis,
|
||||
float(plan["boss_tool_inner_radius"]),
|
||||
float(plan["boss_tool_exact_height"]),
|
||||
).Shape()
|
||||
result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement")
|
||||
old_part_shape = part.shape
|
||||
verification: dict[str, object] = {}
|
||||
try:
|
||||
start = gp_Pnt(*plan["boss_tool_start_point"])
|
||||
direction = gp_Dir(*plan["boss_tool_axis_direction"])
|
||||
axis = gp_Ax2(start, direction)
|
||||
height = float(plan["boss_tool_height"])
|
||||
if plan["resize_mode"] == "enlarge":
|
||||
tool = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_radius"]), height).Shape()
|
||||
op = BRepAlgoAPI_Fuse(part.shape, tool)
|
||||
result = _finalize_boolean_result(op, "cylindrical boss fuse", use_glue=False)
|
||||
action = "enlarged by bounded fuse"
|
||||
else:
|
||||
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
|
||||
result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False)
|
||||
action = "shrunk by removing old envelope and fusing target cylinder"
|
||||
removal = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_outer_radius"]), height).Shape()
|
||||
replacement = BRepPrimAPI_MakeCylinder(axis, float(plan["boss_tool_inner_radius"]), height).Shape()
|
||||
remove_op = BRepAlgoAPI_Cut(part.shape, removal)
|
||||
removed = _finalize_boolean_result(remove_op, "cylindrical boss shrink remove envelope", use_glue=False)
|
||||
if _topology_shape_count(removed, TopAbs_FACE) == 0:
|
||||
exact_start = gp_Pnt(*plan["boss_tool_exact_start_point"])
|
||||
exact_axis = gp_Ax2(exact_start, direction)
|
||||
exact_replacement = BRepPrimAPI_MakeCylinder(
|
||||
exact_axis,
|
||||
float(plan["boss_tool_inner_radius"]),
|
||||
float(plan["boss_tool_exact_height"]),
|
||||
).Shape()
|
||||
result = _ensure_valid_or_repaired_shape(exact_replacement, "cylindrical boss shrink replacement")
|
||||
else:
|
||||
fuse_op = BRepAlgoAPI_Fuse(removed, replacement)
|
||||
result = _finalize_boolean_result(fuse_op, "cylindrical boss shrink rebuild", use_glue=False)
|
||||
action = "shrunk by removing old envelope and fusing target cylinder"
|
||||
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
|
||||
verification_plan = dict(plan)
|
||||
verification_plan["cutter_axis_point"] = plan["boss_tool_axis_point"]
|
||||
verification_plan["cutter_axis_direction"] = plan["boss_tool_axis_direction"]
|
||||
verification = self._verify_cylindrical_resize_result(verification_plan, part_id)
|
||||
if not verification["matched"]:
|
||||
detail = str(verification.get("detail", "cylindrical boss resize result verification failed"))
|
||||
raise RuntimeError(
|
||||
"Cylindrical boss resize returned a result, but no target-diameter boss with "
|
||||
f"valid first-level topology was found; rolled back to the previous model.{detail}"
|
||||
)
|
||||
except Exception:
|
||||
part.shape = old_part_shape
|
||||
self.refresh_topology()
|
||||
raise
|
||||
|
||||
part.shape = result
|
||||
self.refresh_topology()
|
||||
return (
|
||||
f"Cylindrical boss resize completed: diameter {float(plan['current_diameter']):g} -> {new_diameter:g}, "
|
||||
f"mode={plan['resize_mode']}, action={action}, risk={plan['risk']}, "
|
||||
f"feature={plan['feature_guess']}, tool={plan['boss_tool_strategy']}, "
|
||||
f"height={float(plan['boss_tool_height']):g}."
|
||||
f"height={float(plan['boss_tool_height']):g}, "
|
||||
f"verified_face={verification.get('face_id', '')}, "
|
||||
f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}."
|
||||
)
|
||||
|
||||
def move_cylindrical_boss_axis(
|
||||
@@ -12011,7 +12424,8 @@ class OperationMixin:
|
||||
f"diameter={float(plan['target_diameter']):g}, "
|
||||
f"height={float(plan['boss_tool_height']):g}, "
|
||||
f"risk={plan['risk']}, action={action}, "
|
||||
f"verified_face={verification.get('face_id', '')}."
|
||||
f"verified_face={verification.get('face_id', '')}, "
|
||||
f"first_level_topology_matched={verification.get('first_level_topology_matched', '')}."
|
||||
)
|
||||
|
||||
def suppress_cylindrical_hole(self, face_id: int) -> str:
|
||||
|
||||
+70
-3
@@ -80,14 +80,27 @@ class PolydataMixin:
|
||||
|
||||
face_key = _polydata_id_key(face_ids)
|
||||
part_key = _polydata_id_key(part_ids)
|
||||
cache_key = ("faces", face_key, part_key, float(deflection))
|
||||
requested_deflection = max(float(deflection), 1e-9)
|
||||
local_mesh_face_ids = self._local_fine_mesh_face_ids(face_key, part_key, requested_deflection)
|
||||
mesh_deflection = self._effective_face_mesh_deflection(
|
||||
requested_deflection,
|
||||
local_mesh_face_ids=local_mesh_face_ids,
|
||||
)
|
||||
cache_key = (
|
||||
"faces",
|
||||
face_key,
|
||||
part_key,
|
||||
float(requested_deflection),
|
||||
float(mesh_deflection),
|
||||
local_mesh_face_ids,
|
||||
)
|
||||
cached = self._polydata_cache_get("_face_polydata_cache", cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
selected_faces = set(face_key) if face_key is not None else None
|
||||
selected_parts = set(part_key) if part_key is not None else None
|
||||
self._ensure_mesh(deflection)
|
||||
self._ensure_mesh(mesh_deflection, face_ids=local_mesh_face_ids)
|
||||
|
||||
points = vtk.vtkPoints()
|
||||
polys = vtk.vtkCellArray()
|
||||
@@ -137,8 +150,47 @@ class PolydataMixin:
|
||||
poly.GetCellData().AddArray(solid_arr)
|
||||
return self._polydata_cache_remember("_face_polydata_cache", cache_key, poly)
|
||||
|
||||
def _ensure_mesh(self, deflection: float) -> None:
|
||||
def _local_fine_mesh_face_ids(
|
||||
self,
|
||||
face_key: tuple[int, ...] | None,
|
||||
part_key: tuple[int, ...] | None,
|
||||
requested: float,
|
||||
) -> tuple[int, ...] | None:
|
||||
if requested > 0.04 or part_key is not None or face_key is None:
|
||||
return None
|
||||
valid_face_ids = tuple(face_id for face_id in face_key if 0 <= face_id < len(self.faces))
|
||||
if 0 < len(valid_face_ids) <= 64:
|
||||
return valid_face_ids
|
||||
return None
|
||||
|
||||
def _effective_face_mesh_deflection(
|
||||
self,
|
||||
requested: float,
|
||||
*,
|
||||
local_mesh_face_ids: tuple[int, ...] | None = None,
|
||||
) -> float:
|
||||
requested = max(float(requested), 1e-9)
|
||||
if local_mesh_face_ids:
|
||||
return requested
|
||||
face_count = len(getattr(self, "faces", ()) or ())
|
||||
if requested <= 0.04 and face_count > 600:
|
||||
# Ultra-fine analytic remeshing can create million-cell VTK meshes
|
||||
# on large STEP assemblies. Keep the B-Rep edit precision separate
|
||||
# from the display mesh budget so selection and rotation stay usable.
|
||||
return 0.1 if face_count > 1500 else 0.06
|
||||
return requested
|
||||
|
||||
def _ensure_mesh(self, deflection: float, face_ids: tuple[int, ...] | None = None) -> None:
|
||||
requested = max(float(deflection), 1e-9)
|
||||
if face_ids:
|
||||
local_deflections = getattr(self, "_face_mesh_deflections", {})
|
||||
for face_id in face_ids:
|
||||
if local_deflections.get(face_id) is not None and requested >= float(local_deflections[face_id]) * 0.999:
|
||||
continue
|
||||
self._mesh_single_face(face_id, requested)
|
||||
local_deflections[face_id] = requested
|
||||
self._face_mesh_deflections = local_deflections
|
||||
return
|
||||
if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999:
|
||||
if requested <= 0.04:
|
||||
BRepMesh_IncrementalMesh(
|
||||
@@ -162,6 +214,21 @@ class PolydataMixin:
|
||||
BRepMesh_IncrementalMesh(self.shape, requested, False, math.radians(angular_degrees), True)
|
||||
self._mesh_deflection = requested
|
||||
|
||||
def _mesh_single_face(self, face_id: int, requested: float) -> None:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
return
|
||||
try:
|
||||
face = self.faces[face_id]
|
||||
surface_type = BRepAdaptor_Surface(face).GetType()
|
||||
if surface_type in {GeomAbs_Cylinder, GeomAbs_Cone}:
|
||||
BRepMesh_IncrementalMesh(face, requested, False, math.radians(1.2), True)
|
||||
elif surface_type in {GeomAbs_Sphere, GeomAbs_Torus}:
|
||||
BRepMesh_IncrementalMesh(face, max(requested, 0.1), False, math.radians(4.0), True)
|
||||
else:
|
||||
BRepMesh_IncrementalMesh(face, requested, False, math.radians(12.0), True)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8):
|
||||
shape = _compound_from_shapes(value for value in snapshot.values() if isinstance(value, TopoDS_Shape))
|
||||
BRepMesh_IncrementalMesh(shape, deflection)
|
||||
|
||||
@@ -430,6 +430,10 @@ INFO_LABELS = {
|
||||
"cad_recommended_operation": "推荐操作",
|
||||
"feature_type": "特征类型",
|
||||
"feature_source_face_id": "特征来源 Face",
|
||||
"feature_detection_level": "特征探测级别",
|
||||
"associated_feature_count": "关联特征数",
|
||||
"associated_feature_face_ids": "关联特征 Face",
|
||||
"feature_context_note": "关联探测",
|
||||
"recognition_summary": "识别摘要",
|
||||
"recognition_candidate": "识别候选",
|
||||
"recognition_confidence": "识别置信度",
|
||||
@@ -507,6 +511,7 @@ INFO_LABELS = {
|
||||
"length_change_ratio": "Edge长度变化比例",
|
||||
"edge_length_anchor_mode": "Edge长度基准模式",
|
||||
"edge_length_anchor_label": "Edge长度基准",
|
||||
"edge_first_level_topology": "一级关系",
|
||||
"local_edge_deform_target_kind": "局部边形变目标",
|
||||
"local_edge_deform_anchor": "局部边形变基准",
|
||||
"local_edge_deform_face_count": "局部边形变重建面数",
|
||||
|
||||
@@ -7062,7 +7062,7 @@ class WindowActionMixin:
|
||||
target_logical_id = self._edit_target_logical_id(target_kind, target_id)
|
||||
result_deflection = float(getattr(self, "edit_result_deflection", 1.6))
|
||||
if operation_name == "拉伸/切除平面":
|
||||
result_deflection = max(result_deflection, 2.4)
|
||||
result_deflection = max(result_deflection, 0.35)
|
||||
context = {
|
||||
"operation_name": operation_name,
|
||||
"target": target,
|
||||
|
||||
+683
-5
@@ -4,15 +4,18 @@ from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
import vtk
|
||||
from PySide6.QtCore import QEvent, Qt, QThread, QTimer, Slot
|
||||
from PySide6.QtWidgets import QToolTip
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QTableWidgetItem,
|
||||
QTreeWidgetItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from .model import StepModel
|
||||
@@ -22,6 +25,9 @@ from .workers import EditWorker, LoadWorker, ScanWorker
|
||||
|
||||
|
||||
VIEW_BACKGROUND_COLOR = (226 / 255.0, 238 / 255.0, 247 / 255.0)
|
||||
AXIS_X_COLOR = (0.82, 0.12, 0.10)
|
||||
AXIS_Y_COLOR = (0.12, 0.58, 0.20)
|
||||
AXIS_Z_COLOR = (0.10, 0.28, 0.88)
|
||||
|
||||
|
||||
def _prepare_static_mapper(mapper) -> None:
|
||||
@@ -91,15 +97,20 @@ class WindowCoreMixin:
|
||||
self.ui_task_requested.emit(callback)
|
||||
|
||||
def eventFilter(self, watched, event):
|
||||
if event.type() == QEvent.Type.ToolTip and self._should_suppress_transient_tooltip(watched):
|
||||
QToolTip.hideText()
|
||||
return True
|
||||
if watched is getattr(self, "vtk_widget", None):
|
||||
event_type = event.type()
|
||||
if event_type == QEvent.Type.MouseButtonRelease:
|
||||
QToolTip.hideText()
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
x, y = self._vtk_position_from_qt_event(event)
|
||||
self.skip_next_vtk_left_release = True
|
||||
self._handle_left_button_release(x, y)
|
||||
self.pointer_button_down = bool(event.buttons() != Qt.MouseButton.NoButton)
|
||||
elif event_type == QEvent.Type.MouseButtonPress:
|
||||
QToolTip.hideText()
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
x, y = self._vtk_position_from_qt_event(event)
|
||||
self.skip_next_vtk_left_press = True
|
||||
@@ -114,8 +125,58 @@ class WindowCoreMixin:
|
||||
self._queue_hover_from_qt_event(event)
|
||||
elif event_type == QEvent.Type.Leave:
|
||||
self._cancel_hover_tracking(render=True)
|
||||
elif event_type == QEvent.Type.Resize and hasattr(self, "_sync_vtk_render_window_size"):
|
||||
QTimer.singleShot(0, self._sync_vtk_render_window_size)
|
||||
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)
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def _should_suppress_transient_tooltip(self, watched) -> bool:
|
||||
if not isinstance(watched, QWidget):
|
||||
return False
|
||||
suppressed_roots = (
|
||||
getattr(self, "vtk_widget", None),
|
||||
getattr(self, "object_edit_box", None),
|
||||
getattr(self, "property_card_scroll", None),
|
||||
getattr(self, "property_card_container", None),
|
||||
)
|
||||
for root in suppressed_roots:
|
||||
if root is None:
|
||||
continue
|
||||
widget = watched
|
||||
while widget is not None:
|
||||
if widget is root:
|
||||
return True
|
||||
widget = widget.parentWidget()
|
||||
return False
|
||||
|
||||
def _sync_vtk_render_window_size(self) -> None:
|
||||
widget = getattr(self, "vtk_widget", None)
|
||||
render_window = getattr(self, "render_window", None)
|
||||
if widget is None or render_window is None:
|
||||
return
|
||||
try:
|
||||
scale = widget._getPixelRatio() if hasattr(widget, "_getPixelRatio") else widget.devicePixelRatioF()
|
||||
except Exception:
|
||||
scale = 1.0
|
||||
width = max(int(round(float(widget.width()) * float(scale))), 1)
|
||||
height = max(int(round(float(widget.height()) * float(scale))), 1)
|
||||
try:
|
||||
current_width, current_height = render_window.GetSize()
|
||||
except Exception:
|
||||
current_width, current_height = width, height
|
||||
if int(current_width) == width and int(current_height) == height:
|
||||
return
|
||||
try:
|
||||
render_window.SetSize(width, height)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.interactor.SetSize(width, height)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _vtk_position_from_qt_event(self, event) -> tuple[int, int]:
|
||||
position = event.position() if hasattr(event, "position") else event.pos()
|
||||
scale = self.vtk_widget._getPixelRatio() if hasattr(self.vtk_widget, "_getPixelRatio") else 1.0
|
||||
@@ -123,15 +184,53 @@ class WindowCoreMixin:
|
||||
y = int(round((float(self.vtk_widget.height()) - float(position.y()) - 1.0) * scale))
|
||||
return x, y
|
||||
|
||||
def _configure_render_antialiasing(self) -> None:
|
||||
fxaa_enabled = False
|
||||
if bool(getattr(self, "prefer_fxaa_antialiasing", False)) and hasattr(self.renderer, "UseFXAAOn"):
|
||||
try:
|
||||
self.renderer.UseFXAAOn()
|
||||
fxaa_enabled = True
|
||||
except Exception:
|
||||
fxaa_enabled = False
|
||||
if not fxaa_enabled and hasattr(self.renderer, "UseFXAAOff"):
|
||||
try:
|
||||
self.renderer.UseFXAAOff()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fallback_samples = max(int(getattr(self, "fallback_multi_samples", 2) or 0), 0)
|
||||
if fxaa_enabled:
|
||||
self.interactive_multi_samples = 0
|
||||
self.still_multi_samples = 0
|
||||
else:
|
||||
self.interactive_multi_samples = fallback_samples
|
||||
self.still_multi_samples = fallback_samples
|
||||
self.fxaa_antialiasing_enabled = fxaa_enabled
|
||||
self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", fallback_samples)))
|
||||
|
||||
def _build_vtk(self) -> None:
|
||||
self.renderer = vtk.vtkRenderer()
|
||||
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
|
||||
self.render_window = self.vtk_widget.GetRenderWindow()
|
||||
if hasattr(self.render_window, "SetMultiSamples"):
|
||||
self.render_window.SetMultiSamples(0)
|
||||
self._configure_render_antialiasing()
|
||||
if hasattr(self.render_window, "SetNumberOfLayers"):
|
||||
self.render_window.SetNumberOfLayers(3)
|
||||
if hasattr(self.renderer, "SetLayer"):
|
||||
self.renderer.SetLayer(0)
|
||||
self.render_window.AddRenderer(self.renderer)
|
||||
if hasattr(self.renderer, "UseFXAAOff"):
|
||||
self.renderer.UseFXAAOff()
|
||||
self._build_scene_axes_renderer()
|
||||
self._build_fps_overlay()
|
||||
self._fps_frame_count = 0
|
||||
self._fps_window_started_at = time.perf_counter()
|
||||
self._fps_last_text_update_at = self._fps_window_started_at
|
||||
self._fps_last_value = 0.0
|
||||
self._fps_idle_timeout_ms = 900
|
||||
self._fps_idle_zero_displayed = False
|
||||
self._fps_idle_rendering_zero = False
|
||||
self.fps_idle_timer = QTimer(self)
|
||||
self.fps_idle_timer.setSingleShot(True)
|
||||
self.fps_idle_timer.timeout.connect(self._show_idle_fps_zero)
|
||||
self.render_window.AddObserver("EndEvent", self._on_render_window_end_event)
|
||||
|
||||
self.interactor = self.render_window.GetInteractor()
|
||||
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
|
||||
@@ -166,6 +265,523 @@ class WindowCoreMixin:
|
||||
self.renderer.AddLight(fill_light)
|
||||
|
||||
self.interactor.Initialize()
|
||||
self._build_orientation_marker()
|
||||
|
||||
def _build_scene_axes_renderer(self) -> None:
|
||||
try:
|
||||
axes_renderer = vtk.vtkRenderer()
|
||||
axes_renderer.SetLayer(1)
|
||||
axes_renderer.InteractiveOff()
|
||||
axes_renderer.EraseOff()
|
||||
axes_renderer.PreserveColorBufferOn()
|
||||
axes_renderer.PreserveDepthBufferOff()
|
||||
axes_renderer.SetActiveCamera(self.renderer.GetActiveCamera())
|
||||
axes_light = vtk.vtkLight()
|
||||
axes_light.SetLightTypeToHeadlight()
|
||||
axes_light.SetIntensity(0.8)
|
||||
axes_renderer.AddLight(axes_light)
|
||||
self.render_window.AddRenderer(axes_renderer)
|
||||
except Exception:
|
||||
self.scene_axes_renderer = None
|
||||
return
|
||||
self.scene_axes_renderer = axes_renderer
|
||||
|
||||
def _build_fps_overlay(self) -> None:
|
||||
self.fps_actor = vtk.vtkTextActor()
|
||||
self.fps_actor.SetInput("FPS --")
|
||||
text_property = self.fps_actor.GetTextProperty()
|
||||
text_property.SetFontFamilyToArial()
|
||||
text_property.SetFontSize(17)
|
||||
text_property.BoldOn()
|
||||
text_property.SetColor(0.05, 0.08, 0.12)
|
||||
text_property.SetOpacity(0.92)
|
||||
text_property.SetJustificationToRight()
|
||||
text_property.SetVerticalJustificationToTop()
|
||||
if hasattr(text_property, "SetBackgroundColor"):
|
||||
text_property.SetBackgroundColor(1.0, 1.0, 1.0)
|
||||
if hasattr(text_property, "SetBackgroundOpacity"):
|
||||
text_property.SetBackgroundOpacity(0.58)
|
||||
position = self.fps_actor.GetPositionCoordinate()
|
||||
position.SetCoordinateSystemToNormalizedViewport()
|
||||
position.SetValue(0.985, 0.982)
|
||||
self.renderer.AddActor2D(self.fps_actor)
|
||||
|
||||
def _ensure_fps_overlay(self) -> None:
|
||||
actor = getattr(self, "fps_actor", None)
|
||||
if actor is None:
|
||||
self._build_fps_overlay()
|
||||
return
|
||||
try:
|
||||
self.renderer.AddActor2D(actor)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_render_window_end_event(self, _obj, _event) -> None:
|
||||
now = time.perf_counter()
|
||||
if bool(getattr(self, "_fps_idle_rendering_zero", False)):
|
||||
self._fps_idle_rendering_zero = False
|
||||
self._fps_frame_count = 0
|
||||
self._fps_window_started_at = now
|
||||
self._fps_last_text_update_at = now
|
||||
return
|
||||
self._fps_idle_zero_displayed = False
|
||||
self._restart_fps_idle_timer()
|
||||
self._fps_frame_count = int(getattr(self, "_fps_frame_count", 0) or 0) + 1
|
||||
window_started_at = float(getattr(self, "_fps_window_started_at", now) or now)
|
||||
elapsed = now - window_started_at
|
||||
if elapsed < 0.35:
|
||||
return
|
||||
fps = self._fps_frame_count / max(elapsed, 1e-9)
|
||||
previous = float(getattr(self, "_fps_last_value", 0.0) or 0.0)
|
||||
if previous > 0.0:
|
||||
fps = previous * 0.35 + fps * 0.65
|
||||
self._fps_last_value = fps
|
||||
self._fps_frame_count = 0
|
||||
self._fps_window_started_at = now
|
||||
actor = getattr(self, "fps_actor", None)
|
||||
if actor is not None:
|
||||
actor.SetInput(f"FPS {fps:4.1f}")
|
||||
|
||||
def _restart_fps_idle_timer(self) -> None:
|
||||
timer = getattr(self, "fps_idle_timer", None)
|
||||
if timer is None:
|
||||
return
|
||||
try:
|
||||
timer.start(int(getattr(self, "_fps_idle_timeout_ms", 900) or 900))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _show_idle_fps_zero(self) -> None:
|
||||
if bool(getattr(self, "_fps_idle_zero_displayed", False)):
|
||||
return
|
||||
actor = getattr(self, "fps_actor", None)
|
||||
render_window = getattr(self, "render_window", None)
|
||||
if actor is None or render_window is None:
|
||||
return
|
||||
self._fps_idle_zero_displayed = True
|
||||
self._fps_last_value = 0.0
|
||||
self._fps_frame_count = 0
|
||||
self._fps_window_started_at = time.perf_counter()
|
||||
try:
|
||||
actor.SetInput("FPS 0.0")
|
||||
except Exception:
|
||||
return
|
||||
self._fps_idle_rendering_zero = True
|
||||
try:
|
||||
render_window.Render()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._fps_idle_rendering_zero = False
|
||||
|
||||
def _axis_caption_property(self, caption_actor):
|
||||
try:
|
||||
return caption_actor.GetCaptionTextProperty()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
text_actor = caption_actor.GetTextActor()
|
||||
return text_actor.GetTextProperty()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _style_axis_caption(self, caption_actor, color: tuple[float, float, float], scale: float) -> None:
|
||||
text_property = self._axis_caption_property(caption_actor)
|
||||
if text_property is not None:
|
||||
try:
|
||||
text_property.SetFontFamilyToArial()
|
||||
text_property.BoldOn()
|
||||
text_property.SetColor(*color)
|
||||
text_property.SetOpacity(0.96)
|
||||
except Exception:
|
||||
pass
|
||||
for method_name in ("SetWidth", "SetHeight"):
|
||||
try:
|
||||
getattr(caption_actor, method_name)(scale)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _configure_axes_actor(self, axes_actor, length: float, caption_scale: float) -> None:
|
||||
length = max(float(length), 1e-6)
|
||||
axes_actor.SetTotalLength(length, length, length)
|
||||
axes_actor.SetXAxisLabelText("X")
|
||||
axes_actor.SetYAxisLabelText("Y")
|
||||
axes_actor.SetZAxisLabelText("Z")
|
||||
axes_actor.AxisLabelsOn()
|
||||
for method_name, args in (
|
||||
("SetShaftTypeToCylinder", ()),
|
||||
("SetCylinderRadius", (0.012,)),
|
||||
("SetConeRadius", (0.045,)),
|
||||
("SetSphereRadius", (0.035,)),
|
||||
("SetNormalizedShaftLength", (0.78, 0.78, 0.78)),
|
||||
("SetNormalizedTipLength", (0.22, 0.22, 0.22)),
|
||||
):
|
||||
try:
|
||||
getattr(axes_actor, method_name)(*args)
|
||||
except Exception:
|
||||
pass
|
||||
for shaft_name, tip_name, caption_name, color in (
|
||||
("GetXAxisShaftProperty", "GetXAxisTipProperty", "GetXAxisCaptionActor2D", AXIS_X_COLOR),
|
||||
("GetYAxisShaftProperty", "GetYAxisTipProperty", "GetYAxisCaptionActor2D", AXIS_Y_COLOR),
|
||||
("GetZAxisShaftProperty", "GetZAxisTipProperty", "GetZAxisCaptionActor2D", AXIS_Z_COLOR),
|
||||
):
|
||||
for prop_name in (shaft_name, tip_name):
|
||||
try:
|
||||
prop = getattr(axes_actor, prop_name)()
|
||||
prop.SetColor(*color)
|
||||
prop.SetAmbient(0.25)
|
||||
prop.SetDiffuse(0.78)
|
||||
prop.SetSpecular(0.2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._style_axis_caption(getattr(axes_actor, caption_name)(), color, caption_scale)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
axes_actor.PickableOff()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _blend_axis_color(
|
||||
color: tuple[float, float, float],
|
||||
target: tuple[float, float, float],
|
||||
amount: float,
|
||||
) -> tuple[float, float, float]:
|
||||
amount = min(max(float(amount), 0.0), 1.0)
|
||||
return tuple(color[index] * (1.0 - amount) + target[index] * amount for index in range(3))
|
||||
|
||||
def _apply_axis_material(
|
||||
self,
|
||||
actor,
|
||||
color: tuple[float, float, float],
|
||||
*,
|
||||
opacity: float = 1.0,
|
||||
ambient: float = 0.48,
|
||||
diffuse: float = 0.7,
|
||||
specular: float = 0.22,
|
||||
specular_power: float = 28.0,
|
||||
) -> None:
|
||||
try:
|
||||
prop = actor.GetProperty()
|
||||
prop.SetColor(*color)
|
||||
prop.SetOpacity(float(opacity))
|
||||
prop.SetAmbient(float(ambient))
|
||||
prop.SetDiffuse(float(diffuse))
|
||||
prop.SetSpecular(float(specular))
|
||||
prop.SetSpecularPower(float(specular_power))
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
actor.PickableOff()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _make_axis_tube_actor(
|
||||
self,
|
||||
start: tuple[float, float, float],
|
||||
end: tuple[float, float, float],
|
||||
radius: float,
|
||||
color: tuple[float, float, float],
|
||||
*,
|
||||
opacity: float = 1.0,
|
||||
):
|
||||
line = vtk.vtkLineSource()
|
||||
line.SetPoint1(*start)
|
||||
line.SetPoint2(*end)
|
||||
tube = vtk.vtkTubeFilter()
|
||||
tube.SetInputConnection(line.GetOutputPort())
|
||||
tube.SetRadius(max(float(radius), 1e-9))
|
||||
tube.SetNumberOfSides(28)
|
||||
tube.CappingOn()
|
||||
mapper = vtk.vtkPolyDataMapper()
|
||||
mapper.SetInputConnection(tube.GetOutputPort())
|
||||
actor = vtk.vtkActor()
|
||||
actor.SetMapper(mapper)
|
||||
self._apply_axis_material(actor, color, opacity=opacity)
|
||||
return actor
|
||||
|
||||
def _make_axis_cone_actor(
|
||||
self,
|
||||
center: tuple[float, float, float],
|
||||
direction: tuple[float, float, float],
|
||||
height: float,
|
||||
radius: float,
|
||||
color: tuple[float, float, float],
|
||||
):
|
||||
cone = vtk.vtkConeSource()
|
||||
cone.SetCenter(*center)
|
||||
cone.SetDirection(*direction)
|
||||
cone.SetHeight(max(float(height), 1e-9))
|
||||
cone.SetRadius(max(float(radius), 1e-9))
|
||||
cone.SetResolution(36)
|
||||
mapper = vtk.vtkPolyDataMapper()
|
||||
mapper.SetInputConnection(cone.GetOutputPort())
|
||||
actor = vtk.vtkActor()
|
||||
actor.SetMapper(mapper)
|
||||
self._apply_axis_material(actor, color, ambient=0.3, diffuse=0.72, specular=0.36, specular_power=42.0)
|
||||
return actor
|
||||
|
||||
def _make_axis_sphere_actor(
|
||||
self,
|
||||
center: tuple[float, float, float],
|
||||
radius: float,
|
||||
color: tuple[float, float, float],
|
||||
):
|
||||
sphere = vtk.vtkSphereSource()
|
||||
sphere.SetCenter(*center)
|
||||
sphere.SetRadius(max(float(radius), 1e-9))
|
||||
sphere.SetThetaResolution(36)
|
||||
sphere.SetPhiResolution(24)
|
||||
mapper = vtk.vtkPolyDataMapper()
|
||||
mapper.SetInputConnection(sphere.GetOutputPort())
|
||||
actor = vtk.vtkActor()
|
||||
actor.SetMapper(mapper)
|
||||
self._apply_axis_material(actor, color, ambient=0.42, diffuse=0.6, specular=0.4, specular_power=46.0)
|
||||
return actor
|
||||
|
||||
def _make_axis_label_actor(
|
||||
self,
|
||||
text: str,
|
||||
position: tuple[float, float, float],
|
||||
scale: float,
|
||||
color: tuple[float, float, float],
|
||||
):
|
||||
vector_text = vtk.vtkVectorText()
|
||||
vector_text.SetText(text)
|
||||
mapper = vtk.vtkPolyDataMapper()
|
||||
mapper.SetInputConnection(vector_text.GetOutputPort())
|
||||
actor = vtk.vtkFollower()
|
||||
actor.SetMapper(mapper)
|
||||
actor.SetScale(scale, scale, scale)
|
||||
actor.SetPosition(*position)
|
||||
try:
|
||||
actor.SetCamera(self.renderer.GetActiveCamera())
|
||||
except Exception:
|
||||
pass
|
||||
self._apply_axis_material(actor, color, ambient=0.78, diffuse=0.38, specular=0.0)
|
||||
return actor
|
||||
|
||||
def _set_annotated_cube_property(
|
||||
self,
|
||||
cube_actor,
|
||||
method_name: str,
|
||||
color: tuple[float, float, float],
|
||||
opacity: float,
|
||||
) -> None:
|
||||
try:
|
||||
prop = getattr(cube_actor, method_name)()
|
||||
prop.SetColor(*color)
|
||||
prop.SetOpacity(float(opacity))
|
||||
prop.SetAmbient(0.35)
|
||||
prop.SetDiffuse(0.65)
|
||||
prop.SetSpecular(0.18)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _build_orientation_marker_prop(self):
|
||||
axes_actor = vtk.vtkAxesActor()
|
||||
self._configure_axes_actor(axes_actor, 1.72, caption_scale=0.14)
|
||||
|
||||
cube_actor = vtk.vtkAnnotatedCubeActor()
|
||||
cube_actor.SetXPlusFaceText("+X")
|
||||
cube_actor.SetXMinusFaceText("-X")
|
||||
cube_actor.SetYPlusFaceText("+Y")
|
||||
cube_actor.SetYMinusFaceText("-Y")
|
||||
cube_actor.SetZPlusFaceText("+Z")
|
||||
cube_actor.SetZMinusFaceText("-Z")
|
||||
cube_actor.SetFaceTextScale(0.54)
|
||||
cube_actor.SetTextEdgesVisibility(1)
|
||||
cube_actor.SetCubeVisibility(1)
|
||||
self._set_annotated_cube_property(cube_actor, "GetCubeProperty", (0.88, 0.92, 0.96), 0.72)
|
||||
self._set_annotated_cube_property(cube_actor, "GetXPlusFaceProperty", AXIS_X_COLOR, 0.92)
|
||||
self._set_annotated_cube_property(
|
||||
cube_actor,
|
||||
"GetXMinusFaceProperty",
|
||||
self._blend_axis_color(AXIS_X_COLOR, (1.0, 1.0, 1.0), 0.52),
|
||||
0.78,
|
||||
)
|
||||
self._set_annotated_cube_property(cube_actor, "GetYPlusFaceProperty", AXIS_Y_COLOR, 0.92)
|
||||
self._set_annotated_cube_property(
|
||||
cube_actor,
|
||||
"GetYMinusFaceProperty",
|
||||
self._blend_axis_color(AXIS_Y_COLOR, (1.0, 1.0, 1.0), 0.52),
|
||||
0.78,
|
||||
)
|
||||
self._set_annotated_cube_property(cube_actor, "GetZPlusFaceProperty", AXIS_Z_COLOR, 0.92)
|
||||
self._set_annotated_cube_property(
|
||||
cube_actor,
|
||||
"GetZMinusFaceProperty",
|
||||
self._blend_axis_color(AXIS_Z_COLOR, (1.0, 1.0, 1.0), 0.52),
|
||||
0.78,
|
||||
)
|
||||
try:
|
||||
edge_property = cube_actor.GetTextEdgesProperty()
|
||||
edge_property.SetColor(0.09, 0.12, 0.17)
|
||||
edge_property.SetOpacity(0.75)
|
||||
edge_property.SetLineWidth(1.5)
|
||||
except Exception:
|
||||
pass
|
||||
for actor in (axes_actor, cube_actor):
|
||||
try:
|
||||
actor.PickableOff()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prop = vtk.vtkPropAssembly()
|
||||
prop.AddPart(axes_actor)
|
||||
prop.AddPart(cube_actor)
|
||||
return prop, axes_actor, cube_actor
|
||||
|
||||
def _build_orientation_marker(self) -> None:
|
||||
try:
|
||||
prop, axes_actor, cube_actor = self._build_orientation_marker_prop()
|
||||
marker_widget = vtk.vtkOrientationMarkerWidget()
|
||||
marker_widget.SetOrientationMarker(prop)
|
||||
marker_widget.SetInteractor(self.interactor)
|
||||
marker_widget.SetViewport(0.015, 0.015, 0.235, 0.235)
|
||||
if hasattr(marker_widget, "SetOutlineColor"):
|
||||
marker_widget.SetOutlineColor(0.72, 0.78, 0.86)
|
||||
marker_widget.SetEnabled(1)
|
||||
marker_widget.InteractiveOff()
|
||||
except Exception:
|
||||
self.orientation_marker_widget = None
|
||||
self.orientation_marker_prop = None
|
||||
self.orientation_marker_axes_actor = None
|
||||
self.orientation_marker_cube_actor = None
|
||||
return
|
||||
self.orientation_marker_widget = marker_widget
|
||||
self.orientation_marker_prop = prop
|
||||
self.orientation_marker_axes_actor = axes_actor
|
||||
self.orientation_marker_cube_actor = cube_actor
|
||||
|
||||
def _valid_bounds_tuple(self, bounds) -> tuple[float, float, float, float, float, float] | None:
|
||||
try:
|
||||
values = tuple(float(value) for value in bounds)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if len(values) != 6:
|
||||
return None
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
return None
|
||||
x0, x1, y0, y1, z0, z1 = values
|
||||
if x1 < x0 or y1 < y0 or z1 < z0:
|
||||
return None
|
||||
return values
|
||||
|
||||
def _current_model_bounds(self) -> tuple[float, float, float, float, float, float] | None:
|
||||
polydata = getattr(self, "model_polydata", None)
|
||||
if polydata is None:
|
||||
return None
|
||||
try:
|
||||
return self._valid_bounds_tuple(polydata.GetBounds())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _step_coordinate_axis_length(self) -> float:
|
||||
bounds = self._current_model_bounds()
|
||||
if bounds is None:
|
||||
return 1.0
|
||||
x0, x1, y0, y1, z0, z1 = bounds
|
||||
dx = max(x1 - x0, 0.0)
|
||||
dy = max(y1 - y0, 0.0)
|
||||
dz = max(z1 - z0, 0.0)
|
||||
model_size = max(math.sqrt(dx * dx + dy * dy + dz * dz), dx, dy, dz, 1e-6)
|
||||
return max(model_size * 0.18, 1e-4)
|
||||
|
||||
def _scene_coordinate_axes_anchor(self, length: float) -> tuple[float, float, float]:
|
||||
# STEP display geometry is already in the file/global coordinate frame.
|
||||
# Keep the scene axes at the real file origin; visibility is handled by
|
||||
# the foreground renderer, not by moving the origin marker.
|
||||
return (0.0, 0.0, 0.0)
|
||||
|
||||
def _build_scene_coordinate_axes(self, length: float, anchor: tuple[float, float, float]):
|
||||
length = max(float(length), 1e-6)
|
||||
assembly = vtk.vtkAssembly()
|
||||
assembly.SetPosition(*anchor)
|
||||
shaft_radius = length * 0.012
|
||||
tip_length = length * 0.18
|
||||
tip_radius = length * 0.045
|
||||
negative_length = length * 0.28
|
||||
label_scale = length * 0.075
|
||||
origin_radius = length * 0.032
|
||||
|
||||
assembly.AddPart(self._make_axis_sphere_actor((0.0, 0.0, 0.0), origin_radius, (0.96, 0.98, 1.0)))
|
||||
for label, direction, color in (
|
||||
("X", (1.0, 0.0, 0.0), AXIS_X_COLOR),
|
||||
("Y", (0.0, 1.0, 0.0), AXIS_Y_COLOR),
|
||||
("Z", (0.0, 0.0, 1.0), AXIS_Z_COLOR),
|
||||
):
|
||||
shaft_end = tuple(direction[index] * (length - tip_length * 0.78) for index in range(3))
|
||||
cone_center = tuple(direction[index] * (length - tip_length * 0.5) for index in range(3))
|
||||
label_position = tuple(direction[index] * (length * 1.08) for index in range(3))
|
||||
negative_start = tuple(-direction[index] * negative_length for index in range(3))
|
||||
pale_color = self._blend_axis_color(color, (1.0, 1.0, 1.0), 0.48)
|
||||
|
||||
assembly.AddPart(
|
||||
self._make_axis_tube_actor(
|
||||
negative_start,
|
||||
(0.0, 0.0, 0.0),
|
||||
shaft_radius * 0.58,
|
||||
pale_color,
|
||||
opacity=0.62,
|
||||
)
|
||||
)
|
||||
assembly.AddPart(self._make_axis_tube_actor((0.0, 0.0, 0.0), shaft_end, shaft_radius, color))
|
||||
assembly.AddPart(self._make_axis_cone_actor(cone_center, direction, tip_length, tip_radius, color))
|
||||
assembly.AddPart(self._make_axis_label_actor(label, label_position, label_scale, color))
|
||||
try:
|
||||
assembly.PickableOff()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
assembly.UseBoundsOff()
|
||||
except Exception:
|
||||
pass
|
||||
return assembly
|
||||
|
||||
def _ensure_step_coordinate_axes(self) -> None:
|
||||
previous_actor = getattr(self, "step_coordinate_axes_actor", None)
|
||||
if previous_actor is not None:
|
||||
for renderer in (getattr(self, "scene_axes_renderer", None), self.renderer):
|
||||
if renderer is None:
|
||||
continue
|
||||
try:
|
||||
renderer.RemoveViewProp(previous_actor)
|
||||
except Exception:
|
||||
pass
|
||||
axes_renderer = getattr(self, "scene_axes_renderer", None) or self.renderer
|
||||
try:
|
||||
axes_renderer.SetActiveCamera(self.renderer.GetActiveCamera())
|
||||
except Exception:
|
||||
pass
|
||||
length = self._step_coordinate_axis_length()
|
||||
anchor = self._scene_coordinate_axes_anchor(length)
|
||||
try:
|
||||
axes_actor = self._build_scene_coordinate_axes(length, anchor)
|
||||
self.step_coordinate_axes_actor = axes_actor
|
||||
except Exception:
|
||||
axes_actor = vtk.vtkAxesActor()
|
||||
axes_actor.SetPosition(*anchor)
|
||||
self._configure_axes_actor(
|
||||
axes_actor,
|
||||
length,
|
||||
caption_scale=0.055,
|
||||
)
|
||||
self.step_coordinate_axes_actor = axes_actor
|
||||
for method_name in ("PickableOff", "UseBoundsOff"):
|
||||
try:
|
||||
getattr(axes_actor, method_name)()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
axes_renderer.AddViewProp(axes_actor)
|
||||
except Exception:
|
||||
try:
|
||||
axes_renderer.AddActor(axes_actor)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def on_camera_interaction_start(self, _obj, _event) -> None:
|
||||
if not self._is_ui_thread():
|
||||
@@ -184,15 +800,73 @@ class WindowCoreMixin:
|
||||
return
|
||||
self.camera_interaction_active = True
|
||||
self._cancel_hover_tracking(render=False)
|
||||
self._set_camera_interaction_visual_mode(True)
|
||||
|
||||
def _end_camera_interaction(self) -> None:
|
||||
if not getattr(self, "camera_interaction_active", False):
|
||||
return
|
||||
self.camera_interaction_active = False
|
||||
self._set_camera_interaction_visual_mode(False)
|
||||
self.last_camera_interaction_ended_at = datetime.now()
|
||||
self.pending_hover_position = None
|
||||
self.last_hover_pick_position = None
|
||||
|
||||
def _set_render_window_multisamples(self, samples: int) -> bool:
|
||||
render_window = getattr(self, "render_window", None)
|
||||
if render_window is None or not hasattr(render_window, "SetMultiSamples"):
|
||||
return False
|
||||
target = max(int(samples), 0)
|
||||
try:
|
||||
if hasattr(render_window, "GetMultiSamples") and int(render_window.GetMultiSamples()) == target:
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
render_window.SetMultiSamples(target)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _set_camera_interaction_visual_mode(self, active: bool) -> None:
|
||||
edge_actor = getattr(self, "edge_actor", None)
|
||||
changed = False
|
||||
if active:
|
||||
if bool(getattr(self, "hide_edges_during_camera_interaction", True)) and edge_actor is not None:
|
||||
try:
|
||||
self.edge_visibility_before_camera_interaction = int(edge_actor.GetVisibility())
|
||||
if self.edge_visibility_before_camera_interaction:
|
||||
edge_actor.VisibilityOff()
|
||||
changed = True
|
||||
except Exception:
|
||||
self.edge_visibility_before_camera_interaction = None
|
||||
changed = self._set_render_window_multisamples(
|
||||
int(getattr(self, "interactive_multi_samples", 0))
|
||||
) or changed
|
||||
self.camera_interaction_visual_changed = changed
|
||||
return
|
||||
|
||||
if edge_actor is not None and self.edge_visibility_before_camera_interaction is not None:
|
||||
try:
|
||||
previous_visibility = int(self.edge_visibility_before_camera_interaction)
|
||||
if int(edge_actor.GetVisibility()) != previous_visibility:
|
||||
edge_actor.SetVisibility(previous_visibility)
|
||||
changed = True
|
||||
except Exception:
|
||||
pass
|
||||
self.edge_visibility_before_camera_interaction = None
|
||||
changed = self._set_render_window_multisamples(int(getattr(self, "still_multi_samples", 4))) or changed
|
||||
changed = bool(getattr(self, "camera_interaction_visual_changed", False)) or changed
|
||||
self.camera_interaction_visual_changed = False
|
||||
if changed:
|
||||
try:
|
||||
self.renderer.ResetCameraClippingRange()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.render_window.Render()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _hover_suppressed_after_camera(self) -> bool:
|
||||
ended_at = getattr(self, "last_camera_interaction_ended_at", None)
|
||||
if ended_at is None:
|
||||
@@ -268,7 +942,7 @@ class WindowCoreMixin:
|
||||
if not background:
|
||||
self._load_step_sync(
|
||||
new_path,
|
||||
deflection=0.8,
|
||||
deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35),
|
||||
show_internal_edges=self._show_same_domain_internal_edges(),
|
||||
status_prefix="Loading",
|
||||
)
|
||||
@@ -1027,6 +1701,7 @@ class WindowCoreMixin:
|
||||
self._clear_overlay_polydata_cache()
|
||||
self.renderer.RemoveAllViewProps()
|
||||
self.renderer.SetBackground(*VIEW_BACKGROUND_COLOR)
|
||||
self._ensure_fps_overlay()
|
||||
self.scene_isolated = False
|
||||
self.highlight_signature = None
|
||||
|
||||
@@ -1067,6 +1742,7 @@ class WindowCoreMixin:
|
||||
self.edge_actor.SetMapper(edge_mapper)
|
||||
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
|
||||
self.edge_actor.GetProperty().SetLineWidth(1.0)
|
||||
self.edge_actor.GetProperty().LightingOff()
|
||||
self.renderer.AddActor(self.edge_actor)
|
||||
|
||||
self.highlight_actor = None
|
||||
@@ -1082,6 +1758,8 @@ class WindowCoreMixin:
|
||||
self.diff_actors = []
|
||||
if reset_camera:
|
||||
self.renderer.ResetCamera()
|
||||
self._ensure_step_coordinate_axes()
|
||||
self.renderer.ResetCameraClippingRange()
|
||||
self.render_window.Render()
|
||||
|
||||
def _on_mode_changed(self, mode: str) -> None:
|
||||
|
||||
+755
-17
@@ -8,11 +8,18 @@ from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QTableWidgetItem,
|
||||
QTreeWidgetItem,
|
||||
QToolTip,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtGui import QColor
|
||||
|
||||
@@ -25,13 +32,43 @@ from .workers import EditWorker, LoadWorker, ScanWorker
|
||||
|
||||
|
||||
PROPERTY_VALUE_TOLERANCE = 1e-9
|
||||
PROPERTY_LABEL_COLUMN = 0
|
||||
PROPERTY_CURRENT_COLUMN = 1
|
||||
PROPERTY_SCOPE_COLUMN = 2
|
||||
PROPERTY_TARGET_COLUMN = 3
|
||||
PROPERTY_ACTION_COLUMN = 4
|
||||
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (54, 118, 48, 66, 74)
|
||||
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (68, 168, 58, 92, 82)
|
||||
PROPERTY_COMMAND_ORDER = ("offset", "move", "scale", "rotate", "feature", "diagnostics")
|
||||
PROPERTY_COMMAND_LABELS = {
|
||||
"offset": "偏移",
|
||||
"move": "移动",
|
||||
"scale": "缩放",
|
||||
"rotate": "旋转",
|
||||
"feature": "特征",
|
||||
"diagnostics": "诊断",
|
||||
}
|
||||
PROPERTY_COMMAND_SUBTITLES = {
|
||||
"offset": "沿法线调面",
|
||||
"move": "移动中心/坐标",
|
||||
"scale": "改尺寸/半径",
|
||||
"rotate": "绕轴旋转",
|
||||
"feature": "孔槽/圆角",
|
||||
"diagnostics": "识别与关系",
|
||||
}
|
||||
PROPERTY_COMMAND_HELP = {
|
||||
"offset": "沿 Face 法线调整位置,适合平面推拉、偏移或切除深度类修改。",
|
||||
"move": "移动当前 Face、Edge、特征或 Solid 的中心/轴心,不直接修改尺寸。",
|
||||
"scale": "修改面积、宽度、高度、半径、直径、长度等尺寸参数。",
|
||||
"rotate": "设置旋转轴或旋转角度,适合 Part/Solid 的整体姿态调整。",
|
||||
"feature": "执行孔、槽、圆角、倒角、封堵等离散特征命令。",
|
||||
"diagnostics": "查看识别依据、一级拓扑关系和当前不支持修改的原因。",
|
||||
}
|
||||
|
||||
FEATURE_EDIT_SEMANTICS_KEYS = {
|
||||
"cad_modeling_form",
|
||||
"cad_recommended_operation",
|
||||
"edge_first_level_topology",
|
||||
"face_first_level_topology",
|
||||
"cylindrical_feature_first_level_topology",
|
||||
"slot_edit_semantics",
|
||||
@@ -44,6 +81,57 @@ FEATURE_EDIT_SEMANTICS_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _property_table_column_widths(available_width: int) -> tuple[int, int, int, int, int]:
|
||||
"""Prefer current value and action visibility over a wide parameter-name column."""
|
||||
column_count = len(PROPERTY_TABLE_MIN_COLUMN_WIDTHS)
|
||||
available = max(int(available_width or 0), column_count * 44)
|
||||
minimum = PROPERTY_TABLE_MIN_COLUMN_WIDTHS
|
||||
preferred = PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS
|
||||
min_total = sum(minimum)
|
||||
preferred_total = sum(preferred)
|
||||
|
||||
if available >= preferred_total:
|
||||
widths = [int(value) for value in preferred]
|
||||
extra = available - preferred_total
|
||||
weights = (0.05, 0.62, 0.04, 0.19, 0.10)
|
||||
for index, weight in enumerate(weights):
|
||||
addition = int(extra * weight)
|
||||
widths[index] += addition
|
||||
widths[1] += available - sum(widths)
|
||||
return tuple(widths) # type: ignore[return-value]
|
||||
|
||||
if available >= min_total:
|
||||
scale = (available - min_total) / max(preferred_total - min_total, 1)
|
||||
widths = [
|
||||
int(round(min_width + (pref_width - min_width) * scale))
|
||||
for min_width, pref_width in zip(minimum, preferred)
|
||||
]
|
||||
widths[1] += available - sum(widths)
|
||||
return tuple(widths) # type: ignore[return-value]
|
||||
|
||||
floors = (48, 96, 44, 56, 70)
|
||||
widths = [int(value) for value in minimum]
|
||||
deficit = min_total - available
|
||||
for index in (0, 2, 3, 1, 4):
|
||||
if deficit <= 0:
|
||||
break
|
||||
reducible = max(0, widths[index] - floors[index])
|
||||
reduction = min(reducible, deficit)
|
||||
widths[index] -= reduction
|
||||
deficit -= reduction
|
||||
if deficit > 0:
|
||||
widths[1] = max(44, widths[1] - deficit)
|
||||
widths[1] += available - sum(widths)
|
||||
return tuple(max(44, width) for width in widths) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _compact_property_card_text(text: str, limit: int = 220) -> str:
|
||||
compact = " ".join(str(text or "").replace("\r", "\n").split())
|
||||
if len(compact) <= limit:
|
||||
return compact
|
||||
return f"{compact[: max(0, limit - 1)].rstrip()}…"
|
||||
|
||||
|
||||
def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
|
||||
"""Return the independent, user-facing dimensions for a feature candidate."""
|
||||
surface = str(action_info.get("surface", "") or "")
|
||||
@@ -895,6 +983,13 @@ class WindowStateMixin:
|
||||
"查看当前选中对象的属性;可修改的行可以输入目标值。",
|
||||
"请先选择一个对象。",
|
||||
)
|
||||
if hasattr(self, "property_card_scroll"):
|
||||
self._set_control_state(
|
||||
self.property_card_scroll,
|
||||
has_selection and has_current_info,
|
||||
"查看当前选中对象的参数卡片;可修改的卡片可以输入目标值。",
|
||||
"请先选择一个对象。",
|
||||
)
|
||||
self._update_edit_action_states(has_model)
|
||||
|
||||
def _update_edit_action_states(self, has_model: bool) -> None:
|
||||
@@ -1321,14 +1416,475 @@ class WindowStateMixin:
|
||||
return
|
||||
self.property_editor_specs = []
|
||||
self.property_table_expanded = False
|
||||
self.property_editor_selected_row = None
|
||||
self.property_command_active_key = ""
|
||||
was_blocked = self.property_table.blockSignals(True)
|
||||
try:
|
||||
self.property_table.clearSpans()
|
||||
self.property_table.setRowCount(0)
|
||||
finally:
|
||||
self.property_table.blockSignals(was_blocked)
|
||||
self._clear_property_cards()
|
||||
self._clear_property_command_bar()
|
||||
self._resize_property_table_height()
|
||||
self._update_property_apply_state(False)
|
||||
|
||||
def _clear_property_cards(self) -> None:
|
||||
self.property_card_rows = {}
|
||||
layout = getattr(self, "property_card_layout", None)
|
||||
if layout is None:
|
||||
return
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
|
||||
def _clear_property_command_bar(self) -> None:
|
||||
self.property_command_buttons = {}
|
||||
if hasattr(self, "property_command_summary_label"):
|
||||
self.property_command_summary_label.setText("未选择可编辑对象")
|
||||
self.property_command_summary_label.setToolTip("")
|
||||
if hasattr(self, "property_command_help_label"):
|
||||
self.property_command_help_label.setText("")
|
||||
self.property_command_help_label.setToolTip("")
|
||||
self.property_command_help_label.setVisible(False)
|
||||
layout = getattr(self, "property_command_layout", None)
|
||||
if layout is not None:
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.hide()
|
||||
widget.setParent(None)
|
||||
widget.deleteLater()
|
||||
if hasattr(self, "property_command_bar"):
|
||||
self.property_command_bar.setVisible(False)
|
||||
if hasattr(self, "property_command_summary_label"):
|
||||
self.property_command_summary_label.setVisible(False)
|
||||
|
||||
def _is_actionable_property_row(self, row: int, spec: dict[str, object]) -> bool:
|
||||
effective_spec = self._effective_property_spec(spec, row=row)
|
||||
return bool(effective_spec.get("editable") and effective_spec.get("enabled") and effective_spec.get("action"))
|
||||
|
||||
def _actionable_property_rows(self) -> list[tuple[int, dict[str, object]]]:
|
||||
specs = list(getattr(self, "property_editor_specs", []) or [])
|
||||
return [(row, spec) for row, spec in enumerate(specs) if self._is_actionable_property_row(row, spec)]
|
||||
|
||||
def _property_command_summary_text(self) -> tuple[str, str]:
|
||||
specs = list(getattr(self, "property_editor_specs", []) or [])
|
||||
topology_text = ""
|
||||
for spec in specs:
|
||||
key = str(spec.get("key", "") or "")
|
||||
current_text = str(spec.get("current_text", "") or "").strip()
|
||||
if key in {
|
||||
"face_first_level_topology",
|
||||
"edge_first_level_topology",
|
||||
"cylindrical_feature_first_level_topology",
|
||||
} and current_text:
|
||||
topology_text = _compact_property_card_text(current_text, 86)
|
||||
actionable = self._actionable_property_rows()
|
||||
labels = [str(spec.get("label", "") or "").strip() for _row, spec in actionable]
|
||||
labels = [label for label in labels if label]
|
||||
if labels:
|
||||
summary = f"可修改项 {len(labels)} 个:{'、'.join(labels[:4])}"
|
||||
if topology_text:
|
||||
summary = f"{summary} | {topology_text}"
|
||||
else:
|
||||
summary = topology_text or "当前对象没有稳定可修改项;请查看诊断信息。"
|
||||
tooltip_parts = [str(spec.get("current_text", "") or "") for spec in specs if bool(spec.get("pin_top"))]
|
||||
return summary, "\n\n".join(part for part in tooltip_parts if part)
|
||||
|
||||
def _rebuild_property_command_bar(self) -> None:
|
||||
layout = getattr(self, "property_command_layout", None)
|
||||
if layout is None:
|
||||
return
|
||||
self.property_command_buttons = {}
|
||||
while layout.count():
|
||||
item = layout.takeAt(0)
|
||||
widget = item.widget()
|
||||
if widget is not None:
|
||||
widget.deleteLater()
|
||||
self.property_command_active_key = ""
|
||||
summary, tooltip = self._property_command_summary_text()
|
||||
if hasattr(self, "property_command_summary_label"):
|
||||
self.property_command_summary_label.setText(summary)
|
||||
self.property_command_summary_label.setToolTip(tooltip or summary)
|
||||
self.property_command_summary_label.setVisible(True)
|
||||
if hasattr(self, "property_command_help_label"):
|
||||
self.property_command_help_label.setText("")
|
||||
self.property_command_help_label.setToolTip("")
|
||||
self.property_command_help_label.setVisible(False)
|
||||
if hasattr(self, "property_command_bar"):
|
||||
self.property_command_bar.setVisible(False)
|
||||
|
||||
def _select_property_command(self, command_key: str) -> None:
|
||||
if not command_key:
|
||||
return
|
||||
|
||||
def _visible_property_card_rows(self) -> list[tuple[int, dict[str, object]]]:
|
||||
specs = list(getattr(self, "property_editor_specs", []) or [])
|
||||
if not specs:
|
||||
return []
|
||||
if bool(getattr(self, "property_table_expanded", False)):
|
||||
return [(row, spec) for row, spec in enumerate(specs)]
|
||||
|
||||
visible = self._actionable_property_rows()
|
||||
if not visible:
|
||||
visible = [
|
||||
(row, spec)
|
||||
for row, spec in enumerate(specs)
|
||||
if bool(spec.get("editable") or spec.get("action") or spec.get("pin_top"))
|
||||
]
|
||||
if not visible:
|
||||
visible = [(row, spec) for row, spec in enumerate(specs)]
|
||||
selected_row = getattr(self, "property_editor_selected_row", None)
|
||||
if selected_row is not None:
|
||||
selected_items = [(row, spec) for row, spec in enumerate(specs) if row == selected_row]
|
||||
if selected_items:
|
||||
visible = selected_items + [(row, spec) for row, spec in visible if row != selected_row]
|
||||
collapsed_rows = max(int(getattr(self, "property_table_collapsed_rows", 6) or 6), 6)
|
||||
return visible[:collapsed_rows]
|
||||
|
||||
def _rebuild_property_cards(self) -> None:
|
||||
layout = getattr(self, "property_card_layout", None)
|
||||
if layout is None:
|
||||
return
|
||||
self._clear_property_cards()
|
||||
visible_rows = self._visible_property_card_rows()
|
||||
visible_row_ids = {row for row, _spec in visible_rows}
|
||||
selected_row = getattr(self, "property_editor_selected_row", None)
|
||||
if selected_row not in visible_row_ids:
|
||||
self.property_editor_selected_row = None
|
||||
for row, spec in visible_rows:
|
||||
card = self._build_property_card(row, spec)
|
||||
layout.addWidget(card)
|
||||
|
||||
def _select_property_card_row(self, row: int) -> None:
|
||||
specs = getattr(self, "property_editor_specs", [])
|
||||
if row < 0 or row >= len(specs):
|
||||
return
|
||||
if getattr(self, "property_editor_selected_row", None) == row:
|
||||
return
|
||||
self.property_editor_selected_row = row
|
||||
self._rebuild_property_cards()
|
||||
self._resize_property_table_height()
|
||||
self._update_property_apply_state()
|
||||
self._scroll_property_card_row_to_top(row)
|
||||
|
||||
def _toggle_property_card_row(self, row: int) -> None:
|
||||
QToolTip.hideText()
|
||||
specs = getattr(self, "property_editor_specs", [])
|
||||
if row < 0 or row >= len(specs):
|
||||
return
|
||||
if getattr(self, "property_editor_selected_row", None) == row:
|
||||
self.property_editor_selected_row = None
|
||||
self._rebuild_property_cards()
|
||||
self._resize_property_table_height()
|
||||
self._update_property_apply_state()
|
||||
self._scroll_property_cards_to_top()
|
||||
return
|
||||
self._select_property_card_row(row)
|
||||
|
||||
def _scroll_property_cards_to_top(self) -> None:
|
||||
scroll = getattr(self, "property_card_scroll", None)
|
||||
if scroll is None:
|
||||
return
|
||||
bar = scroll.verticalScrollBar()
|
||||
if bar is None:
|
||||
return
|
||||
bar.setValue(0)
|
||||
QTimer.singleShot(0, lambda target_bar=bar: target_bar.setValue(0))
|
||||
|
||||
def _scroll_property_card_row_to_top(self, row: int) -> None:
|
||||
scroll = getattr(self, "property_card_scroll", None)
|
||||
card = self._property_card_widgets(row).get("card")
|
||||
if scroll is None or not isinstance(card, QWidget):
|
||||
return
|
||||
bar = scroll.verticalScrollBar()
|
||||
if bar is None:
|
||||
return
|
||||
|
||||
def apply_scroll() -> None:
|
||||
bar.setValue(max(0, int(card.y()) - 4))
|
||||
|
||||
apply_scroll()
|
||||
QTimer.singleShot(0, apply_scroll)
|
||||
|
||||
def _property_card_widgets(self, row: int) -> dict[str, object]:
|
||||
rows = getattr(self, "property_card_rows", {})
|
||||
if isinstance(rows, dict):
|
||||
widgets = rows.get(row, {})
|
||||
if isinstance(widgets, dict):
|
||||
return widgets
|
||||
return {}
|
||||
|
||||
def _build_property_card(self, row: int, spec: dict[str, object]) -> QFrame:
|
||||
effective_spec = self._effective_property_spec(spec, row=row)
|
||||
editable = bool(effective_spec.get("editable") and effective_spec.get("enabled"))
|
||||
value_type = str(effective_spec.get("value_type", "number"))
|
||||
input_editable = editable and value_type != "command"
|
||||
span_value_columns = bool(effective_spec.get("span_value_columns")) and not editable
|
||||
selected = row == getattr(self, "property_editor_selected_row", None)
|
||||
|
||||
card = QFrame()
|
||||
card.setObjectName("propertyCard")
|
||||
card.setProperty("editable", bool(editable))
|
||||
card.setProperty("pinTop", bool(effective_spec.get("pin_top")))
|
||||
card.setProperty("selected", bool(selected))
|
||||
card.setProperty("compact", bool(not selected))
|
||||
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
card.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
card.mousePressEvent = lambda _event, target_row=row: self._toggle_property_card_row(target_row)
|
||||
card_layout = QVBoxLayout(card)
|
||||
card_layout.setContentsMargins(7 if not selected else 8, 2 if not selected else 7, 7 if not selected else 8, 2 if not selected else 7)
|
||||
card_layout.setSpacing(0 if not selected else 5)
|
||||
diagnostics_expanded = bool(getattr(self, "property_table_expanded", False))
|
||||
|
||||
header = QHBoxLayout()
|
||||
header.setContentsMargins(0, 0, 0, 0)
|
||||
header.setSpacing(8 if not selected else 4)
|
||||
title = QLabel(str(effective_spec.get("label", "")))
|
||||
title.setObjectName("propertyCardTitle")
|
||||
title.setMinimumWidth(0)
|
||||
title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
|
||||
if selected or span_value_columns:
|
||||
title.setToolTip(str(effective_spec.get("disabled_tip") or effective_spec.get("enabled_tip") or ""))
|
||||
header.addWidget(title, 1)
|
||||
compact_detail_below_header = False
|
||||
if not selected and not span_value_columns:
|
||||
current_text = str(effective_spec.get("current_text", ""))
|
||||
detail_parts = [current_text] if current_text else []
|
||||
scope_text = str(effective_spec.get("scope_text") or effective_spec.get("scope_label") or "").strip()
|
||||
target_text = self._property_target_text(row) or str(effective_spec.get("target_text", ""))
|
||||
if editable and value_type != "command":
|
||||
validation_error = (
|
||||
"" if not target_text.strip() else self._property_target_validation_error(effective_spec, target_text)
|
||||
)
|
||||
row_changed = self._property_target_changed(effective_spec, target_text)
|
||||
if validation_error:
|
||||
scope_text = "目标无效"
|
||||
elif row_changed:
|
||||
scope_text = f"目标 {target_text}"
|
||||
elif editable and value_type == "command":
|
||||
scope_text = str(effective_spec.get("button_text") or scope_text or "可执行").strip()
|
||||
if scope_text:
|
||||
detail_parts.append(scope_text)
|
||||
compact_detail_text = _compact_property_card_text(" · ".join(detail_parts), 150)
|
||||
compact_multiline = len(str(effective_spec.get("label", ""))) >= 14 or len(compact_detail_text) >= 30
|
||||
title.setWordWrap(compact_multiline)
|
||||
if compact_multiline:
|
||||
title.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
card_layout.setSpacing(1)
|
||||
compact_detail = QLabel(_compact_property_card_text(" · ".join(detail_parts), 150 if compact_multiline else 96))
|
||||
compact_detail.setObjectName("propertyCardCompactValue")
|
||||
compact_detail.setAlignment(
|
||||
(Qt.AlignmentFlag.AlignLeft if compact_multiline else Qt.AlignmentFlag.AlignRight)
|
||||
| Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
compact_detail.setMinimumWidth(0)
|
||||
compact_detail.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding if compact_multiline else QSizePolicy.Policy.Fixed,
|
||||
QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
if compact_multiline:
|
||||
compact_detail_below_header = True
|
||||
else:
|
||||
header.addWidget(compact_detail, 0)
|
||||
row_widgets_placeholder = compact_detail
|
||||
status_text = "可修改" if editable else str(effective_spec.get("status_text", ""))
|
||||
else:
|
||||
status_text = str(effective_spec.get("status_text", ""))
|
||||
status_label = QLabel(str(effective_spec.get("status_text", "")), card)
|
||||
status_label.setObjectName("propertyCardMetaLabel")
|
||||
status_label.setText(status_text)
|
||||
status_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
||||
header.addWidget(status_label, 0)
|
||||
card_layout.addLayout(header)
|
||||
status_label.setVisible(bool(status_text))
|
||||
if compact_detail_below_header:
|
||||
card_layout.addWidget(row_widgets_placeholder)
|
||||
|
||||
row_widgets: dict[str, object] = {
|
||||
"card": card,
|
||||
"status_label": status_label,
|
||||
}
|
||||
if not selected and not span_value_columns:
|
||||
row_widgets["current_value"] = row_widgets_placeholder
|
||||
self.property_card_rows[row] = row_widgets
|
||||
card.style().unpolish(card)
|
||||
card.style().polish(card)
|
||||
return card
|
||||
|
||||
if span_value_columns:
|
||||
current_text = str(effective_spec.get("current_text", ""))
|
||||
show_full_span_text = bool(selected or diagnostics_expanded)
|
||||
span_limit = 520 if show_full_span_text else 96
|
||||
value = QLabel(_compact_property_card_text(current_text, span_limit))
|
||||
value.setObjectName("propertyCardValue")
|
||||
value.setWordWrap(show_full_span_text)
|
||||
value.setToolTip(str(effective_spec.get("disabled_tip") or current_text))
|
||||
card_layout.addWidget(value)
|
||||
hint_text = _compact_property_card_text(str(effective_spec.get("disabled_tip") or ""), 150)
|
||||
if selected and hint_text and hint_text != value.text():
|
||||
hint = QLabel(hint_text)
|
||||
hint.setObjectName("propertyCardHint")
|
||||
hint.setWordWrap(True)
|
||||
card_layout.addWidget(hint)
|
||||
row_widgets["hint_label"] = hint
|
||||
self.property_card_rows[row] = row_widgets
|
||||
card.style().unpolish(card)
|
||||
card.style().polish(card)
|
||||
return card
|
||||
|
||||
current_row = QHBoxLayout()
|
||||
current_row.setContentsMargins(0, 0, 0, 0)
|
||||
current_label = QLabel("当前值")
|
||||
current_label.setObjectName("propertyCardMetaLabel")
|
||||
current_row.addWidget(current_label, 0)
|
||||
current_text = str(effective_spec.get("current_text", ""))
|
||||
current_value = QLabel(_compact_property_card_text(current_text, 220 if selected else 118))
|
||||
current_value.setObjectName("propertyCardValue")
|
||||
current_value.setWordWrap(bool(selected))
|
||||
current_value.setToolTip(current_text)
|
||||
current_row.addWidget(current_value, 1)
|
||||
card_layout.addLayout(current_row)
|
||||
|
||||
if not selected:
|
||||
compact_parts: list[str] = []
|
||||
scope_text = str(effective_spec.get("scope_text") or effective_spec.get("scope_label") or "").strip()
|
||||
if scope_text:
|
||||
compact_parts.append(scope_text)
|
||||
target_text = self._property_target_text(row) or str(effective_spec.get("target_text", ""))
|
||||
if editable and value_type != "command":
|
||||
validation_error = (
|
||||
"" if not target_text.strip() else self._property_target_validation_error(effective_spec, target_text)
|
||||
)
|
||||
row_changed = self._property_target_changed(effective_spec, target_text)
|
||||
if validation_error:
|
||||
compact_parts.append("目标无效")
|
||||
elif row_changed:
|
||||
compact_parts.append(f"目标 {target_text}")
|
||||
elif editable and value_type == "command":
|
||||
button_text = str(effective_spec.get("button_text") or "可执行").strip()
|
||||
if button_text:
|
||||
compact_parts.append(button_text)
|
||||
status_text = str(effective_spec.get("status_text") or "").strip()
|
||||
if status_text and not compact_parts:
|
||||
compact_parts.append(status_text)
|
||||
if compact_parts:
|
||||
compact_meta = QLabel(_compact_property_card_text(" · ".join(compact_parts), 118))
|
||||
compact_meta.setObjectName("propertyCardCompactMeta")
|
||||
compact_meta.setWordWrap(True)
|
||||
compact_meta.setToolTip(" · ".join(compact_parts))
|
||||
card_layout.addWidget(compact_meta)
|
||||
row_widgets["compact_meta"] = compact_meta
|
||||
self.property_card_rows[row] = row_widgets
|
||||
card.style().unpolish(card)
|
||||
card.style().polish(card)
|
||||
return card
|
||||
|
||||
if input_editable:
|
||||
target_row = QHBoxLayout()
|
||||
target_row.setContentsMargins(0, 0, 0, 0)
|
||||
target_label = QLabel("目标值")
|
||||
target_label.setObjectName("propertyCardMetaLabel")
|
||||
target_row.addWidget(target_label, 0)
|
||||
editor = QLineEdit(self._property_target_text(row) or str(effective_spec.get("target_text", "")))
|
||||
editor.setObjectName("propertyCardTargetEditor")
|
||||
editor.setToolTip(self._property_target_tooltip(effective_spec, editable=True))
|
||||
editor.setPlaceholderText("输入目标值")
|
||||
editor.setCursor(Qt.CursorShape.IBeamCursor)
|
||||
editor.textChanged.connect(lambda _text="", _row=row: self._on_property_card_target_changed(_row))
|
||||
editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row))
|
||||
target_row.addWidget(editor, 1)
|
||||
card_layout.addLayout(target_row)
|
||||
row_widgets["target_editor"] = editor
|
||||
|
||||
modes = spec.get("scope_modes")
|
||||
if isinstance(modes, dict) and modes:
|
||||
scope_row = QHBoxLayout()
|
||||
scope_row.setContentsMargins(0, 0, 0, 0)
|
||||
scope_label = QLabel("建模意图")
|
||||
scope_label.setObjectName("propertyCardMetaLabel")
|
||||
scope_row.addWidget(scope_label, 0)
|
||||
combo = self._make_property_scope_combo(row, spec)
|
||||
scope_row.addWidget(combo, 1)
|
||||
card_layout.addLayout(scope_row)
|
||||
row_widgets["scope_combo"] = combo
|
||||
|
||||
hint_text = self._property_card_hint_text(effective_spec, editable=editable)
|
||||
hint = QLabel(hint_text, card)
|
||||
hint.setObjectName("propertyCardHint")
|
||||
hint.setWordWrap(True)
|
||||
hint.setToolTip(str(effective_spec.get("enabled_tip") or effective_spec.get("disabled_tip") or hint_text))
|
||||
card_layout.addWidget(hint)
|
||||
hint.setVisible(bool(hint_text))
|
||||
row_widgets["hint_label"] = hint
|
||||
|
||||
if editable and effective_spec.get("action"):
|
||||
button = QPushButton("未改动")
|
||||
button.setObjectName("propertyRowEditButton")
|
||||
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
button.setProperty("changed", False)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row))
|
||||
card_layout.addWidget(button)
|
||||
status_label.setVisible(False)
|
||||
row_widgets["action_button"] = button
|
||||
|
||||
self.property_card_rows[row] = row_widgets
|
||||
card.style().unpolish(card)
|
||||
card.style().polish(card)
|
||||
return card
|
||||
|
||||
def _property_card_hint_text(self, spec: dict[str, object], *, editable: bool) -> str:
|
||||
parts: list[str] = []
|
||||
if editable:
|
||||
scope_label = str(spec.get("scope_label") or "").strip()
|
||||
if scope_label:
|
||||
parts.append(f"建模意图:{scope_label}")
|
||||
tip = str(spec.get("enabled_tip") or "").strip()
|
||||
if tip:
|
||||
parts.append(tip)
|
||||
range_hint = self._property_range_hint(spec)
|
||||
if range_hint:
|
||||
parts.append(range_hint)
|
||||
else:
|
||||
tip = str(spec.get("disabled_tip") or spec.get("enabled_tip") or "").strip()
|
||||
status = str(spec.get("status_text") or "").strip()
|
||||
if tip:
|
||||
parts.append(tip)
|
||||
elif status:
|
||||
parts.append(status)
|
||||
return _compact_property_card_text(" ".join(part for part in parts if part), 150)
|
||||
|
||||
def _make_property_scope_combo(self, row: int, spec: dict[str, object]) -> NoWheelComboBox:
|
||||
combo = NoWheelComboBox()
|
||||
combo.setObjectName("propertyScopeCombo")
|
||||
modes = spec.get("scope_modes")
|
||||
default_scope = self._property_scope_value(row, spec) or self._property_scope_default(spec)
|
||||
selected_index = 0
|
||||
if isinstance(modes, dict):
|
||||
for index, (scope_key, mode) in enumerate(modes.items()):
|
||||
if not isinstance(mode, dict):
|
||||
continue
|
||||
label = str(mode.get("label") or scope_key)
|
||||
if not bool(mode.get("enabled", True)):
|
||||
label = f"{label}(不可用)"
|
||||
combo.addItem(label, scope_key)
|
||||
tip = str(mode.get("enabled_tip") or mode.get("disabled_tip") or "").strip()
|
||||
if tip:
|
||||
combo.setItemData(index, tip, Qt.ItemDataRole.ToolTipRole)
|
||||
if scope_key == default_scope:
|
||||
selected_index = index
|
||||
combo.setCurrentIndex(selected_index)
|
||||
combo.setToolTip(self._property_scope_tooltip(spec, default_scope))
|
||||
combo.currentIndexChanged.connect(lambda _index=0, target_row=row: self._on_property_scope_changed(target_row))
|
||||
return combo
|
||||
|
||||
def _refresh_property_editor(self) -> None:
|
||||
if not hasattr(self, "property_table"):
|
||||
return
|
||||
@@ -1352,9 +1908,12 @@ class WindowStateMixin:
|
||||
specs = self._sort_property_specs_for_display(self._property_editor_specs(info, action_info))
|
||||
self.property_editor_specs = specs
|
||||
self.property_table_expanded = False
|
||||
self.property_editor_selected_row = None
|
||||
self.property_command_active_key = ""
|
||||
self.property_editor_updating = True
|
||||
was_blocked = self.property_table.blockSignals(True)
|
||||
try:
|
||||
self.property_table.clearSpans()
|
||||
self.property_table.setRowCount(len(specs))
|
||||
for row, spec in enumerate(specs):
|
||||
effective_spec = self._effective_property_spec(spec)
|
||||
@@ -1363,12 +1922,16 @@ class WindowStateMixin:
|
||||
input_editable = editable and value_type != "command"
|
||||
label_item = self._property_table_item(str(effective_spec.get("label", "")), editable=False)
|
||||
current_item = self._property_table_item(str(effective_spec.get("current_text", "")), editable=False)
|
||||
scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False)
|
||||
target_item = self._property_table_item(
|
||||
"" if input_editable else str(effective_spec.get("target_text", "")),
|
||||
span_value_columns = bool(effective_spec.get("span_value_columns")) and not editable
|
||||
scope_item = self._property_table_item(
|
||||
"" if span_value_columns else str(effective_spec.get("scope_text", "")),
|
||||
editable=False,
|
||||
)
|
||||
action_text = "" if editable else str(effective_spec.get("status_text", ""))
|
||||
target_item = self._property_table_item(
|
||||
"" if input_editable or span_value_columns else str(effective_spec.get("target_text", "")),
|
||||
editable=False,
|
||||
)
|
||||
action_text = "" if editable or span_value_columns else str(effective_spec.get("status_text", ""))
|
||||
action_item = self._property_table_item(action_text, editable=False)
|
||||
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
|
||||
row_items = (label_item, current_item, scope_item, target_item, action_item)
|
||||
@@ -1376,6 +1939,9 @@ class WindowStateMixin:
|
||||
for column, item in enumerate(row_items):
|
||||
item.setToolTip(item.toolTip() or item.text())
|
||||
self.property_table.setItem(row, column, item)
|
||||
if span_value_columns:
|
||||
current_item.setToolTip(str(effective_spec.get("disabled_tip") or current_item.text()))
|
||||
self.property_table.setSpan(row, PROPERTY_CURRENT_COLUMN, 1, 4)
|
||||
self.property_table.setRowHeight(row, 28 if editable else 24)
|
||||
if spec.get("scope_modes"):
|
||||
self._set_property_scope_editor(row, spec)
|
||||
@@ -1390,10 +1956,13 @@ class WindowStateMixin:
|
||||
else:
|
||||
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
|
||||
self.property_table.removeCellWidget(row, PROPERTY_ACTION_COLUMN)
|
||||
self._rebuild_property_command_bar()
|
||||
self._rebuild_property_cards()
|
||||
self._resize_property_table_height()
|
||||
finally:
|
||||
self.property_table.blockSignals(was_blocked)
|
||||
self.property_editor_updating = False
|
||||
self._resize_property_table_columns()
|
||||
self._update_property_apply_state()
|
||||
|
||||
def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
@@ -1479,6 +2048,11 @@ class WindowStateMixin:
|
||||
modes = spec.get("scope_modes")
|
||||
if not isinstance(modes, dict) or not modes:
|
||||
return ""
|
||||
card_widget = self._property_card_widgets(row).get("scope_combo")
|
||||
if isinstance(card_widget, NoWheelComboBox):
|
||||
value = card_widget.currentData()
|
||||
if value in modes:
|
||||
return str(value)
|
||||
widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN) if hasattr(self, "property_table") else None
|
||||
if isinstance(widget, NoWheelComboBox):
|
||||
value = widget.currentData()
|
||||
@@ -1578,8 +2152,30 @@ class WindowStateMixin:
|
||||
effective_spec = self._effective_property_spec(specs[row], row=row)
|
||||
editable = bool(effective_spec.get("editable") and effective_spec.get("enabled"))
|
||||
input_editable = editable and str(effective_spec.get("value_type", "number")) != "command"
|
||||
card_widgets = self._property_card_widgets(row)
|
||||
card_scope_widget = card_widgets.get("scope_combo")
|
||||
if isinstance(card_scope_widget, NoWheelComboBox):
|
||||
selected_scope = card_scope_widget.currentData()
|
||||
card_scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row])))
|
||||
card_target_widget = card_widgets.get("target_editor")
|
||||
if isinstance(card_target_widget, QLineEdit):
|
||||
card_target_widget.setEnabled(input_editable)
|
||||
card_target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
|
||||
hint_label = card_widgets.get("hint_label")
|
||||
if isinstance(hint_label, QLabel):
|
||||
hint_text = self._property_card_hint_text(effective_spec, editable=editable)
|
||||
hint_label.setText(hint_text)
|
||||
hint_label.setVisible(bool(hint_text))
|
||||
scope_widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN)
|
||||
if isinstance(scope_widget, NoWheelComboBox):
|
||||
if "selected_scope" in locals():
|
||||
target_index = scope_widget.findData(selected_scope)
|
||||
if target_index >= 0 and target_index != scope_widget.currentIndex():
|
||||
was_blocked = scope_widget.blockSignals(True)
|
||||
try:
|
||||
scope_widget.setCurrentIndex(target_index)
|
||||
finally:
|
||||
scope_widget.blockSignals(was_blocked)
|
||||
scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row])))
|
||||
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
|
||||
if isinstance(target_widget, QLineEdit):
|
||||
@@ -1593,6 +2189,19 @@ class WindowStateMixin:
|
||||
action_item.setText(str(effective_spec.get("status_text", "")))
|
||||
self._update_property_apply_state()
|
||||
|
||||
def _on_property_card_target_changed(self, row: int) -> None:
|
||||
card_widget = self._property_card_widgets(row).get("target_editor")
|
||||
table_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if hasattr(self, "property_table") else None
|
||||
if isinstance(card_widget, QLineEdit) and isinstance(table_widget, QLineEdit):
|
||||
text = card_widget.text()
|
||||
if table_widget.text() != text:
|
||||
was_blocked = table_widget.blockSignals(True)
|
||||
try:
|
||||
table_widget.setText(text)
|
||||
finally:
|
||||
table_widget.blockSignals(was_blocked)
|
||||
self._update_property_apply_state()
|
||||
|
||||
def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str:
|
||||
tip_key = "enabled_tip" if editable else "disabled_tip"
|
||||
parts = [str(spec.get(tip_key, "")).strip()]
|
||||
@@ -1625,7 +2234,7 @@ class WindowStateMixin:
|
||||
def _resize_property_table_height(self) -> None:
|
||||
if not hasattr(self, "property_table"):
|
||||
return
|
||||
row_count = self.property_table.rowCount()
|
||||
row_count = len(getattr(self, "property_editor_specs", []) or [])
|
||||
collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6)
|
||||
expanded = bool(getattr(self, "property_table_expanded", False))
|
||||
visible_rows = row_count if expanded else min(row_count, collapsed_rows)
|
||||
@@ -1642,19 +2251,54 @@ class WindowStateMixin:
|
||||
if expanded or not has_hidden_rows
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
if hasattr(self, "property_card_scroll"):
|
||||
card_visible_rows = self._visible_property_card_rows()
|
||||
card_heights: list[int] = []
|
||||
for row, _spec in card_visible_rows:
|
||||
card = self._property_card_widgets(row).get("card")
|
||||
if isinstance(card, QFrame):
|
||||
card_heights.append(max(int(card.sizeHint().height()), 40))
|
||||
if card_heights:
|
||||
content_height = sum(card_heights) + max(0, len(card_heights) - 1) * 4 + 10
|
||||
else:
|
||||
content_height = 96
|
||||
card_height = content_height
|
||||
card_height = min(card_height, 560 if expanded else 360)
|
||||
card_height = max(96, card_height)
|
||||
if hasattr(self, "property_card_container"):
|
||||
self.property_card_container.setMinimumHeight(content_height)
|
||||
self.property_card_scroll.setMinimumHeight(card_height)
|
||||
self.property_card_scroll.setMaximumHeight(card_height)
|
||||
self.property_card_scroll.setVerticalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAsNeeded if content_height > card_height else Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
if hasattr(self, "property_expand_button"):
|
||||
self.property_expand_button.setVisible(has_hidden_rows)
|
||||
if expanded:
|
||||
self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项")
|
||||
self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几项。")
|
||||
self.property_expand_button.setText(f"收起诊断信息,保留前 {collapsed_rows} 项")
|
||||
self.property_expand_button.setToolTip("收起只读诊断信息,保留可编辑参数和关键说明。")
|
||||
else:
|
||||
self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)")
|
||||
self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。")
|
||||
self.property_expand_button.setText(f"更多诊断信息 ({row_count} 项)")
|
||||
self.property_expand_button.setToolTip("展开完整参数、识别依据和一级关系诊断;参数化建模按钮会继续留在下方。")
|
||||
self._resize_property_table_columns()
|
||||
|
||||
def _resize_property_table_columns(self) -> None:
|
||||
if not hasattr(self, "property_table"):
|
||||
return
|
||||
table = self.property_table
|
||||
viewport_width = int(table.viewport().width()) if table.viewport() is not None else int(table.width())
|
||||
if viewport_width <= 0:
|
||||
return
|
||||
widths = _property_table_column_widths(viewport_width)
|
||||
for column, width in enumerate(widths):
|
||||
table.setColumnWidth(column, width)
|
||||
|
||||
def toggle_property_table_expanded(self) -> None:
|
||||
if not hasattr(self, "property_table"):
|
||||
return
|
||||
self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False))
|
||||
self._rebuild_property_command_bar()
|
||||
self._rebuild_property_cards()
|
||||
self._resize_property_table_height()
|
||||
|
||||
def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem:
|
||||
@@ -1764,9 +2408,34 @@ class WindowStateMixin:
|
||||
action_info: dict[str, object],
|
||||
) -> list[dict[str, object]]:
|
||||
root_rows = self._feature_property_specs(root_specs, action_info)
|
||||
context_rows: list[dict[str, object]] = []
|
||||
context_note = str(action_info.get("feature_context_note") or "").strip()
|
||||
if context_note:
|
||||
level_text = str(action_info.get("feature_detection_level") or "").strip()
|
||||
associated_count = _int_or_none(action_info.get("associated_feature_count"))
|
||||
count_text = "" if associated_count is None else f";关联特征 {associated_count} 项"
|
||||
context_rows.append(
|
||||
{
|
||||
"key": "feature_context_note",
|
||||
"label": "关联探测",
|
||||
"current_text": context_note,
|
||||
"current_raw": context_note,
|
||||
"target_text": "",
|
||||
"editable": False,
|
||||
"enabled": False,
|
||||
"status_text": "说明",
|
||||
"scope_text": f"{level_text}{count_text}".strip(";"),
|
||||
"disabled_tip": (
|
||||
"这里说明当前特征探测级别和已经找到的一级/二级关联特征;"
|
||||
"有关联尺寸时,会作为带“关联 Face”的可修改行显示在同一张参数表里。"
|
||||
),
|
||||
"pin_top": True,
|
||||
"span_value_columns": True,
|
||||
}
|
||||
)
|
||||
associated = action_info.get("associated_feature_infos")
|
||||
if not isinstance(associated, (list, tuple)) or not associated:
|
||||
return root_rows
|
||||
return context_rows + root_rows
|
||||
|
||||
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
|
||||
root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"]
|
||||
@@ -1789,7 +2458,7 @@ class WindowStateMixin:
|
||||
related["association_index"] = index
|
||||
related_rows.append(related)
|
||||
|
||||
return root_dimensions + related_rows + root_explanations
|
||||
return context_rows + root_dimensions + related_rows + root_explanations
|
||||
|
||||
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
|
||||
items = self._ordered_info_items(info)
|
||||
@@ -2266,6 +2935,7 @@ class WindowStateMixin:
|
||||
"status_text": "说明",
|
||||
"disabled_tip": tip or text,
|
||||
"pin_top": pin_top,
|
||||
"span_value_columns": True,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2624,6 +3294,36 @@ class WindowStateMixin:
|
||||
)
|
||||
|
||||
if has_edge:
|
||||
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
|
||||
if topology_depth == 1:
|
||||
selected_edge_count = _int_or_none(action_info.get("selected_edge_count")) or 1
|
||||
vertex_count = _int_or_none(action_info.get("first_level_vertex_count")) or 0
|
||||
adjacent_edge_count = _int_or_none(action_info.get("first_level_adjacent_edge_count")) or 0
|
||||
adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0
|
||||
included_edge_count = _int_or_none(action_info.get("first_level_edge_count")) or 0
|
||||
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
|
||||
fact_summary = str(action_info.get("first_level_fact_summary") or "").strip()
|
||||
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
|
||||
topology_tip = "\n".join(
|
||||
item
|
||||
for item in (
|
||||
topology_note,
|
||||
fact_summary,
|
||||
ignored_note,
|
||||
"当前阶段只把被选 Edge、端点 Vertex、共享端点相邻 Edge 和直接包含该 Edge 的 Face 作为一级关系;不会自动递归传播到二级、三级关系。",
|
||||
)
|
||||
if item
|
||||
)
|
||||
add_readonly_spec(
|
||||
key="edge_first_level_topology",
|
||||
label="一级关系",
|
||||
text=(
|
||||
f"当前 Edge {selected_edge_count} 条;端点 Vertex {vertex_count} 个;"
|
||||
f"共享端点相邻 Edge {adjacent_edge_count} 条;直接相邻 Face {adjacent_face_count} 个;"
|
||||
f"一级范围 Edge {included_edge_count} 条。"
|
||||
),
|
||||
tip=topology_tip,
|
||||
)
|
||||
add_readonly_spec(
|
||||
key="edge_edit_semantics",
|
||||
label="建模意图",
|
||||
@@ -4702,18 +5402,46 @@ class WindowStateMixin:
|
||||
)
|
||||
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
|
||||
effective_spec = self._effective_property_spec(spec, row=row)
|
||||
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
|
||||
card_widgets = self._property_card_widgets(row)
|
||||
target_widget = card_widgets.get("target_editor")
|
||||
if not isinstance(target_widget, QLineEdit):
|
||||
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
|
||||
if isinstance(target_widget, QLineEdit):
|
||||
editable = bool(effective_spec.get("editable") and effective_spec.get("enabled"))
|
||||
input_editable = editable and str(effective_spec.get("value_type", "number")) != "command"
|
||||
target_widget.setEnabled(input_editable)
|
||||
target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
|
||||
widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN)
|
||||
hint_label = card_widgets.get("hint_label")
|
||||
if isinstance(hint_label, QLabel):
|
||||
hint_text = self._property_card_hint_text(
|
||||
effective_spec,
|
||||
editable=bool(effective_spec.get("editable") and effective_spec.get("enabled")),
|
||||
)
|
||||
hint_label.setText(hint_text)
|
||||
hint_label.setVisible(bool(hint_text))
|
||||
card = card_widgets.get("card")
|
||||
card_changed = False
|
||||
card_invalid = False
|
||||
if str(effective_spec.get("value_type", "number")) != "command":
|
||||
text = self._property_target_text(row)
|
||||
card_changed = self._property_target_changed(effective_spec, text)
|
||||
card_invalid = bool(text.strip() and self._property_target_validation_error(effective_spec, text))
|
||||
if isinstance(card, QFrame):
|
||||
card_state = (bool(card_changed and not card_invalid), bool(card_invalid))
|
||||
if getattr(card, "_geom_param_card_state", None) != card_state:
|
||||
card.setProperty("changed", card_state[0])
|
||||
card.setProperty("invalid", card_state[1])
|
||||
card.style().unpolish(card)
|
||||
card.style().polish(card)
|
||||
setattr(card, "_geom_param_card_state", card_state)
|
||||
widget = card_widgets.get("action_button")
|
||||
if not isinstance(widget, QPushButton):
|
||||
widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN)
|
||||
if isinstance(widget, QPushButton):
|
||||
validation_error = ""
|
||||
if str(effective_spec.get("value_type", "number")) == "command":
|
||||
row_changed = True
|
||||
widget.setText(str(effective_spec.get("button_text") or "执行"))
|
||||
widget.setText(str(effective_spec.get("button_text") or "执行当前命令"))
|
||||
enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action"))
|
||||
tooltip = f"执行“{effective_spec.get('label', '当前操作')}”。"
|
||||
else:
|
||||
@@ -4722,11 +5450,11 @@ class WindowStateMixin:
|
||||
empty_target = not bool(text.strip())
|
||||
validation_error = "" if empty_target else self._property_target_validation_error(effective_spec, text)
|
||||
if empty_target:
|
||||
widget.setText("未输入")
|
||||
widget.setText("输入目标值后应用")
|
||||
elif validation_error:
|
||||
widget.setText("无效")
|
||||
widget.setText("目标无效")
|
||||
else:
|
||||
widget.setText("应用" if row_changed else "未改动")
|
||||
widget.setText("应用当前命令" if row_changed else "修改目标值后应用")
|
||||
enabled = bool(
|
||||
has_model
|
||||
and effective_spec.get("enabled")
|
||||
@@ -4753,6 +5481,10 @@ class WindowStateMixin:
|
||||
tooltip = f"{tooltip}\n\n{range_hint}"
|
||||
widget.setProperty("changed", bool(row_changed and not validation_error))
|
||||
widget.setProperty("invalid", bool(validation_error))
|
||||
card = card_widgets.get("card")
|
||||
if isinstance(card, QFrame):
|
||||
card.setProperty("changed", bool(row_changed and not validation_error))
|
||||
card.setProperty("invalid", bool(validation_error))
|
||||
button_state = (
|
||||
widget.text(),
|
||||
bool(enabled),
|
||||
@@ -4766,6 +5498,9 @@ class WindowStateMixin:
|
||||
widget.style().unpolish(widget)
|
||||
widget.style().polish(widget)
|
||||
widget.setEnabled(enabled)
|
||||
if isinstance(card, QFrame):
|
||||
card.style().unpolish(card)
|
||||
card.style().polish(card)
|
||||
setattr(widget, "_geom_param_button_state", button_state)
|
||||
if not hasattr(self, "apply_property_button"):
|
||||
return
|
||||
@@ -4807,6 +5542,9 @@ class WindowStateMixin:
|
||||
def _property_target_text(self, row: int) -> str:
|
||||
if not hasattr(self, "property_table"):
|
||||
return ""
|
||||
card_widget = self._property_card_widgets(row).get("target_editor")
|
||||
if isinstance(card_widget, QLineEdit):
|
||||
return card_widget.text().strip()
|
||||
widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
|
||||
if isinstance(widget, QLineEdit):
|
||||
return widget.text().strip()
|
||||
|
||||
Reference in New Issue
Block a user