feat: 完善一级关系编辑 UI 与视图体验

This commit is contained in:
2026-08-06 18:15:14 +08:00
parent 57d75541c3
commit eef9efcc1e
29 changed files with 3907 additions and 178 deletions
+187 -19
View File
@@ -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("几何参数化")
+13
View File
@@ -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,
+2
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+5
View File
@@ -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": "局部边形变重建面数",
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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()