26216064e0
将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
827 lines
42 KiB
Python
827 lines
42 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
import faulthandler
|
|
import math
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import vtk
|
|
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView,
|
|
QApplication,
|
|
QCheckBox,
|
|
QComboBox,
|
|
QFileDialog,
|
|
QGridLayout,
|
|
QGroupBox,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QListWidget,
|
|
QMainWindow,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QPlainTextEdit,
|
|
QScrollArea,
|
|
QTableWidget,
|
|
QTableWidgetItem,
|
|
QTabWidget,
|
|
QTreeWidget,
|
|
QTreeWidgetItem,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
|
|
|
|
from .records import OperationRecord
|
|
from .model import StepModel
|
|
from .widgets import NoWheelComboBox
|
|
from .workers import EditWorker, LoadWorker, ScanWorker
|
|
|
|
|
|
from .info_panel import InfoPanelMixin
|
|
from .ui_helpers import * # noqa: F403
|
|
from .window_actions import WindowActionMixin
|
|
from .window_core import WindowCoreMixin
|
|
from .window_state import WindowStateMixin
|
|
|
|
|
|
_CRASH_LOG_HANDLE = None
|
|
|
|
|
|
def _enable_crash_log() -> None:
|
|
global _CRASH_LOG_HANDLE
|
|
if _CRASH_LOG_HANDLE is not None:
|
|
return
|
|
try:
|
|
log_path = Path("step_editor_crash.log")
|
|
_CRASH_LOG_HANDLE = log_path.open("a", encoding="utf-8")
|
|
faulthandler.enable(file=_CRASH_LOG_HANDLE, all_threads=True)
|
|
except Exception:
|
|
faulthandler.enable(all_threads=True)
|
|
|
|
|
|
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
|
|
def __init__(self, step_path: str | Path, *, background_load: bool = True):
|
|
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
|
|
self.hover_face_actor = None
|
|
self.hover_edge_actor = None
|
|
self.hover_signature: tuple[str, int] | None = None
|
|
self.hover_interval_ms = 45
|
|
self.hover_move_threshold_px = 0
|
|
self.pending_hover_position: tuple[int, int] | None = None
|
|
self.last_hover_pick_position: tuple[int, int] | None = None
|
|
self.pointer_button_down = False
|
|
self.pick_marker_actor = None
|
|
self.edit_preview_actor = None
|
|
self.edit_preview_actors: list[object] = []
|
|
self.edit_preview_timer: QTimer | None = None
|
|
self.hover_timer = QTimer(self)
|
|
self.hover_timer.setSingleShot(True)
|
|
self.hover_timer.timeout.connect(self._update_hover_target)
|
|
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
|
|
self.model_face_id_array = None
|
|
self.model_part_id_array = None
|
|
self.model_solid_id_array = None
|
|
self.edge_id_array = None
|
|
self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {}
|
|
self.edge_overlay_polydata_cache: dict[int, object] = {}
|
|
self.overlay_cache_limit = 160
|
|
self.show_internal_edges_checkbox: QCheckBox | None = None
|
|
self.scene_isolated = False
|
|
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.measure_point_a: tuple[float, float, float] | None = None
|
|
self.measure_point_b: tuple[float, float, float] | None = None
|
|
self.measure_label_a = ""
|
|
self.measure_label_b = ""
|
|
self.measure_actor = None
|
|
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.pending_edit_context: dict[str, object] | None = None
|
|
self.scan_in_progress = False
|
|
self.scan_thread: QThread | None = None
|
|
self.scan_worker: ScanWorker | None = None
|
|
self.pending_scan_kind: str | None = None
|
|
self.load_in_progress = False
|
|
self.load_thread: QThread | None = None
|
|
self.load_worker: LoadWorker | None = None
|
|
self.load_refine_thread: QThread | None = None
|
|
self.load_refine_worker: LoadWorker | None = None
|
|
self.pending_load_path: Path | None = None
|
|
self.initial_load_deflection = 0.8
|
|
self.last_id_kind = "Face"
|
|
|
|
self._build_ui()
|
|
self._build_vtk()
|
|
self.load_step(self.step_path, background=background_load)
|
|
|
|
def _build_ui(self) -> None:
|
|
help_tip = self._set_help_tip
|
|
|
|
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)
|
|
panel_layout.setSpacing(10)
|
|
panel_scroll.setWidget(panel)
|
|
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;
|
|
}
|
|
"""
|
|
)
|
|
|
|
file_box = QGroupBox("STEP 文件")
|
|
file_box.setObjectName("fileSection")
|
|
help_tip(file_box, "打开、重新加载和查看当前 STEP 文件路径。")
|
|
file_layout = QVBoxLayout(file_box)
|
|
self.path_label = QLabel("")
|
|
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
self.path_label.setWordWrap(True)
|
|
help_tip(self.path_label, "当前打开的 STEP 文件路径。可以选中文字复制路径。")
|
|
file_layout.addWidget(self.path_label)
|
|
|
|
file_buttons = QHBoxLayout()
|
|
open_button = QPushButton("打开")
|
|
help_tip(open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。")
|
|
open_button.clicked.connect(self.open_step)
|
|
reload_button = QPushButton("重新加载")
|
|
help_tip(reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。")
|
|
reload_button.clicked.connect(self.reload_step)
|
|
file_buttons.addWidget(open_button)
|
|
file_buttons.addWidget(reload_button)
|
|
file_layout.addLayout(file_buttons)
|
|
panel_layout.addWidget(file_box)
|
|
|
|
tree_box = QGroupBox("模型结构树")
|
|
tree_box.setObjectName("treeSection")
|
|
help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。")
|
|
tree_layout = QVBoxLayout(tree_box)
|
|
self.part_tree = QTreeWidget()
|
|
self.part_tree.setHeaderLabels(["对象", "内容"])
|
|
self.part_tree.setMinimumHeight(180)
|
|
help_tip(self.part_tree, "模型里的装配、零件和 solid 列表。点击一行可以选中并高亮对应对象。")
|
|
self.part_tree.currentItemChanged.connect(self.on_part_tree_select)
|
|
tree_layout.addWidget(self.part_tree)
|
|
panel_layout.addWidget(tree_box)
|
|
|
|
mode_box = QGroupBox("鼠标选择模式")
|
|
mode_box.setObjectName("modeSection")
|
|
help_tip(mode_box, "决定鼠标点模型时选中零件、solid、面、边,还是识别局部特征。")
|
|
mode_layout = QVBoxLayout(mode_box)
|
|
self.mode_combo = NoWheelComboBox()
|
|
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"])
|
|
self.mode_combo.setCurrentText("Face")
|
|
help_tip(
|
|
self.mode_combo,
|
|
"选择鼠标点击模型时要选什么:零件、solid、面、边,或把面解释成孔/槽/圆角等特征候选。",
|
|
)
|
|
self.mode_combo.currentTextChanged.connect(self._on_mode_changed)
|
|
mode_layout.addWidget(self.mode_combo)
|
|
panel_layout.addWidget(mode_box)
|
|
|
|
select_box = QGroupBox("按 ID 选择")
|
|
select_box.setObjectName("idSection")
|
|
help_tip(select_box, "知道对象 ID 时,可以直接输入 ID 跳转选择。")
|
|
select_layout = QGridLayout(select_box)
|
|
self.id_input = QLineEdit("")
|
|
self.id_input.setPlaceholderText("输入 ID")
|
|
help_tip(self.id_input, "输入要选中的对象 ID。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)
|
|
help_tip(self.id_mode_display, "当前按这个鼠标选择模式解释输入的 ID。它会跟随鼠标选择模式自动变化。")
|
|
select_button = QPushButton("选择")
|
|
help_tip(select_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。")
|
|
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)
|
|
select_layout.addWidget(self.id_input, 0, 1)
|
|
select_layout.addWidget(self.id_mode_display, 0, 2)
|
|
select_layout.addWidget(select_button, 0, 3)
|
|
panel_layout.addWidget(select_box)
|
|
|
|
view_box = QGroupBox("显示")
|
|
view_box.setObjectName("viewSection")
|
|
help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。")
|
|
view_layout = QVBoxLayout(view_box)
|
|
view_buttons = QHBoxLayout()
|
|
isolate_button = QPushButton("只显示选中")
|
|
help_tip(isolate_button, "把视图临时隔离到当前选中的零件、solid、面、边或特征区域。不会修改模型。")
|
|
isolate_button.clicked.connect(self.isolate_selected)
|
|
fit_button = QPushButton("对准选中")
|
|
help_tip(fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。")
|
|
fit_button.clicked.connect(self.fit_selected)
|
|
show_all_button = QPushButton("显示全部")
|
|
help_tip(show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。")
|
|
show_all_button.clicked.connect(self.show_all_geometry)
|
|
view_buttons.addWidget(isolate_button)
|
|
view_buttons.addWidget(fit_button)
|
|
view_buttons.addWidget(show_all_button)
|
|
view_layout.addLayout(view_buttons)
|
|
self.show_internal_edges_checkbox = QCheckBox("显示同域内部边")
|
|
help_tip(
|
|
self.show_internal_edges_checkbox,
|
|
"显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔推拉后常见的视觉接缝线。",
|
|
)
|
|
self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled)
|
|
view_layout.addWidget(self.show_internal_edges_checkbox)
|
|
panel_layout.addWidget(view_box)
|
|
|
|
measure_box = QGroupBox("测量")
|
|
measure_box.setObjectName("viewSection")
|
|
help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。")
|
|
measure_layout = QVBoxLayout(measure_box)
|
|
self.measure_text = QPlainTextEdit()
|
|
self.measure_text.setReadOnly(True)
|
|
self.measure_text.setMaximumHeight(90)
|
|
help_tip(self.measure_text, "显示 A 点、B 点、两点距离和各方向差值。")
|
|
measure_buttons = QGridLayout()
|
|
set_measure_a_button = QPushButton("设为 A")
|
|
help_tip(set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。")
|
|
set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A"))
|
|
set_measure_b_button = QPushButton("设为 B")
|
|
help_tip(set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。")
|
|
set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B"))
|
|
copy_measure_button = QPushButton("复制测量")
|
|
help_tip(copy_measure_button, "复制当前测量结果文本,方便记录尺寸。")
|
|
copy_measure_button.clicked.connect(self.copy_measurement)
|
|
clear_measure_button = QPushButton("清除测量")
|
|
help_tip(clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。")
|
|
clear_measure_button.clicked.connect(self.clear_measurement)
|
|
measure_buttons.addWidget(set_measure_a_button, 0, 0)
|
|
measure_buttons.addWidget(set_measure_b_button, 0, 1)
|
|
measure_buttons.addWidget(copy_measure_button, 1, 0)
|
|
measure_buttons.addWidget(clear_measure_button, 1, 1)
|
|
measure_layout.addWidget(self.measure_text)
|
|
measure_layout.addLayout(measure_buttons)
|
|
panel_layout.addWidget(measure_box)
|
|
self._refresh_measurement_panel()
|
|
|
|
export_box = QGroupBox("导出")
|
|
export_box.setObjectName("exportSection")
|
|
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
|
|
export_layout = QVBoxLayout(export_box)
|
|
self.export_all_button = QPushButton("导出当前完整 STEP")
|
|
help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。")
|
|
self.export_all_button.clicked.connect(self.export_all)
|
|
self.export_part_button = QPushButton("导出选中零件")
|
|
help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个 part。")
|
|
self.export_part_button.clicked.connect(self.export_selected_part)
|
|
self.export_solid_button = QPushButton("导出选中 solid")
|
|
help_tip(self.export_solid_button, "只导出当前选中的实体 solid。适合检查或单独保存某个实体。")
|
|
self.export_solid_button.clicked.connect(self.export_selected_solid)
|
|
self.export_face_button = QPushButton("导出选中面区域")
|
|
help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。")
|
|
self.export_face_button.clicked.connect(self.export_selected_face)
|
|
self.export_feature_button = QPushButton("导出选中特征区域")
|
|
help_tip(self.export_feature_button, "导出 Feature 模式识别到的局部特征区域,例如孔、槽、圆角或凸台候选。")
|
|
self.export_feature_button.clicked.connect(self.export_selected_feature)
|
|
self.export_edge_button = QPushButton("导出选中 edge")
|
|
help_tip(self.export_edge_button, "导出当前选中的边。主要用于调试、定位或把边界单独拿出去检查。")
|
|
self.export_edge_button.clicked.connect(self.export_selected_edge)
|
|
self.export_check_button = QPushButton("检查导出质量")
|
|
help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、face/edge 数量、体积和包围盒等。")
|
|
self.export_check_button.clicked.connect(self.check_export_quality)
|
|
self.repair_model_button = QPushButton("修复当前模型")
|
|
help_tip(self.repair_model_button, "对完整模型尝试 ShapeFix 和同域面/边合并。会写入历史,可撤销。")
|
|
self.repair_model_button.clicked.connect(self.repair_model)
|
|
self.repair_selected_button = QPushButton("修复选中零件/solid")
|
|
help_tip(self.repair_selected_button, "只修复当前选中的零件或 solid,范围比修复完整模型更小。会写入历史,可撤销。")
|
|
self.repair_selected_button.clicked.connect(self.repair_selected_shape)
|
|
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)
|
|
export_layout.addWidget(self.repair_model_button)
|
|
export_layout.addWidget(self.repair_selected_button)
|
|
panel_layout.addWidget(export_box)
|
|
|
|
edit_box = QGroupBox("实验性编辑")
|
|
edit_box.setObjectName("editSection")
|
|
help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。")
|
|
edit_layout = QGridLayout(edit_box)
|
|
edit_layout.addWidget(QLabel("面偏移"), 0, 0)
|
|
self.offset_input = QLineEdit("5.0")
|
|
help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。")
|
|
edit_layout.addWidget(self.offset_input, 0, 1)
|
|
self.push_button = QPushButton("推拉平面")
|
|
help_tip(self.push_button, "移动当前选中的平面区域:正数加料,负数切削。会先显示半透明预览,再后台执行。")
|
|
self.push_button.clicked.connect(self.push_pull_face)
|
|
edit_layout.addWidget(self.push_button, 1, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("孔直径"), 2, 0)
|
|
self.hole_diameter_input = QLineEdit("")
|
|
help_tip(self.hole_diameter_input, "圆柱孔/槽的目标直径。选中候选后会自动填一个参考值,可以手动改。")
|
|
edit_layout.addWidget(self.hole_diameter_input, 2, 1)
|
|
self.resize_button = QPushButton("调整圆柱孔径")
|
|
help_tip(self.resize_button, "修改孔或圆柱槽的直径。扩大时切削,缩小时会先补料再重切。")
|
|
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.slot_width_input = QLineEdit("")
|
|
help_tip(self.slot_width_input, "槽或半孔的目标开口宽度。程序会把它换算成对应圆柱直径来执行。")
|
|
edit_layout.addWidget(self.slot_width_input, 4, 1)
|
|
self.resize_slot_button = QPushButton("调整槽/半孔宽度")
|
|
help_tip(self.resize_slot_button, "修改已识别槽/半孔候选的宽度。第一版是几何近似,失败会回滚。")
|
|
self.resize_slot_button.clicked.connect(self.resize_slot_width)
|
|
edit_layout.addWidget(self.resize_slot_button, 5, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("凸台直径"), 6, 0)
|
|
self.boss_diameter_input = QLineEdit("")
|
|
help_tip(self.boss_diameter_input, "圆柱凸台的目标直径。选中凸台候选后会自动填一个参考值。")
|
|
edit_layout.addWidget(self.boss_diameter_input, 6, 1)
|
|
self.resize_boss_button = QPushButton("调整圆柱凸台直径")
|
|
help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。")
|
|
self.resize_boss_button.clicked.connect(self.resize_boss)
|
|
edit_layout.addWidget(self.resize_boss_button, 7, 0, 1, 2)
|
|
|
|
self.suppress_button = QPushButton("封堵圆柱孔")
|
|
help_tip(self.suppress_button, "用补料体填住完整圆柱孔。适合通孔/盲孔,不适合半孔或槽。")
|
|
self.suppress_button.clicked.connect(self.suppress_hole)
|
|
edit_layout.addWidget(self.suppress_button, 8, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("孔深度"), 9, 0)
|
|
depth_inputs = QWidget()
|
|
depth_layout = QHBoxLayout(depth_inputs)
|
|
depth_layout.setContentsMargins(0, 0, 0, 0)
|
|
self.hole_depth_input = QLineEdit("")
|
|
help_tip(self.hole_depth_input, "盲孔或盲槽的目标深度。选中候选后会填入参考值,深度来自几何估算。")
|
|
self.hole_bottom_face_input = QLineEdit("")
|
|
self.hole_bottom_face_input.setPlaceholderText("底面 Face ID")
|
|
help_tip(self.hole_bottom_face_input, "自动识别孔底不稳定时,可手动输入底面 Face ID,让孔深计算有明确底面。")
|
|
self.hole_bottom_face_input.textChanged.connect(lambda _text: self._update_action_states())
|
|
depth_layout.addWidget(self.hole_depth_input, stretch=2)
|
|
depth_layout.addWidget(self.hole_bottom_face_input, stretch=1)
|
|
edit_layout.addWidget(depth_inputs, 9, 1)
|
|
self.resize_depth_button = QPushButton("调整盲孔深度")
|
|
help_tip(self.resize_depth_button, "加深或变浅盲孔/盲槽。需要识别到底面,或手动填写底面 Face ID。")
|
|
self.resize_depth_button.clicked.connect(self.resize_hole_depth)
|
|
edit_layout.addWidget(self.resize_depth_button, 10, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("圆角半径"), 11, 0)
|
|
self.edge_fillet_radius_input = QLineEdit("")
|
|
help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中边/圆角候选后会自动填参考值。")
|
|
edit_layout.addWidget(self.edge_fillet_radius_input, 11, 1)
|
|
self.fillet_edge_button = QPushButton("给边添加圆角")
|
|
help_tip(self.fillet_edge_button, "给当前直线边新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。")
|
|
self.fillet_edge_button.clicked.connect(self.fillet_edge)
|
|
edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2)
|
|
|
|
self.resize_existing_fillet_button = QPushButton("修改已有圆角半径")
|
|
help_tip(self.resize_existing_fillet_button, "尝试修改已识别圆角面的半径。会先移除原圆角再重建,复杂圆角可能失败并回滚。")
|
|
self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet)
|
|
edit_layout.addWidget(self.resize_existing_fillet_button, 13, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("倒角距离"), 14, 0)
|
|
self.edge_chamfer_distance_input = QLineEdit("")
|
|
help_tip(self.edge_chamfer_distance_input, "给边添加倒角时使用的距离。选中直线边后会填一个较小参考值。")
|
|
edit_layout.addWidget(self.edge_chamfer_distance_input, 14, 1)
|
|
self.chamfer_edge_button = QPushButton("给边添加倒角")
|
|
help_tip(self.chamfer_edge_button, "给当前直线边新增对称倒角。会先预览,再后台执行,失败会回滚。")
|
|
self.chamfer_edge_button.clicked.connect(self.chamfer_edge)
|
|
edit_layout.addWidget(self.chamfer_edge_button, 15, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("边目标长度"), 16, 0)
|
|
edge_length_inputs = QWidget()
|
|
edge_length_layout = QHBoxLayout(edge_length_inputs)
|
|
edge_length_layout.setContentsMargins(0, 0, 0, 0)
|
|
self.edge_target_length_input = QLineEdit("")
|
|
help_tip(self.edge_target_length_input, "当前 edge 的目标长度。选中边后会自动填当前长度,改成新长度再执行。")
|
|
self.edge_length_anchor_combo = QComboBox()
|
|
self.edge_length_anchor_combo.addItem("自动", "auto")
|
|
self.edge_length_anchor_combo.addItem("中心", "center")
|
|
self.edge_length_anchor_combo.addItem("固定起点", "keep-start")
|
|
self.edge_length_anchor_combo.addItem("固定终点", "keep-end")
|
|
self.edge_length_anchor_combo.setCurrentIndex(0)
|
|
self.edge_length_anchor_combo.setMinimumWidth(90)
|
|
help_tip(
|
|
self.edge_length_anchor_combo,
|
|
"选择边长修改时尽量固定哪里:自动会优先找局部端面;中心/起点/终点会影响缩放 fallback 的基准点。",
|
|
)
|
|
edge_length_layout.addWidget(self.edge_target_length_input, stretch=2)
|
|
edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1)
|
|
edit_layout.addWidget(edge_length_inputs, 16, 1)
|
|
self.resize_edge_length_button = QPushButton("直接修改边长")
|
|
help_tip(
|
|
self.resize_edge_length_button,
|
|
"直接修改当前 edge 长度。直线边优先端面推拉,圆边优先换算相邻圆柱直径,必要时再用几何缩放 fallback。",
|
|
)
|
|
self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length)
|
|
edit_layout.addWidget(self.resize_edge_length_button, 17, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 18, 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")
|
|
help_tip(self.translate_x_input, "沿 X 方向平移的距离。输入 0 表示 X 方向不移动。")
|
|
help_tip(self.translate_y_input, "沿 Y 方向平移的距离。输入 0 表示 Y 方向不移动。")
|
|
help_tip(self.translate_z_input, "沿 Z 方向平移的距离。输入 0 表示 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, 18, 1)
|
|
self.translate_part_button = QPushButton("平移选中零件")
|
|
help_tip(self.translate_part_button, "按上面的 X/Y/Z 距离移动当前零件。会写入历史,可撤销。")
|
|
self.translate_part_button.clicked.connect(self.translate_selected_part)
|
|
self.translate_solid_button = QPushButton("平移选中 solid")
|
|
help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前 solid。单 solid 零件中相当于移动整个零件。")
|
|
self.translate_solid_button.clicked.connect(self.translate_selected_solid)
|
|
edit_layout.addWidget(self.translate_part_button, 19, 0, 1, 2)
|
|
edit_layout.addWidget(self.translate_solid_button, 20, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("旋转轴/角度"), 21, 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")
|
|
help_tip(self.rotate_axis_combo, "选择旋转轴。对象会绕自身包围盒中心旋转。")
|
|
self.rotate_angle_input = QLineEdit("90")
|
|
self.rotate_angle_input.setPlaceholderText("度")
|
|
help_tip(self.rotate_angle_input, "旋转角度,单位是度。正负号决定旋转方向。")
|
|
rotate_layout.addWidget(self.rotate_axis_combo)
|
|
rotate_layout.addWidget(self.rotate_angle_input)
|
|
edit_layout.addWidget(rotate_inputs, 21, 1)
|
|
self.rotate_part_button = QPushButton("旋转选中零件")
|
|
help_tip(self.rotate_part_button, "绕所选轴旋转当前零件。会写入历史,可撤销。")
|
|
self.rotate_part_button.clicked.connect(self.rotate_selected_part)
|
|
self.rotate_solid_button = QPushButton("旋转选中 solid")
|
|
help_tip(self.rotate_solid_button, "绕所选轴旋转当前 solid。单 solid 零件中相当于旋转整个零件。")
|
|
self.rotate_solid_button.clicked.connect(self.rotate_selected_solid)
|
|
edit_layout.addWidget(self.rotate_part_button, 22, 0, 1, 2)
|
|
edit_layout.addWidget(self.rotate_solid_button, 23, 0, 1, 2)
|
|
|
|
self.cylinders_button = QPushButton("列出圆柱候选")
|
|
help_tip(self.cylinders_button, "扫描模型里的圆柱面,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
|
|
self.cylinders_button.clicked.connect(self.list_cylinders)
|
|
edit_layout.addWidget(self.cylinders_button, 24, 0, 1, 2)
|
|
|
|
self.undo_button = QPushButton("撤销")
|
|
help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。")
|
|
self.undo_button.clicked.connect(self.undo_edit)
|
|
self.redo_button = QPushButton("重做")
|
|
help_tip(self.redo_button, "重做刚刚撤销的编辑。")
|
|
self.redo_button.clicked.connect(self.redo_edit)
|
|
edit_layout.addWidget(self.undo_button, 25, 0)
|
|
edit_layout.addWidget(self.redo_button, 25, 1)
|
|
panel_layout.addWidget(edit_box)
|
|
|
|
editable_box = QGroupBox("第一版可编辑对象")
|
|
editable_box.setObjectName("editableSection")
|
|
help_tip(editable_box, "自动找出一批可以尝试编辑的 face 或 edge,方便不用手动到处点。")
|
|
editable_layout = QVBoxLayout(editable_box)
|
|
editable_button_row = QHBoxLayout()
|
|
self.editable_refresh_button = QPushButton("扫描可编辑对象")
|
|
help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。")
|
|
self.editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True))
|
|
self.editable_deep_scan_button = QPushButton("深度扫描")
|
|
help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。")
|
|
self.editable_deep_scan_button.clicked.connect(
|
|
lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True)
|
|
)
|
|
editable_button_row.addWidget(self.editable_refresh_button)
|
|
editable_button_row.addWidget(self.editable_deep_scan_button)
|
|
editable_layout.addLayout(editable_button_row)
|
|
self.editable_table = QTableWidget(0, 8)
|
|
self.editable_table.setHorizontalHeaderLabels(
|
|
["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"]
|
|
)
|
|
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)
|
|
help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应 face 或 edge,并自动填入相关输入框。")
|
|
self.editable_table.cellClicked.connect(self.on_editable_row_clicked)
|
|
editable_layout.addWidget(self.editable_table)
|
|
panel_layout.addWidget(editable_box)
|
|
|
|
candidate_box = QGroupBox("圆柱候选")
|
|
candidate_box.setObjectName("candidateSection")
|
|
help_tip(candidate_box, "列出圆柱面并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
|
|
candidate_layout = QVBoxLayout(candidate_box)
|
|
filter_layout = QHBoxLayout()
|
|
filter_layout.addWidget(QLabel("类型"))
|
|
self.candidate_filter_combo = NoWheelComboBox()
|
|
self.candidate_filter_combo.addItems(
|
|
[
|
|
"All",
|
|
"Hole/Groove",
|
|
"Round/Fillet",
|
|
"Boss/Outer",
|
|
"Unclear",
|
|
]
|
|
)
|
|
help_tip(
|
|
self.candidate_filter_combo,
|
|
"筛选下面的圆柱候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。",
|
|
)
|
|
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)
|
|
help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应 face,并在属性区显示可用操作和风险。")
|
|
self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked)
|
|
candidate_layout.addWidget(self.cylinder_table)
|
|
panel_layout.addWidget(candidate_box)
|
|
|
|
history_box = QGroupBox("操作历史")
|
|
history_box.setObjectName("historySection")
|
|
help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。")
|
|
history_layout = QVBoxLayout(history_box)
|
|
self.history_list = QListWidget()
|
|
self.history_list.setMinimumHeight(110)
|
|
help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。")
|
|
self.history_list.currentRowChanged.connect(self.on_history_row_changed)
|
|
history_buttons = QHBoxLayout()
|
|
clear_diff_button = QPushButton("清除差异预览")
|
|
help_tip(clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。")
|
|
clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview())
|
|
export_diff_button = QPushButton("导出差异报告")
|
|
help_tip(export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。")
|
|
export_diff_button.clicked.connect(self.export_diff_report)
|
|
export_history_button = QPushButton("导出编辑历史")
|
|
help_tip(export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。")
|
|
export_history_button.clicked.connect(self.export_operation_history)
|
|
history_layout.addWidget(self.history_list)
|
|
history_buttons.addWidget(clear_diff_button)
|
|
history_buttons.addWidget(export_diff_button)
|
|
history_buttons.addWidget(export_history_button)
|
|
history_layout.addLayout(history_buttons)
|
|
panel_layout.addWidget(history_box)
|
|
|
|
info_box = QGroupBox("选中对象信息")
|
|
info_box.setObjectName("infoSection")
|
|
help_tip(info_box, "显示当前选中对象的几何、拓扑、特征判断和可用操作信息。")
|
|
info_layout = QVBoxLayout(info_box)
|
|
info_buttons = QHBoxLayout()
|
|
copy_id_button = QPushButton("复制 ID")
|
|
help_tip(copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。")
|
|
copy_id_button.clicked.connect(self.copy_selected_id)
|
|
copy_pick_button = QPushButton("复制坐标")
|
|
help_tip(copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。")
|
|
copy_pick_button.clicked.connect(self.copy_pick_position)
|
|
copy_info_button = QPushButton("复制信息")
|
|
help_tip(copy_info_button, "复制当前属性区里的完整文本,方便发给别人或做问题记录。")
|
|
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)
|
|
info_layout.addLayout(info_buttons)
|
|
|
|
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)
|
|
help_tip(self.info_tree, "当前选中对象的结构化属性。悬停单元格可以看到完整字段和值。")
|
|
self.info_text = QPlainTextEdit()
|
|
self.info_text.setReadOnly(True)
|
|
help_tip(self.info_text, "当前选中对象信息的原始文本版本,适合复制或排查问题。")
|
|
self.info_tabs.addTab(self.info_tree, "属性表")
|
|
self.info_tabs.addTab(self.info_text, "原始文本")
|
|
help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。")
|
|
info_layout.addWidget(self.info_tabs)
|
|
panel_layout.addWidget(info_box, stretch=1)
|
|
|
|
self._update_action_states()
|
|
|
|
self.vtk_widget = QVTKRenderWindowInteractor(central)
|
|
self.vtk_widget.setMouseTracking(True)
|
|
self.vtk_widget.installEventFilter(self)
|
|
root_layout.addWidget(self.vtk_widget, stretch=1)
|
|
|
|
self.statusBar().showMessage("Ready")
|
|
|
|
def _set_help_tip(self, widget, text: str) -> None:
|
|
widget.setToolTip(text)
|
|
widget.setStatusTip(text)
|
|
try:
|
|
widget.setToolTipDuration(14000)
|
|
except AttributeError:
|
|
pass
|
|
|
|
|
|
|
|
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:
|
|
_enable_crash_log()
|
|
path, smoke_test = _parse_args(sys.argv)
|
|
app = QApplication(sys.argv)
|
|
window = StepEditorWindow(path, background_load=not smoke_test)
|
|
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())
|