2234 lines
90 KiB
Python
2234 lines
90 KiB
Python
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))
|
|
|
|
|
|
INFO_GROUPS: list[tuple[str, list[str]]] = [
|
|
(
|
|
"身份",
|
|
[
|
|
"kind",
|
|
"name",
|
|
"path",
|
|
"file",
|
|
"part_id",
|
|
"solid_id",
|
|
"face_id",
|
|
"edge_id",
|
|
"parent_id",
|
|
"depth",
|
|
"feature_mode",
|
|
],
|
|
),
|
|
(
|
|
"拓扑",
|
|
[
|
|
"parts",
|
|
"solids",
|
|
"faces",
|
|
"edges",
|
|
"vertices",
|
|
"boundary_edges",
|
|
"orientation",
|
|
"surface",
|
|
"curve",
|
|
],
|
|
),
|
|
(
|
|
"测量",
|
|
[
|
|
"volume",
|
|
"surface_area",
|
|
"area",
|
|
"length",
|
|
"radius",
|
|
"diameter",
|
|
"major_radius",
|
|
"minor_radius",
|
|
"reference_radius",
|
|
"semi_angle",
|
|
"angular_span",
|
|
"height_estimate",
|
|
"hole_depth_estimate",
|
|
"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",
|
|
],
|
|
),
|
|
(
|
|
"方向 / 轴线",
|
|
[
|
|
"normal",
|
|
"oriented_normal",
|
|
"plane_origin",
|
|
"axis_point",
|
|
"axis",
|
|
"direction",
|
|
"line_origin",
|
|
"push_pull_outward_direction",
|
|
"push_pull_inward_direction",
|
|
"push_pull_plus_side",
|
|
"push_pull_minus_side",
|
|
"push_pull_confidence",
|
|
"push_pull_note",
|
|
],
|
|
),
|
|
(
|
|
"参数",
|
|
[
|
|
"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",
|
|
"resize_status",
|
|
"resize_mode",
|
|
"resize_risk",
|
|
"resize_warnings",
|
|
"resize_blockers",
|
|
"resize_note",
|
|
"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",
|
|
"cutter_note",
|
|
"fill_strategy",
|
|
"fill_height",
|
|
"fill_radius",
|
|
"fill_radius_overlap",
|
|
"fill_start_point",
|
|
"fill_note",
|
|
],
|
|
),
|
|
]
|
|
|
|
|
|
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": "特征模式说明",
|
|
"parts": "零件数",
|
|
"solids": "Solid 数",
|
|
"faces": "Face 数",
|
|
"edges": "Edge 数",
|
|
"vertices": "Vertex 数",
|
|
"boundary_edges": "边界边数",
|
|
"orientation": "拓扑方向",
|
|
"surface": "曲面类型",
|
|
"curve": "曲线类型",
|
|
"volume": "体积",
|
|
"surface_area": "表面积",
|
|
"area": "面积",
|
|
"length": "长度",
|
|
"radius": "半径",
|
|
"diameter": "直径",
|
|
"major_radius": "主半径",
|
|
"minor_radius": "小半径",
|
|
"reference_radius": "参考半径",
|
|
"semi_angle": "半角",
|
|
"angular_span": "角度跨度",
|
|
"height_estimate": "估算高度",
|
|
"hole_depth_estimate": "孔/槽深度估算",
|
|
"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": "终点",
|
|
"normal": "几何法向",
|
|
"oriented_normal": "拓扑修正法向",
|
|
"plane_origin": "平面原点",
|
|
"axis_point": "轴线点",
|
|
"axis": "轴方向",
|
|
"direction": "方向",
|
|
"line_origin": "直线原点",
|
|
"push_pull_outward_direction": "推拉向外方向",
|
|
"push_pull_inward_direction": "推拉向内方向",
|
|
"push_pull_plus_side": "原始法向侧",
|
|
"push_pull_minus_side": "反向法向侧",
|
|
"push_pull_confidence": "推拉方向置信度",
|
|
"push_pull_note": "推拉方向说明",
|
|
"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": "备注",
|
|
"resize_status": "切削状态",
|
|
"resize_mode": "调整模式",
|
|
"resize_risk": "切削风险",
|
|
"resize_warnings": "切削警告",
|
|
"resize_blockers": "切削阻止原因",
|
|
"resize_note": "切削说明",
|
|
"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 起点",
|
|
"cutter_note": "Cutter 说明",
|
|
"fill_strategy": "补料策略",
|
|
"fill_height": "补料高度",
|
|
"fill_radius": "补料半径",
|
|
"fill_radius_overlap": "补料重叠量",
|
|
"fill_start_point": "补料起点",
|
|
"fill_note": "补料说明",
|
|
}
|
|
|
|
|
|
PART_TREE_KIND_ROLE = Qt.UserRole
|
|
PART_TREE_ID_ROLE = Qt.UserRole + 1
|
|
PART_TREE_PART_ID_ROLE = Qt.UserRole + 2
|
|
EDITABLE_FACE_ROLE = Qt.UserRole
|
|
EDITABLE_ACTION_ROLE = Qt.UserRole + 1
|
|
|
|
|
|
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
|
|
self.pick_marker_actor = None
|
|
self.edit_preview_actor = None
|
|
self.edit_preview_timer: QTimer | None = None
|
|
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.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
|
|
|
|
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)
|
|
panel_layout.setSpacing(8)
|
|
panel_scroll.setWidget(panel)
|
|
|
|
panel_layout.addWidget(QLabel("STEP 文件"))
|
|
self.path_label = QLabel("")
|
|
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
|
self.path_label.setWordWrap(True)
|
|
panel_layout.addWidget(self.path_label)
|
|
|
|
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)
|
|
panel_layout.addLayout(file_buttons)
|
|
|
|
panel_layout.addWidget(QLabel("零件 / 装配树"))
|
|
self.part_tree = QTreeWidget()
|
|
self.part_tree.setHeaderLabels(["名称", "类型 / 数量"])
|
|
self.part_tree.setMinimumHeight(180)
|
|
self.part_tree.currentItemChanged.connect(self.on_part_tree_select)
|
|
panel_layout.addWidget(self.part_tree)
|
|
|
|
panel_layout.addWidget(QLabel("选择模式"))
|
|
self.mode_combo = QComboBox()
|
|
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"])
|
|
self.mode_combo.setCurrentText("Face")
|
|
panel_layout.addWidget(self.mode_combo)
|
|
|
|
select_box = QGroupBox("按 ID 选择")
|
|
select_layout = QGridLayout(select_box)
|
|
self.id_kind_combo = QComboBox()
|
|
self.id_kind_combo.addItems(["Part", "Solid", "Face", "Edge"])
|
|
self.id_input = QLineEdit("")
|
|
select_button = QPushButton("选择")
|
|
select_button.clicked.connect(self.select_by_id)
|
|
self.id_input.returnPressed.connect(self.select_by_id)
|
|
select_layout.addWidget(self.id_kind_combo, 0, 0)
|
|
select_layout.addWidget(self.id_input, 0, 1)
|
|
select_layout.addWidget(select_button, 0, 2)
|
|
panel_layout.addWidget(select_box)
|
|
|
|
export_box = QGroupBox("导出")
|
|
export_layout = QVBoxLayout(export_box)
|
|
export_all_button = QPushButton("导出当前完整 STEP")
|
|
export_all_button.clicked.connect(self.export_all)
|
|
export_part_button = QPushButton("导出选中零件")
|
|
export_part_button.clicked.connect(self.export_selected_part)
|
|
export_solid_button = QPushButton("导出选中 solid")
|
|
export_solid_button.clicked.connect(self.export_selected_solid)
|
|
export_face_button = QPushButton("导出选中 face")
|
|
export_face_button.clicked.connect(self.export_selected_face)
|
|
export_layout.addWidget(export_all_button)
|
|
export_layout.addWidget(export_part_button)
|
|
export_layout.addWidget(export_solid_button)
|
|
export_layout.addWidget(export_face_button)
|
|
panel_layout.addWidget(export_box)
|
|
|
|
edit_box = QGroupBox("实验性编辑")
|
|
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)
|
|
push_button = QPushButton("推拉平面")
|
|
push_button.clicked.connect(self.push_pull_face)
|
|
edit_layout.addWidget(push_button, 1, 0, 1, 2)
|
|
|
|
edit_layout.addWidget(QLabel("孔直径"), 2, 0)
|
|
self.hole_diameter_input = QLineEdit("")
|
|
edit_layout.addWidget(self.hole_diameter_input, 2, 1)
|
|
resize_button = QPushButton("调整圆柱孔径")
|
|
resize_button.clicked.connect(self.resize_hole)
|
|
edit_layout.addWidget(resize_button, 3, 0, 1, 2)
|
|
|
|
cylinders_button = QPushButton("列出圆柱候选")
|
|
cylinders_button.clicked.connect(self.list_cylinders)
|
|
edit_layout.addWidget(cylinders_button, 4, 0, 1, 2)
|
|
|
|
undo_button = QPushButton("撤销")
|
|
undo_button.clicked.connect(self.undo_edit)
|
|
redo_button = QPushButton("重做")
|
|
redo_button.clicked.connect(self.redo_edit)
|
|
edit_layout.addWidget(undo_button, 5, 0)
|
|
edit_layout.addWidget(redo_button, 5, 1)
|
|
panel_layout.addWidget(edit_box)
|
|
|
|
editable_box = QGroupBox("第一版可编辑对象")
|
|
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))
|
|
editable_button_row.addWidget(editable_refresh_button)
|
|
editable_layout.addLayout(editable_button_row)
|
|
self.editable_table = QTableWidget(0, 8)
|
|
self.editable_table.setHorizontalHeaderLabels(
|
|
["操作", "face", "对象", "当前值", "状态", "风险", "置信度", "说明"]
|
|
)
|
|
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("圆柱候选")
|
|
candidate_layout = QVBoxLayout(candidate_box)
|
|
filter_layout = QHBoxLayout()
|
|
filter_layout.addWidget(QLabel("类型"))
|
|
self.candidate_filter_combo = QComboBox()
|
|
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("操作历史")
|
|
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)
|
|
|
|
panel_layout.addWidget(QLabel("选中对象信息"))
|
|
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)
|
|
panel_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)
|
|
self.info_text = QPlainTextEdit()
|
|
self.info_text.setReadOnly(True)
|
|
self.info_tabs.addTab(self.info_tree, "属性表")
|
|
self.info_tabs.addTab(self.info_text, "原始文本")
|
|
panel_layout.addWidget(self.info_tabs, stretch=1)
|
|
|
|
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)
|
|
|
|
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:
|
|
try:
|
|
self.model = StepModel.load(path)
|
|
except Exception as exc:
|
|
QMessageBox.critical(self, "加载失败", str(exc))
|
|
return
|
|
|
|
self.step_path = Path(path)
|
|
self._clear_history()
|
|
self.path_label.setText(str(self.step_path))
|
|
self._populate_part_tree()
|
|
self._reset_selection()
|
|
self._rebuild_scene(reset_camera=True)
|
|
self._clear_editable_candidates()
|
|
self._clear_cylinder_candidates()
|
|
stats = self.model.stats()
|
|
self.set_info(
|
|
{
|
|
"file": str(self.step_path),
|
|
"parts": stats.parts,
|
|
"solids": stats.solids,
|
|
"faces": stats.faces,
|
|
"edges": stats.edges,
|
|
"vertices": stats.vertices,
|
|
}
|
|
)
|
|
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:
|
|
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
|
|
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:
|
|
item = QTreeWidgetItem([part.name, self._part_tree_part_detail(part.id, part.kind)])
|
|
item.setData(0, PART_TREE_KIND_ROLE, part.kind)
|
|
item.setData(0, PART_TREE_ID_ROLE, part.id)
|
|
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
|
|
item = QTreeWidgetItem([f"Solid {solid_id}", self._part_tree_solid_detail(solid_id)])
|
|
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)
|
|
parent.addChild(item)
|
|
|
|
self.part_tree.expandAll()
|
|
self.part_tree.resizeColumnToContents(0)
|
|
self.part_tree.resizeColumnToContents(1)
|
|
finally:
|
|
self.part_tree.blockSignals(was_blocked)
|
|
|
|
def _part_tree_part_detail(self, part_id: int, kind: str) -> str:
|
|
if self.model is None:
|
|
return kind
|
|
try:
|
|
info = self.model.part_info(part_id)
|
|
except Exception:
|
|
return kind
|
|
return f"{kind} | solids {info.get('solids', 0)} | faces {info.get('faces', 0)}"
|
|
|
|
def _part_tree_solid_detail(self, solid_id: int) -> str:
|
|
if self.model is None:
|
|
return "solid"
|
|
try:
|
|
info = self.model.solid_info(solid_id)
|
|
except Exception:
|
|
return "solid"
|
|
return f"solid | faces {info.get('faces', 0)} | edges {info.get('edges', 0)}"
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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
|
|
self.pick_marker_actor = None
|
|
self.edit_preview_actor = None
|
|
self.diff_actors = []
|
|
if reset_camera:
|
|
self.renderer.ResetCamera()
|
|
self.render_window.Render()
|
|
|
|
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()
|
|
actor = self.edge_actor if mode == "Edge" else self.model_actor
|
|
if actor is None:
|
|
return
|
|
|
|
self.picker.InitializePickList()
|
|
self.picker.PickFromListOn()
|
|
self.picker.AddPickList(actor)
|
|
picked = self.picker.Pick(x, y, 0, self.renderer)
|
|
if not picked or self.picker.GetCellId() < 0:
|
|
self.statusBar().showMessage("未选中对象")
|
|
return
|
|
|
|
cell_id = self.picker.GetCellId()
|
|
pick_position = _vector_tuple(self.picker.GetPickPosition())
|
|
if mode == "Edge":
|
|
self._select_edge_from_cell(cell_id, pick_position=pick_position)
|
|
else:
|
|
self._select_face_cell(cell_id, mode, pick_position=pick_position)
|
|
|
|
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":
|
|
self.select_face(face_id, feature_mode=True, pick_position=pick_position)
|
|
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:
|
|
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:
|
|
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")
|
|
self.select_face(int(face_id), feature_mode=True)
|
|
|
|
def on_editable_row_clicked(self, row: int, _column: int) -> None:
|
|
item = self.editable_table.item(row, 0)
|
|
if item is None:
|
|
return
|
|
face_id = item.data(EDITABLE_FACE_ROLE)
|
|
action = item.data(EDITABLE_ACTION_ROLE)
|
|
if face_id is None:
|
|
return
|
|
if action == "resize_cylinder":
|
|
self.mode_combo.setCurrentText("Feature")
|
|
self.select_face(int(face_id), feature_mode=True)
|
|
self.statusBar().showMessage(f"已选择可调整孔径候选 face {face_id}")
|
|
else:
|
|
self.mode_combo.setCurrentText("Face")
|
|
self.select_face(int(face_id))
|
|
self.statusBar().showMessage(f"已选择可推拉平面 face {face_id}")
|
|
|
|
def select_by_id(self) -> None:
|
|
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
|
|
|
|
kind = self.id_kind_combo.currentText()
|
|
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)
|
|
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)
|
|
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
|
|
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))
|
|
self.statusBar().showMessage(self._selection_status(f"已选择零件 {part_id}", pick_position))
|
|
|
|
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
|
|
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))
|
|
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
|
|
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 历史特征"
|
|
if "diameter" in info:
|
|
self.hole_diameter_input.setText(_format_float(float(info["diameter"]) * 1.2))
|
|
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)
|
|
self._sync_id_picker("Face", face_id)
|
|
self.set_info(self._with_pick_info(info, pick_position))
|
|
self.statusBar().showMessage(self._selection_status(f"已选择 face {face_id}", pick_position))
|
|
|
|
def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
|
if self.model is None:
|
|
return
|
|
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)
|
|
self.selected_part_id = int(info["part_id"])
|
|
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))
|
|
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)
|
|
actor = vtk.vtkActor()
|
|
actor.SetMapper(mapper)
|
|
actor.GetProperty().SetColor(1.0, 0.72, 0.08)
|
|
actor.GetProperty().SetOpacity(0.75)
|
|
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()
|
|
polydata = self.model.build_edge_polydata()
|
|
extract = vtk.vtkExtractCells()
|
|
ids = vtk.vtkIdList()
|
|
edge_arr = polydata.GetCellData().GetArray("edge_id")
|
|
for cell_id in range(polydata.GetNumberOfCells()):
|
|
if int(edge_arr.GetValue(cell_id)) == edge_id:
|
|
ids.InsertNextId(cell_id)
|
|
break
|
|
extract.SetInputData(polydata)
|
|
extract.SetCellList(ids)
|
|
extract.Update()
|
|
mapper = vtk.vtkDataSetMapper()
|
|
mapper.SetInputConnection(extract.GetOutputPort())
|
|
actor = vtk.vtkActor()
|
|
actor.SetMapper(mapper)
|
|
actor.GetProperty().SetColor(1.0, 0.78, 0.0)
|
|
actor.GetProperty().SetLineWidth(5)
|
|
self.edge_highlight_actor = actor
|
|
self.renderer.AddActor(actor)
|
|
self.render_window.Render()
|
|
|
|
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()
|
|
if self.edit_preview_actor is not None and hasattr(self, "renderer"):
|
|
self.renderer.RemoveActor(self.edit_preview_actor)
|
|
self.edit_preview_actor = None
|
|
if render and hasattr(self, "render_window"):
|
|
self.render_window.Render()
|
|
|
|
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
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
mapper.SetInputData(polydata)
|
|
actor = vtk.vtkActor()
|
|
actor.SetMapper(mapper)
|
|
if distance >= 0:
|
|
actor.GetProperty().SetColor(0.0, 0.86, 0.34)
|
|
else:
|
|
actor.GetProperty().SetColor(1.0, 0.18, 0.06)
|
|
actor.GetProperty().SetOpacity(self.edit_preview_base_opacity)
|
|
actor.GetProperty().SetSpecular(0.28)
|
|
actor.GetProperty().SetLineWidth(1)
|
|
self.edit_preview_actor = actor
|
|
self.renderer.AddActor(actor)
|
|
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 self.edit_preview_actor is None:
|
|
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)
|
|
self.edit_preview_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)
|
|
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()
|
|
|
|
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:
|
|
self.id_kind_combo.setCurrentText(kind)
|
|
self.id_input.setText(str(target_id))
|
|
|
|
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} 在当前模型索引中已经不存在。"
|
|
|
|
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 = "定位: 这条历史记录没有可定位的目标或拾取点。"
|
|
self.statusBar().showMessage(locator_note.splitlines()[0])
|
|
return locator_note
|
|
|
|
def undo_edit(self) -> None:
|
|
if self.model is None:
|
|
return
|
|
if not self.undo_stack:
|
|
self.statusBar().showMessage("没有可撤销的编辑")
|
|
return
|
|
current = self.model.snapshot()
|
|
snapshot = self.undo_stack.pop()
|
|
undone = self.operation_history.pop() if self.operation_history else OperationRecord("编辑", "编辑")
|
|
self.redo_stack.append(current)
|
|
self.redo_history.append(undone)
|
|
self._restore_snapshot(snapshot)
|
|
self._refresh_history_list()
|
|
self.statusBar().showMessage(f"已撤销:{undone.summary}")
|
|
|
|
def redo_edit(self) -> None:
|
|
if self.model is None:
|
|
return
|
|
if not self.redo_stack:
|
|
self.statusBar().showMessage("没有可重做的编辑")
|
|
return
|
|
current = self.model.snapshot()
|
|
snapshot = self.redo_stack.pop()
|
|
redone = self.redo_history.pop() if self.redo_history else OperationRecord("编辑", "编辑")
|
|
self.undo_stack.append(current)
|
|
self.operation_history.append(redone)
|
|
self._restore_snapshot(snapshot)
|
|
self._refresh_history_list()
|
|
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,
|
|
}
|
|
)
|
|
|
|
def export_all(self) -> None:
|
|
if self.model is None:
|
|
return
|
|
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
|
return
|
|
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
|
|
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
|
|
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
|
|
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}",
|
|
)
|
|
|
|
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
|
|
face_info = self.model.face_info(face_id)
|
|
if face_info.get("surface") != "plane":
|
|
QMessageBox.information(self, "不是平面", "当前选中的 face 不是平面,不能执行推拉平面。")
|
|
return
|
|
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={
|
|
"semantic_distance": distance,
|
|
"distance_rule": "positive=outward fuse, negative=inward cut",
|
|
"surface": face_info.get("surface"),
|
|
"outward_direction": face_info.get("push_pull_outward_direction"),
|
|
"direction_confidence": face_info.get("push_pull_confidence"),
|
|
"direction_note": face_info.get("push_pull_note"),
|
|
},
|
|
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":
|
|
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"调整模式: {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"
|
|
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"
|
|
f"补料策略: {plan.get('fill_strategy', '')}\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
|
|
face_id = self.selected_face_id
|
|
|
|
def action():
|
|
return self.model.resize_cylindrical_hole(face_id, diameter)
|
|
|
|
self._run_edit_action(
|
|
action,
|
|
operation_name="调整圆柱孔径",
|
|
target=f"face {face_id}",
|
|
parameters={
|
|
"new_diameter": diameter,
|
|
"old_diameter": info.get("diameter"),
|
|
"delta_diameter": plan.get("delta_diameter"),
|
|
"resize_mode": plan.get("resize_mode"),
|
|
"feature_guess": guess,
|
|
"confidence": plan.get("confidence"),
|
|
"resize_risk": plan.get("risk"),
|
|
"resize_message": plan.get("message"),
|
|
"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"),
|
|
"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,
|
|
)
|
|
|
|
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) -> None:
|
|
if self.model is None:
|
|
return
|
|
if self._edit_busy("编辑计算中,暂时不能扫描可编辑对象。"):
|
|
return
|
|
self.statusBar().showMessage("正在扫描第一版可编辑对象...")
|
|
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
|
QApplication.processEvents()
|
|
try:
|
|
candidates = self.model.editable_feature_candidates(
|
|
limit=40,
|
|
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)} 个"]
|
|
for row, item in enumerate(candidates):
|
|
operation_item = QTableWidgetItem(str(item["operation"]))
|
|
operation_item.setData(EDITABLE_FACE_ROLE, int(item["face_id"]))
|
|
operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"]))
|
|
face_item = QTableWidgetItem(str(item["face_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,
|
|
face_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)
|
|
lines.append(
|
|
f"face {item['face_id']}: {item['operation']}, "
|
|
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()
|
|
self.statusBar().showMessage(f"已扫描第一版可编辑对象:{len(candidates)} 个")
|
|
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)
|
|
worker = EditWorker(self._make_edit_job(action))
|
|
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()
|
|
|
|
def _make_edit_job(self, action):
|
|
def job():
|
|
if self.model is None:
|
|
raise RuntimeError("Model is not loaded.")
|
|
snapshot = self.model.snapshot()
|
|
before_stats = self.model.stats()
|
|
before_geometry = self.model.geometry_stats()
|
|
result = action()
|
|
after_snapshot = self.model.snapshot()
|
|
after_stats = self.model.stats()
|
|
after_geometry = self.model.geometry_stats()
|
|
model_polydata = self.model.build_face_polydata()
|
|
edge_polydata = self.model.build_edge_polydata()
|
|
return {
|
|
"message": str(result),
|
|
"snapshot": snapshot,
|
|
"before_stats": before_stats,
|
|
"before_geometry": before_geometry,
|
|
"after_snapshot": after_snapshot,
|
|
"after_stats": after_stats,
|
|
"after_geometry": after_geometry,
|
|
"model_polydata": model_polydata,
|
|
"edge_polydata": edge_polydata,
|
|
}
|
|
|
|
return job
|
|
|
|
def _begin_edit_task(self, operation_name: str) -> None:
|
|
self.operation_in_progress = True
|
|
self.statusBar().showMessage(f"{operation_name} 正在后台计算...")
|
|
if "推拉" in operation_name:
|
|
progress_text = f"{operation_name} 正在计算,半透明预览表示推拉方向和大致范围..."
|
|
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"],
|
|
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"],
|
|
)
|
|
except Exception as exc:
|
|
self._end_edit_task(clear_preview=True)
|
|
QMessageBox.critical(self, "操作失败", str(exc))
|
|
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_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)
|
|
self._clear_editable_candidates()
|
|
self._clear_cylinder_candidates()
|
|
self._refresh_history_list()
|
|
self._end_edit_task(clear_preview=False)
|
|
self.statusBar().showMessage(message)
|
|
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)
|
|
self.statusBar().showMessage("操作失败,模型未更新")
|
|
|
|
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)
|
|
if self.edit_progress is not None:
|
|
self.edit_progress.close()
|
|
self.edit_progress.deleteLater()
|
|
self.edit_progress = None
|
|
QApplication.processEvents()
|
|
|
|
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],
|
|
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)}")
|
|
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())
|
|
|
|
|
|
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)
|
|
|
|
|
|
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())
|