Files
pythonocc-step-editor/main.py
T

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())