Files
pythonocc-step-editor/main.py
T

5033 lines
219 KiB
Python
Raw Normal View History

2026-07-23 18:35:02 +08:00
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
import math
import sys
from pathlib import Path
import vtk
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QComboBox,
QFileDialog,
QGridLayout,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidget,
QMainWindow,
QMessageBox,
QPushButton,
QPlainTextEdit,
QProgressDialog,
QScrollArea,
QTableWidget,
QTableWidgetItem,
QTabWidget,
QTreeWidget,
QTreeWidgetItem,
QVBoxLayout,
QWidget,
)
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
from step_model import StepModel
@dataclass
class OperationRecord:
summary: str
detail: str
target_kind: str | None = None
target_id: int | None = None
pick_position: tuple[float, float, float] | None = None
before_snapshot: dict[int, object] | None = None
after_snapshot: dict[int, object] | None = None
diff_stats: dict[str, object] | None = None
class EditWorker(QObject):
finished = Signal(object)
failed = Signal(str)
def __init__(self, action):
super().__init__()
self.action = action
@Slot()
def run(self) -> None:
try:
self.finished.emit(self.action())
except Exception as exc:
self.failed.emit(str(exc))
2026-07-24 17:47:17 +08:00
class NoWheelComboBox(QComboBox):
def wheelEvent(self, event) -> None:
event.accept()
2026-07-23 18:35:02 +08:00
INFO_GROUPS: list[tuple[str, list[str]]] = [
(
"身份",
[
"kind",
"name",
"path",
"file",
"part_id",
"solid_id",
"face_id",
"edge_id",
"parent_id",
"depth",
"feature_mode",
2026-07-24 17:47:17 +08:00
"feature_type",
"feature_source_face_id",
2026-07-23 18:35:02 +08:00
],
),
(
"拓扑",
[
"parts",
"solids",
"faces",
"edges",
"vertices",
"boundary_edges",
"orientation",
"surface",
"curve",
2026-07-24 17:47:17 +08:00
"adjacent_face_ids",
"adjacent_face_count",
2026-07-23 18:35:02 +08:00
],
),
(
"测量",
[
"volume",
"surface_area",
"area",
"length",
2026-07-24 17:47:17 +08:00
"edge_length",
"current_length",
"target_length",
"delta_length",
"length_change_ratio",
2026-07-23 18:35:02 +08:00
"radius",
"diameter",
2026-07-24 17:47:17 +08:00
"target_radius",
"radius_to_length_ratio",
"target_distance",
"distance_to_length_ratio",
"translation_distance",
"rotation_angle_degrees",
"current_diameter",
"target_diameter",
"delta_diameter",
"diameter_delta_ratio",
"target_to_height_ratio",
2026-07-23 18:35:02 +08:00
"major_radius",
"minor_radius",
"reference_radius",
"semi_angle",
"angular_span",
"height_estimate",
"hole_depth_estimate",
2026-07-24 17:47:17 +08:00
"slot_chord_width_estimate",
"slot_arc_length_estimate",
"slot_sagitta_depth_estimate",
"existing_fillet_radius_estimate",
"existing_fillet_angular_span",
"existing_fillet_arc_length_estimate",
"current_depth",
"target_depth",
"delta_depth",
"depth_delta_ratio",
2026-07-23 18:35:02 +08:00
"is_full_cylinder",
"bbox_diagonal",
],
),
(
"位置",
[
"pick_position",
"center",
"center_of_mass",
"surface_center",
"area_center",
"length_center",
"bbox_min",
"bbox_max",
"bbox_size",
"start_point",
"end_point",
2026-07-24 17:47:17 +08:00
"translation_vector",
"rotation_center",
"end_face_id",
"end_face_label",
"end_face_plane_distance",
"push_pull_distance",
2026-07-23 18:35:02 +08:00
],
),
(
"方向 / 轴线",
[
"normal",
"oriented_normal",
"plane_origin",
"axis_point",
"axis",
"direction",
"line_origin",
2026-07-24 17:47:17 +08:00
"rotation_axis",
"end_face_outward_direction",
"desired_movement_vector",
2026-07-23 18:35:02 +08:00
"push_pull_outward_direction",
"push_pull_inward_direction",
"push_pull_plus_side",
"push_pull_minus_side",
"push_pull_confidence",
"push_pull_note",
2026-07-24 17:47:17 +08:00
"push_pull_status",
"push_pull_risk",
"push_pull_message",
"push_pull_scope_face_ids",
"push_pull_scope_face_count",
"push_pull_scope_note",
2026-07-23 18:35:02 +08:00
],
),
(
"参数",
[
"u_range",
"v_range",
"first_parameter",
"last_parameter",
"param_height",
],
),
(
"特征判断",
[
"feature_guess",
"confidence",
"material_vote_summary",
"material_sample_count",
"material_toward_axis",
"material_away_axis",
"cylinder_end_type",
"start_end_state",
"end_end_state",
"start_end_open",
"end_end_open",
"open_end_count",
"closed_end_count",
"end_sample_offset",
"end_sample_note",
"note",
2026-07-24 17:47:17 +08:00
"feature_face_ids",
"feature_side_face_ids",
"feature_end_face_ids",
"feature_bottom_face_ids",
"feature_opening_face_ids",
"feature_start_end_face_ids",
"feature_end_end_face_ids",
"feature_highlight_face_ids",
"feature_adjacent_face_ids",
"feature_boundary_edge_ids",
"feature_bottom_confidence",
"feature_bottom_detection",
"feature_bottom_note",
"feature_slot_face_ids",
"feature_slot_boundary_face_ids",
"slot_kind",
"slot_status",
"slot_angular_span",
"slot_open_angle",
"slot_note",
"feature_existing_fillet_face_ids",
"feature_existing_fillet_support_face_ids",
"existing_fillet_kind",
"existing_fillet_status",
"existing_fillet_note",
"feature_edit_actions",
2026-07-23 18:35:02 +08:00
"resize_status",
"resize_mode",
"resize_risk",
"resize_warnings",
"resize_blockers",
"resize_note",
2026-07-24 17:47:17 +08:00
"boss_resize_status",
"boss_resize_risk",
"boss_resize_warnings",
"boss_resize_blockers",
"boss_resize_note",
"suppress_status",
"suppress_risk",
"suppress_warnings",
"suppress_blockers",
"suppress_note",
"depth_status",
"depth_mode",
"depth_risk",
"depth_warnings",
"depth_blockers",
"depth_note",
2026-07-23 18:35:02 +08:00
"cutter_strategy",
"cutter_height",
"cutter_margin",
"cutter_start_margin",
"cutter_end_margin",
"cutter_radius",
"cutter_start_parameter",
"cutter_end_parameter",
"cutter_axis_direction",
"cutter_start_point",
2026-07-24 17:47:17 +08:00
"cutter_bottom_protection",
"cutter_protected_bottom_face_ids",
"cutter_opening_face_ids",
"cutter_bottom_note",
2026-07-23 18:35:02 +08:00
"cutter_note",
"fill_strategy",
"fill_height",
"fill_radius",
"fill_radius_overlap",
"fill_start_point",
"fill_note",
2026-07-24 17:47:17 +08:00
"boss_tool_strategy",
"boss_tool_note",
"boss_tool_height",
"boss_tool_radius",
"boss_tool_old_radius",
"boss_tool_outer_radius",
"boss_tool_inner_radius",
"boss_tool_axial_margin",
"boss_tool_radial_overlap",
"boss_tool_start_parameter",
"boss_tool_end_parameter",
"boss_tool_axis_point",
"boss_tool_axis_direction",
"boss_tool_start_point",
"depth_tool_strategy",
"depth_tool_role",
"depth_tool_note",
"depth_tool_height",
"depth_tool_radius",
"depth_tool_radius_overlap",
"depth_tool_start_parameter",
"depth_tool_end_parameter",
"depth_open_parameter",
"depth_bottom_parameter",
"depth_nominal_bottom_parameter",
"depth_bottom_parameter_source",
"depth_current_depth",
"depth_current_depth_source",
"depth_target_bottom_parameter",
"depth_axis_direction",
"depth_open_point",
"depth_current_bottom_point",
"depth_target_bottom_point",
"depth_tool_start_point",
"fillet_status",
"fillet_risk",
"fillet_warnings",
"fillet_blockers",
"fillet_note",
"chamfer_status",
"chamfer_risk",
"chamfer_warnings",
"chamfer_blockers",
"chamfer_note",
"translate_status",
"translate_risk",
"translate_warnings",
"translate_blockers",
"translate_note",
"part_solid_count",
"rotate_status",
"rotate_risk",
"rotate_warnings",
"rotate_blockers",
"rotate_note",
2026-07-23 18:35:02 +08:00
],
),
]
INFO_LABELS = {
"kind": "类型",
"name": "名称",
"path": "层级路径",
"file": "文件",
"part_id": "Part ID",
"solid_id": "Solid ID",
"face_id": "Face ID",
"edge_id": "Edge ID",
"parent_id": "父级 ID",
"depth": "层级深度",
"feature_mode": "特征模式说明",
2026-07-24 17:47:17 +08:00
"feature_type": "特征类型",
"feature_source_face_id": "特征来源 Face",
2026-07-23 18:35:02 +08:00
"parts": "零件数",
"solids": "Solid 数",
"faces": "Face 数",
"edges": "Edge 数",
"vertices": "Vertex 数",
"boundary_edges": "边界边数",
"orientation": "拓扑方向",
"surface": "曲面类型",
"curve": "曲线类型",
2026-07-24 17:47:17 +08:00
"adjacent_face_ids": "相邻 Face",
"adjacent_face_count": "相邻 Face 数",
2026-07-23 18:35:02 +08:00
"volume": "体积",
"surface_area": "表面积",
"area": "面积",
"length": "长度",
2026-07-24 17:47:17 +08:00
"edge_length": "边长",
"current_length": "当前边长",
"target_length": "目标边长",
"delta_length": "边长变化量",
"length_change_ratio": "边长变化比例",
2026-07-23 18:35:02 +08:00
"radius": "半径",
"diameter": "直径",
2026-07-24 17:47:17 +08:00
"target_radius": "目标圆角半径",
"radius_to_length_ratio": "半径/边长比例",
"target_distance": "目标倒角距离",
"distance_to_length_ratio": "倒角距离/边长比例",
"translation_distance": "平移距离",
"rotation_angle_degrees": "旋转角度",
"current_diameter": "当前直径",
"target_diameter": "目标直径",
"delta_diameter": "直径变化量",
"diameter_delta_ratio": "直径变化比例",
"target_to_height_ratio": "目标直径/估算高度",
2026-07-23 18:35:02 +08:00
"major_radius": "主半径",
"minor_radius": "小半径",
"reference_radius": "参考半径",
"semi_angle": "半角",
"angular_span": "角度跨度",
"height_estimate": "估算高度",
"hole_depth_estimate": "孔/槽深度估算",
2026-07-24 17:47:17 +08:00
"current_depth": "当前深度",
"target_depth": "目标深度",
"delta_depth": "深度变化量",
"depth_delta_ratio": "深度变化比例",
2026-07-23 18:35:02 +08:00
"is_full_cylinder": "接近完整圆柱",
"bbox_diagonal": "包围盒对角线",
"pick_position": "拾取点",
"center": "中心",
"center_of_mass": "重心",
"surface_center": "表面积中心",
"area_center": "面积中心",
"length_center": "长度中心",
"bbox_min": "包围盒最小点",
"bbox_max": "包围盒最大点",
"bbox_size": "包围盒尺寸",
"start_point": "起点",
"end_point": "终点",
2026-07-24 17:47:17 +08:00
"translation_vector": "平移向量",
"rotation_center": "旋转中心",
"end_face_id": "端面 Face",
"end_face_label": "端面位置",
"end_face_plane_distance": "端面匹配距离",
"push_pull_distance": "端面推拉距离",
2026-07-23 18:35:02 +08:00
"normal": "几何法向",
"oriented_normal": "拓扑修正法向",
"plane_origin": "平面原点",
"axis_point": "轴线点",
"axis": "轴方向",
"direction": "方向",
"line_origin": "直线原点",
2026-07-24 17:47:17 +08:00
"rotation_axis": "旋转轴",
"end_face_outward_direction": "端面向外方向",
"desired_movement_vector": "目标移动向量",
2026-07-23 18:35:02 +08:00
"push_pull_outward_direction": "推拉向外方向",
"push_pull_inward_direction": "推拉向内方向",
"push_pull_plus_side": "原始法向侧",
"push_pull_minus_side": "反向法向侧",
"push_pull_confidence": "推拉方向置信度",
"push_pull_note": "推拉方向说明",
2026-07-24 17:47:17 +08:00
"push_pull_status": "推拉状态",
"push_pull_risk": "推拉风险",
"push_pull_message": "推拉说明",
"push_pull_scope_face_ids": "推拉共面区域 Face",
"push_pull_scope_face_count": "推拉共面区域 Face 数",
"push_pull_scope_note": "推拉共面区域说明",
2026-07-23 18:35:02 +08:00
"u_range": "U 参数范围",
"v_range": "V 参数范围",
"first_parameter": "起始参数",
"last_parameter": "结束参数",
"param_height": "参数高度",
"feature_guess": "候选判断",
"confidence": "置信度",
"material_vote_summary": "材料投票",
"material_sample_count": "采样数量",
"material_toward_axis": "轴侧材料",
"material_away_axis": "外侧材料",
"cylinder_end_type": "端部类型",
"start_end_state": "起点端状态",
"end_end_state": "终点端状态",
"start_end_open": "起点端开口",
"end_end_open": "终点端开口",
"open_end_count": "开口端数量",
"closed_end_count": "封闭端数量",
"end_sample_offset": "端部采样偏移",
"end_sample_note": "端部采样说明",
"note": "备注",
2026-07-24 17:47:17 +08:00
"feature_face_ids": "特征 Face",
"feature_side_face_ids": "特征侧壁 Face",
"feature_end_face_ids": "特征端面 Face",
"feature_bottom_face_ids": "疑似底面 Face",
"feature_opening_face_ids": "开口端相邻 Face",
"feature_start_end_face_ids": "起点端 Face",
"feature_end_end_face_ids": "终点端 Face",
"feature_highlight_face_ids": "特征高亮 Face",
"feature_adjacent_face_ids": "相邻 Face",
"feature_boundary_edge_ids": "特征边界 Edge",
"feature_bottom_confidence": "底面判断置信度",
"feature_bottom_detection": "底面识别来源",
"feature_bottom_note": "底面判断说明",
"feature_slot_face_ids": "槽圆柱 Face",
"feature_slot_boundary_face_ids": "槽边界相邻 Face",
"slot_kind": "槽类型",
"slot_status": "槽识别状态",
"slot_angular_span": "槽圆弧角度",
"slot_open_angle": "槽开口角度",
"slot_chord_width_estimate": "槽宽估算",
"slot_arc_length_estimate": "槽圆弧长度估算",
"slot_sagitta_depth_estimate": "槽深估算",
"slot_note": "槽识别说明",
"feature_existing_fillet_face_ids": "已有圆角 Face",
"feature_existing_fillet_support_face_ids": "已有圆角支撑 Face",
"existing_fillet_kind": "已有圆角类型",
"existing_fillet_status": "已有圆角识别状态",
"existing_fillet_radius_estimate": "已有圆角半径估算",
"existing_fillet_angular_span": "已有圆角圆弧角度",
"existing_fillet_arc_length_estimate": "已有圆角圆弧长度估算",
"existing_fillet_note": "已有圆角识别说明",
"feature_edit_actions": "当前可用操作",
2026-07-23 18:35:02 +08:00
"resize_status": "切削状态",
"resize_mode": "调整模式",
"resize_risk": "切削风险",
"resize_warnings": "切削警告",
"resize_blockers": "切削阻止原因",
"resize_note": "切削说明",
2026-07-24 17:47:17 +08:00
"boss_resize_status": "凸台调整状态",
"boss_resize_risk": "凸台调整风险",
"boss_resize_warnings": "凸台调整警告",
"boss_resize_blockers": "凸台调整阻止原因",
"boss_resize_note": "凸台调整说明",
"suppress_status": "封堵状态",
"suppress_risk": "封堵风险",
"suppress_warnings": "封堵警告",
"suppress_blockers": "封堵阻止原因",
"suppress_note": "封堵说明",
"depth_status": "孔深状态",
"depth_mode": "孔深调整模式",
"depth_risk": "孔深风险",
"depth_warnings": "孔深警告",
"depth_blockers": "孔深阻止原因",
"depth_note": "孔深说明",
2026-07-23 18:35:02 +08:00
"cutter_strategy": "Cutter 策略",
"cutter_height": "Cutter 高度",
"cutter_margin": "Cutter 余量",
"cutter_start_margin": "Cutter 起点余量",
"cutter_end_margin": "Cutter 终点余量",
"cutter_radius": "Cutter 半径",
"cutter_start_parameter": "Cutter 起始参数",
"cutter_end_parameter": "Cutter 结束参数",
"cutter_axis_direction": "Cutter 轴方向",
"cutter_start_point": "Cutter 起点",
2026-07-24 17:47:17 +08:00
"cutter_bottom_protection": "Cutter 底面保护",
"cutter_protected_bottom_face_ids": "Cutter 保护底面 Face",
"cutter_opening_face_ids": "Cutter 开口端 Face",
"cutter_bottom_note": "Cutter 底面保护说明",
2026-07-23 18:35:02 +08:00
"cutter_note": "Cutter 说明",
"fill_strategy": "补料策略",
"fill_height": "补料高度",
"fill_radius": "补料半径",
"fill_radius_overlap": "补料重叠量",
"fill_start_point": "补料起点",
"fill_note": "补料说明",
2026-07-24 17:47:17 +08:00
"boss_tool_strategy": "凸台工具策略",
"boss_tool_note": "凸台工具说明",
"boss_tool_height": "凸台工具高度",
"boss_tool_radius": "凸台目标半径",
"boss_tool_old_radius": "凸台原半径",
"boss_tool_outer_radius": "凸台环形 cutter 外半径",
"boss_tool_inner_radius": "凸台环形 cutter 内半径",
"boss_tool_axial_margin": "凸台工具轴向余量",
"boss_tool_radial_overlap": "凸台工具径向重叠",
"boss_tool_start_parameter": "凸台工具起始参数",
"boss_tool_end_parameter": "凸台工具结束参数",
"boss_tool_axis_point": "凸台工具轴线点",
"boss_tool_axis_direction": "凸台工具轴线方向",
"boss_tool_start_point": "凸台工具起点",
"depth_tool_strategy": "孔深工具策略",
"depth_tool_role": "孔深工具类型",
"depth_tool_note": "孔深工具说明",
"depth_tool_height": "孔深工具高度",
"depth_tool_radius": "孔深工具半径",
"depth_tool_radius_overlap": "孔深工具半径重叠",
"depth_tool_start_parameter": "孔深工具起始参数",
"depth_tool_end_parameter": "孔深工具结束参数",
"depth_open_parameter": "孔开口参数",
"depth_bottom_parameter": "当前底面参数",
"depth_nominal_bottom_parameter": "圆柱参数底面",
"depth_bottom_parameter_source": "底面参数来源",
"depth_current_depth": "当前几何深度",
"depth_current_depth_source": "当前深度来源",
"depth_target_bottom_parameter": "目标底面参数",
"depth_axis_direction": "孔深方向",
"depth_open_point": "孔开口点",
"depth_current_bottom_point": "当前底面点",
"depth_target_bottom_point": "目标底面点",
"depth_tool_start_point": "孔深工具起点",
"fillet_status": "圆角状态",
"fillet_risk": "圆角风险",
"fillet_warnings": "圆角警告",
"fillet_blockers": "圆角阻止原因",
"fillet_note": "圆角说明",
"chamfer_status": "倒角状态",
"chamfer_risk": "倒角风险",
"chamfer_warnings": "倒角警告",
"chamfer_blockers": "倒角阻止原因",
"chamfer_note": "倒角说明",
"translate_status": "平移状态",
"translate_risk": "平移风险",
"translate_warnings": "平移警告",
"translate_blockers": "平移阻止原因",
"translate_note": "平移说明",
"part_solid_count": "Part 内 Solid 数",
"rotate_status": "旋转状态",
"rotate_risk": "旋转风险",
"rotate_warnings": "旋转警告",
"rotate_blockers": "旋转阻止原因",
"rotate_note": "旋转说明",
2026-07-23 18:35:02 +08:00
}
PART_TREE_KIND_ROLE = Qt.UserRole
PART_TREE_ID_ROLE = Qt.UserRole + 1
PART_TREE_PART_ID_ROLE = Qt.UserRole + 2
2026-07-24 17:47:17 +08:00
EDITABLE_TARGET_ID_ROLE = Qt.UserRole
2026-07-23 18:35:02 +08:00
EDITABLE_ACTION_ROLE = Qt.UserRole + 1
2026-07-24 17:47:17 +08:00
EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
2026-07-23 18:35:02 +08:00
class StepEditorWindow(QMainWindow):
def __init__(self, step_path: str | Path):
super().__init__()
self.setWindowTitle("STEP 零件查看与编辑原型")
self.resize(1280, 820)
self.model: StepModel | None = None
self.step_path = Path(step_path)
self.selected_kind: str | None = None
self.selected_part_id: int | None = None
self.selected_solid_id: int | None = None
self.selected_face_id: int | None = None
self.selected_edge_id: int | None = None
self.selected_pick_position: tuple[float, float, float] | None = None
self.model_actor = None
self.edge_actor = None
self.highlight_actor = None
self.edge_highlight_actor = None
2026-07-24 17:47:17 +08:00
self.hover_face_actor = None
self.hover_edge_actor = None
self.hover_signature: tuple[str, int] | None = None
self.pending_hover_position: tuple[int, int] | None = None
2026-07-23 18:35:02 +08:00
self.pick_marker_actor = None
self.edit_preview_actor = None
2026-07-24 17:47:17 +08:00
self.edit_preview_actors: list[object] = []
2026-07-23 18:35:02 +08:00
self.edit_preview_timer: QTimer | None = None
2026-07-24 17:47:17 +08:00
self.hover_timer = QTimer(self)
self.hover_timer.setSingleShot(True)
self.hover_timer.timeout.connect(self._update_hover_target)
2026-07-23 18:35:02 +08:00
self.edit_preview_phase = 0.0
self.edit_preview_base_opacity = 0.35
self.diff_actors: list[object] = []
self.model_polydata = None
self.edge_polydata = None
2026-07-24 17:47:17 +08:00
self.scene_isolated = False
2026-07-23 18:35:02 +08:00
self.undo_stack: list[dict[int, object]] = []
self.redo_stack: list[dict[int, object]] = []
self.operation_history: list[OperationRecord] = []
self.redo_history: list[OperationRecord] = []
self.current_info_text = ""
self.current_info_values: dict[str, object] = {}
self.cylinder_candidate_cache: list[dict[str, object]] = []
self.cylinder_candidates_loaded = False
self.operation_in_progress = False
self.edit_thread: QThread | None = None
self.edit_worker: EditWorker | None = None
self.edit_progress: QProgressDialog | None = None
self.pending_edit_context: dict[str, object] | None = None
2026-07-24 17:47:17 +08:00
self.main_content_enabled_before_edit = True
self.last_id_kind = "Face"
2026-07-23 18:35:02 +08:00
self._build_ui()
self._build_vtk()
self.load_step(self.step_path)
def _build_ui(self) -> None:
central = QWidget()
root_layout = QHBoxLayout(central)
root_layout.setContentsMargins(10, 10, 10, 10)
root_layout.setSpacing(10)
self.setCentralWidget(central)
panel_scroll = QScrollArea()
panel_scroll.setWidgetResizable(True)
panel_scroll.setFixedWidth(440)
panel_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
root_layout.addWidget(panel_scroll)
panel = QWidget()
panel.setFixedWidth(420)
panel_layout = QVBoxLayout(panel)
panel_layout.setContentsMargins(0, 0, 0, 0)
2026-07-24 17:47:17 +08:00
panel_layout.setSpacing(10)
2026-07-23 18:35:02 +08:00
panel_scroll.setWidget(panel)
2026-07-24 17:47:17 +08:00
panel_scroll.setStyleSheet(
"""
QScrollArea {
background: #f4f7fb;
border: none;
}
QScrollArea > QWidget > QWidget {
background: #f4f7fb;
}
QGroupBox {
background: #ffffff;
border: 2px solid #6b7cff;
border-radius: 8px;
color: #1f2937;
margin-top: 12px;
padding: 12px 10px 10px 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 12px;
padding: 0 6px;
color: #172033;
background: #f4f7fb;
font-weight: 700;
}
QGroupBox#fileSection {
border-color: #3478f6;
}
QGroupBox#treeSection {
border-color: #0f9f8f;
}
QGroupBox#modeSection,
QGroupBox#idSection {
border-color: #6b5cff;
}
QGroupBox#viewSection {
border-color: #0e9bd8;
}
QGroupBox#exportSection {
border-color: #1f9d55;
}
QGroupBox#editSection {
border-color: #d97706;
}
QGroupBox#editableSection,
QGroupBox#candidateSection {
border-color: #b45309;
}
QGroupBox#historySection {
border-color: #be3b6b;
}
QGroupBox#infoSection {
border-color: #64748b;
}
QLabel {
color: #2f3846;
}
QLineEdit,
QComboBox {
background: #ffffff;
border: 1px solid #c8d2df;
border-radius: 5px;
color: #172033;
min-height: 24px;
padding: 4px 6px;
}
QLineEdit:focus,
QComboBox:focus {
border: 1px solid #3478f6;
}
QLineEdit#idModeDisplay {
background: #eef2ff;
border-color: #93a5e8;
color: #2d3a8c;
font-weight: 700;
}
QPushButton {
background: #eef3f8;
border: 1px solid #c8d2df;
border-radius: 6px;
color: #172033;
font-weight: 600;
min-height: 24px;
padding: 5px 8px;
}
QPushButton:hover {
background: #e3edf9;
border-color: #8fb0dc;
}
QPushButton:pressed {
background: #d7e6f6;
}
QPushButton:disabled {
background: #eef0f3;
border-color: #dfe3ea;
color: #7a7f86;
}
QTreeWidget,
QTableWidget,
QListWidget,
QPlainTextEdit {
background: #ffffff;
border: 1px solid #d8e0eb;
border-radius: 6px;
color: #172033;
selection-background-color: #dceafe;
selection-color: #0f172a;
}
QHeaderView::section {
background: #edf2f7;
border: 0;
border-bottom: 1px solid #d8e0eb;
color: #334155;
font-weight: 700;
padding: 4px 6px;
}
QTabWidget::pane {
border: 1px solid #d8e0eb;
border-radius: 6px;
top: -1px;
}
QTabBar::tab {
background: #e9eef6;
border: 1px solid #d8e0eb;
border-bottom: none;
border-top-left-radius: 5px;
border-top-right-radius: 5px;
color: #334155;
padding: 5px 10px;
}
QTabBar::tab:selected {
background: #ffffff;
color: #172033;
font-weight: 700;
}
"""
)
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
file_box = QGroupBox("STEP 文件")
file_box.setObjectName("fileSection")
file_layout = QVBoxLayout(file_box)
2026-07-23 18:35:02 +08:00
self.path_label = QLabel("")
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
self.path_label.setWordWrap(True)
2026-07-24 17:47:17 +08:00
file_layout.addWidget(self.path_label)
2026-07-23 18:35:02 +08:00
file_buttons = QHBoxLayout()
open_button = QPushButton("打开")
open_button.clicked.connect(self.open_step)
reload_button = QPushButton("重新加载")
reload_button.clicked.connect(self.reload_step)
file_buttons.addWidget(open_button)
file_buttons.addWidget(reload_button)
2026-07-24 17:47:17 +08:00
file_layout.addLayout(file_buttons)
panel_layout.addWidget(file_box)
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
tree_box = QGroupBox("模型结构树")
tree_box.setObjectName("treeSection")
tree_layout = QVBoxLayout(tree_box)
2026-07-23 18:35:02 +08:00
self.part_tree = QTreeWidget()
2026-07-24 17:47:17 +08:00
self.part_tree.setHeaderLabels(["对象", "内容"])
2026-07-23 18:35:02 +08:00
self.part_tree.setMinimumHeight(180)
self.part_tree.currentItemChanged.connect(self.on_part_tree_select)
2026-07-24 17:47:17 +08:00
tree_layout.addWidget(self.part_tree)
panel_layout.addWidget(tree_box)
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
mode_box = QGroupBox("鼠标选择模式")
mode_box.setObjectName("modeSection")
mode_layout = QVBoxLayout(mode_box)
self.mode_combo = NoWheelComboBox()
2026-07-23 18:35:02 +08:00
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"])
self.mode_combo.setCurrentText("Face")
2026-07-24 17:47:17 +08:00
self.mode_combo.currentTextChanged.connect(self._on_mode_changed)
mode_layout.addWidget(self.mode_combo)
panel_layout.addWidget(mode_box)
2026-07-23 18:35:02 +08:00
select_box = QGroupBox("按 ID 选择")
2026-07-24 17:47:17 +08:00
select_box.setObjectName("idSection")
2026-07-23 18:35:02 +08:00
select_layout = QGridLayout(select_box)
self.id_input = QLineEdit("")
2026-07-24 17:47:17 +08:00
self.id_input.setPlaceholderText("输入 ID")
self.id_mode_display = QLineEdit(self.mode_combo.currentText())
self.id_mode_display.setObjectName("idModeDisplay")
self.id_mode_display.setReadOnly(True)
self.id_mode_display.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.id_mode_display.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.id_mode_display.setMinimumWidth(70)
self.id_mode_display.setToolTip("当前按这个鼠标选择模式解释输入的 ID。")
2026-07-23 18:35:02 +08:00
select_button = QPushButton("选择")
2026-07-24 17:47:17 +08:00
select_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText()))
self.id_input.returnPressed.connect(lambda: self.select_by_id(self.mode_combo.currentText()))
select_layout.addWidget(QLabel("ID"), 0, 0)
2026-07-23 18:35:02 +08:00
select_layout.addWidget(self.id_input, 0, 1)
2026-07-24 17:47:17 +08:00
select_layout.addWidget(self.id_mode_display, 0, 2)
select_layout.addWidget(select_button, 0, 3)
2026-07-23 18:35:02 +08:00
panel_layout.addWidget(select_box)
2026-07-24 17:47:17 +08:00
view_box = QGroupBox("显示")
view_box.setObjectName("viewSection")
view_layout = QHBoxLayout(view_box)
isolate_button = QPushButton("只显示选中")
isolate_button.clicked.connect(self.isolate_selected)
fit_button = QPushButton("对准选中")
fit_button.clicked.connect(self.fit_selected)
show_all_button = QPushButton("显示全部")
show_all_button.clicked.connect(self.show_all_geometry)
view_layout.addWidget(isolate_button)
view_layout.addWidget(fit_button)
view_layout.addWidget(show_all_button)
panel_layout.addWidget(view_box)
2026-07-23 18:35:02 +08:00
export_box = QGroupBox("导出")
2026-07-24 17:47:17 +08:00
export_box.setObjectName("exportSection")
2026-07-23 18:35:02 +08:00
export_layout = QVBoxLayout(export_box)
2026-07-24 17:47:17 +08:00
self.export_all_button = QPushButton("导出当前完整 STEP")
self.export_all_button.clicked.connect(self.export_all)
self.export_part_button = QPushButton("导出选中零件")
self.export_part_button.clicked.connect(self.export_selected_part)
self.export_solid_button = QPushButton("导出选中 solid")
self.export_solid_button.clicked.connect(self.export_selected_solid)
self.export_face_button = QPushButton("导出选中 face")
self.export_face_button.clicked.connect(self.export_selected_face)
self.export_feature_button = QPushButton("导出选中特征区域")
self.export_feature_button.clicked.connect(self.export_selected_feature)
self.export_edge_button = QPushButton("导出选中 edge")
self.export_edge_button.clicked.connect(self.export_selected_edge)
self.export_check_button = QPushButton("检查导出质量")
self.export_check_button.clicked.connect(self.check_export_quality)
export_layout.addWidget(self.export_all_button)
export_layout.addWidget(self.export_part_button)
export_layout.addWidget(self.export_solid_button)
export_layout.addWidget(self.export_face_button)
export_layout.addWidget(self.export_feature_button)
export_layout.addWidget(self.export_edge_button)
export_layout.addWidget(self.export_check_button)
2026-07-23 18:35:02 +08:00
panel_layout.addWidget(export_box)
edit_box = QGroupBox("实验性编辑")
2026-07-24 17:47:17 +08:00
edit_box.setObjectName("editSection")
2026-07-23 18:35:02 +08:00
edit_layout = QGridLayout(edit_box)
edit_layout.addWidget(QLabel("面偏移"), 0, 0)
self.offset_input = QLineEdit("5.0")
edit_layout.addWidget(self.offset_input, 0, 1)
2026-07-24 17:47:17 +08:00
self.push_button = QPushButton("推拉平面")
self.push_button.clicked.connect(self.push_pull_face)
edit_layout.addWidget(self.push_button, 1, 0, 1, 2)
2026-07-23 18:35:02 +08:00
edit_layout.addWidget(QLabel("孔直径"), 2, 0)
self.hole_diameter_input = QLineEdit("")
edit_layout.addWidget(self.hole_diameter_input, 2, 1)
2026-07-24 17:47:17 +08:00
self.resize_button = QPushButton("调整圆柱孔径")
self.resize_button.clicked.connect(self.resize_hole)
edit_layout.addWidget(self.resize_button, 3, 0, 1, 2)
edit_layout.addWidget(QLabel("凸台直径"), 4, 0)
self.boss_diameter_input = QLineEdit("")
edit_layout.addWidget(self.boss_diameter_input, 4, 1)
self.resize_boss_button = QPushButton("调整圆柱凸台直径")
self.resize_boss_button.clicked.connect(self.resize_boss)
edit_layout.addWidget(self.resize_boss_button, 5, 0, 1, 2)
self.suppress_button = QPushButton("封堵圆柱孔")
self.suppress_button.clicked.connect(self.suppress_hole)
edit_layout.addWidget(self.suppress_button, 6, 0, 1, 2)
edit_layout.addWidget(QLabel("孔深度"), 7, 0)
self.hole_depth_input = QLineEdit("")
edit_layout.addWidget(self.hole_depth_input, 7, 1)
self.resize_depth_button = QPushButton("调整盲孔深度")
self.resize_depth_button.clicked.connect(self.resize_hole_depth)
edit_layout.addWidget(self.resize_depth_button, 8, 0, 1, 2)
edit_layout.addWidget(QLabel("圆角半径"), 9, 0)
self.edge_fillet_radius_input = QLineEdit("")
edit_layout.addWidget(self.edge_fillet_radius_input, 9, 1)
self.fillet_edge_button = QPushButton("给边添加圆角")
self.fillet_edge_button.clicked.connect(self.fillet_edge)
edit_layout.addWidget(self.fillet_edge_button, 10, 0, 1, 2)
self.resize_existing_fillet_button = QPushButton("修改已有圆角半径")
self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet)
edit_layout.addWidget(self.resize_existing_fillet_button, 11, 0, 1, 2)
edit_layout.addWidget(QLabel("倒角距离"), 12, 0)
self.edge_chamfer_distance_input = QLineEdit("")
edit_layout.addWidget(self.edge_chamfer_distance_input, 12, 1)
self.chamfer_edge_button = QPushButton("给边添加倒角")
self.chamfer_edge_button.clicked.connect(self.chamfer_edge)
edit_layout.addWidget(self.chamfer_edge_button, 13, 0, 1, 2)
edit_layout.addWidget(QLabel("边目标长度"), 14, 0)
self.edge_target_length_input = QLineEdit("")
edit_layout.addWidget(self.edge_target_length_input, 14, 1)
self.resize_edge_length_button = QPushButton("调整直线边长度")
self.resize_edge_length_button.clicked.connect(self.resize_edge_length)
edit_layout.addWidget(self.resize_edge_length_button, 15, 0, 1, 2)
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 16, 0)
translate_inputs = QWidget()
translate_layout = QHBoxLayout(translate_inputs)
translate_layout.setContentsMargins(0, 0, 0, 0)
self.translate_x_input = QLineEdit("0")
self.translate_y_input = QLineEdit("0")
self.translate_z_input = QLineEdit("0")
self.translate_x_input.setPlaceholderText("X")
self.translate_y_input.setPlaceholderText("Y")
self.translate_z_input.setPlaceholderText("Z")
translate_layout.addWidget(self.translate_x_input)
translate_layout.addWidget(self.translate_y_input)
translate_layout.addWidget(self.translate_z_input)
edit_layout.addWidget(translate_inputs, 16, 1)
self.translate_part_button = QPushButton("平移选中零件")
self.translate_part_button.clicked.connect(self.translate_selected_part)
self.translate_solid_button = QPushButton("平移选中 solid")
self.translate_solid_button.clicked.connect(self.translate_selected_solid)
edit_layout.addWidget(self.translate_part_button, 17, 0, 1, 2)
edit_layout.addWidget(self.translate_solid_button, 18, 0, 1, 2)
edit_layout.addWidget(QLabel("旋转轴/角度"), 19, 0)
rotate_inputs = QWidget()
rotate_layout = QHBoxLayout(rotate_inputs)
rotate_layout.setContentsMargins(0, 0, 0, 0)
self.rotate_axis_combo = QComboBox()
self.rotate_axis_combo.addItems(["X", "Y", "Z"])
self.rotate_axis_combo.setCurrentText("Z")
self.rotate_angle_input = QLineEdit("90")
self.rotate_angle_input.setPlaceholderText("度")
rotate_layout.addWidget(self.rotate_axis_combo)
rotate_layout.addWidget(self.rotate_angle_input)
edit_layout.addWidget(rotate_inputs, 19, 1)
self.rotate_part_button = QPushButton("旋转选中零件")
self.rotate_part_button.clicked.connect(self.rotate_selected_part)
self.rotate_solid_button = QPushButton("旋转选中 solid")
self.rotate_solid_button.clicked.connect(self.rotate_selected_solid)
edit_layout.addWidget(self.rotate_part_button, 20, 0, 1, 2)
edit_layout.addWidget(self.rotate_solid_button, 21, 0, 1, 2)
self.cylinders_button = QPushButton("列出圆柱候选")
self.cylinders_button.clicked.connect(self.list_cylinders)
edit_layout.addWidget(self.cylinders_button, 22, 0, 1, 2)
self.undo_button = QPushButton("撤销")
self.undo_button.clicked.connect(self.undo_edit)
self.redo_button = QPushButton("重做")
self.redo_button.clicked.connect(self.redo_edit)
edit_layout.addWidget(self.undo_button, 23, 0)
edit_layout.addWidget(self.redo_button, 23, 1)
2026-07-23 18:35:02 +08:00
panel_layout.addWidget(edit_box)
editable_box = QGroupBox("第一版可编辑对象")
2026-07-24 17:47:17 +08:00
editable_box.setObjectName("editableSection")
2026-07-23 18:35:02 +08:00
editable_layout = QVBoxLayout(editable_box)
editable_button_row = QHBoxLayout()
editable_refresh_button = QPushButton("扫描可编辑对象")
editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True))
2026-07-24 17:47:17 +08:00
editable_deep_scan_button = QPushButton("深度扫描")
editable_deep_scan_button.clicked.connect(
lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True)
)
2026-07-23 18:35:02 +08:00
editable_button_row.addWidget(editable_refresh_button)
2026-07-24 17:47:17 +08:00
editable_button_row.addWidget(editable_deep_scan_button)
2026-07-23 18:35:02 +08:00
editable_layout.addLayout(editable_button_row)
self.editable_table = QTableWidget(0, 8)
self.editable_table.setHorizontalHeaderLabels(
2026-07-24 17:47:17 +08:00
["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"]
2026-07-23 18:35:02 +08:00
)
self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.editable_table.setMinimumHeight(130)
self.editable_table.cellClicked.connect(self.on_editable_row_clicked)
editable_layout.addWidget(self.editable_table)
panel_layout.addWidget(editable_box)
candidate_box = QGroupBox("圆柱候选")
2026-07-24 17:47:17 +08:00
candidate_box.setObjectName("candidateSection")
2026-07-23 18:35:02 +08:00
candidate_layout = QVBoxLayout(candidate_box)
filter_layout = QHBoxLayout()
filter_layout.addWidget(QLabel("类型"))
2026-07-24 17:47:17 +08:00
self.candidate_filter_combo = NoWheelComboBox()
2026-07-23 18:35:02 +08:00
self.candidate_filter_combo.addItems(
[
"All",
"Hole/Groove",
"Round/Fillet",
"Boss/Outer",
"Unclear",
]
)
self.candidate_filter_combo.currentTextChanged.connect(
lambda _text: self._filter_cached_cylinder_candidates(show_info=True)
)
filter_layout.addWidget(self.candidate_filter_combo)
candidate_layout.addLayout(filter_layout)
self.cylinder_table = QTableWidget(0, 8)
self.cylinder_table.setHorizontalHeaderLabels(
["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"]
)
self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.cylinder_table.setMinimumHeight(140)
self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked)
candidate_layout.addWidget(self.cylinder_table)
panel_layout.addWidget(candidate_box)
history_box = QGroupBox("操作历史")
2026-07-24 17:47:17 +08:00
history_box.setObjectName("historySection")
2026-07-23 18:35:02 +08:00
history_layout = QVBoxLayout(history_box)
self.history_list = QListWidget()
self.history_list.setMinimumHeight(110)
self.history_list.currentRowChanged.connect(self.on_history_row_changed)
history_buttons = QHBoxLayout()
clear_diff_button = QPushButton("清除差异预览")
clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview())
export_diff_button = QPushButton("导出差异报告")
export_diff_button.clicked.connect(self.export_diff_report)
history_layout.addWidget(self.history_list)
history_buttons.addWidget(clear_diff_button)
history_buttons.addWidget(export_diff_button)
history_layout.addLayout(history_buttons)
panel_layout.addWidget(history_box)
2026-07-24 17:47:17 +08:00
info_box = QGroupBox("选中对象信息")
info_box.setObjectName("infoSection")
info_layout = QVBoxLayout(info_box)
2026-07-23 18:35:02 +08:00
info_buttons = QHBoxLayout()
copy_id_button = QPushButton("复制 ID")
copy_id_button.clicked.connect(self.copy_selected_id)
copy_pick_button = QPushButton("复制坐标")
copy_pick_button.clicked.connect(self.copy_pick_position)
copy_info_button = QPushButton("复制信息")
copy_info_button.clicked.connect(self.copy_current_info)
info_buttons.addWidget(copy_id_button)
info_buttons.addWidget(copy_pick_button)
info_buttons.addWidget(copy_info_button)
2026-07-24 17:47:17 +08:00
info_layout.addLayout(info_buttons)
2026-07-23 18:35:02 +08:00
self.info_tabs = QTabWidget()
self.info_tree = QTreeWidget()
self.info_tree.setHeaderLabels(["属性", "值"])
self.info_tree.setAlternatingRowColors(True)
self.info_tree.setTextElideMode(Qt.TextElideMode.ElideMiddle)
self.info_tree.setUniformRowHeights(True)
self.info_text = QPlainTextEdit()
self.info_text.setReadOnly(True)
self.info_tabs.addTab(self.info_tree, "属性表")
self.info_tabs.addTab(self.info_text, "原始文本")
2026-07-24 17:47:17 +08:00
info_layout.addWidget(self.info_tabs)
panel_layout.addWidget(info_box, stretch=1)
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.vtk_widget = QVTKRenderWindowInteractor(central)
root_layout.addWidget(self.vtk_widget, stretch=1)
self.statusBar().showMessage("Ready")
def _build_vtk(self) -> None:
self.renderer = vtk.vtkRenderer()
self.renderer.SetBackground(0.11, 0.13, 0.15)
self.render_window = self.vtk_widget.GetRenderWindow()
self.render_window.AddRenderer(self.renderer)
self.interactor = self.render_window.GetInteractor()
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
self.picker = vtk.vtkCellPicker()
self.picker.SetTolerance(0.003)
self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click)
2026-07-24 17:47:17 +08:00
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
2026-07-23 18:35:02 +08:00
light = vtk.vtkLight()
light.SetLightTypeToSceneLight()
light.SetPosition(1, 1, 1)
light.SetIntensity(0.9)
self.renderer.AddLight(light)
self.interactor.Initialize()
def closeEvent(self, event) -> None:
if self.operation_in_progress:
QMessageBox.information(self, "编辑进行中", "请等待当前编辑计算完成后再关闭窗口。")
event.ignore()
return
super().closeEvent(event)
def load_step(self, path: str | Path) -> None:
2026-07-24 17:47:17 +08:00
new_path = Path(path)
self.statusBar().showMessage(f"正在加载 {new_path.name}...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
load_error: Exception | None = None
2026-07-23 18:35:02 +08:00
try:
2026-07-24 17:47:17 +08:00
new_model = StepModel.load(new_path)
stats = new_model.stats()
model_polydata = new_model.build_face_polydata()
edge_polydata = new_model.build_edge_polydata()
2026-07-23 18:35:02 +08:00
except Exception as exc:
2026-07-24 17:47:17 +08:00
load_error = exc
finally:
QApplication.restoreOverrideCursor()
if load_error is not None:
QMessageBox.critical(self, "加载失败", str(load_error))
self.statusBar().showMessage("STEP 加载失败,当前模型保持不变")
2026-07-23 18:35:02 +08:00
return
2026-07-24 17:47:17 +08:00
self.model = new_model
self.step_path = new_path
2026-07-23 18:35:02 +08:00
self._clear_history()
self.path_label.setText(str(self.step_path))
self._populate_part_tree()
self._reset_selection()
2026-07-24 17:47:17 +08:00
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True)
2026-07-23 18:35:02 +08:00
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self.set_info(
{
"file": str(self.step_path),
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
}
)
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(f"已加载 {self.step_path.name}")
def open_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再打开文件。"):
return
path, _ = QFileDialog.getOpenFileName(
self,
"打开 STEP 文件",
str(self.step_path.parent if self.step_path else Path.cwd()),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if path:
self.load_step(path)
def reload_step(self) -> None:
if self._edit_busy("请等待当前编辑完成后再重新加载。"):
return
self.load_step(self.step_path)
def _clear_history(self) -> None:
self.undo_stack.clear()
self.redo_stack.clear()
self.operation_history.clear()
self.redo_history.clear()
self.clear_diff_preview(render=False)
if hasattr(self, "history_list"):
self.history_list.clear()
def _refresh_history_list(self) -> None:
was_blocked = self.history_list.blockSignals(True)
try:
self.history_list.clear()
for index, entry in enumerate(self.operation_history, start=1):
self.history_list.addItem(f"{index}. {entry.summary}")
finally:
self.history_list.blockSignals(was_blocked)
def on_history_row_changed(self, row: int) -> None:
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能查看历史记录详情。"):
return
2026-07-23 18:35:02 +08:00
if 0 <= row < len(self.operation_history):
record = self.operation_history[row]
locate_message = self._locate_operation_record(record)
diff_message = self._show_operation_diff(record)
detail = record.detail
if locate_message:
detail = f"{detail}\n\n{locate_message}"
if diff_message:
detail = f"{detail}\n\n{diff_message}"
self.set_plain_info(detail)
def export_diff_report(self) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能导出差异报告。"):
return
2026-07-23 18:35:02 +08:00
row = self.history_list.currentRow()
if row < 0 or row >= len(self.operation_history):
QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。")
return
record = self.operation_history[row]
self._ensure_record_diff_stats(record)
target, _ = QFileDialog.getSaveFileName(
self,
"导出差异报告",
str(self.step_path.parent / f"{self.step_path.stem}_diff_{row + 1}.txt"),
"文本文件 (*.txt);;所有文件 (*.*)",
)
if not target:
return
report = self._diff_report_text(record, row + 1)
try:
Path(target).write_text(report, encoding="utf-8")
except Exception as exc:
QMessageBox.critical(self, "导出失败", str(exc))
self.statusBar().showMessage("差异报告导出失败")
return
self.statusBar().showMessage(f"已导出差异报告 {Path(target).name}")
self.set_plain_info(f"已导出差异报告:{target}\n\n{report}")
def _populate_part_tree(self) -> None:
was_blocked = self.part_tree.blockSignals(True)
try:
self.part_tree.clear()
if self.model is None:
return
inserted: dict[int, QTreeWidgetItem] = {}
for part in self.model.parts:
2026-07-24 17:47:17 +08:00
item = QTreeWidgetItem(
[
self._part_tree_part_name(part.id, part.name, part.kind),
self._part_tree_part_detail(part.id, part.kind),
]
)
2026-07-23 18:35:02 +08:00
item.setData(0, PART_TREE_KIND_ROLE, part.kind)
item.setData(0, PART_TREE_ID_ROLE, part.id)
2026-07-24 17:47:17 +08:00
item.setToolTip(0, self._part_tree_part_tooltip(part.id))
item.setToolTip(1, self._part_tree_part_tooltip(part.id))
2026-07-23 18:35:02 +08:00
parent = inserted.get(part.parent_id)
if parent is None:
self.part_tree.addTopLevelItem(item)
else:
parent.addChild(item)
inserted[part.id] = item
for solid_id, (part_id, _solid) in enumerate(self.model.solids):
parent = inserted.get(part_id)
if parent is None:
continue
2026-07-24 17:47:17 +08:00
item = QTreeWidgetItem([f"实体 {solid_id}", self._part_tree_solid_detail(solid_id)])
2026-07-23 18:35:02 +08:00
item.setData(0, PART_TREE_KIND_ROLE, "solid")
item.setData(0, PART_TREE_ID_ROLE, solid_id)
item.setData(0, PART_TREE_PART_ID_ROLE, part_id)
2026-07-24 17:47:17 +08:00
item.setToolTip(0, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}")
item.setToolTip(1, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}")
2026-07-23 18:35:02 +08:00
parent.addChild(item)
self.part_tree.expandAll()
self.part_tree.resizeColumnToContents(0)
self.part_tree.resizeColumnToContents(1)
finally:
self.part_tree.blockSignals(was_blocked)
2026-07-24 17:47:17 +08:00
def _part_tree_part_name(self, part_id: int, name: str, kind: str) -> str:
kind_label = _part_tree_kind_label(kind)
clean_name = str(name).strip()
if clean_name:
return f"{kind_label} {part_id}{clean_name}"
return f"{kind_label} {part_id}"
2026-07-23 18:35:02 +08:00
def _part_tree_part_detail(self, part_id: int, kind: str) -> str:
if self.model is None:
2026-07-24 17:47:17 +08:00
return _part_tree_kind_label(kind)
try:
info = self.model.part_info(part_id)
except Exception:
return _part_tree_kind_label(kind)
return (
f"{_part_tree_kind_label(kind)} | "
f"实体 {info.get('solids', 0)} 个 | "
f"面 {info.get('faces', 0)} 个 | "
f"边 {info.get('edges', 0)} 条"
)
def _part_tree_part_tooltip(self, part_id: int) -> str:
if self.model is None:
return f"Part ID: {part_id}"
2026-07-23 18:35:02 +08:00
try:
info = self.model.part_info(part_id)
except Exception:
2026-07-24 17:47:17 +08:00
return f"Part ID: {part_id}"
lines = [
f"ID: {part_id}",
f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}",
f"名称: {info.get('name', '')}",
]
path = str(info.get("path", "")).strip()
if path:
lines.append(f"路径: {path}")
return "\n".join(lines)
2026-07-23 18:35:02 +08:00
def _part_tree_solid_detail(self, solid_id: int) -> str:
if self.model is None:
2026-07-24 17:47:17 +08:00
return "实体"
2026-07-23 18:35:02 +08:00
try:
info = self.model.solid_info(solid_id)
except Exception:
2026-07-24 17:47:17 +08:00
return "实体"
return f"面 {info.get('faces', 0)} 个 | 边 {info.get('edges', 0)} 条"
2026-07-23 18:35:02 +08:00
def _rebuild_scene(self, reset_camera: bool = False) -> None:
if self.model is None:
return
model_polydata = self.model.build_face_polydata()
edge_polydata = self.model.build_edge_polydata()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera)
2026-07-24 17:47:17 +08:00
def isolate_selected(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
return
face_ids: list[int] | None = None
edge_ids: list[int] | None = None
part_ids: list[int] | None = None
label = ""
if self.selected_kind == "part" and self.selected_part_id is not None:
part_ids = [self.selected_part_id]
label = f"part {self.selected_part_id}"
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
label = f"solid {self.selected_solid_id}"
elif self.selected_kind == "feature" and self.selected_face_id is not None:
info = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
label = f"feature face {self.selected_face_id}"
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_ids = [self.selected_face_id]
edge_ids = _int_values(self.model.face_info(self.selected_face_id).get("feature_boundary_edge_ids"))
label = f"face {self.selected_face_id}"
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
info = self.model.edge_info(self.selected_edge_id)
face_ids = _int_values(info.get("adjacent_face_ids"))
edge_ids = [self.selected_edge_id]
label = f"edge {self.selected_edge_id}"
else:
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
return
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids, part_ids=part_ids)
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True)
self.scene_isolated = True
self._refresh_selection_highlight()
self.statusBar().showMessage(f"已只显示选中对象:{label}")
def show_all_geometry(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
return
self._rebuild_scene(reset_camera=True)
self._refresh_selection_highlight()
self.statusBar().showMessage("已显示完整模型")
def fit_selected(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能调整视角。"):
return
bounds = self._selected_focus_bounds()
if bounds is None:
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
return
self._fit_camera_to_bounds(bounds)
self.render_window.Render()
self.statusBar().showMessage("已对准选中对象")
def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None:
if self.model is None:
return None
face_polydata = None
edge_polydata = None
if self.selected_kind == "part" and self.selected_part_id is not None:
face_polydata = self.model.build_face_polydata(part_ids=[self.selected_part_id])
edge_polydata = self.model.build_edge_polydata(part_ids=[self.selected_part_id])
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
elif self.selected_kind == "feature" and self.selected_face_id is not None:
info = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None:
face_polydata = self.model.build_face_polydata(face_ids=[self.selected_face_id])
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
edge_polydata = self.model.build_edge_polydata(edge_ids=[self.selected_edge_id])
return _merge_polydata_bounds(face_polydata, edge_polydata)
def _fit_camera_to_bounds(self, bounds: tuple[float, float, float, float, float, float]) -> None:
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)
diagonal = max(math.sqrt(dx * dx + dy * dy + dz * dz), 1.0)
pad = diagonal * 0.18
padded_bounds = (x0 - pad, x1 + pad, y0 - pad, y1 + pad, z0 - pad, z1 + pad)
try:
self.renderer.ResetCamera(padded_bounds)
except TypeError:
self.renderer.ResetCamera(*padded_bounds)
self.renderer.ResetCameraClippingRange()
def _refresh_selection_highlight(self) -> None:
if self.model is None:
return
if self.selected_kind == "part" and self.selected_part_id is not None:
self._highlight_faces(part_ids=[self.selected_part_id])
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "feature" and self.selected_face_id is not None:
info = self.model.feature_info(self.selected_face_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
self._highlight_faces(face_ids=face_ids)
elif self.selected_kind == "face" and self.selected_face_id is not None:
self._highlight_faces(face_ids=[self.selected_face_id])
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
self._highlight_edge(self.selected_edge_id)
2026-07-23 18:35:02 +08:00
def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None:
self.renderer.RemoveAllViewProps()
self.renderer.SetBackground(0.11, 0.13, 0.15)
2026-07-24 17:47:17 +08:00
self.scene_isolated = False
2026-07-23 18:35:02 +08:00
self.model_polydata = model_polydata
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(self.model_polydata)
self.model_actor = vtk.vtkActor()
self.model_actor.SetMapper(mapper)
self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73)
self.model_actor.GetProperty().SetDiffuse(0.82)
self.model_actor.GetProperty().SetSpecular(0.25)
self.model_actor.GetProperty().SetSpecularPower(18)
self.renderer.AddActor(self.model_actor)
self.edge_polydata = edge_polydata
edge_mapper = vtk.vtkPolyDataMapper()
edge_mapper.SetInputData(self.edge_polydata)
self.edge_actor = vtk.vtkActor()
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.renderer.AddActor(self.edge_actor)
self.highlight_actor = None
self.edge_highlight_actor = None
2026-07-24 17:47:17 +08:00
self.hover_face_actor = None
self.hover_edge_actor = None
self.hover_signature = None
self.pending_hover_position = None
2026-07-23 18:35:02 +08:00
self.pick_marker_actor = None
self.edit_preview_actor = None
2026-07-24 17:47:17 +08:00
self.edit_preview_actors = []
2026-07-23 18:35:02 +08:00
self.diff_actors = []
if reset_camera:
self.renderer.ResetCamera()
self.render_window.Render()
2026-07-24 17:47:17 +08:00
def _on_mode_changed(self, mode: str) -> None:
self._clear_hover(render=True)
if hasattr(self, "id_mode_display"):
self.id_mode_display.setText(mode)
2026-07-23 18:35:02 +08:00
def on_left_click(self, _obj, _event) -> None:
if self.operation_in_progress:
self.statusBar().showMessage("编辑计算中,请等待当前操作完成")
return
if self.model is None:
return
mode = self.mode_combo.currentText()
x, y = self.interactor.GetEventPosition()
2026-07-24 17:47:17 +08:00
target = self._pick_selection_target(mode, x, y)
if target is None:
self.statusBar().showMessage("未选中对象")
return
self._clear_hover(render=False)
self._select_pick_target(target)
def on_mouse_move(self, _obj, _event) -> None:
if self.operation_in_progress or self.model is None:
self._clear_hover(render=True)
2026-07-23 18:35:02 +08:00
return
2026-07-24 17:47:17 +08:00
x, y = self.interactor.GetEventPosition()
self.pending_hover_position = (int(x), int(y))
if not self.hover_timer.isActive():
self.hover_timer.start(35)
def _update_hover_target(self) -> None:
if self.operation_in_progress or self.model is None or self.pending_hover_position is None:
self._clear_hover(render=True)
return
x, y = self.pending_hover_position
target = self._pick_selection_target(self.mode_combo.currentText(), x, y)
self._show_hover_target(target)
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None:
if self.model is None:
return None
if mode == "Edge":
edge_hit = self._pick_edge_cell(x, y)
if edge_hit is not None:
return self._edge_target_from_cell_hit(edge_hit)
face_hit = self._pick_face_cell(x, y)
if face_hit is not None:
return self._edge_target_from_face_hit(face_hit)
return None
face_hit = self._pick_face_cell(x, y)
if face_hit is not None:
return self._target_from_face_hit(face_hit, mode)
edge_hit = self._pick_edge_cell(x, y)
if edge_hit is not None:
edge_target = self._edge_target_from_cell_hit(edge_hit)
if edge_target is not None:
return self._target_from_edge_id(int(edge_target["target_id"]), mode, edge_target["pick_position"])
return None
def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None:
if actor is None:
return None
2026-07-23 18:35:02 +08:00
self.picker.InitializePickList()
self.picker.PickFromListOn()
self.picker.AddPickList(actor)
2026-07-24 17:47:17 +08:00
picked = self.picker.Pick(int(x), int(y), 0, self.renderer)
self.picker.PickFromListOff()
2026-07-23 18:35:02 +08:00
if not picked or self.picker.GetCellId() < 0:
2026-07-24 17:47:17 +08:00
return None
return {
"cell_id": int(self.picker.GetCellId()),
"pick_position": _vector_tuple(self.picker.GetPickPosition()),
}
def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None:
hit = self._pick_actor_cell(self.model_actor, x, y)
if hit is None or self.model_polydata is None:
return None
cell_data = self._face_cell_data(int(hit["cell_id"]))
if cell_data is None:
return None
hit.update(cell_data)
return hit
def _pick_edge_cell(self, x: int, y: int) -> dict[str, object] | None:
hit = self._pick_actor_cell(self.edge_actor, x, y)
if hit is None or self.edge_polydata is None:
return None
edge_id = self._edge_id_from_cell(int(hit["cell_id"]))
if edge_id is None:
return None
hit["edge_id"] = edge_id
return hit
def _face_cell_data(self, cell_id: int) -> dict[str, int] | None:
if self.model_polydata is None or cell_id < 0:
return None
face_arr = self.model_polydata.GetCellData().GetArray("face_id")
part_arr = self.model_polydata.GetCellData().GetArray("part_id")
solid_arr = self.model_polydata.GetCellData().GetArray("solid_id")
if face_arr is None or part_arr is None or solid_arr is None:
return None
return {
"face_id": int(face_arr.GetValue(cell_id)),
"part_id": int(part_arr.GetValue(cell_id)),
"solid_id": int(solid_arr.GetValue(cell_id)),
}
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
def _edge_id_from_cell(self, cell_id: int) -> int | None:
if self.edge_polydata is None or cell_id < 0:
return None
edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id")
if edge_arr is None:
return None
return int(edge_arr.GetValue(cell_id))
def _target_from_face_hit(self, hit: dict[str, object], mode: str) -> dict[str, object] | None:
face_id = int(hit["face_id"])
part_id = int(hit["part_id"])
solid_id = int(hit["solid_id"])
pick_position = hit["pick_position"]
if mode == "Part":
if part_id < 0:
return None
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
if mode == "Solid":
if solid_id < 0:
return None
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
if mode == "Feature":
return {"kind": "feature", "target_id": face_id, "pick_position": pick_position}
return {"kind": "face", "target_id": face_id, "pick_position": pick_position}
def _edge_target_from_cell_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
edge_id = hit.get("edge_id")
if edge_id is None:
return None
return {"kind": "edge", "target_id": int(edge_id), "pick_position": hit["pick_position"]}
def _edge_target_from_face_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
if self.model is None:
return None
face_id = int(hit["face_id"])
pick_position = hit["pick_position"]
edge_ids = self.model.face_boundary_edge_ids(face_id)
edge_id = self.model.nearest_edge_id_to_point(edge_ids, pick_position)
if edge_id is None:
return None
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
def _target_from_edge_id(
self,
edge_id: int,
mode: str,
pick_position: tuple[float, float, float] | None,
) -> dict[str, object] | None:
if self.model is None or edge_id < 0 or edge_id >= len(self.model.edges):
return None
info = self.model.edge_info(edge_id)
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
if mode == "Part" and part_id >= 0:
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
if mode == "Solid" and solid_id >= 0:
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
if mode == "Feature" and adjacent_face_ids:
return {"kind": "feature", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
if mode == "Face" and adjacent_face_ids:
return {"kind": "face", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
2026-07-23 18:35:02 +08:00
if mode == "Edge":
2026-07-24 17:47:17 +08:00
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
return None
def _select_pick_target(self, target: dict[str, object]) -> None:
kind = str(target["kind"])
target_id = int(target["target_id"])
pick_position = target.get("pick_position")
if kind == "part":
self.select_part(target_id, pick_position=pick_position)
elif kind == "solid":
self.select_solid(target_id, int(target["part_id"]), pick_position=pick_position)
elif kind == "feature":
self.select_feature(target_id, pick_position=pick_position)
elif kind == "edge":
self.select_edge(target_id, pick_position=pick_position)
2026-07-23 18:35:02 +08:00
else:
2026-07-24 17:47:17 +08:00
self.select_face(target_id, pick_position=pick_position)
2026-07-23 18:35:02 +08:00
def _select_face_cell(
self,
cell_id: int,
mode: str,
pick_position: tuple[float, float, float] | None = None,
) -> None:
if self.model is None or self.model_polydata is None:
return
face_arr = self.model_polydata.GetCellData().GetArray("face_id")
part_arr = self.model_polydata.GetCellData().GetArray("part_id")
solid_arr = self.model_polydata.GetCellData().GetArray("solid_id")
face_id = int(face_arr.GetValue(cell_id))
part_id = int(part_arr.GetValue(cell_id))
solid_id = int(solid_arr.GetValue(cell_id))
if mode == "Part":
self.select_part(part_id, pick_position=pick_position)
elif mode == "Solid":
self.select_solid(solid_id, part_id, pick_position=pick_position)
elif mode == "Feature":
2026-07-24 17:47:17 +08:00
self.select_feature(face_id, pick_position=pick_position)
2026-07-23 18:35:02 +08:00
else:
self.select_face(face_id, pick_position=pick_position)
def _select_edge_from_cell(
self,
cell_id: int,
pick_position: tuple[float, float, float] | None = None,
) -> None:
if self.model is None or self.edge_polydata is None:
return
edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id")
edge_id = int(edge_arr.GetValue(cell_id))
self.select_edge(edge_id, pick_position=pick_position)
def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None:
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能切换零件树选择。"):
return
2026-07-23 18:35:02 +08:00
if current is None:
return
node_kind = current.data(0, PART_TREE_KIND_ROLE)
target_id = current.data(0, PART_TREE_ID_ROLE)
if node_kind == "solid" and target_id is not None:
part_id = current.data(0, PART_TREE_PART_ID_ROLE)
if part_id is not None:
self.mode_combo.setCurrentText("Solid")
self.select_solid(int(target_id), int(part_id))
return
if node_kind in {"part", "assembly"} and target_id is not None:
self.mode_combo.setCurrentText("Part")
self.select_part(int(target_id))
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"):
return
2026-07-23 18:35:02 +08:00
item = self.cylinder_table.item(row, 0)
if item is None:
return
face_id = item.data(Qt.UserRole)
if face_id is None:
return
self.mode_combo.setCurrentText("Feature")
2026-07-24 17:47:17 +08:00
self.select_feature(int(face_id))
2026-07-23 18:35:02 +08:00
def on_editable_row_clicked(self, row: int, _column: int) -> None:
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"):
return
2026-07-23 18:35:02 +08:00
item = self.editable_table.item(row, 0)
if item is None:
return
2026-07-24 17:47:17 +08:00
target_id = item.data(EDITABLE_TARGET_ID_ROLE)
target_kind = item.data(EDITABLE_TARGET_KIND_ROLE)
2026-07-23 18:35:02 +08:00
action = item.data(EDITABLE_ACTION_ROLE)
2026-07-24 17:47:17 +08:00
if target_id is None:
2026-07-23 18:35:02 +08:00
return
if action == "resize_cylinder":
self.mode_combo.setCurrentText("Feature")
2026-07-24 17:47:17 +08:00
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}")
elif action == "resize_boss":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}")
elif action == "suppress_cylinder":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}")
elif action == "resize_depth":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择可调整盲孔深度候选 face {target_id}")
elif action == "inspect_existing_fillet":
self.mode_combo.setCurrentText("Feature")
self.select_feature(int(target_id))
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}")
elif action == "fillet_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}")
elif action == "chamfer_edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可添加倒角 edge {target_id}")
elif action == "resize_edge_length":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}")
elif target_kind == "edge":
self.mode_combo.setCurrentText("Edge")
self.select_edge(int(target_id))
self.statusBar().showMessage(f"已选择 edge {target_id}")
2026-07-23 18:35:02 +08:00
else:
self.mode_combo.setCurrentText("Face")
2026-07-24 17:47:17 +08:00
self.select_face(int(target_id))
self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}")
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
def select_by_id(self, kind: str | None = None) -> None:
2026-07-23 18:35:02 +08:00
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能切换选择对象。"):
return
try:
target_id = int(self.id_input.text())
except ValueError:
QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
return
2026-07-24 17:47:17 +08:00
kind = kind or self.mode_combo.currentText()
2026-07-23 18:35:02 +08:00
try:
if kind == "Part":
if self.model.part_by_id(target_id) is None:
raise ValueError(f"不存在 Part ID {target_id}")
self.mode_combo.setCurrentText("Part")
self.select_part(target_id)
elif kind == "Solid":
if target_id < 0 or target_id >= len(self.model.solids):
raise ValueError(f"不存在 Solid ID {target_id}")
part_id = self.model.solids[target_id][0]
self.mode_combo.setCurrentText("Solid")
self.select_solid(target_id, part_id)
elif kind == "Face":
if target_id < 0 or target_id >= len(self.model.faces):
raise ValueError(f"不存在 Face ID {target_id}")
self.mode_combo.setCurrentText("Face")
self.select_face(target_id)
2026-07-24 17:47:17 +08:00
elif kind == "Feature":
if target_id < 0 or target_id >= len(self.model.faces):
raise ValueError(f"不存在 Feature 来源 Face ID {target_id}")
self.mode_combo.setCurrentText("Feature")
self.select_feature(target_id)
2026-07-23 18:35:02 +08:00
elif kind == "Edge":
if target_id < 0 or target_id >= len(self.model.edges):
raise ValueError(f"不存在 Edge ID {target_id}")
self.mode_combo.setCurrentText("Edge")
self.select_edge(target_id)
2026-07-24 17:47:17 +08:00
self.last_id_kind = kind
2026-07-23 18:35:02 +08:00
except ValueError as exc:
QMessageBox.information(self, "未找到对象", str(exc))
def select_part(self, part_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能选择零件。"):
return
2026-07-23 18:35:02 +08:00
info = self.model.part_info(part_id)
self._reset_selection(clear_highlight=False)
self.selected_kind = "part"
self.selected_part_id = part_id
self.selected_pick_position = pick_position
self._highlight_faces(part_ids=[part_id])
self._show_pick_marker(pick_position)
self._sync_id_picker("Part", part_id)
self.set_info(self._with_pick_info(info, pick_position))
2026-07-24 17:47:17 +08:00
self._update_action_states()
kind_label = _part_tree_kind_label(str(info.get("kind", "part")))
self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position))
2026-07-23 18:35:02 +08:00
def select_solid(
self,
solid_id: int,
part_id: int,
pick_position: tuple[float, float, float] | None = None,
) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能选择 solid。"):
return
2026-07-23 18:35:02 +08:00
info = self.model.solid_info(solid_id)
self._reset_selection(clear_highlight=False)
self.selected_kind = "solid"
self.selected_part_id = part_id
self.selected_solid_id = solid_id
self.selected_pick_position = pick_position
face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id]
self._highlight_faces(face_ids=face_ids)
self._show_pick_marker(pick_position)
self._sync_id_picker("Solid", solid_id)
self.set_info(self._with_pick_info(info, pick_position))
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(self._selection_status(f"已选择 solid {solid_id}", pick_position))
def select_face(
self,
face_id: int,
feature_mode: bool = False,
pick_position: tuple[float, float, float] | None = None,
) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能选择 face。"):
return
2026-07-23 18:35:02 +08:00
self._reset_selection(clear_highlight=False)
self.selected_kind = "feature" if feature_mode else "face"
self.selected_face_id = face_id
self.selected_pick_position = pick_position
info = self.model.face_info(face_id)
if feature_mode:
info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征"
2026-07-24 17:47:17 +08:00
self._sync_cylindrical_edit_inputs(info)
2026-07-23 18:35:02 +08:00
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
self._highlight_faces(face_ids=[face_id])
self._show_pick_marker(pick_position)
2026-07-24 17:47:17 +08:00
self._sync_id_picker("Feature" if feature_mode else "Face", face_id)
2026-07-23 18:35:02 +08:00
self.set_info(self._with_pick_info(info, pick_position))
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(self._selection_status(f"已选择 face {face_id}", pick_position))
2026-07-24 17:47:17 +08:00
def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
return
info = self.model.feature_info(face_id)
self._reset_selection(clear_highlight=False)
self.selected_kind = "feature"
self.selected_face_id = face_id
self.selected_pick_position = pick_position
self._sync_cylindrical_edit_inputs(info)
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids"))
self._highlight_faces(face_ids=highlight_face_ids or [face_id])
self._show_pick_marker(pick_position)
self._sync_id_picker("Feature", face_id)
self.set_info(self._with_pick_info(info, pick_position))
feature_type = str(info.get("feature_type", "局部特征候选"))
self._update_action_states()
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 face {face_id}", pick_position))
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
fillet_radius_suggestion: str | None = None
if "diameter" in info:
feature_guess = str(info.get("feature_guess", ""))
if feature_guess == "boss/outer-round candidate":
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
self.hole_diameter_input.clear()
self.boss_diameter_input.setText(suggested_diameter)
elif feature_guess == "round/fillet candidate":
radius = _float_or_none(info.get("existing_fillet_radius_estimate"))
if radius is None:
radius = _float_or_none(info.get("radius"))
if radius is not None:
fillet_radius_suggestion = _format_float(max(radius * 1.2, 0.01))
self.hole_diameter_input.clear()
self.boss_diameter_input.clear()
else:
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
self.hole_diameter_input.setText(suggested_diameter)
self.boss_diameter_input.clear()
else:
self.hole_diameter_input.clear()
self.boss_diameter_input.clear()
if info.get("cylinder_end_type") == "blind" and "hole_depth_estimate" in info:
self.hole_depth_input.setText(_format_float(float(info["hole_depth_estimate"]) * 1.2))
else:
self.hole_depth_input.clear()
if hasattr(self, "edge_fillet_radius_input"):
if fillet_radius_suggestion is None:
self.edge_fillet_radius_input.clear()
else:
self.edge_fillet_radius_input.setText(fillet_radius_suggestion)
if hasattr(self, "edge_chamfer_distance_input"):
self.edge_chamfer_distance_input.clear()
if hasattr(self, "edge_target_length_input"):
self.edge_target_length_input.clear()
def _sync_edge_edit_inputs(self, info: dict[str, object]) -> None:
self.hole_diameter_input.clear()
self.boss_diameter_input.clear()
self.hole_depth_input.clear()
if info.get("curve") == "line" and "length" in info:
length = float(info["length"])
self.edge_fillet_radius_input.setText(_format_float(max(length * 0.05, 0.01)))
self.edge_chamfer_distance_input.setText(_format_float(max(length * 0.04, 0.01)))
self.edge_target_length_input.setText(_format_float(length))
else:
self.edge_fillet_radius_input.clear()
self.edge_chamfer_distance_input.clear()
self.edge_target_length_input.clear()
2026-07-23 18:35:02 +08:00
def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能选择 edge。"):
return
2026-07-23 18:35:02 +08:00
self._reset_selection(clear_highlight=False)
self.selected_kind = "edge"
self.selected_edge_id = edge_id
self.selected_pick_position = pick_position
info = self.model.edge_info(edge_id)
2026-07-24 17:47:17 +08:00
self._sync_edge_edit_inputs(info)
2026-07-23 18:35:02 +08:00
self.selected_part_id = int(info["part_id"])
2026-07-24 17:47:17 +08:00
self.selected_solid_id = int(info["solid_id"]) if int(info.get("solid_id", -1)) >= 0 else None
2026-07-23 18:35:02 +08:00
self._highlight_edge(edge_id)
self._show_pick_marker(pick_position)
self._sync_id_picker("Edge", edge_id)
self.set_info(self._with_pick_info(info, pick_position))
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(self._selection_status(f"已选择 edge {edge_id}", pick_position))
def _highlight_faces(self, face_ids=None, part_ids=None) -> None:
if self.model is None:
return
self._clear_highlight()
polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
2026-07-24 17:47:17 +08:00
_enable_overlay_depth_offset(mapper)
2026-07-23 18:35:02 +08:00
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1.0, 0.72, 0.08)
2026-07-24 17:47:17 +08:00
actor.GetProperty().SetOpacity(0.82)
actor.GetProperty().SetAmbient(0.45)
actor.GetProperty().SetDiffuse(0.65)
2026-07-23 18:35:02 +08:00
actor.GetProperty().SetSpecular(0.35)
actor.GetProperty().SetLineWidth(2)
self.highlight_actor = actor
self.renderer.AddActor(actor)
self.render_window.Render()
def _highlight_edge(self, edge_id: int) -> None:
if self.model is None:
return
self._clear_highlight()
2026-07-24 17:47:17 +08:00
polydata = self.model.build_edge_polydata(edge_ids=[edge_id])
2026-07-23 18:35:02 +08:00
mapper = vtk.vtkDataSetMapper()
2026-07-24 17:47:17 +08:00
mapper.SetInputData(polydata)
_enable_overlay_depth_offset(mapper)
2026-07-23 18:35:02 +08:00
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1.0, 0.78, 0.0)
2026-07-24 17:47:17 +08:00
actor.GetProperty().SetAmbient(0.7)
actor.GetProperty().SetDiffuse(0.8)
2026-07-23 18:35:02 +08:00
actor.GetProperty().SetLineWidth(5)
self.edge_highlight_actor = actor
self.renderer.AddActor(actor)
self.render_window.Render()
2026-07-24 17:47:17 +08:00
def _show_hover_target(self, target: dict[str, object] | None) -> None:
if self.model is None:
self._clear_hover(render=True)
return
signature = None if target is None else (str(target["kind"]), int(target["target_id"]))
if signature == self.hover_signature:
return
self._clear_hover(render=False)
if target is None:
self.render_window.Render()
return
kind = str(target["kind"])
target_id = int(target["target_id"])
if kind == "part":
self._highlight_hover_faces(part_ids=[target_id])
elif kind == "solid":
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "feature":
info = self.model.feature_info(target_id)
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id]
self._highlight_hover_faces(face_ids=face_ids)
elif kind == "edge":
self._highlight_hover_edge(target_id)
else:
self._highlight_hover_faces(face_ids=[target_id])
self.hover_signature = signature
self.render_window.Render()
def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None:
if self.model is None:
return
polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
_enable_overlay_depth_offset(mapper)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1.0, 0.12, 0.06)
actor.GetProperty().SetOpacity(0.58)
actor.GetProperty().SetAmbient(0.5)
actor.GetProperty().SetDiffuse(0.7)
actor.GetProperty().SetSpecular(0.35)
actor.GetProperty().SetLineWidth(2)
self.hover_face_actor = actor
self.renderer.AddActor(actor)
def _highlight_hover_edge(self, edge_id: int) -> None:
if self.model is None:
return
polydata = self.model.build_edge_polydata(edge_ids=[edge_id])
mapper = vtk.vtkDataSetMapper()
mapper.SetInputData(polydata)
_enable_overlay_depth_offset(mapper)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(1.0, 0.08, 0.02)
actor.GetProperty().SetAmbient(0.75)
actor.GetProperty().SetDiffuse(0.8)
actor.GetProperty().SetLineWidth(4)
self.hover_edge_actor = actor
self.renderer.AddActor(actor)
def _clear_hover(self, render: bool = False) -> None:
if not hasattr(self, "renderer"):
return
removed = False
if self.hover_face_actor is not None:
self.renderer.RemoveActor(self.hover_face_actor)
self.hover_face_actor = None
removed = True
if self.hover_edge_actor is not None:
self.renderer.RemoveActor(self.hover_edge_actor)
self.hover_edge_actor = None
removed = True
self.hover_signature = None
if render and removed and hasattr(self, "render_window"):
self.render_window.Render()
2026-07-23 18:35:02 +08:00
def _clear_highlight(self) -> None:
if self.highlight_actor is not None:
self.renderer.RemoveActor(self.highlight_actor)
self.highlight_actor = None
if self.edge_highlight_actor is not None:
self.renderer.RemoveActor(self.edge_highlight_actor)
self.edge_highlight_actor = None
if self.pick_marker_actor is not None:
self.renderer.RemoveActor(self.pick_marker_actor)
self.pick_marker_actor = None
def clear_edit_preview(self, render: bool = True) -> None:
if self.edit_preview_timer is not None:
self.edit_preview_timer.stop()
2026-07-24 17:47:17 +08:00
if hasattr(self, "renderer"):
for actor in self.edit_preview_actors:
self.renderer.RemoveActor(actor)
if self.edit_preview_actor is not None and self.edit_preview_actor not in self.edit_preview_actors:
self.renderer.RemoveActor(self.edit_preview_actor)
self.edit_preview_actors = []
self.edit_preview_actor = None
2026-07-23 18:35:02 +08:00
if render and hasattr(self, "render_window"):
self.render_window.Render()
2026-07-24 17:47:17 +08:00
def _add_edit_preview_actor(
self,
polydata,
color: tuple[float, float, float],
opacity: float | None = None,
) -> None:
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(*color)
actor.GetProperty().SetOpacity(opacity if opacity is not None else self.edit_preview_base_opacity)
actor.GetProperty().SetSpecular(0.28)
actor.GetProperty().SetLineWidth(1)
self.edit_preview_actors.append(actor)
self.edit_preview_actor = self.edit_preview_actor or actor
self.renderer.AddActor(actor)
2026-07-23 18:35:02 +08:00
def _show_push_pull_preview(self, face_id: int, distance: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.push_pull_preview_polydata(face_id, distance)
except Exception as exc:
self.statusBar().showMessage(f"推拉预览不可用:{exc}")
return
if distance >= 0:
2026-07-24 17:47:17 +08:00
color = (0.0, 0.86, 0.34)
2026-07-23 18:35:02 +08:00
else:
2026-07-24 17:47:17 +08:00
color = (1.0, 0.18, 0.06)
self._add_edit_preview_actor(polydata, color)
2026-07-23 18:35:02 +08:00
self._start_edit_preview_pulse()
self.render_window.Render()
2026-07-24 17:47:17 +08:00
def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None:
if self.model is None:
2026-07-23 18:35:02 +08:00
return
2026-07-24 17:47:17 +08:00
self.clear_edit_preview(render=False)
try:
previews = self.model.cylindrical_resize_preview_polydata(face_id, diameter)
except Exception as exc:
self.statusBar().showMessage(f"孔径调整预览不可用:{exc}")
return
for preview in previews:
role = str(preview["role"])
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
opacity = 0.28 if role == "fill" else 0.32
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
self._start_edit_preview_pulse()
2026-07-23 18:35:02 +08:00
self.render_window.Render()
2026-07-24 17:47:17 +08:00
def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None:
if self.model is None:
2026-07-23 18:35:02 +08:00
return
2026-07-24 17:47:17 +08:00
self.clear_edit_preview(render=False)
try:
previews = self.model.cylindrical_boss_resize_preview_polydata(face_id, diameter)
except Exception as exc:
self.statusBar().showMessage(f"凸台直径调整预览不可用:{exc}")
return
for preview in previews:
role = str(preview["role"])
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
opacity = 0.3 if role == "fill" else 0.34
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
self._start_edit_preview_pulse()
self.render_window.Render()
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
def _show_cylinder_suppress_preview(self, face_id: int) -> None:
2026-07-23 18:35:02 +08:00
if self.model is None:
2026-07-24 17:47:17 +08:00
return
self.clear_edit_preview(render=False)
try:
previews = self.model.cylindrical_suppress_preview_polydata(face_id)
except Exception as exc:
self.statusBar().showMessage(f"封堵圆柱孔预览不可用:{exc}")
return
for preview in previews:
self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3)
self._start_edit_preview_pulse()
self.render_window.Render()
2026-07-23 18:35:02 +08:00
2026-07-24 17:47:17 +08:00
def _show_cylinder_depth_preview(self, face_id: int, target_depth: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
previews = self.model.cylindrical_depth_preview_polydata(face_id, target_depth)
except Exception as exc:
self.statusBar().showMessage(f"孔深调整预览不可用:{exc}")
return
for preview in previews:
role = str(preview["role"])
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
opacity = 0.3 if role == "fill" else 0.34
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
previews = self.model.existing_fillet_resize_preview_polydata(face_id, target_radius)
except Exception as exc:
self.statusBar().showMessage(f"已有圆角半径修改预览不可用:{exc}")
return
for preview in previews:
self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36)
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None:
if self.model is None:
return
self._show_edge_tube_preview(edge_id, radius, (0.1, 0.62, 1.0), "圆角预览")
def _show_edge_chamfer_preview(self, edge_id: int, distance: float) -> None:
if self.model is None:
return
self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览")
def _show_edge_length_preview(self, edge_id: int, target_length: float) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
try:
polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length)
except Exception as exc:
self.statusBar().showMessage(f"直线边长度调整预览不可用:{exc}")
return
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
self._start_edit_preview_pulse()
self.render_window.Render()
def _show_edge_tube_preview(
self,
edge_id: int,
radius: float,
color: tuple[float, float, float],
label: str,
) -> None:
if self.model is None:
return
self.clear_edit_preview(render=False)
polydata = self.model.build_edge_polydata()
edge_arr = polydata.GetCellData().GetArray("edge_id")
ids = vtk.vtkIdList()
for cell_id in range(polydata.GetNumberOfCells()):
if int(edge_arr.GetValue(cell_id)) == edge_id:
ids.InsertNextId(cell_id)
break
if ids.GetNumberOfIds() == 0:
self.statusBar().showMessage(f"{label}不可用:没有找到选中的 edge。")
return
extract = vtk.vtkExtractCells()
extract.SetInputData(polydata)
extract.SetCellList(ids)
extract.Update()
geometry = vtk.vtkGeometryFilter()
geometry.SetInputConnection(extract.GetOutputPort())
tube = vtk.vtkTubeFilter()
tube.SetInputConnection(geometry.GetOutputPort())
tube.SetRadius(radius)
tube.SetNumberOfSides(24)
tube.CappingOn()
tube.Update()
self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34)
self._start_edit_preview_pulse()
self.render_window.Render()
def _start_edit_preview_pulse(self) -> None:
if self.edit_preview_timer is None:
self.edit_preview_timer = QTimer(self)
self.edit_preview_timer.timeout.connect(self._pulse_edit_preview)
self.edit_preview_phase = 0.0
self.edit_preview_timer.start(120)
def _pulse_edit_preview(self) -> None:
if not self.edit_preview_actors:
if self.edit_preview_timer is not None:
self.edit_preview_timer.stop()
return
self.edit_preview_phase += 0.35
opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0)
for actor in self.edit_preview_actors:
actor.GetProperty().SetOpacity(opacity)
self.render_window.Render()
def clear_diff_preview(self, render: bool = True) -> None:
if not hasattr(self, "renderer"):
self.diff_actors.clear()
return
for actor in self.diff_actors:
self.renderer.RemoveViewProp(actor)
self.diff_actors.clear()
if render and hasattr(self, "render_window"):
self.render_window.Render()
self.statusBar().showMessage("已清除差异预览")
def _show_operation_diff(self, record: OperationRecord) -> str:
if self.model is None:
return ""
self.clear_diff_preview(render=False)
if record.before_snapshot is None or record.after_snapshot is None:
return "差异预览: 这条历史记录没有可显示的前后模型快照。"
before_polydata = self.model.build_snapshot_polydata(record.before_snapshot)
after_polydata = self.model.build_snapshot_polydata(record.after_snapshot)
before_actor = self._make_diff_actor(before_polydata, color=(1.0, 0.16, 0.08), opacity=0.24)
after_actor = self._make_diff_actor(after_polydata, color=(0.0, 0.9, 0.28), opacity=0.16)
2026-07-23 18:35:02 +08:00
heatmap_actor, scalar_bar, heatmap_stats = self._make_distance_heatmap_props(before_polydata, after_polydata)
self.diff_actors = [before_actor, after_actor]
if heatmap_actor is not None:
self.diff_actors.append(heatmap_actor)
if scalar_bar is not None:
self.diff_actors.append(scalar_bar)
for actor in self.diff_actors:
self.renderer.AddViewProp(actor)
self.render_window.Render()
if heatmap_stats:
record.diff_stats = heatmap_stats
return (
"差异预览: 红色半透明为编辑前,绿色半透明为编辑后;"
"热力图覆盖在编辑后模型上,蓝色接近无变化,黄色/红色表示变化更大。\n"
f"热力图统计: max_distance={_format_value(heatmap_stats['max_distance'])}, "
f"mean_distance={_format_value(heatmap_stats['mean_distance'])}, "
f"changed_points={heatmap_stats['changed_points']}/{heatmap_stats['points']} "
f"({_format_value(heatmap_stats['changed_ratio'] * 100.0)}%)."
)
return "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;热力图无法生成。"
def _ensure_record_diff_stats(self, record: OperationRecord) -> dict[str, object]:
if record.diff_stats is not None:
return record.diff_stats
if self.model is None or record.before_snapshot is None or record.after_snapshot is None:
record.diff_stats = {}
return record.diff_stats
before_polydata = self.model.build_snapshot_polydata(record.before_snapshot)
after_polydata = self.model.build_snapshot_polydata(record.after_snapshot)
_heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata)
record.diff_stats = stats
return stats
def _diff_report_text(self, record: OperationRecord, history_index: int) -> str:
lines = [
"STEP 编辑差异报告",
"",
f"generated_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
f"source_file: {self.step_path}",
f"history_index: {history_index}",
f"summary: {record.summary}",
"",
"操作详情:",
record.detail,
"",
"距离热力图统计:",
]
stats = record.diff_stats or {}
if stats:
lines.extend(
[
f" points: {stats.get('points', '')}",
f" max_distance: {_format_value(stats.get('max_distance', ''))}",
f" mean_distance: {_format_value(stats.get('mean_distance', ''))}",
f" changed_points: {stats.get('changed_points', '')}",
f" changed_ratio: {_format_value(float(stats.get('changed_ratio', 0.0)) * 100.0)}%",
f" changed_threshold: {_format_value(stats.get('changed_threshold', ''))}",
]
)
else:
lines.append(" unavailable")
lines.extend(
[
"",
"说明:",
" 热力图使用“编辑后模型顶点到编辑前模型表面”的距离估算。",
" 它适合做可视化诊断和修改留档,不等同于完整 CAD 公差报告。",
]
)
return "\n".join(lines)
def _make_diff_actor(self, polydata, color: tuple[float, float, float], opacity: float):
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(polydata)
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(*color)
actor.GetProperty().SetOpacity(opacity)
actor.GetProperty().SetSpecular(0.15)
actor.GetProperty().SetLineWidth(1)
return actor
def _make_distance_heatmap_props(self, before_polydata, after_polydata):
heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata)
if heat_polydata is None:
return None, None, {}
max_distance = float(stats["max_distance"])
lut = self._make_heatmap_lookup_table(max_distance)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputData(heat_polydata)
mapper.SetLookupTable(lut)
mapper.SetScalarRange(0.0, max(max_distance, 1e-9))
mapper.SetScalarModeToUsePointData()
mapper.ScalarVisibilityOn()
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetOpacity(0.82)
actor.GetProperty().SetSpecular(0.22)
actor.GetProperty().SetSpecularPower(16)
scalar_bar = vtk.vtkScalarBarActor()
scalar_bar.SetLookupTable(lut)
scalar_bar.SetTitle("distance")
scalar_bar.SetNumberOfLabels(4)
scalar_bar.SetWidth(0.08)
scalar_bar.SetHeight(0.32)
scalar_bar.SetPosition(0.89, 0.05)
scalar_bar.GetTitleTextProperty().SetColor(1.0, 1.0, 1.0)
scalar_bar.GetLabelTextProperty().SetColor(1.0, 1.0, 1.0)
return actor, scalar_bar, stats
def _build_distance_heatmap_polydata(self, before_polydata, after_polydata):
point_count = after_polydata.GetNumberOfPoints()
if before_polydata.GetNumberOfPoints() == 0 or point_count == 0:
return None, {}
heat_polydata = vtk.vtkPolyData()
heat_polydata.DeepCopy(after_polydata)
distance = vtk.vtkImplicitPolyDataDistance()
distance.SetInput(before_polydata)
values = vtk.vtkFloatArray()
values.SetName("edit_distance")
values.SetNumberOfValues(point_count)
max_distance = 0.0
total_distance = 0.0
raw_values: list[float] = []
for point_id in range(point_count):
point = heat_polydata.GetPoint(point_id)
value = abs(float(distance.EvaluateFunction(point)))
raw_values.append(value)
values.SetValue(point_id, value)
total_distance += value
max_distance = max(max_distance, value)
heat_polydata.GetPointData().SetScalars(values)
changed_threshold = max(max_distance * 0.01, 1e-6)
changed_points = sum(1 for value in raw_values if value > changed_threshold)
stats = {
"points": point_count,
"max_distance": max_distance,
"mean_distance": total_distance / point_count,
"changed_points": changed_points,
"changed_ratio": changed_points / point_count,
"changed_threshold": changed_threshold,
}
return heat_polydata, stats
def _make_heatmap_lookup_table(self, max_distance: float):
lut = vtk.vtkLookupTable()
lut.SetNumberOfTableValues(256)
lut.SetRange(0.0, max(max_distance, 1e-9))
lut.Build()
stops = [
(0.0, (0.08, 0.16, 0.85)),
(0.35, (0.0, 0.72, 1.0)),
(0.7, (1.0, 0.9, 0.08)),
(1.0, (1.0, 0.08, 0.02)),
]
for index in range(256):
t = index / 255.0
left = stops[0]
right = stops[-1]
for stop_index in range(len(stops) - 1):
if stops[stop_index][0] <= t <= stops[stop_index + 1][0]:
left = stops[stop_index]
right = stops[stop_index + 1]
break
span = max(right[0] - left[0], 1e-9)
local_t = (t - left[0]) / span
color = tuple(left[1][axis] + (right[1][axis] - left[1][axis]) * local_t for axis in range(3))
lut.SetTableValue(index, color[0], color[1], color[2], 1.0)
return lut
def _reset_selection(self, clear_highlight: bool = True) -> None:
self.selected_kind = None
self.selected_part_id = None
self.selected_solid_id = None
self.selected_face_id = None
self.selected_edge_id = None
self.selected_pick_position = None
if clear_highlight:
self._clear_highlight()
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None:
if pick_position is None:
return
radius = self._pick_marker_radius()
sphere = vtk.vtkSphereSource()
sphere.SetCenter(*pick_position)
sphere.SetRadius(radius)
sphere.SetThetaResolution(20)
sphere.SetPhiResolution(12)
mapper = vtk.vtkPolyDataMapper()
mapper.SetInputConnection(sphere.GetOutputPort())
actor = vtk.vtkActor()
actor.SetMapper(mapper)
actor.GetProperty().SetColor(0.1, 0.95, 0.95)
actor.GetProperty().SetSpecular(0.4)
actor.GetProperty().SetSpecularPower(18)
self.pick_marker_actor = actor
self.renderer.AddActor(actor)
self.render_window.Render()
def _pick_marker_radius(self) -> float:
bounds = self.model_actor.GetBounds() if self.model_actor is not None else None
if bounds is None:
return 1.0
dx = bounds[1] - bounds[0]
dy = bounds[3] - bounds[2]
dz = bounds[5] - bounds[4]
diagonal = math.sqrt(dx * dx + dy * dy + dz * dz)
return max(diagonal * 0.004, 0.1)
def _with_pick_info(
self,
info: dict[str, object],
pick_position: tuple[float, float, float] | None,
) -> dict[str, object]:
if pick_position is None:
return info
enriched = dict(info)
enriched["pick_position"] = pick_position
return enriched
def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str:
if pick_position is None:
return message
return f"{message},拾取点 {_format_value(pick_position)}"
def _edit_busy(self, message: str = "编辑计算中,请等待当前操作完成。") -> bool:
if not self.operation_in_progress:
return False
self.statusBar().showMessage(message)
return True
def _sync_id_picker(self, kind: str, target_id: int) -> None:
2026-07-24 17:47:17 +08:00
self.last_id_kind = kind
2026-07-23 18:35:02 +08:00
self.id_input.setText(str(target_id))
2026-07-24 17:47:17 +08:00
def _update_action_states(self) -> None:
if not hasattr(self, "export_all_button"):
return
has_model = self.model is not None and not self.operation_in_progress
selected_kind = self.selected_kind
self.export_all_button.setEnabled(has_model)
self.export_check_button.setEnabled(has_model)
self.export_part_button.setEnabled(has_model and selected_kind == "part" and self.selected_part_id is not None)
self.export_solid_button.setEnabled(has_model and selected_kind == "solid" and self.selected_solid_id is not None)
self.export_face_button.setEnabled(has_model and selected_kind == "face" and self.selected_face_id is not None)
self.export_feature_button.setEnabled(has_model and selected_kind == "feature" and self.selected_face_id is not None)
self.export_edge_button.setEnabled(has_model and selected_kind == "edge" and self.selected_edge_id is not None)
self._update_edit_action_states(has_model)
def _update_edit_action_states(self, has_model: bool) -> None:
if not hasattr(self, "push_button"):
return
action_info = self._selected_action_info()
surface = str(action_info.get("surface", ""))
curve = str(action_info.get("curve", ""))
feature_guess = str(action_info.get("feature_guess", ""))
angular_span = _float_or_none(action_info.get("angular_span"))
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
is_plane = has_face and surface == "plane"
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
is_blind = action_info.get("cylinder_end_type") == "blind"
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
self._set_control_state(
self.push_button,
has_model and is_plane,
"对当前平面 face 执行推拉。",
"请先选择一个平面 face,或在 Feature 模式下选择可推拉平面候选。",
)
self._set_control_state(
self.resize_button,
has_model and is_hole_or_groove,
"调整当前圆柱孔/槽候选的直径。",
"请先选择被识别为孔/槽候选的圆柱 face。",
)
self._set_control_state(
self.resize_boss_button,
has_model and is_boss and is_full_cylinder,
"调整当前完整圆柱凸台候选的直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 face。",
)
self._set_control_state(
self.suppress_button,
has_model and is_hole_or_groove and is_full_cylinder,
"封堵当前完整圆柱孔候选。",
"请先选择接近完整圆柱的孔候选;半孔/槽不会放行。",
)
self._set_control_state(
self.resize_depth_button,
has_model and is_hole_or_groove and is_blind and has_bottom,
"调整当前已识别底面的盲孔/盲槽深度。",
"请先选择已识别出疑似底面的盲孔/盲槽候选。",
)
self._set_control_state(
self.fillet_edge_button,
has_model and is_line_edge,
"给当前直线 edge 添加新圆角。",
"请先选择一条直线 edge。",
)
self._set_control_state(
self.resize_existing_fillet_button,
has_model and is_existing_fillet and has_fillet_support,
"尝试修改当前已有圆角/倒圆候选的半径。",
"请先选择一个已有圆角/倒圆候选 face;第一版需要识别到至少两个支撑 face。",
)
self._set_control_state(
self.chamfer_edge_button,
has_model and is_line_edge,
"给当前直线 edge 添加倒角。",
"请先选择一条直线 edge。",
)
self._set_control_state(
self.resize_edge_length_button,
has_model and is_line_edge,
"通过移动端面尝试调整当前直线 edge 的长度。",
"请先选择一条直线 edge;第一版会寻找可推拉的端面来改变长度。",
)
self._set_control_state(
self.translate_part_button,
has_model and self.selected_part_id is not None,
"平移当前选中对象所属零件。",
"请先选择一个零件,或选择属于某个零件的对象。",
)
self._set_control_state(
self.rotate_part_button,
has_model and self.selected_part_id is not None,
"旋转当前选中对象所属零件。",
"请先选择一个零件,或选择属于某个零件的对象。",
)
self._set_control_state(
self.translate_solid_button,
has_model and self.selected_solid_id is not None,
"平移当前选中对象所属 solid。",
"请先选择一个 solid,或选择属于某个 solid 的 face/edge。",
)
self._set_control_state(
self.rotate_solid_button,
has_model and self.selected_solid_id is not None,
"旋转当前选中对象所属 solid。",
"请先选择一个 solid,或选择属于某个 solid 的 face/edge。",
)
self._set_control_state(
self.cylinders_button,
has_model,
"扫描当前模型中的圆柱候选。",
"请先加载 STEP 文件。",
)
self._set_control_state(
self.undo_button,
has_model and bool(self.undo_stack),
"撤销上一步编辑。",
"当前没有可撤销的编辑。",
)
self._set_control_state(
self.redo_button,
has_model and bool(self.redo_stack),
"重做刚撤销的编辑。",
"当前没有可重做的编辑。",
)
def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None:
widget.setEnabled(enabled)
widget.setToolTip(enabled_tip if enabled else disabled_tip)
def _selected_action_info(self) -> dict[str, object]:
if self.model is None:
return {}
try:
if self.selected_kind == "feature" and self.selected_face_id is not None:
return self.model.feature_info(self.selected_face_id)
if self.selected_kind == "face" and self.selected_face_id is not None:
info = self.model.face_info(self.selected_face_id)
if info.get("surface") == "cylinder":
return self.model.feature_info(self.selected_face_id)
return info
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return self.model.edge_info(self.selected_edge_id)
except Exception:
return dict(self.current_info_values)
return dict(self.current_info_values)
2026-07-23 18:35:02 +08:00
def _locate_operation_record(self, record: OperationRecord) -> str:
if self.model is None:
return ""
self._reset_selection(clear_highlight=True)
located = False
locator_note = ""
if record.target_kind == "face" and record.target_id is not None:
if 0 <= record.target_id < len(self.model.faces):
info = self.model.face_info(record.target_id)
self.selected_kind = "face"
self.selected_face_id = record.target_id
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
self.selected_pick_position = record.pick_position
self.mode_combo.setCurrentText("Face")
self._sync_id_picker("Face", record.target_id)
self._highlight_faces(face_ids=[record.target_id])
located = True
locator_note = (
f"定位: 已尝试高亮当前模型中的 face {record.target_id}。"
"布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。"
)
else:
locator_note = f"定位: 原目标 face {record.target_id} 在当前模型索引中已经不存在。"
2026-07-24 17:47:17 +08:00
elif record.target_kind == "edge" and record.target_id is not None:
if 0 <= record.target_id < len(self.model.edges):
info = self.model.edge_info(record.target_id)
self.selected_kind = "edge"
self.selected_edge_id = record.target_id
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info.get("solid_id", -1)) if int(info.get("solid_id", -1)) >= 0 else None
self.selected_pick_position = record.pick_position
self.mode_combo.setCurrentText("Edge")
self._sync_id_picker("Edge", record.target_id)
self._highlight_edge(record.target_id)
located = True
locator_note = (
f"定位: 已尝试高亮当前模型中的 edge {record.target_id}。"
"布尔/倒圆编辑后 edge ID 可能发生语义变化,请结合拾取点确认。"
)
else:
locator_note = f"定位: 原目标 edge {record.target_id} 在当前模型索引中已经不存在。"
2026-07-23 18:35:02 +08:00
if record.pick_position is not None:
self._show_pick_marker(record.pick_position)
if not locator_note:
locator_note = "定位: 已显示当时记录的拾取点。"
elif located:
locator_note += f"\n拾取点: {_format_value(record.pick_position)}"
else:
locator_note += f"\n已显示当时记录的拾取点: {_format_value(record.pick_position)}"
if not locator_note:
locator_note = "定位: 这条历史记录没有可定位的目标或拾取点。"
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(locator_note.splitlines()[0])
return locator_note
def undo_edit(self) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能撤销。"):
return
2026-07-23 18:35:02 +08:00
if not self.undo_stack:
self.statusBar().showMessage("没有可撤销的编辑")
return
current = self.model.snapshot()
2026-07-24 17:47:17 +08:00
snapshot = self.undo_stack[-1]
undone = self.operation_history[-1] if self.operation_history else OperationRecord("编辑", "编辑")
try:
self._restore_snapshot(snapshot)
except Exception as exc:
rollback_message = self._restore_after_failed_undo_redo(current)
QMessageBox.critical(self, "撤销失败", f"{exc}\n\n{rollback_message}")
self.statusBar().showMessage("撤销失败,模型已尽量恢复到撤销前状态")
return
self.undo_stack.pop()
if self.operation_history:
self.operation_history.pop()
2026-07-23 18:35:02 +08:00
self.redo_stack.append(current)
self.redo_history.append(undone)
self._refresh_history_list()
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(f"已撤销:{undone.summary}")
def redo_edit(self) -> None:
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("编辑计算中,暂时不能重做。"):
return
2026-07-23 18:35:02 +08:00
if not self.redo_stack:
self.statusBar().showMessage("没有可重做的编辑")
return
current = self.model.snapshot()
2026-07-24 17:47:17 +08:00
snapshot = self.redo_stack[-1]
redone = self.redo_history[-1] if self.redo_history else OperationRecord("编辑", "编辑")
try:
self._restore_snapshot(snapshot)
except Exception as exc:
rollback_message = self._restore_after_failed_undo_redo(current)
QMessageBox.critical(self, "重做失败", f"{exc}\n\n{rollback_message}")
self.statusBar().showMessage("重做失败,模型已尽量恢复到重做前状态")
return
self.redo_stack.pop()
if self.redo_history:
self.redo_history.pop()
2026-07-23 18:35:02 +08:00
self.undo_stack.append(current)
self.operation_history.append(redone)
self._refresh_history_list()
2026-07-24 17:47:17 +08:00
self._update_action_states()
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(f"已重做:{redone.summary}")
def _restore_snapshot(self, snapshot: dict[int, object]) -> None:
if self.model is None:
return
self.model.restore_snapshot(snapshot)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene(reset_camera=False)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
stats = self.model.stats()
self.set_info(
{
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
}
)
2026-07-24 17:47:17 +08:00
def _restore_after_failed_undo_redo(self, snapshot: dict[int, object]) -> str:
try:
self._restore_snapshot(snapshot)
except Exception as rollback_exc:
return f"恢复原状态也失败:{rollback_exc}。建议重新加载 STEP 文件。"
return "模型已恢复到操作前状态,历史记录未移动。"
2026-07-23 18:35:02 +08:00
def export_all(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
2026-07-24 17:47:17 +08:00
if not self._confirm_export_quality("all"):
return
2026-07-23 18:35:02 +08:00
target, _ = QFileDialog.getSaveFileName(
self,
"导出当前完整 STEP",
str(self.step_path.parent / f"{self.step_path.stem}_edited.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(lambda: self.model.export_all(target), f"已导出 {Path(target).name}")
def export_selected_part(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个零件。")
return
2026-07-24 17:47:17 +08:00
if not self._confirm_export_quality("part", self.selected_part_id):
return
2026-07-23 18:35:02 +08:00
part = self.model.part_by_id(self.selected_part_id)
default_name = f"{part.name if part else 'part'}_export.step".replace(" ", "_")
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中零件",
str(self.step_path.parent / default_name),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_part(self.selected_part_id, target),
f"已导出选中零件到 {Path(target).name}",
)
def export_selected_solid(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_solid_id is None:
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择一个属于 solid 的 face。")
return
2026-07-24 17:47:17 +08:00
if not self._confirm_export_quality("solid", self.selected_solid_id):
return
2026-07-23 18:35:02 +08:00
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中 solid",
str(self.step_path.parent / f"solid_{self.selected_solid_id}_export.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_solid(self.selected_solid_id, target),
f"已导出选中 solid 到 {Path(target).name}",
)
def export_selected_face(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择 face", "请先切换到 Face 或 Feature 模式并选择一个 face。")
return
2026-07-24 17:47:17 +08:00
if not self._confirm_export_quality("face", self.selected_face_id):
return
2026-07-23 18:35:02 +08:00
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中 face",
str(self.step_path.parent / f"face_{self.selected_face_id}_export.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_face(self.selected_face_id, target),
f"已导出选中 face 到 {Path(target).name}",
)
2026-07-24 17:47:17 +08:00
def export_selected_feature(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。")
return
if not self._confirm_export_quality("feature", self.selected_face_id):
return
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中特征区域",
str(self.step_path.parent / f"feature_face_{self.selected_face_id}_export.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_feature(self.selected_face_id, target),
f"已导出选中特征区域到 {Path(target).name}",
)
def export_selected_edge(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择 edge", "请先切换到 Edge 模式并选择一个 edge。")
return
if not self._confirm_export_quality("edge", self.selected_edge_id):
return
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中 edge",
str(self.step_path.parent / f"edge_{self.selected_edge_id}_export.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_edge(self.selected_edge_id, target),
f"已导出选中 edge 到 {Path(target).name}",
)
def check_export_quality(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能检查导出质量。"):
return
scope, target_id = self._current_export_quality_target()
try:
info = self.model.export_quality_info(scope, target_id)
except Exception as exc:
QMessageBox.critical(self, "质量检查失败", str(exc))
self.statusBar().showMessage("导出质量检查失败")
return
report = _export_quality_text(info)
self.set_plain_info(report)
if info.get("quality_status") == "ok":
self.statusBar().showMessage("导出质量检查通过")
else:
self.statusBar().showMessage("导出质量检查发现警告")
def _current_export_quality_target(self) -> tuple[str, int | None]:
if self.selected_kind == "feature" and self.selected_face_id is not None:
return "feature", self.selected_face_id
if self.selected_kind == "face" and self.selected_face_id is not None:
return "face", self.selected_face_id
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return "edge", self.selected_edge_id
if self.selected_kind == "edge":
if self.selected_solid_id is not None:
return "solid", self.selected_solid_id
if self.selected_part_id is not None:
return "part", self.selected_part_id
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return "solid", self.selected_solid_id
if self.selected_kind == "part" and self.selected_part_id is not None:
return "part", self.selected_part_id
return "all", None
def _confirm_export_quality(self, scope: str, target_id: int | None = None) -> bool:
if self.model is None:
return False
try:
info = self.model.export_quality_info(scope, target_id)
except Exception as exc:
QMessageBox.critical(self, "导出质量检查失败", str(exc))
self.statusBar().showMessage("导出质量检查失败")
return False
report = _export_quality_text(info)
self.set_plain_info(report)
if info.get("quality_status") == "ok":
return True
result = QMessageBox.question(
self,
"导出质量警告",
f"{report}\n\n仍然继续导出吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return result == QMessageBox.StandardButton.Yes
2026-07-23 18:35:02 +08:00
def push_pull_face(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再执行新的推拉。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择面", "请先选择一个平面 face。")
return
try:
distance = float(self.offset_input.text())
except ValueError:
QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。")
return
face_id = self.selected_face_id
2026-07-24 17:47:17 +08:00
plan = self.model.push_pull_plan(face_id, distance)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能推拉平面", str(plan["message"]))
self.statusBar().showMessage("推拉平面已阻止")
2026-07-23 18:35:02 +08:00
return
2026-07-24 17:47:17 +08:00
if plan["risk"] != "low":
result = QMessageBox.question(
self,
"确认推拉平面",
(
f"face: {plan['face_id']}\n"
f"推拉距离: {_format_value(plan['distance'])}\n"
f"共面推拉范围: {plan.get('push_pull_scope_face_count', 1)} 个 face\n"
f"{plan.get('push_pull_scope_note', '')}\n"
f"风险: {plan['risk']}\n"
f"方向置信度: {plan['direction_confidence']}\n"
f"方向说明: {plan['direction_note']}\n"
f"face 尺寸参考: bbox_diagonal={_format_value(plan.get('bbox_diagonal', ''))}\n\n"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消推拉平面")
return
2026-07-23 18:35:02 +08:00
self._show_push_pull_preview(face_id, distance)
def action():
return self.model.push_pull_face(face_id, distance)
self._run_edit_action(
action,
operation_name="推拉平面",
target=f"face {face_id}",
parameters={
2026-07-24 17:47:17 +08:00
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
2026-07-23 18:35:02 +08:00
"semantic_distance": distance,
"distance_rule": "positive=outward fuse, negative=inward cut",
2026-07-24 17:47:17 +08:00
"surface": plan.get("surface"),
"outward_direction": plan.get("outward_direction"),
"direction_confidence": plan.get("direction_confidence"),
"direction_note": plan.get("direction_note"),
"push_pull_risk": plan.get("risk"),
"push_pull_status": plan.get("status"),
"push_pull_message": plan.get("message"),
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
"push_pull_scope_note": plan.get("push_pull_scope_note"),
"bbox_diagonal": plan.get("bbox_diagonal"),
2026-07-23 18:35:02 +08:00
},
target_kind="face",
target_id=face_id,
)
def resize_hole(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再调整孔径。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱面。")
return
try:
diameter = float(self.hole_diameter_input.text())
except ValueError:
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。")
return
info = self.model.face_info(self.selected_face_id)
if "diameter" not in info:
QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱孔径。")
return
plan = self.model.cylindrical_resize_plan(self.selected_face_id, diameter)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整孔径", str(plan["message"]))
self.statusBar().showMessage("圆柱孔径调整已阻止")
return
guess = str(info.get("feature_guess", "cylindrical face"))
if plan["risk"] != "low":
2026-07-24 17:47:17 +08:00
target_to_height_ratio = plan.get("target_to_height_ratio", "")
target_to_height_line = (
""
if target_to_height_ratio is None or target_to_height_ratio == ""
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
)
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
2026-07-23 18:35:02 +08:00
result = QMessageBox.question(
self,
"确认圆柱孔径调整",
(
f"face: {plan['face_id']}\n"
f"当前直径: {_format_value(plan['current_diameter'])}\n"
f"目标直径: {_format_value(plan['target_diameter'])}\n"
2026-07-24 17:47:17 +08:00
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
f"{target_to_height_line}"
2026-07-23 18:35:02 +08:00
f"调整模式: {plan['resize_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n"
2026-07-24 17:47:17 +08:00
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
2026-07-23 18:35:02 +08:00
f"风险: {plan['risk']}\n\n"
f"Cutter: {plan['cutter_strategy']}\n"
f"Cutter 高度: {_format_value(plan['cutter_height'])}\n"
f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / "
f"{_format_value(plan['cutter_end_margin'])}\n\n"
2026-07-24 17:47:17 +08:00
f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n"
f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n"
2026-07-23 18:35:02 +08:00
f"补料策略: {plan.get('fill_strategy', '')}\n\n"
2026-07-24 17:47:17 +08:00
f"{warnings_line}"
2026-07-23 18:35:02 +08:00
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆柱孔径调整")
return
face_id = self.selected_face_id
2026-07-24 17:47:17 +08:00
self._show_cylinder_resize_preview(face_id, diameter)
2026-07-23 18:35:02 +08:00
def action():
return self.model.resize_cylindrical_hole(face_id, diameter)
self._run_edit_action(
action,
operation_name="调整圆柱孔径",
target=f"face {face_id}",
parameters={
2026-07-24 17:47:17 +08:00
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
2026-07-23 18:35:02 +08:00
"new_diameter": diameter,
"old_diameter": info.get("diameter"),
"delta_diameter": plan.get("delta_diameter"),
2026-07-24 17:47:17 +08:00
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"target_to_height_ratio": plan.get("target_to_height_ratio"),
2026-07-23 18:35:02 +08:00
"resize_mode": plan.get("resize_mode"),
2026-07-24 17:47:17 +08:00
"feature_type": plan.get("feature_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_note": plan.get("feature_bottom_note"),
2026-07-23 18:35:02 +08:00
"feature_guess": guess,
"confidence": plan.get("confidence"),
2026-07-24 17:47:17 +08:00
"resize_status": plan.get("status"),
2026-07-23 18:35:02 +08:00
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
2026-07-24 17:47:17 +08:00
"resize_warnings": plan.get("warnings"),
"resize_blockers": plan.get("blockers"),
2026-07-23 18:35:02 +08:00
"material_vote_summary": plan.get("material_vote_summary"),
"cylinder_end_type": plan.get("cylinder_end_type"),
"hole_depth_estimate": plan.get("hole_depth_estimate"),
"start_end_state": plan.get("start_end_state"),
"end_end_state": plan.get("end_end_state"),
"cutter_strategy": plan.get("cutter_strategy"),
"cutter_height": plan.get("cutter_height"),
"cutter_margin": plan.get("cutter_margin"),
"cutter_start_margin": plan.get("cutter_start_margin"),
"cutter_end_margin": plan.get("cutter_end_margin"),
2026-07-24 17:47:17 +08:00
"cutter_bottom_protection": plan.get("cutter_bottom_protection"),
"cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"),
"cutter_opening_face_ids": plan.get("cutter_opening_face_ids"),
"cutter_bottom_note": plan.get("cutter_bottom_note"),
2026-07-23 18:35:02 +08:00
"cutter_note": plan.get("cutter_note"),
"fill_strategy": plan.get("fill_strategy"),
"fill_height": plan.get("fill_height"),
"fill_radius": plan.get("fill_radius"),
"fill_radius_overlap": plan.get("fill_radius_overlap"),
"fill_note": plan.get("fill_note"),
},
target_kind="face",
target_id=face_id,
)
2026-07-24 17:47:17 +08:00
def resize_boss(self) -> None:
2026-07-23 18:35:02 +08:00
if self.model is None:
return
2026-07-24 17:47:17 +08:00
if self._edit_busy("请等待当前编辑完成后再调整凸台直径。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱凸台", "请先选择一个圆柱凸台候选 face。")
2026-07-23 18:35:02 +08:00
return
try:
2026-07-24 17:47:17 +08:00
diameter = float(self.boss_diameter_input.text())
except ValueError:
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标凸台直径。")
2026-07-23 18:35:02 +08:00
return
2026-07-24 17:47:17 +08:00
info = self.model.face_info(self.selected_face_id)
if "diameter" not in info:
QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。")
return
plan = self.model.cylindrical_boss_resize_plan(self.selected_face_id, diameter)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整凸台直径", str(plan["message"]))
self.statusBar().showMessage("圆柱凸台直径调整已阻止")
return
if plan["risk"] != "low":
target_to_height_ratio = plan.get("target_to_height_ratio", "")
target_to_height_line = (
""
if target_to_height_ratio is None or target_to_height_ratio == ""
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
2026-07-23 18:35:02 +08:00
)
2026-07-24 17:47:17 +08:00
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认圆柱凸台直径调整",
(
f"face: {plan['face_id']}\n"
f"当前凸台直径: {_format_value(plan['current_diameter'])}\n"
f"目标凸台直径: {_format_value(plan['target_diameter'])}\n"
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
f"{target_to_height_line}"
f"调整模式: {plan['resize_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"工具策略: {plan['boss_tool_strategy']}\n"
f"工具高度: {_format_value(plan['boss_tool_height'])}\n"
f"轴向重叠余量: {_format_value(plan['boss_tool_axial_margin'])}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆柱凸台直径调整")
return
face_id = self.selected_face_id
self._show_cylinder_boss_resize_preview(face_id, diameter)
def action():
return self.model.resize_cylindrical_boss(face_id, diameter)
self._run_edit_action(
action,
operation_name="调整圆柱凸台直径",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"new_diameter": diameter,
"old_diameter": info.get("diameter"),
"delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"target_to_height_ratio": plan.get("target_to_height_ratio"),
"resize_mode": plan.get("resize_mode"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"boss_resize_status": plan.get("status"),
"boss_resize_risk": plan.get("risk"),
"boss_resize_message": plan.get("message"),
"boss_resize_warnings": plan.get("warnings"),
"boss_resize_blockers": plan.get("blockers"),
"material_vote_summary": plan.get("material_vote_summary"),
"boss_tool_strategy": plan.get("boss_tool_strategy"),
"boss_tool_height": plan.get("boss_tool_height"),
"boss_tool_axial_margin": plan.get("boss_tool_axial_margin"),
"boss_tool_radius": plan.get("boss_tool_radius"),
"boss_tool_outer_radius": plan.get("boss_tool_outer_radius"),
"boss_tool_inner_radius": plan.get("boss_tool_inner_radius"),
"boss_tool_radial_overlap": plan.get("boss_tool_radial_overlap"),
"boss_tool_note": plan.get("boss_tool_note"),
"feature_adjacent_face_ids": plan.get("feature_adjacent_face_ids"),
"feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"),
},
target_kind="face",
target_id=face_id,
)
def suppress_hole(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再封堵圆柱孔。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱孔 face。")
return
info = self.model.face_info(self.selected_face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
QMessageBox.information(self, "不是圆柱孔", "当前选中的 face 不是可封堵的圆柱孔。")
return
plan = self.model.cylindrical_suppress_plan(self.selected_face_id)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能封堵圆柱孔", str(plan["message"]))
self.statusBar().showMessage("封堵圆柱孔已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认封堵圆柱孔",
(
f"face: {plan['face_id']}\n"
f"直径: {_format_value(plan['diameter'])}\n"
f"估算高度: {_format_value(plan['height_estimate'])}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"补料策略: {plan['fill_strategy']}\n"
f"补料半径: {_format_value(plan['fill_radius'])}\n"
f"补料高度: {_format_value(plan['fill_height'])}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会用补料体封堵当前孔,并对当前零件执行实验性 Fuse。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消封堵圆柱孔")
return
face_id = self.selected_face_id
self._show_cylinder_suppress_preview(face_id)
def action():
return self.model.suppress_cylindrical_hole(face_id)
self._run_edit_action(
action,
operation_name="封堵圆柱孔",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"diameter": plan.get("diameter"),
"height_estimate": plan.get("height_estimate"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"suppress_status": plan.get("status"),
"suppress_risk": plan.get("risk"),
"suppress_message": plan.get("message"),
"suppress_warnings": plan.get("warnings"),
"suppress_blockers": plan.get("blockers"),
"cylinder_end_type": plan.get("cylinder_end_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_note": plan.get("feature_bottom_note"),
"fill_strategy": plan.get("fill_strategy"),
"fill_height": plan.get("fill_height"),
"fill_radius": plan.get("fill_radius"),
"fill_radius_overlap": plan.get("fill_radius_overlap"),
"fill_note": plan.get("fill_note"),
},
target_kind="face",
target_id=face_id,
)
def resize_hole_depth(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再调整孔深。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱面", "请先选择一个盲孔或盲槽的圆柱面。")
return
try:
target_depth = float(self.hole_depth_input.text())
except ValueError:
QMessageBox.critical(self, "深度无效", "请输入数字形式的目标孔深。")
return
info = self.model.face_info(self.selected_face_id)
if info.get("surface") != "cylinder" or "hole_depth_estimate" not in info:
QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 face 不是可调整孔深的圆柱孔/槽。")
return
plan = self.model.cylindrical_depth_plan(self.selected_face_id, target_depth)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整孔深", str(plan["message"]))
self.statusBar().showMessage("盲孔深度调整已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认盲孔深度调整",
(
f"face: {plan['face_id']}\n"
f"当前深度: {_format_value(plan['current_depth'])}\n"
f"目标深度: {_format_value(plan['target_depth'])}\n"
f"深度变化量: {_format_value(plan['delta_depth'])}\n"
f"深度变化比例: {_format_percent(plan['depth_delta_ratio'])}\n"
f"调整模式: {plan['depth_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n"
f"当前深度来源: {_format_value(plan.get('depth_current_depth_source', ''))}\n"
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"工具策略: {plan['depth_tool_strategy']}\n"
f"工具类型: {plan['depth_tool_role']}\n"
f"工具高度: {_format_value(plan['depth_tool_height'])}\n"
f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消盲孔深度调整")
return
face_id = self.selected_face_id
self._show_cylinder_depth_preview(face_id, target_depth)
def action():
return self.model.resize_cylindrical_depth(face_id, target_depth)
self._run_edit_action(
action,
operation_name="调整盲孔深度",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"old_depth": plan.get("current_depth"),
"new_depth": target_depth,
"delta_depth": plan.get("delta_depth"),
"depth_delta_ratio": plan.get("depth_delta_ratio"),
"depth_mode": plan.get("depth_mode"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"depth_status": plan.get("status"),
"depth_risk": plan.get("risk"),
"depth_message": plan.get("message"),
"depth_warnings": plan.get("warnings"),
"depth_blockers": plan.get("blockers"),
"cylinder_end_type": plan.get("cylinder_end_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_confidence": plan.get("feature_bottom_confidence"),
"feature_bottom_detection": plan.get("feature_bottom_detection"),
"feature_bottom_note": plan.get("feature_bottom_note"),
"depth_tool_strategy": plan.get("depth_tool_strategy"),
"depth_tool_role": plan.get("depth_tool_role"),
"depth_tool_height": plan.get("depth_tool_height"),
"depth_tool_radius": plan.get("depth_tool_radius"),
"depth_tool_radius_overlap": plan.get("depth_tool_radius_overlap"),
"depth_current_depth": plan.get("depth_current_depth"),
"depth_current_depth_source": plan.get("depth_current_depth_source"),
"depth_bottom_parameter_source": plan.get("depth_bottom_parameter_source"),
"depth_open_point": plan.get("depth_open_point"),
"depth_current_bottom_point": plan.get("depth_current_bottom_point"),
"depth_target_bottom_point": plan.get("depth_target_bottom_point"),
},
target_kind="face",
target_id=face_id,
)
def resize_existing_fillet(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改已有圆角。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择已有圆角", "请先选择一个已有圆角/倒圆候选 face。")
return
try:
target_radius = float(self.edge_fillet_radius_input.text())
except ValueError:
QMessageBox.critical(self, "半径无效", "请输入数字形式的目标圆角半径。")
return
face_id = self.selected_face_id
plan = self.model.existing_fillet_resize_plan(face_id, target_radius)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改已有圆角", str(plan["message"]))
self.statusBar().showMessage("已有圆角半径修改已阻止")
return
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认修改已有圆角半径",
(
f"face: {plan['face_id']}\n"
f"当前估算半径: {_format_value(plan['current_radius'])}\n"
f"目标半径: {_format_value(plan['target_radius'])}\n"
f"半径变化量: {_format_value(plan['delta_radius'])}\n"
f"半径变化比例: {_format_percent(plan['radius_delta_ratio'])}\n"
f"圆弧跨度: {_format_value(plan.get('angular_span', ''))}\n"
f"长度估算: {_format_value(plan.get('height_estimate', ''))}\n"
f"支撑 Face: {_format_value(plan.get('feature_existing_fillet_support_face_ids', ''))}\n"
f"边界 Edge: {_format_value(plan.get('feature_boundary_edge_ids', ''))}\n"
f"策略: {plan['resize_strategy']}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会先尝试移除当前圆角面,再对恢复出的锐边重新倒圆。\n"
"这不是 CAD 历史特征参数编辑,复杂圆角可能失败。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消修改已有圆角半径")
return
self._show_existing_fillet_resize_preview(face_id, target_radius)
def action():
return self.model.resize_existing_fillet(face_id, target_radius)
self._run_edit_action(
action,
operation_name="修改已有圆角半径",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"old_radius": plan.get("current_radius"),
"new_radius": target_radius,
"delta_radius": plan.get("delta_radius"),
"radius_delta_ratio": plan.get("radius_delta_ratio"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"existing_fillet_status": plan.get("status"),
"existing_fillet_risk": plan.get("risk"),
"existing_fillet_message": plan.get("message"),
"existing_fillet_warnings": plan.get("warnings"),
"existing_fillet_blockers": plan.get("blockers"),
"feature_existing_fillet_support_face_ids": plan.get("feature_existing_fillet_support_face_ids"),
"feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"),
"resize_strategy": plan.get("resize_strategy"),
"resize_note": plan.get("resize_note"),
"axis_point": plan.get("axis_point"),
"axis": plan.get("axis"),
"height_estimate": plan.get("height_estimate"),
"angular_span": plan.get("angular_span"),
},
target_kind="face",
target_id=face_id,
)
def fillet_edge(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再添加圆角。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。")
return
try:
radius = float(self.edge_fillet_radius_input.text())
except ValueError:
QMessageBox.critical(self, "半径无效", "请输入数字形式的圆角半径。")
return
edge_id = self.selected_edge_id
plan = self.model.edge_fillet_plan(edge_id, radius)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能添加圆角", str(plan["message"]))
self.statusBar().showMessage("添加圆角已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认给边添加圆角",
(
f"edge: {plan['edge_id']}\n"
f"边长: {_format_value(plan['edge_length'])}\n"
f"目标圆角半径: {_format_value(plan['target_radius'])}\n"
f"半径/边长比例: {_format_percent(plan['radius_to_length_ratio'])}\n"
f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n"
f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性 OCCT 倒圆。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消添加圆角")
return
self._show_edge_fillet_preview(edge_id, radius)
def action():
return self.model.fillet_edge(edge_id, radius)
self._run_edit_action(
action,
operation_name="给边添加圆角",
target=f"edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"edge_length": plan.get("edge_length"),
"fillet_radius": radius,
"radius_to_length_ratio": plan.get("radius_to_length_ratio"),
"curve": plan.get("curve"),
"adjacent_face_ids": plan.get("adjacent_face_ids"),
"adjacent_face_count": plan.get("adjacent_face_count"),
"fillet_status": plan.get("status"),
"fillet_risk": plan.get("risk"),
"fillet_message": plan.get("message"),
"fillet_warnings": plan.get("warnings"),
"fillet_blockers": plan.get("blockers"),
},
target_kind="edge",
target_id=edge_id,
)
def chamfer_edge(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再添加倒角。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。")
return
try:
distance = float(self.edge_chamfer_distance_input.text())
except ValueError:
QMessageBox.critical(self, "距离无效", "请输入数字形式的倒角距离。")
return
edge_id = self.selected_edge_id
plan = self.model.edge_chamfer_plan(edge_id, distance)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能添加倒角", str(plan["message"]))
self.statusBar().showMessage("添加倒角已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认给边添加倒角",
(
f"edge: {plan['edge_id']}\n"
f"边长: {_format_value(plan['edge_length'])}\n"
f"目标倒角距离: {_format_value(plan['target_distance'])}\n"
f"倒角距离/边长比例: {_format_percent(plan['distance_to_length_ratio'])}\n"
f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n"
f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性 OCCT 倒角。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消添加倒角")
return
self._show_edge_chamfer_preview(edge_id, distance)
def action():
return self.model.chamfer_edge(edge_id, distance)
self._run_edit_action(
action,
operation_name="给边添加倒角",
target=f"edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"edge_length": plan.get("edge_length"),
"chamfer_distance": distance,
"distance_to_length_ratio": plan.get("distance_to_length_ratio"),
"curve": plan.get("curve"),
"adjacent_face_ids": plan.get("adjacent_face_ids"),
"adjacent_face_count": plan.get("adjacent_face_count"),
"chamfer_status": plan.get("status"),
"chamfer_risk": plan.get("risk"),
"chamfer_message": plan.get("message"),
"chamfer_warnings": plan.get("warnings"),
"chamfer_blockers": plan.get("blockers"),
},
target_kind="edge",
target_id=edge_id,
)
def resize_edge_length(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再调整直线边长度。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择边", "请先选择一条直线 edge。")
return
try:
target_length = float(self.edge_target_length_input.text())
except ValueError:
QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。")
return
edge_id = self.selected_edge_id
plan = self.model.straight_edge_length_plan(edge_id, target_length)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整直线边长度", str(plan["message"]))
self.statusBar().showMessage("直线边长度调整已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认调整直线边长度",
(
f"edge: {plan['edge_id']}\n"
f"当前边长: {_format_value(plan['current_length'])}\n"
f"目标边长: {_format_value(plan['target_length'])}\n"
f"变化量: {_format_value(plan['delta_length'])}\n"
f"端面 face: {_format_value(plan.get('end_face_id', ''))}\n"
f"端面位置: {_format_value(plan.get('end_face_label', ''))}\n"
f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"第一版会通过移动端点附近的平面端面来改变边长,不是通用参数化边长编辑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消调整直线边长度")
return
self._show_edge_length_preview(edge_id, target_length)
def action():
return self.model.resize_straight_edge_length(edge_id, target_length)
self._run_edit_action(
action,
operation_name="调整直线边长度",
target=f"edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"current_length": plan.get("current_length"),
"target_length": plan.get("target_length"),
"delta_length": plan.get("delta_length"),
"length_change_ratio": plan.get("length_change_ratio"),
"end_face_id": plan.get("end_face_id"),
"end_face_label": plan.get("end_face_label"),
"push_pull_distance": plan.get("push_pull_distance"),
"resize_strategy": plan.get("resize_strategy"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
"resize_warnings": plan.get("warnings"),
"resize_blockers": plan.get("blockers"),
},
target_kind="edge",
target_id=edge_id,
)
def translate_selected_part(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再平移零件。"):
return
if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。")
return
try:
vector = self._translation_vector_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "平移向量无效", str(exc))
return
part_id = self.selected_part_id
plan = self.model.translate_part_plan(part_id, vector)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能平移零件", str(plan["message"]))
self.statusBar().showMessage("平移零件已阻止")
return
if not self._confirm_translation_plan("确认平移零件", plan):
self.statusBar().showMessage("已取消平移零件")
return
def action():
return self.model.translate_part(part_id, vector)
self._run_edit_action(
action,
operation_name="平移零件",
target=f"part {part_id}",
parameters={
"part_id": part_id,
"translation_vector": vector,
"translation_distance": plan.get("translation_distance"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"translate_status": plan.get("status"),
"translate_risk": plan.get("risk"),
"translate_message": plan.get("message"),
"translate_warnings": plan.get("warnings"),
"translate_blockers": plan.get("blockers"),
},
target_kind="part",
target_id=part_id,
)
def translate_selected_solid(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再平移 solid。"):
return
if self.selected_solid_id is None:
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。")
return
try:
vector = self._translation_vector_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "平移向量无效", str(exc))
return
solid_id = self.selected_solid_id
plan = self.model.translate_solid_plan(solid_id, vector)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能平移 solid", str(plan["message"]))
self.statusBar().showMessage("平移 solid 已阻止")
return
if not self._confirm_translation_plan("确认平移 solid", plan):
self.statusBar().showMessage("已取消平移 solid")
return
def action():
return self.model.translate_solid(solid_id, vector)
self._run_edit_action(
action,
operation_name="平移 solid",
target=f"solid {solid_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": solid_id,
"part_solid_count": plan.get("part_solid_count"),
"translation_vector": vector,
"translation_distance": plan.get("translation_distance"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"translate_status": plan.get("status"),
"translate_risk": plan.get("risk"),
"translate_message": plan.get("message"),
"translate_warnings": plan.get("warnings"),
"translate_blockers": plan.get("blockers"),
},
target_kind="solid",
target_id=solid_id,
)
def _translation_vector_from_inputs(self) -> tuple[float, float, float]:
try:
return (
float(self.translate_x_input.text()),
float(self.translate_y_input.text()),
float(self.translate_z_input.text()),
)
except ValueError as exc:
raise ValueError("请输入数字形式的 X/Y/Z 平移量。") from exc
def _confirm_translation_plan(self, title: str, plan: dict[str, object]) -> bool:
if plan["risk"] == "low":
return True
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
title,
(
f"对象: {plan.get('target_kind', '')}\n"
f"part: {_format_value(plan.get('part_id', ''))}\n"
f"solid: {_format_value(plan.get('solid_id', ''))}\n"
f"平移向量: {_format_value(plan['translation_vector'])}\n"
f"平移距离: {_format_value(plan['translation_distance'])}\n"
f"对象 bbox_diagonal: {_format_value(plan.get('bbox_diagonal', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会移动当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return result == QMessageBox.StandardButton.Yes
def rotate_selected_part(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再旋转零件。"):
return
if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。")
return
try:
axis, angle = self._rotation_values_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "旋转参数无效", str(exc))
return
part_id = self.selected_part_id
plan = self.model.rotate_part_plan(part_id, axis, angle)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能旋转零件", str(plan["message"]))
self.statusBar().showMessage("旋转零件已阻止")
return
if not self._confirm_rotation_plan("确认旋转零件", plan):
self.statusBar().showMessage("已取消旋转零件")
return
def action():
return self.model.rotate_part(part_id, axis, angle)
self._run_edit_action(
action,
operation_name="旋转零件",
target=f"part {part_id}",
parameters={
"part_id": part_id,
"rotation_axis": plan.get("rotation_axis"),
"rotation_angle_degrees": angle,
"rotation_center": plan.get("rotation_center"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"rotate_status": plan.get("status"),
"rotate_risk": plan.get("risk"),
"rotate_message": plan.get("message"),
"rotate_warnings": plan.get("warnings"),
"rotate_blockers": plan.get("blockers"),
},
target_kind="part",
target_id=part_id,
)
def rotate_selected_solid(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再旋转 solid。"):
return
if self.selected_solid_id is None:
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。")
return
try:
axis, angle = self._rotation_values_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "旋转参数无效", str(exc))
return
solid_id = self.selected_solid_id
plan = self.model.rotate_solid_plan(solid_id, axis, angle)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能旋转 solid", str(plan["message"]))
self.statusBar().showMessage("旋转 solid 已阻止")
return
if not self._confirm_rotation_plan("确认旋转 solid", plan):
self.statusBar().showMessage("已取消旋转 solid")
return
def action():
return self.model.rotate_solid(solid_id, axis, angle)
self._run_edit_action(
action,
operation_name="旋转 solid",
target=f"solid {solid_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": solid_id,
"part_solid_count": plan.get("part_solid_count"),
"rotation_axis": plan.get("rotation_axis"),
"rotation_angle_degrees": angle,
"rotation_center": plan.get("rotation_center"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"rotate_status": plan.get("status"),
"rotate_risk": plan.get("risk"),
"rotate_message": plan.get("message"),
"rotate_warnings": plan.get("warnings"),
"rotate_blockers": plan.get("blockers"),
},
target_kind="solid",
target_id=solid_id,
)
def _rotation_values_from_inputs(self) -> tuple[str, float]:
try:
angle = float(self.rotate_angle_input.text())
except ValueError as exc:
raise ValueError("请输入数字形式的旋转角度。") from exc
return self.rotate_axis_combo.currentText(), angle
def _confirm_rotation_plan(self, title: str, plan: dict[str, object]) -> bool:
if plan["risk"] == "low":
return True
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
title,
(
f"对象: {plan.get('target_kind', '')}\n"
f"part: {_format_value(plan.get('part_id', ''))}\n"
f"solid: {_format_value(plan.get('solid_id', ''))}\n"
f"旋转轴: {_format_value(plan['rotation_axis'])}\n"
f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n"
f"旋转中心: {_format_value(plan['rotation_center'])}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会旋转当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return result == QMessageBox.StandardButton.Yes
def list_cylinders(self) -> None:
self.refresh_cylinder_candidates(show_info=True)
def _clear_editable_candidates(self) -> None:
if hasattr(self, "editable_table"):
self.editable_table.setRowCount(0)
def refresh_editable_candidates(self, show_info: bool = True, deep_scan: bool = False) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能扫描可编辑对象。"):
return
limit = 48 if deep_scan else 24
scan_label = "深度扫描" if deep_scan else "扫描"
self.statusBar().showMessage(f"正在{scan_label}第一版可编辑对象...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
candidates = self.model.editable_feature_candidates(
limit=limit,
progress_callback=QApplication.processEvents,
)
except Exception as exc:
QMessageBox.critical(self, "扫描失败", str(exc))
self.statusBar().showMessage("第一版可编辑对象扫描失败")
return
finally:
QApplication.restoreOverrideCursor()
self.editable_table.setRowCount(len(candidates))
lines = [f"第一版可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit}"]
for row, item in enumerate(candidates):
target_kind = str(item.get("target_kind", "face"))
target_id = int(item.get("target_id", item.get("face_id", item.get("edge_id", -1))))
operation_item = QTableWidgetItem(str(item["operation"]))
operation_item.setData(EDITABLE_TARGET_ID_ROLE, target_id)
operation_item.setData(EDITABLE_TARGET_KIND_ROLE, target_kind)
operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"]))
id_item = QTableWidgetItem(f"{target_kind} {target_id}")
object_item = QTableWidgetItem(str(item["feature_guess"]))
current_item = QTableWidgetItem(
f"{item['current_value_label']}={_format_value(item['current_value'])}"
)
status_item = QTableWidgetItem(str(item["status"]))
risk_item = QTableWidgetItem(str(item["risk"]))
confidence_item = QTableWidgetItem(str(item["confidence"]))
note_item = QTableWidgetItem(str(item["note"]))
row_items = [
operation_item,
id_item,
object_item,
current_item,
status_item,
risk_item,
confidence_item,
note_item,
]
for column, table_item in enumerate(row_items):
table_item.setToolTip(table_item.text())
self.editable_table.setItem(row, column, table_item)
2026-07-23 18:35:02 +08:00
lines.append(
2026-07-24 17:47:17 +08:00
f"{target_kind} {target_id}: {item['operation']}, "
2026-07-23 18:35:02 +08:00
f"part={item['part_id']}, solid={item['solid_id']}, "
f"object={item['feature_guess']}, "
f"{item['current_value_label']}={_format_value(item['current_value'])}, "
f"status={item['status']}, risk={item['risk']}, "
f"confidence={item['confidence']}, note={item['note']}"
)
self.editable_table.resizeColumnsToContents()
2026-07-24 17:47:17 +08:00
self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个")
2026-07-23 18:35:02 +08:00
if show_info:
self.set_plain_info("\n".join(lines))
def _clear_cylinder_candidates(self) -> None:
self.cylinder_candidate_cache = []
self.cylinder_candidates_loaded = False
if hasattr(self, "cylinder_table"):
self.cylinder_table.setRowCount(0)
def refresh_cylinder_candidates(self, show_info: bool = True) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能扫描圆柱候选。"):
return
self.statusBar().showMessage("正在扫描圆柱候选...")
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
self.cylinder_candidate_cache = self.model.cylindrical_feature_candidates(
limit=60,
progress_callback=QApplication.processEvents,
)
self.cylinder_candidates_loaded = True
except Exception as exc:
self._clear_cylinder_candidates()
QMessageBox.critical(self, "扫描失败", str(exc))
self.statusBar().showMessage("圆柱候选扫描失败")
return
finally:
QApplication.restoreOverrideCursor()
self._filter_cached_cylinder_candidates(show_info=show_info)
def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None:
if not self.cylinder_candidates_loaded:
self.cylinder_table.setRowCount(0)
if show_info:
self.set_plain_info("圆柱候选尚未扫描。点击 `列出圆柱候选` 后再切换筛选类型。")
return
candidates = [
item
for item in self.cylinder_candidate_cache
if self._candidate_matches_filter(str(item["feature_guess"]))
]
self._populate_cylinder_table(candidates, show_info=show_info)
def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None:
self.cylinder_table.setRowCount(len(candidates))
lines = [f"圆柱候选:显示 {len(candidates)} 个"]
for row, item in enumerate(candidates):
face_item = QTableWidgetItem(str(item["face_id"]))
face_item.setData(Qt.UserRole, int(item["face_id"]))
guess_item = QTableWidgetItem(str(item["feature_guess"]))
diameter_item = QTableWidgetItem(_format_float(float(item["diameter"])))
span_item = QTableWidgetItem(_format_float(float(item["angular_span"])))
height_item = QTableWidgetItem(_format_float(float(item["height_estimate"])))
confidence_item = QTableWidgetItem(str(item["confidence"]))
risk_item = QTableWidgetItem(str(item["resize_risk"]))
part_item = QTableWidgetItem(str(item["part_id"]))
self.cylinder_table.setItem(row, 0, face_item)
self.cylinder_table.setItem(row, 1, guess_item)
self.cylinder_table.setItem(row, 2, diameter_item)
self.cylinder_table.setItem(row, 3, span_item)
self.cylinder_table.setItem(row, 4, height_item)
self.cylinder_table.setItem(row, 5, confidence_item)
self.cylinder_table.setItem(row, 6, risk_item)
self.cylinder_table.setItem(row, 7, part_item)
lines.append(
f"face {item['face_id']}: part {item['part_id']}, "
f"guess={item['feature_guess']}, "
f"diameter={_format_float(float(item['diameter']))}, "
f"height={_format_float(float(item['height_estimate']))}, "
f"confidence={item['confidence']}, "
f"risk={item['resize_risk']}"
)
self.cylinder_table.resizeColumnsToContents()
self.statusBar().showMessage(f"已显示圆柱候选:{len(candidates)} 个")
if show_info:
self.set_plain_info("\n".join(lines))
def _candidate_matches_filter(self, feature_guess: str) -> bool:
current = self.candidate_filter_combo.currentText()
if current == "All":
return True
if current == "Hole/Groove":
return feature_guess == "hole/groove candidate"
if current == "Round/Fillet":
return feature_guess == "round/fillet candidate"
if current == "Boss/Outer":
return feature_guess == "boss/outer-round candidate"
if current == "Unclear":
return feature_guess == "cylindrical face"
return True
def _run_action(self, action, success_message: str | None) -> None:
try:
result = action()
except Exception as exc:
QMessageBox.critical(self, "操作失败", str(exc))
self.statusBar().showMessage("操作失败")
return
message = success_message or str(result)
self.statusBar().showMessage(message)
self.set_plain_info(message)
def _run_edit_action(
self,
action,
operation_name: str,
target: str,
parameters: dict[str, object],
target_kind: str | None = None,
target_id: int | None = None,
) -> None:
if self.model is None:
return
if self.operation_in_progress:
QMessageBox.information(self, "编辑进行中", "请等待当前编辑计算完成。")
return
context = {
"operation_name": operation_name,
"target": target,
"parameters": parameters,
"target_kind": target_kind,
"target_id": target_id,
"pick_position": self.selected_pick_position,
}
self._begin_edit_task(operation_name)
self.pending_edit_context = context
thread = QThread(self)
2026-07-24 17:47:17 +08:00
worker = EditWorker(self._make_edit_job(action, context))
2026-07-23 18:35:02 +08:00
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_edit_action)
worker.failed.connect(self._fail_edit_action)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_edit_thread)
self.edit_thread = thread
self.edit_worker = worker
thread.start()
2026-07-24 17:47:17 +08:00
def _make_edit_job(self, action, context: dict[str, object]):
2026-07-23 18:35:02 +08:00
def job():
if self.model is None:
raise RuntimeError("Model is not loaded.")
snapshot = self.model.snapshot()
2026-07-24 17:47:17 +08:00
target_part_id = self._edit_context_part_id(context)
2026-07-23 18:35:02 +08:00
before_stats = self.model.stats()
2026-07-24 17:47:17 +08:00
before_part_stats = self._part_stats_or_none(target_part_id)
2026-07-23 18:35:02 +08:00
before_geometry = self.model.geometry_stats()
2026-07-24 17:47:17 +08:00
try:
result = action()
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
after_geometry = self.model.geometry_stats()
model_polydata = self.model.build_face_polydata()
edge_polydata = self.model.build_edge_polydata()
except Exception as exc:
try:
self.model.restore_snapshot(snapshot)
except Exception as rollback_exc:
raise RuntimeError(
f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}"
) from exc
raise RuntimeError(f"编辑失败,模型已恢复到操作前状态:{exc}") from exc
2026-07-23 18:35:02 +08:00
return {
"message": str(result),
"snapshot": snapshot,
"before_stats": before_stats,
2026-07-24 17:47:17 +08:00
"before_part_stats": before_part_stats,
2026-07-23 18:35:02 +08:00
"before_geometry": before_geometry,
"after_snapshot": after_snapshot,
"after_stats": after_stats,
2026-07-24 17:47:17 +08:00
"after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
2026-07-23 18:35:02 +08:00
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
}
return job
2026-07-24 17:47:17 +08:00
def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None:
return None
parameters = context.get("parameters")
if isinstance(parameters, dict):
part_id = parameters.get("part_id")
if part_id not in {"", None}:
try:
return int(part_id)
except (TypeError, ValueError):
pass
target_kind = context.get("target_kind")
target_id = context.get("target_id")
if target_id is None:
return None
try:
numeric_id = int(target_id)
except (TypeError, ValueError):
return None
if target_kind == "part":
return numeric_id
if target_kind in {"face", "feature"} and 0 <= numeric_id < len(self.model.face_part_ids):
return int(self.model.face_part_ids[numeric_id])
if target_kind == "edge" and 0 <= numeric_id < len(self.model.edge_part_ids):
return int(self.model.edge_part_ids[numeric_id])
if target_kind == "solid" and 0 <= numeric_id < len(self.model.solids):
return int(self.model.solids[numeric_id][0])
return None
def _part_stats_or_none(self, part_id: int | None):
if self.model is None or part_id is None:
return None
try:
return self.model.part_topology_stats(part_id)
except Exception:
return None
2026-07-23 18:35:02 +08:00
def _begin_edit_task(self, operation_name: str) -> None:
self.operation_in_progress = True
2026-07-24 17:47:17 +08:00
self._set_main_content_busy(True)
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage(f"{operation_name} 正在后台计算...")
if "推拉" in operation_name:
progress_text = f"{operation_name} 正在计算,半透明预览表示推拉方向和大致范围..."
2026-07-24 17:47:17 +08:00
elif "孔径" in operation_name:
progress_text = f"{operation_name} 正在计算,红色预览表示切削范围,绿色预览表示补料范围..."
elif "凸台" in operation_name:
progress_text = f"{operation_name} 正在计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围..."
elif "封堵" in operation_name:
progress_text = f"{operation_name} 正在计算,绿色预览表示封堵补料范围..."
elif "孔深" in operation_name:
progress_text = f"{operation_name} 正在计算,红色预览表示加深切削范围,绿色预览表示变浅补料范围..."
elif "圆角" in operation_name:
if "已有" in operation_name:
progress_text = f"{operation_name} 正在计算,蓝色预览表示将移除并重建的已有圆角面..."
else:
progress_text = f"{operation_name} 正在计算,蓝色预览表示目标边和半径范围..."
elif "倒角" in operation_name:
progress_text = f"{operation_name} 正在计算,橙色预览表示目标边和倒角距离范围..."
elif "边长度" in operation_name:
progress_text = f"{operation_name} 正在计算,蓝绿色预览表示将移动的端面推拉范围..."
elif "平移" in operation_name:
progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型位置和拓扑索引..."
elif "旋转" in operation_name:
progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型姿态和拓扑索引..."
2026-07-23 18:35:02 +08:00
else:
progress_text = f"{operation_name} 正在后台计算..."
self.edit_progress = QProgressDialog(progress_text, "", 0, 0, self)
self.edit_progress.setWindowTitle("编辑计算中")
self.edit_progress.setCancelButton(None)
self.edit_progress.setAutoClose(False)
self.edit_progress.setAutoReset(False)
self.edit_progress.setMinimumDuration(0)
self.edit_progress.setWindowModality(Qt.WindowModality.WindowModal)
self.edit_progress.show()
QApplication.processEvents()
def _set_edit_progress_text(self, text: str) -> None:
if self.edit_progress is not None:
self.edit_progress.setLabelText(text)
QApplication.processEvents()
@Slot(object)
def _finish_edit_action(self, result: object) -> None:
context = self.pending_edit_context
if context is None:
self._end_edit_task(clear_preview=True)
self.statusBar().showMessage("编辑已完成,但上下文丢失")
return
if self.model is None:
self._end_edit_task(clear_preview=True)
return
self._set_edit_progress_text("布尔计算已完成,正在刷新模型显示和历史记录...")
if not isinstance(result, dict):
self._end_edit_task(clear_preview=True)
QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。")
self.statusBar().showMessage("编辑结果无法识别")
return
message = str(result["message"])
try:
record = self._make_operation_record(
operation_name=str(context["operation_name"]),
target=str(context["target"]),
parameters=dict(context["parameters"]),
result_message=message,
before_stats=result["before_stats"],
after_stats=result["after_stats"],
2026-07-24 17:47:17 +08:00
before_part_stats=result.get("before_part_stats"),
after_part_stats=result.get("after_part_stats"),
quality_warnings=list(result.get("quality_warnings", [])),
2026-07-23 18:35:02 +08:00
before_geometry=dict(result["before_geometry"]),
after_geometry=dict(result["after_geometry"]),
target_kind=context["target_kind"],
target_id=context["target_id"],
pick_position=context["pick_position"],
before_snapshot=result["snapshot"],
after_snapshot=result["after_snapshot"],
)
2026-07-24 17:47:17 +08:00
self.clear_edit_preview(render=False)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene_from_polydata(result["model_polydata"], result["edge_polydata"], reset_camera=False)
2026-07-23 18:35:02 +08:00
except Exception as exc:
2026-07-24 17:47:17 +08:00
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
2026-07-23 18:35:02 +08:00
self._end_edit_task(clear_preview=True)
2026-07-24 17:47:17 +08:00
QMessageBox.critical(self, "操作失败", f"编辑结果刷新失败:{exc}\n\n{rollback_message}")
2026-07-23 18:35:02 +08:00
self.statusBar().showMessage("编辑已完成,但刷新结果时失败")
return
self.undo_stack.append(result["snapshot"])
self.redo_stack.clear()
self.operation_history.append(record)
self.redo_history.clear()
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self._refresh_history_list()
self._end_edit_task(clear_preview=False)
2026-07-24 17:47:17 +08:00
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
else:
self.statusBar().showMessage(message)
2026-07-23 18:35:02 +08:00
self.set_plain_info(record.detail)
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
self._end_edit_task(clear_preview=True)
QMessageBox.critical(self, "操作失败", message)
2026-07-24 17:47:17 +08:00
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self.statusBar().showMessage("操作失败,模型已保持在编辑前状态")
def _restore_failed_edit_snapshot(self, snapshot: object) -> str:
if self.model is None or not isinstance(snapshot, dict):
return "未找到可用的编辑前快照,请重新加载 STEP 文件确认状态。"
try:
self.model.restore_snapshot(snapshot)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene(reset_camera=False)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
except Exception as rollback_exc:
return f"尝试回滚到编辑前状态失败:{rollback_exc}。建议重新加载 STEP 文件。"
return "模型已回滚到编辑前状态。"
2026-07-23 18:35:02 +08:00
def _end_edit_task(self, clear_preview: bool = True) -> None:
self.operation_in_progress = False
self.pending_edit_context = None
if clear_preview:
self.clear_edit_preview(render=False)
2026-07-24 17:47:17 +08:00
self._set_main_content_busy(False)
2026-07-23 18:35:02 +08:00
if self.edit_progress is not None:
self.edit_progress.close()
self.edit_progress.deleteLater()
self.edit_progress = None
QApplication.processEvents()
2026-07-24 17:47:17 +08:00
def _set_main_content_busy(self, busy: bool) -> None:
content = self.centralWidget()
if content is None:
return
if busy:
self.main_content_enabled_before_edit = content.isEnabled()
content.setEnabled(False)
else:
content.setEnabled(self.main_content_enabled_before_edit)
self._update_action_states()
2026-07-23 18:35:02 +08:00
def _forget_edit_thread(self) -> None:
self.edit_thread = None
self.edit_worker = None
def _make_operation_record(
self,
operation_name: str,
target: str,
parameters: dict[str, object],
result_message: str,
before_stats,
after_stats,
before_geometry: dict[str, object],
after_geometry: dict[str, object],
2026-07-24 17:47:17 +08:00
before_part_stats=None,
after_part_stats=None,
quality_warnings: list[str] | None = None,
2026-07-23 18:35:02 +08:00
target_kind: str | None = None,
target_id: int | None = None,
pick_position: tuple[float, float, float] | None = None,
before_snapshot: dict[int, object] | None = None,
after_snapshot: dict[int, object] | None = None,
) -> OperationRecord:
summary_parts = [operation_name, target]
if "distance" in parameters:
summary_parts.append(f"distance={_format_value(parameters['distance'])}")
if "semantic_distance" in parameters:
summary_parts.append(f"distance={_format_value(parameters['semantic_distance'])}")
if "new_diameter" in parameters:
summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}")
summary = " | ".join(summary_parts)
lines = [
f"operation: {operation_name}",
f"target: {target}",
f"target_kind: {target_kind or ''}",
f"target_id: {target_id if target_id is not None else ''}",
"parameters:",
]
if pick_position is not None:
lines.insert(4, f"pick_position: {_format_value(pick_position)}")
for key, value in parameters.items():
lines.append(f" {key}: {_format_value(value)}")
2026-07-24 17:47:17 +08:00
if before_part_stats is not None and after_part_stats is not None:
lines.extend(
[
"target part topology before:",
f" solids: {before_part_stats.solids}",
f" faces: {before_part_stats.faces}",
f" edges: {before_part_stats.edges}",
"target part topology after:",
f" solids: {after_part_stats.solids} ({_signed_delta(after_part_stats.solids - before_part_stats.solids)})",
f" faces: {after_part_stats.faces} ({_signed_delta(after_part_stats.faces - before_part_stats.faces)})",
f" edges: {after_part_stats.edges} ({_signed_delta(after_part_stats.edges - before_part_stats.edges)})",
]
)
if quality_warnings:
lines.append("quality warnings:")
for warning in quality_warnings:
lines.append(f" {warning}")
2026-07-23 18:35:02 +08:00
lines.extend(
[
"topology before:",
f" solids: {before_stats.solids}",
f" faces: {before_stats.faces}",
f" edges: {before_stats.edges}",
"topology after:",
f" solids: {after_stats.solids} ({_signed_delta(after_stats.solids - before_stats.solids)})",
f" faces: {after_stats.faces} ({_signed_delta(after_stats.faces - before_stats.faces)})",
f" edges: {after_stats.edges} ({_signed_delta(after_stats.edges - before_stats.edges)})",
"geometry before:",
f" volume: {_format_value(before_geometry.get('volume', ''))}",
f" surface_area: {_format_value(before_geometry.get('surface_area', ''))}",
f" bbox_size: {_format_value(before_geometry.get('bbox_size', ''))}",
f" bbox_diagonal: {_format_value(before_geometry.get('bbox_diagonal', ''))}",
"geometry after:",
f" volume: {_format_after_delta(before_geometry, after_geometry, 'volume')}",
f" surface_area: {_format_after_delta(before_geometry, after_geometry, 'surface_area')}",
f" bbox_size: {_format_after_delta(before_geometry, after_geometry, 'bbox_size')}",
f" bbox_diagonal: {_format_after_delta(before_geometry, after_geometry, 'bbox_diagonal')}",
"result:",
f" {result_message}",
]
)
return OperationRecord(
summary=summary,
detail="\n".join(lines),
target_kind=target_kind,
target_id=target_id,
pick_position=pick_position,
before_snapshot=before_snapshot,
after_snapshot=after_snapshot,
)
def set_info(self, info: dict[str, object]) -> None:
self.current_info_values = dict(info)
self.current_info_text = _info_to_text(info)
self.info_text.setPlainText(self.current_info_text)
self._populate_info_tree(info)
self.info_tabs.setCurrentWidget(self.info_tree)
def set_plain_info(self, text: str) -> None:
self.current_info_values = {}
self.current_info_text = text
self.info_tree.clear()
self.info_text.setPlainText(text)
self.info_tabs.setCurrentWidget(self.info_text)
def _populate_info_tree(self, info: dict[str, object]) -> None:
self.info_tree.clear()
emitted: set[str] = set()
for group_name, keys in INFO_GROUPS:
items = [(key, info[key]) for key in keys if key in info]
if not items:
continue
self._add_info_group(group_name, items)
emitted.update(key for key, _value in items)
remaining = [(key, value) for key, value in info.items() if key not in emitted]
if remaining:
self._add_info_group("其他", remaining)
self.info_tree.expandAll()
self.info_tree.resizeColumnToContents(0)
def _add_info_group(self, group_name: str, items: list[tuple[str, object]]) -> None:
group = QTreeWidgetItem([group_name, ""])
group.setFirstColumnSpanned(True)
self.info_tree.addTopLevelItem(group)
for key, value in items:
child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_value(value)])
child.setData(0, Qt.UserRole, key)
child.setToolTip(0, key)
child.setToolTip(1, _format_value(value))
group.addChild(child)
def copy_selected_id(self) -> None:
text = self._selected_id_text()
if not text:
self.statusBar().showMessage("没有可复制的对象 ID")
return
QApplication.clipboard().setText(text)
self.statusBar().showMessage(f"已复制 {text}")
def copy_pick_position(self) -> None:
pick_position = self.selected_pick_position
if pick_position is None and "pick_position" in self.current_info_values:
value = self.current_info_values["pick_position"]
if isinstance(value, tuple) and len(value) == 3:
pick_position = (float(value[0]), float(value[1]), float(value[2]))
if pick_position is None:
self.statusBar().showMessage("没有可复制的拾取坐标")
return
text = _format_value(pick_position)
QApplication.clipboard().setText(text)
self.statusBar().showMessage(f"已复制拾取坐标 {text}")
def copy_current_info(self) -> None:
if not self.current_info_text:
self.statusBar().showMessage("没有可复制的信息")
return
QApplication.clipboard().setText(self.current_info_text)
self.statusBar().showMessage("已复制当前信息")
def _selected_id_text(self) -> str:
if self.selected_kind == "part" and self.selected_part_id is not None:
return f"part {self.selected_part_id}"
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return f"solid {self.selected_solid_id}"
if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None:
return f"face {self.selected_face_id}"
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return f"edge {self.selected_edge_id}"
for kind, key in (("face", "face_id"), ("edge", "edge_id"), ("solid", "solid_id"), ("part", "part_id")):
if key in self.current_info_values:
return f"{kind} {self.current_info_values[key]}"
return ""
def _format_float(value: float) -> str:
return f"{value:.6g}"
def _info_to_text(info: dict[str, object]) -> str:
return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
2026-07-24 17:47:17 +08:00
def _part_tree_kind_label(kind: str) -> str:
return {
"assembly": "装配",
"part": "零件",
"solid": "实体",
}.get(kind, kind or "对象")
def _enable_overlay_depth_offset(mapper) -> None:
"""Draw coplanar overlays in front of the base model to avoid highlight flicker."""
if hasattr(mapper, "SetResolveCoincidentTopologyToPolygonOffset"):
mapper.SetResolveCoincidentTopologyToPolygonOffset()
if hasattr(mapper, "SetRelativeCoincidentTopologyPolygonOffsetParameters"):
mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters(-6.0, -6.0)
if hasattr(mapper, "SetRelativeCoincidentTopologyLineOffsetParameters"):
mapper.SetRelativeCoincidentTopologyLineOffsetParameters(-8.0, -8.0)
if hasattr(mapper, "SetRelativeCoincidentTopologyPointOffsetParameter"):
mapper.SetRelativeCoincidentTopologyPointOffsetParameter(-8.0)
def _format_percent(value: object) -> str:
if value is None or value == "":
return ""
try:
return f"{float(value) * 100.0:.6g}%"
except (TypeError, ValueError):
return str(value)
def _edit_quality_warnings(before_part_stats, after_part_stats) -> list[str]:
if before_part_stats is None or after_part_stats is None:
return []
warnings: list[str] = []
if after_part_stats.solids == 0:
warnings.append("目标零件编辑后没有检测到 solid,导出前请确认模型是否有效。")
elif before_part_stats.solids > 0 and after_part_stats.solids != before_part_stats.solids:
warnings.append(
"目标零件 solid 数发生变化:"
f"{before_part_stats.solids} -> {after_part_stats.solids}。"
"如果这是一次局部推拉/孔径修改,请重点检查导出后是否仍是一体实体。"
)
return warnings
def _export_quality_text(info: dict[str, object]) -> str:
warnings = str(info.get("quality_warnings", ""))
lines = [
"导出质量检查:",
f" 对象: {info.get('quality_label', '')}",
f" 状态: {info.get('quality_status', '')}",
f" B-Rep 有效: {_format_value(info.get('brep_valid', ''))}",
f" solids: {_format_value(info.get('solids', ''))}",
f" faces: {_format_value(info.get('faces', ''))}",
f" edges: {_format_value(info.get('edges', ''))}",
f" vertices: {_format_value(info.get('vertices', ''))}",
f" volume: {_format_value(info.get('volume', ''))}",
f" bbox_diagonal: {_format_value(info.get('bbox_diagonal', ''))}",
]
if warnings:
lines.extend([" 警告:", f" {warnings}"])
else:
lines.append(" 警告: 无")
return "\n".join(lines)
2026-07-23 18:35:02 +08:00
def _format_value(value: object) -> str:
if isinstance(value, float):
return _format_float(value)
if isinstance(value, tuple):
return "(" + ", ".join(_format_float(float(v)) for v in value) + ")"
return str(value)
2026-07-24 17:47:17 +08:00
def _int_values(value: object) -> list[int]:
if value is None or value == "":
return []
if isinstance(value, int):
return [value]
if isinstance(value, (list, tuple, set)):
result: list[int] = []
for item in value:
try:
result.append(int(item))
except (TypeError, ValueError):
continue
return result
return []
def _float_or_none(value: object) -> float | None:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _merge_polydata_bounds(*polydatas) -> tuple[float, float, float, float, float, float] | None:
merged: list[float] | None = None
for polydata in polydatas:
if polydata is None or polydata.GetNumberOfPoints() <= 0:
continue
bounds = polydata.GetBounds()
if bounds is None or bounds[0] > bounds[1] or bounds[2] > bounds[3] or bounds[4] > bounds[5]:
continue
values = [float(item) for item in bounds]
if merged is None:
merged = values
else:
merged[0] = min(merged[0], values[0])
merged[1] = max(merged[1], values[1])
merged[2] = min(merged[2], values[2])
merged[3] = max(merged[3], values[3])
merged[4] = min(merged[4], values[4])
merged[5] = max(merged[5], values[5])
return tuple(merged) if merged is not None else None
2026-07-23 18:35:02 +08:00
def _format_after_delta(before: dict[str, object], after: dict[str, object], key: str) -> str:
before_value = before.get(key)
after_value = after.get(key)
if isinstance(before_value, (int, float)) and isinstance(after_value, (int, float)):
return f"{_format_value(float(after_value))} ({_signed_float_delta(float(after_value) - float(before_value))})"
if isinstance(before_value, tuple) and isinstance(after_value, tuple) and len(before_value) == len(after_value):
try:
deltas = tuple(float(after_item) - float(before_item) for before_item, after_item in zip(before_value, after_value))
except (TypeError, ValueError):
return _format_value(after_value)
return f"{_format_value(after_value)} (delta={_format_value(deltas)})"
return _format_value(after_value if after_value is not None else "")
def _vector_tuple(values) -> tuple[float, float, float]:
return (float(values[0]), float(values[1]), float(values[2]))
def _signed_delta(value: int) -> str:
if value > 0:
return f"+{value}"
return str(value)
def _signed_float_delta(value: float) -> str:
if value > 0:
return f"+{_format_float(value)}"
return _format_float(value)
def _parse_args(argv: list[str]) -> tuple[Path, bool]:
smoke_test = "--smoke-test" in argv
paths = [arg for arg in argv[1:] if not arg.startswith("--")]
path = Path(paths[0]) if paths else Path("geom_extract.step")
return path, smoke_test
def main() -> int:
path, smoke_test = _parse_args(sys.argv)
app = QApplication(sys.argv)
window = StepEditorWindow(path)
if smoke_test:
print("smoke test ok")
window.close()
app.quit()
return 0
window.show()
window.vtk_widget.Start()
return app.exec()
if __name__ == "__main__":
raise SystemExit(main())