feat: 拆分 STEP 编辑器并完善最小系统
将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .app import StepEditorWindow, main
|
||||
from .model import StepModel
|
||||
|
||||
__all__ = ["StepEditorWindow", "StepModel", "main"]
|
||||
@@ -0,0 +1,826 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import faulthandler
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import vtk
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFileDialog,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QPlainTextEdit,
|
||||
QScrollArea,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QTabWidget,
|
||||
QTreeWidget,
|
||||
QTreeWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
|
||||
|
||||
from .records import OperationRecord
|
||||
from .model import StepModel
|
||||
from .widgets import NoWheelComboBox
|
||||
from .workers import EditWorker, LoadWorker, ScanWorker
|
||||
|
||||
|
||||
from .info_panel import InfoPanelMixin
|
||||
from .ui_helpers import * # noqa: F403
|
||||
from .window_actions import WindowActionMixin
|
||||
from .window_core import WindowCoreMixin
|
||||
from .window_state import WindowStateMixin
|
||||
|
||||
|
||||
_CRASH_LOG_HANDLE = None
|
||||
|
||||
|
||||
def _enable_crash_log() -> None:
|
||||
global _CRASH_LOG_HANDLE
|
||||
if _CRASH_LOG_HANDLE is not None:
|
||||
return
|
||||
try:
|
||||
log_path = Path("step_editor_crash.log")
|
||||
_CRASH_LOG_HANDLE = log_path.open("a", encoding="utf-8")
|
||||
faulthandler.enable(file=_CRASH_LOG_HANDLE, all_threads=True)
|
||||
except Exception:
|
||||
faulthandler.enable(all_threads=True)
|
||||
|
||||
|
||||
class StepEditorWindow(WindowCoreMixin, WindowStateMixin, WindowActionMixin, InfoPanelMixin, QMainWindow):
|
||||
def __init__(self, step_path: str | Path, *, background_load: bool = True):
|
||||
super().__init__()
|
||||
self.setWindowTitle("STEP 零件查看与编辑原型")
|
||||
self.resize(1280, 820)
|
||||
|
||||
self.model: StepModel | None = None
|
||||
self.step_path = Path(step_path)
|
||||
self.selected_kind: str | None = None
|
||||
self.selected_part_id: int | None = None
|
||||
self.selected_solid_id: int | None = None
|
||||
self.selected_face_id: int | None = None
|
||||
self.selected_edge_id: int | None = None
|
||||
self.selected_pick_position: tuple[float, float, float] | None = None
|
||||
|
||||
self.model_actor = None
|
||||
self.edge_actor = None
|
||||
self.highlight_actor = None
|
||||
self.edge_highlight_actor = None
|
||||
self.hover_face_actor = None
|
||||
self.hover_edge_actor = None
|
||||
self.hover_signature: tuple[str, int] | None = None
|
||||
self.hover_interval_ms = 45
|
||||
self.hover_move_threshold_px = 0
|
||||
self.pending_hover_position: tuple[int, int] | None = None
|
||||
self.last_hover_pick_position: tuple[int, int] | None = None
|
||||
self.pointer_button_down = False
|
||||
self.pick_marker_actor = None
|
||||
self.edit_preview_actor = None
|
||||
self.edit_preview_actors: list[object] = []
|
||||
self.edit_preview_timer: QTimer | None = None
|
||||
self.hover_timer = QTimer(self)
|
||||
self.hover_timer.setSingleShot(True)
|
||||
self.hover_timer.timeout.connect(self._update_hover_target)
|
||||
self.edit_preview_phase = 0.0
|
||||
self.edit_preview_base_opacity = 0.35
|
||||
self.diff_actors: list[object] = []
|
||||
self.model_polydata = None
|
||||
self.edge_polydata = None
|
||||
self.model_face_id_array = None
|
||||
self.model_part_id_array = None
|
||||
self.model_solid_id_array = None
|
||||
self.edge_id_array = None
|
||||
self.face_overlay_polydata_cache: dict[tuple[tuple[int, ...] | None, tuple[int, ...] | None, bool], object] = {}
|
||||
self.edge_overlay_polydata_cache: dict[int, object] = {}
|
||||
self.overlay_cache_limit = 160
|
||||
self.show_internal_edges_checkbox: QCheckBox | None = None
|
||||
self.scene_isolated = False
|
||||
self.undo_stack: list[dict[int, object]] = []
|
||||
self.redo_stack: list[dict[int, object]] = []
|
||||
self.operation_history: list[OperationRecord] = []
|
||||
self.redo_history: list[OperationRecord] = []
|
||||
self.measure_point_a: tuple[float, float, float] | None = None
|
||||
self.measure_point_b: tuple[float, float, float] | None = None
|
||||
self.measure_label_a = ""
|
||||
self.measure_label_b = ""
|
||||
self.measure_actor = None
|
||||
self.current_info_text = ""
|
||||
self.current_info_values: dict[str, object] = {}
|
||||
self.cylinder_candidate_cache: list[dict[str, object]] = []
|
||||
self.cylinder_candidates_loaded = False
|
||||
self.operation_in_progress = False
|
||||
self.edit_thread: QThread | None = None
|
||||
self.edit_worker: EditWorker | None = None
|
||||
self.pending_edit_context: dict[str, object] | None = None
|
||||
self.scan_in_progress = False
|
||||
self.scan_thread: QThread | None = None
|
||||
self.scan_worker: ScanWorker | None = None
|
||||
self.pending_scan_kind: str | None = None
|
||||
self.load_in_progress = False
|
||||
self.load_thread: QThread | None = None
|
||||
self.load_worker: LoadWorker | None = None
|
||||
self.load_refine_thread: QThread | None = None
|
||||
self.load_refine_worker: LoadWorker | None = None
|
||||
self.pending_load_path: Path | None = None
|
||||
self.initial_load_deflection = 0.8
|
||||
self.last_id_kind = "Face"
|
||||
|
||||
self._build_ui()
|
||||
self._build_vtk()
|
||||
self.load_step(self.step_path, background=background_load)
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
help_tip = self._set_help_tip
|
||||
|
||||
central = QWidget()
|
||||
root_layout = QHBoxLayout(central)
|
||||
root_layout.setContentsMargins(10, 10, 10, 10)
|
||||
root_layout.setSpacing(10)
|
||||
self.setCentralWidget(central)
|
||||
|
||||
panel_scroll = QScrollArea()
|
||||
panel_scroll.setWidgetResizable(True)
|
||||
panel_scroll.setFixedWidth(440)
|
||||
panel_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
root_layout.addWidget(panel_scroll)
|
||||
|
||||
panel = QWidget()
|
||||
panel.setFixedWidth(420)
|
||||
panel_layout = QVBoxLayout(panel)
|
||||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||||
panel_layout.setSpacing(10)
|
||||
panel_scroll.setWidget(panel)
|
||||
panel_scroll.setStyleSheet(
|
||||
"""
|
||||
QScrollArea {
|
||||
background: #f4f7fb;
|
||||
border: none;
|
||||
}
|
||||
QScrollArea > QWidget > QWidget {
|
||||
background: #f4f7fb;
|
||||
}
|
||||
QGroupBox {
|
||||
background: #ffffff;
|
||||
border: 2px solid #6b7cff;
|
||||
border-radius: 8px;
|
||||
color: #1f2937;
|
||||
margin-top: 12px;
|
||||
padding: 12px 10px 10px 10px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
left: 12px;
|
||||
padding: 0 6px;
|
||||
color: #172033;
|
||||
background: #f4f7fb;
|
||||
font-weight: 700;
|
||||
}
|
||||
QGroupBox#fileSection {
|
||||
border-color: #3478f6;
|
||||
}
|
||||
QGroupBox#treeSection {
|
||||
border-color: #0f9f8f;
|
||||
}
|
||||
QGroupBox#modeSection,
|
||||
QGroupBox#idSection {
|
||||
border-color: #6b5cff;
|
||||
}
|
||||
QGroupBox#viewSection {
|
||||
border-color: #0e9bd8;
|
||||
}
|
||||
QGroupBox#exportSection {
|
||||
border-color: #1f9d55;
|
||||
}
|
||||
QGroupBox#editSection {
|
||||
border-color: #d97706;
|
||||
}
|
||||
QGroupBox#editableSection,
|
||||
QGroupBox#candidateSection {
|
||||
border-color: #b45309;
|
||||
}
|
||||
QGroupBox#historySection {
|
||||
border-color: #be3b6b;
|
||||
}
|
||||
QGroupBox#infoSection {
|
||||
border-color: #64748b;
|
||||
}
|
||||
QLabel {
|
||||
color: #2f3846;
|
||||
}
|
||||
QLineEdit,
|
||||
QComboBox {
|
||||
background: #ffffff;
|
||||
border: 1px solid #c8d2df;
|
||||
border-radius: 5px;
|
||||
color: #172033;
|
||||
min-height: 24px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
QLineEdit:focus,
|
||||
QComboBox:focus {
|
||||
border: 1px solid #3478f6;
|
||||
}
|
||||
QLineEdit#idModeDisplay {
|
||||
background: #eef2ff;
|
||||
border-color: #93a5e8;
|
||||
color: #2d3a8c;
|
||||
font-weight: 700;
|
||||
}
|
||||
QPushButton {
|
||||
background: #eef3f8;
|
||||
border: 1px solid #c8d2df;
|
||||
border-radius: 6px;
|
||||
color: #172033;
|
||||
font-weight: 600;
|
||||
min-height: 24px;
|
||||
padding: 5px 8px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: #e3edf9;
|
||||
border-color: #8fb0dc;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: #d7e6f6;
|
||||
}
|
||||
QPushButton:disabled {
|
||||
background: #eef0f3;
|
||||
border-color: #dfe3ea;
|
||||
color: #7a7f86;
|
||||
}
|
||||
QTreeWidget,
|
||||
QTableWidget,
|
||||
QListWidget,
|
||||
QPlainTextEdit {
|
||||
background: #ffffff;
|
||||
border: 1px solid #d8e0eb;
|
||||
border-radius: 6px;
|
||||
color: #172033;
|
||||
selection-background-color: #dceafe;
|
||||
selection-color: #0f172a;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #edf2f7;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #d8e0eb;
|
||||
color: #334155;
|
||||
font-weight: 700;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #d8e0eb;
|
||||
border-radius: 6px;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #e9eef6;
|
||||
border: 1px solid #d8e0eb;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 5px;
|
||||
border-top-right-radius: 5px;
|
||||
color: #334155;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #ffffff;
|
||||
color: #172033;
|
||||
font-weight: 700;
|
||||
}
|
||||
"""
|
||||
)
|
||||
|
||||
file_box = QGroupBox("STEP 文件")
|
||||
file_box.setObjectName("fileSection")
|
||||
help_tip(file_box, "打开、重新加载和查看当前 STEP 文件路径。")
|
||||
file_layout = QVBoxLayout(file_box)
|
||||
self.path_label = QLabel("")
|
||||
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
self.path_label.setWordWrap(True)
|
||||
help_tip(self.path_label, "当前打开的 STEP 文件路径。可以选中文字复制路径。")
|
||||
file_layout.addWidget(self.path_label)
|
||||
|
||||
file_buttons = QHBoxLayout()
|
||||
open_button = QPushButton("打开")
|
||||
help_tip(open_button, "选择并打开一个 .step 或 .stp 文件。打开失败时会保留当前模型。")
|
||||
open_button.clicked.connect(self.open_step)
|
||||
reload_button = QPushButton("重新加载")
|
||||
help_tip(reload_button, "从磁盘重新读取当前 STEP 文件,用于放弃本次会话里的临时查看状态。")
|
||||
reload_button.clicked.connect(self.reload_step)
|
||||
file_buttons.addWidget(open_button)
|
||||
file_buttons.addWidget(reload_button)
|
||||
file_layout.addLayout(file_buttons)
|
||||
panel_layout.addWidget(file_box)
|
||||
|
||||
tree_box = QGroupBox("模型结构树")
|
||||
tree_box.setObjectName("treeSection")
|
||||
help_tip(tree_box, "查看 STEP 里的装配、零件和实体层级,并从树上直接选中对象。")
|
||||
tree_layout = QVBoxLayout(tree_box)
|
||||
self.part_tree = QTreeWidget()
|
||||
self.part_tree.setHeaderLabels(["对象", "内容"])
|
||||
self.part_tree.setMinimumHeight(180)
|
||||
help_tip(self.part_tree, "模型里的装配、零件和 solid 列表。点击一行可以选中并高亮对应对象。")
|
||||
self.part_tree.currentItemChanged.connect(self.on_part_tree_select)
|
||||
tree_layout.addWidget(self.part_tree)
|
||||
panel_layout.addWidget(tree_box)
|
||||
|
||||
mode_box = QGroupBox("鼠标选择模式")
|
||||
mode_box.setObjectName("modeSection")
|
||||
help_tip(mode_box, "决定鼠标点模型时选中零件、solid、面、边,还是识别局部特征。")
|
||||
mode_layout = QVBoxLayout(mode_box)
|
||||
self.mode_combo = NoWheelComboBox()
|
||||
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"])
|
||||
self.mode_combo.setCurrentText("Face")
|
||||
help_tip(
|
||||
self.mode_combo,
|
||||
"选择鼠标点击模型时要选什么:零件、solid、面、边,或把面解释成孔/槽/圆角等特征候选。",
|
||||
)
|
||||
self.mode_combo.currentTextChanged.connect(self._on_mode_changed)
|
||||
mode_layout.addWidget(self.mode_combo)
|
||||
panel_layout.addWidget(mode_box)
|
||||
|
||||
select_box = QGroupBox("按 ID 选择")
|
||||
select_box.setObjectName("idSection")
|
||||
help_tip(select_box, "知道对象 ID 时,可以直接输入 ID 跳转选择。")
|
||||
select_layout = QGridLayout(select_box)
|
||||
self.id_input = QLineEdit("")
|
||||
self.id_input.setPlaceholderText("输入 ID")
|
||||
help_tip(self.id_input, "输入要选中的对象 ID。ID 的类型由右侧显示的选择模式决定。")
|
||||
self.id_mode_display = QLineEdit(self.mode_combo.currentText())
|
||||
self.id_mode_display.setObjectName("idModeDisplay")
|
||||
self.id_mode_display.setReadOnly(True)
|
||||
self.id_mode_display.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.id_mode_display.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.id_mode_display.setMinimumWidth(70)
|
||||
help_tip(self.id_mode_display, "当前按这个鼠标选择模式解释输入的 ID。它会跟随鼠标选择模式自动变化。")
|
||||
select_button = QPushButton("选择")
|
||||
help_tip(select_button, "按当前选择模式跳转到输入的 ID,并在 3D 视图中高亮它。")
|
||||
select_button.clicked.connect(lambda _checked=False: self.select_by_id(self.mode_combo.currentText()))
|
||||
self.id_input.returnPressed.connect(lambda: self.select_by_id(self.mode_combo.currentText()))
|
||||
select_layout.addWidget(QLabel("ID"), 0, 0)
|
||||
select_layout.addWidget(self.id_input, 0, 1)
|
||||
select_layout.addWidget(self.id_mode_display, 0, 2)
|
||||
select_layout.addWidget(select_button, 0, 3)
|
||||
panel_layout.addWidget(select_box)
|
||||
|
||||
view_box = QGroupBox("显示")
|
||||
view_box.setObjectName("viewSection")
|
||||
help_tip(view_box, "只改变视图显示方式,不会修改 STEP 几何。")
|
||||
view_layout = QVBoxLayout(view_box)
|
||||
view_buttons = QHBoxLayout()
|
||||
isolate_button = QPushButton("只显示选中")
|
||||
help_tip(isolate_button, "把视图临时隔离到当前选中的零件、solid、面、边或特征区域。不会修改模型。")
|
||||
isolate_button.clicked.connect(self.isolate_selected)
|
||||
fit_button = QPushButton("对准选中")
|
||||
help_tip(fit_button, "把相机移动到当前选中对象附近,方便看清细节。不会修改模型。")
|
||||
fit_button.clicked.connect(self.fit_selected)
|
||||
show_all_button = QPushButton("显示全部")
|
||||
help_tip(show_all_button, "取消隔离显示,恢复查看完整模型。不会修改模型。")
|
||||
show_all_button.clicked.connect(self.show_all_geometry)
|
||||
view_buttons.addWidget(isolate_button)
|
||||
view_buttons.addWidget(fit_button)
|
||||
view_buttons.addWidget(show_all_button)
|
||||
view_layout.addLayout(view_buttons)
|
||||
self.show_internal_edges_checkbox = QCheckBox("显示同域内部边")
|
||||
help_tip(
|
||||
self.show_internal_edges_checkbox,
|
||||
"显示同一平面或同一圆柱面内部的拓扑分割边。关闭时会隐藏布尔推拉后常见的视觉接缝线。",
|
||||
)
|
||||
self.show_internal_edges_checkbox.toggled.connect(self._on_internal_edges_toggled)
|
||||
view_layout.addWidget(self.show_internal_edges_checkbox)
|
||||
panel_layout.addWidget(view_box)
|
||||
|
||||
measure_box = QGroupBox("测量")
|
||||
measure_box.setObjectName("viewSection")
|
||||
help_tip(measure_box, "把当前选中对象的拾取点或中心设为 A/B,计算两点距离和 X/Y/Z 差值。不会修改模型。")
|
||||
measure_layout = QVBoxLayout(measure_box)
|
||||
self.measure_text = QPlainTextEdit()
|
||||
self.measure_text.setReadOnly(True)
|
||||
self.measure_text.setMaximumHeight(90)
|
||||
help_tip(self.measure_text, "显示 A 点、B 点、两点距离和各方向差值。")
|
||||
measure_buttons = QGridLayout()
|
||||
set_measure_a_button = QPushButton("设为 A")
|
||||
help_tip(set_measure_a_button, "把当前选中对象的拾取点设为测量点 A;没有拾取点时使用对象中心。")
|
||||
set_measure_a_button.clicked.connect(lambda _checked=False: self.set_measure_point("A"))
|
||||
set_measure_b_button = QPushButton("设为 B")
|
||||
help_tip(set_measure_b_button, "把当前选中对象的拾取点设为测量点 B;没有拾取点时使用对象中心。")
|
||||
set_measure_b_button.clicked.connect(lambda _checked=False: self.set_measure_point("B"))
|
||||
copy_measure_button = QPushButton("复制测量")
|
||||
help_tip(copy_measure_button, "复制当前测量结果文本,方便记录尺寸。")
|
||||
copy_measure_button.clicked.connect(self.copy_measurement)
|
||||
clear_measure_button = QPushButton("清除测量")
|
||||
help_tip(clear_measure_button, "清除 A/B 测量点和 3D 测量线。不会影响模型。")
|
||||
clear_measure_button.clicked.connect(self.clear_measurement)
|
||||
measure_buttons.addWidget(set_measure_a_button, 0, 0)
|
||||
measure_buttons.addWidget(set_measure_b_button, 0, 1)
|
||||
measure_buttons.addWidget(copy_measure_button, 1, 0)
|
||||
measure_buttons.addWidget(clear_measure_button, 1, 1)
|
||||
measure_layout.addWidget(self.measure_text)
|
||||
measure_layout.addLayout(measure_buttons)
|
||||
panel_layout.addWidget(measure_box)
|
||||
self._refresh_measurement_panel()
|
||||
|
||||
export_box = QGroupBox("导出")
|
||||
export_box.setObjectName("exportSection")
|
||||
help_tip(export_box, "把当前模型或选中对象导出为 STEP,也可以做基础质量检查和修复。")
|
||||
export_layout = QVBoxLayout(export_box)
|
||||
self.export_all_button = QPushButton("导出当前完整 STEP")
|
||||
help_tip(self.export_all_button, "把当前编辑后的整个模型导出为 STEP 文件。导出前会做基础质量检查。")
|
||||
self.export_all_button.clicked.connect(self.export_all)
|
||||
self.export_part_button = QPushButton("导出选中零件")
|
||||
help_tip(self.export_part_button, "只导出当前选中的零件。适合从装配里拆出一个 part。")
|
||||
self.export_part_button.clicked.connect(self.export_selected_part)
|
||||
self.export_solid_button = QPushButton("导出选中 solid")
|
||||
help_tip(self.export_solid_button, "只导出当前选中的实体 solid。适合检查或单独保存某个实体。")
|
||||
self.export_solid_button.clicked.connect(self.export_selected_solid)
|
||||
self.export_face_button = QPushButton("导出选中面区域")
|
||||
help_tip(self.export_face_button, "导出当前选中的面区域;同域高亮的共面/同圆柱区域也会一起导出。")
|
||||
self.export_face_button.clicked.connect(self.export_selected_face)
|
||||
self.export_feature_button = QPushButton("导出选中特征区域")
|
||||
help_tip(self.export_feature_button, "导出 Feature 模式识别到的局部特征区域,例如孔、槽、圆角或凸台候选。")
|
||||
self.export_feature_button.clicked.connect(self.export_selected_feature)
|
||||
self.export_edge_button = QPushButton("导出选中 edge")
|
||||
help_tip(self.export_edge_button, "导出当前选中的边。主要用于调试、定位或把边界单独拿出去检查。")
|
||||
self.export_edge_button.clicked.connect(self.export_selected_edge)
|
||||
self.export_check_button = QPushButton("检查导出质量")
|
||||
help_tip(self.export_check_button, "检查当前导出对象是否像有效实体:B-Rep、face/edge 数量、体积和包围盒等。")
|
||||
self.export_check_button.clicked.connect(self.check_export_quality)
|
||||
self.repair_model_button = QPushButton("修复当前模型")
|
||||
help_tip(self.repair_model_button, "对完整模型尝试 ShapeFix 和同域面/边合并。会写入历史,可撤销。")
|
||||
self.repair_model_button.clicked.connect(self.repair_model)
|
||||
self.repair_selected_button = QPushButton("修复选中零件/solid")
|
||||
help_tip(self.repair_selected_button, "只修复当前选中的零件或 solid,范围比修复完整模型更小。会写入历史,可撤销。")
|
||||
self.repair_selected_button.clicked.connect(self.repair_selected_shape)
|
||||
export_layout.addWidget(self.export_all_button)
|
||||
export_layout.addWidget(self.export_part_button)
|
||||
export_layout.addWidget(self.export_solid_button)
|
||||
export_layout.addWidget(self.export_face_button)
|
||||
export_layout.addWidget(self.export_feature_button)
|
||||
export_layout.addWidget(self.export_edge_button)
|
||||
export_layout.addWidget(self.export_check_button)
|
||||
export_layout.addWidget(self.repair_model_button)
|
||||
export_layout.addWidget(self.repair_selected_button)
|
||||
panel_layout.addWidget(export_box)
|
||||
|
||||
edit_box = QGroupBox("实验性编辑")
|
||||
edit_box.setObjectName("editSection")
|
||||
help_tip(edit_box, "这里的按钮会真实修改当前 B-Rep 模型;执行前通常会预览,成功后可撤销。")
|
||||
edit_layout = QGridLayout(edit_box)
|
||||
edit_layout.addWidget(QLabel("面偏移"), 0, 0)
|
||||
self.offset_input = QLineEdit("5.0")
|
||||
help_tip(self.offset_input, "平面推拉距离。正数通常向外加料,负数通常向内切削;单位沿用 STEP 模型单位。")
|
||||
edit_layout.addWidget(self.offset_input, 0, 1)
|
||||
self.push_button = QPushButton("推拉平面")
|
||||
help_tip(self.push_button, "移动当前选中的平面区域:正数加料,负数切削。会先显示半透明预览,再后台执行。")
|
||||
self.push_button.clicked.connect(self.push_pull_face)
|
||||
edit_layout.addWidget(self.push_button, 1, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("孔直径"), 2, 0)
|
||||
self.hole_diameter_input = QLineEdit("")
|
||||
help_tip(self.hole_diameter_input, "圆柱孔/槽的目标直径。选中候选后会自动填一个参考值,可以手动改。")
|
||||
edit_layout.addWidget(self.hole_diameter_input, 2, 1)
|
||||
self.resize_button = QPushButton("调整圆柱孔径")
|
||||
help_tip(self.resize_button, "修改孔或圆柱槽的直径。扩大时切削,缩小时会先补料再重切。")
|
||||
self.resize_button.clicked.connect(self.resize_hole)
|
||||
edit_layout.addWidget(self.resize_button, 3, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("槽/半孔宽度"), 4, 0)
|
||||
self.slot_width_input = QLineEdit("")
|
||||
help_tip(self.slot_width_input, "槽或半孔的目标开口宽度。程序会把它换算成对应圆柱直径来执行。")
|
||||
edit_layout.addWidget(self.slot_width_input, 4, 1)
|
||||
self.resize_slot_button = QPushButton("调整槽/半孔宽度")
|
||||
help_tip(self.resize_slot_button, "修改已识别槽/半孔候选的宽度。第一版是几何近似,失败会回滚。")
|
||||
self.resize_slot_button.clicked.connect(self.resize_slot_width)
|
||||
edit_layout.addWidget(self.resize_slot_button, 5, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("凸台直径"), 6, 0)
|
||||
self.boss_diameter_input = QLineEdit("")
|
||||
help_tip(self.boss_diameter_input, "圆柱凸台的目标直径。选中凸台候选后会自动填一个参考值。")
|
||||
edit_layout.addWidget(self.boss_diameter_input, 6, 1)
|
||||
self.resize_boss_button = QPushButton("调整圆柱凸台直径")
|
||||
help_tip(self.resize_boss_button, "修改完整圆柱凸台直径。变大会加料,变小会重建凸台区域。")
|
||||
self.resize_boss_button.clicked.connect(self.resize_boss)
|
||||
edit_layout.addWidget(self.resize_boss_button, 7, 0, 1, 2)
|
||||
|
||||
self.suppress_button = QPushButton("封堵圆柱孔")
|
||||
help_tip(self.suppress_button, "用补料体填住完整圆柱孔。适合通孔/盲孔,不适合半孔或槽。")
|
||||
self.suppress_button.clicked.connect(self.suppress_hole)
|
||||
edit_layout.addWidget(self.suppress_button, 8, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("孔深度"), 9, 0)
|
||||
depth_inputs = QWidget()
|
||||
depth_layout = QHBoxLayout(depth_inputs)
|
||||
depth_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.hole_depth_input = QLineEdit("")
|
||||
help_tip(self.hole_depth_input, "盲孔或盲槽的目标深度。选中候选后会填入参考值,深度来自几何估算。")
|
||||
self.hole_bottom_face_input = QLineEdit("")
|
||||
self.hole_bottom_face_input.setPlaceholderText("底面 Face ID")
|
||||
help_tip(self.hole_bottom_face_input, "自动识别孔底不稳定时,可手动输入底面 Face ID,让孔深计算有明确底面。")
|
||||
self.hole_bottom_face_input.textChanged.connect(lambda _text: self._update_action_states())
|
||||
depth_layout.addWidget(self.hole_depth_input, stretch=2)
|
||||
depth_layout.addWidget(self.hole_bottom_face_input, stretch=1)
|
||||
edit_layout.addWidget(depth_inputs, 9, 1)
|
||||
self.resize_depth_button = QPushButton("调整盲孔深度")
|
||||
help_tip(self.resize_depth_button, "加深或变浅盲孔/盲槽。需要识别到底面,或手动填写底面 Face ID。")
|
||||
self.resize_depth_button.clicked.connect(self.resize_hole_depth)
|
||||
edit_layout.addWidget(self.resize_depth_button, 10, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("圆角半径"), 11, 0)
|
||||
self.edge_fillet_radius_input = QLineEdit("")
|
||||
help_tip(self.edge_fillet_radius_input, "新圆角或已有圆角的目标半径。选中边/圆角候选后会自动填参考值。")
|
||||
edit_layout.addWidget(self.edge_fillet_radius_input, 11, 1)
|
||||
self.fillet_edge_button = QPushButton("给边添加圆角")
|
||||
help_tip(self.fillet_edge_button, "给当前直线边新增圆角。不是修改已有圆角;已有圆角请用下面那个按钮。")
|
||||
self.fillet_edge_button.clicked.connect(self.fillet_edge)
|
||||
edit_layout.addWidget(self.fillet_edge_button, 12, 0, 1, 2)
|
||||
|
||||
self.resize_existing_fillet_button = QPushButton("修改已有圆角半径")
|
||||
help_tip(self.resize_existing_fillet_button, "尝试修改已识别圆角面的半径。会先移除原圆角再重建,复杂圆角可能失败并回滚。")
|
||||
self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet)
|
||||
edit_layout.addWidget(self.resize_existing_fillet_button, 13, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("倒角距离"), 14, 0)
|
||||
self.edge_chamfer_distance_input = QLineEdit("")
|
||||
help_tip(self.edge_chamfer_distance_input, "给边添加倒角时使用的距离。选中直线边后会填一个较小参考值。")
|
||||
edit_layout.addWidget(self.edge_chamfer_distance_input, 14, 1)
|
||||
self.chamfer_edge_button = QPushButton("给边添加倒角")
|
||||
help_tip(self.chamfer_edge_button, "给当前直线边新增对称倒角。会先预览,再后台执行,失败会回滚。")
|
||||
self.chamfer_edge_button.clicked.connect(self.chamfer_edge)
|
||||
edit_layout.addWidget(self.chamfer_edge_button, 15, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("边目标长度"), 16, 0)
|
||||
edge_length_inputs = QWidget()
|
||||
edge_length_layout = QHBoxLayout(edge_length_inputs)
|
||||
edge_length_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.edge_target_length_input = QLineEdit("")
|
||||
help_tip(self.edge_target_length_input, "当前 edge 的目标长度。选中边后会自动填当前长度,改成新长度再执行。")
|
||||
self.edge_length_anchor_combo = QComboBox()
|
||||
self.edge_length_anchor_combo.addItem("自动", "auto")
|
||||
self.edge_length_anchor_combo.addItem("中心", "center")
|
||||
self.edge_length_anchor_combo.addItem("固定起点", "keep-start")
|
||||
self.edge_length_anchor_combo.addItem("固定终点", "keep-end")
|
||||
self.edge_length_anchor_combo.setCurrentIndex(0)
|
||||
self.edge_length_anchor_combo.setMinimumWidth(90)
|
||||
help_tip(
|
||||
self.edge_length_anchor_combo,
|
||||
"选择边长修改时尽量固定哪里:自动会优先找局部端面;中心/起点/终点会影响缩放 fallback 的基准点。",
|
||||
)
|
||||
edge_length_layout.addWidget(self.edge_target_length_input, stretch=2)
|
||||
edge_length_layout.addWidget(self.edge_length_anchor_combo, stretch=1)
|
||||
edit_layout.addWidget(edge_length_inputs, 16, 1)
|
||||
self.resize_edge_length_button = QPushButton("直接修改边长")
|
||||
help_tip(
|
||||
self.resize_edge_length_button,
|
||||
"直接修改当前 edge 长度。直线边优先端面推拉,圆边优先换算相邻圆柱直径,必要时再用几何缩放 fallback。",
|
||||
)
|
||||
self.resize_edge_length_button.clicked.connect(self.resize_any_edge_length)
|
||||
edit_layout.addWidget(self.resize_edge_length_button, 17, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 18, 0)
|
||||
translate_inputs = QWidget()
|
||||
translate_layout = QHBoxLayout(translate_inputs)
|
||||
translate_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.translate_x_input = QLineEdit("0")
|
||||
self.translate_y_input = QLineEdit("0")
|
||||
self.translate_z_input = QLineEdit("0")
|
||||
self.translate_x_input.setPlaceholderText("X")
|
||||
self.translate_y_input.setPlaceholderText("Y")
|
||||
self.translate_z_input.setPlaceholderText("Z")
|
||||
help_tip(self.translate_x_input, "沿 X 方向平移的距离。输入 0 表示 X 方向不移动。")
|
||||
help_tip(self.translate_y_input, "沿 Y 方向平移的距离。输入 0 表示 Y 方向不移动。")
|
||||
help_tip(self.translate_z_input, "沿 Z 方向平移的距离。输入 0 表示 Z 方向不移动。")
|
||||
translate_layout.addWidget(self.translate_x_input)
|
||||
translate_layout.addWidget(self.translate_y_input)
|
||||
translate_layout.addWidget(self.translate_z_input)
|
||||
edit_layout.addWidget(translate_inputs, 18, 1)
|
||||
self.translate_part_button = QPushButton("平移选中零件")
|
||||
help_tip(self.translate_part_button, "按上面的 X/Y/Z 距离移动当前零件。会写入历史,可撤销。")
|
||||
self.translate_part_button.clicked.connect(self.translate_selected_part)
|
||||
self.translate_solid_button = QPushButton("平移选中 solid")
|
||||
help_tip(self.translate_solid_button, "按上面的 X/Y/Z 距离移动当前 solid。单 solid 零件中相当于移动整个零件。")
|
||||
self.translate_solid_button.clicked.connect(self.translate_selected_solid)
|
||||
edit_layout.addWidget(self.translate_part_button, 19, 0, 1, 2)
|
||||
edit_layout.addWidget(self.translate_solid_button, 20, 0, 1, 2)
|
||||
|
||||
edit_layout.addWidget(QLabel("旋转轴/角度"), 21, 0)
|
||||
rotate_inputs = QWidget()
|
||||
rotate_layout = QHBoxLayout(rotate_inputs)
|
||||
rotate_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.rotate_axis_combo = QComboBox()
|
||||
self.rotate_axis_combo.addItems(["X", "Y", "Z"])
|
||||
self.rotate_axis_combo.setCurrentText("Z")
|
||||
help_tip(self.rotate_axis_combo, "选择旋转轴。对象会绕自身包围盒中心旋转。")
|
||||
self.rotate_angle_input = QLineEdit("90")
|
||||
self.rotate_angle_input.setPlaceholderText("度")
|
||||
help_tip(self.rotate_angle_input, "旋转角度,单位是度。正负号决定旋转方向。")
|
||||
rotate_layout.addWidget(self.rotate_axis_combo)
|
||||
rotate_layout.addWidget(self.rotate_angle_input)
|
||||
edit_layout.addWidget(rotate_inputs, 21, 1)
|
||||
self.rotate_part_button = QPushButton("旋转选中零件")
|
||||
help_tip(self.rotate_part_button, "绕所选轴旋转当前零件。会写入历史,可撤销。")
|
||||
self.rotate_part_button.clicked.connect(self.rotate_selected_part)
|
||||
self.rotate_solid_button = QPushButton("旋转选中 solid")
|
||||
help_tip(self.rotate_solid_button, "绕所选轴旋转当前 solid。单 solid 零件中相当于旋转整个零件。")
|
||||
self.rotate_solid_button.clicked.connect(self.rotate_selected_solid)
|
||||
edit_layout.addWidget(self.rotate_part_button, 22, 0, 1, 2)
|
||||
edit_layout.addWidget(self.rotate_solid_button, 23, 0, 1, 2)
|
||||
|
||||
self.cylinders_button = QPushButton("列出圆柱候选")
|
||||
help_tip(self.cylinders_button, "扫描模型里的圆柱面,并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
|
||||
self.cylinders_button.clicked.connect(self.list_cylinders)
|
||||
edit_layout.addWidget(self.cylinders_button, 24, 0, 1, 2)
|
||||
|
||||
self.undo_button = QPushButton("撤销")
|
||||
help_tip(self.undo_button, "撤销上一次成功编辑,把模型恢复到编辑前快照。")
|
||||
self.undo_button.clicked.connect(self.undo_edit)
|
||||
self.redo_button = QPushButton("重做")
|
||||
help_tip(self.redo_button, "重做刚刚撤销的编辑。")
|
||||
self.redo_button.clicked.connect(self.redo_edit)
|
||||
edit_layout.addWidget(self.undo_button, 25, 0)
|
||||
edit_layout.addWidget(self.redo_button, 25, 1)
|
||||
panel_layout.addWidget(edit_box)
|
||||
|
||||
editable_box = QGroupBox("第一版可编辑对象")
|
||||
editable_box.setObjectName("editableSection")
|
||||
help_tip(editable_box, "自动找出一批可以尝试编辑的 face 或 edge,方便不用手动到处点。")
|
||||
editable_layout = QVBoxLayout(editable_box)
|
||||
editable_button_row = QHBoxLayout()
|
||||
self.editable_refresh_button = QPushButton("扫描可编辑对象")
|
||||
help_tip(self.editable_refresh_button, "快速扫描一批可以尝试编辑的对象,并列在下面表格里。")
|
||||
self.editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True))
|
||||
self.editable_deep_scan_button = QPushButton("深度扫描")
|
||||
help_tip(self.editable_deep_scan_button, "扫描更多候选对象,结果更全但更慢。默认列表找不到目标时再用。")
|
||||
self.editable_deep_scan_button.clicked.connect(
|
||||
lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True)
|
||||
)
|
||||
editable_button_row.addWidget(self.editable_refresh_button)
|
||||
editable_button_row.addWidget(self.editable_deep_scan_button)
|
||||
editable_layout.addLayout(editable_button_row)
|
||||
self.editable_table = QTableWidget(0, 8)
|
||||
self.editable_table.setHorizontalHeaderLabels(
|
||||
["操作", "ID", "对象", "当前值", "状态", "风险", "置信度", "说明"]
|
||||
)
|
||||
self.editable_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.editable_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.editable_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.editable_table.setMinimumHeight(130)
|
||||
help_tip(self.editable_table, "这里列出可尝试编辑的对象。点击一行会选中模型里的对应 face 或 edge,并自动填入相关输入框。")
|
||||
self.editable_table.cellClicked.connect(self.on_editable_row_clicked)
|
||||
editable_layout.addWidget(self.editable_table)
|
||||
panel_layout.addWidget(editable_box)
|
||||
|
||||
candidate_box = QGroupBox("圆柱候选")
|
||||
candidate_box.setObjectName("candidateSection")
|
||||
help_tip(candidate_box, "列出圆柱面并粗略判断它们像孔、槽、圆角、凸台还是普通圆柱。")
|
||||
candidate_layout = QVBoxLayout(candidate_box)
|
||||
filter_layout = QHBoxLayout()
|
||||
filter_layout.addWidget(QLabel("类型"))
|
||||
self.candidate_filter_combo = NoWheelComboBox()
|
||||
self.candidate_filter_combo.addItems(
|
||||
[
|
||||
"All",
|
||||
"Hole/Groove",
|
||||
"Round/Fillet",
|
||||
"Boss/Outer",
|
||||
"Unclear",
|
||||
]
|
||||
)
|
||||
help_tip(
|
||||
self.candidate_filter_combo,
|
||||
"筛选下面的圆柱候选:孔/槽、圆角、凸台/外圆,或暂时无法明确分类的圆柱面。",
|
||||
)
|
||||
self.candidate_filter_combo.currentTextChanged.connect(
|
||||
lambda _text: self._filter_cached_cylinder_candidates(show_info=True)
|
||||
)
|
||||
filter_layout.addWidget(self.candidate_filter_combo)
|
||||
candidate_layout.addLayout(filter_layout)
|
||||
self.cylinder_table = QTableWidget(0, 8)
|
||||
self.cylinder_table.setHorizontalHeaderLabels(
|
||||
["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"]
|
||||
)
|
||||
self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
self.cylinder_table.setMinimumHeight(140)
|
||||
help_tip(self.cylinder_table, "这里显示圆柱面候选。点击一行会选中对应 face,并在属性区显示可用操作和风险。")
|
||||
self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked)
|
||||
candidate_layout.addWidget(self.cylinder_table)
|
||||
panel_layout.addWidget(candidate_box)
|
||||
|
||||
history_box = QGroupBox("操作历史")
|
||||
history_box.setObjectName("historySection")
|
||||
help_tip(history_box, "查看已经成功执行的编辑、差异预览,并导出报告或 JSON 历史。")
|
||||
history_layout = QVBoxLayout(history_box)
|
||||
self.history_list = QListWidget()
|
||||
self.history_list.setMinimumHeight(110)
|
||||
help_tip(self.history_list, "成功执行过的编辑会出现在这里。点击一条记录可查看参数、差异预览和目标位置。")
|
||||
self.history_list.currentRowChanged.connect(self.on_history_row_changed)
|
||||
history_buttons = QHBoxLayout()
|
||||
clear_diff_button = QPushButton("清除差异预览")
|
||||
help_tip(clear_diff_button, "关闭历史记录产生的红/绿差异叠加和热力图显示。不会修改模型。")
|
||||
clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview())
|
||||
export_diff_button = QPushButton("导出差异报告")
|
||||
help_tip(export_diff_button, "把当前选中的历史记录导出成文本报告,包含参数、拓扑变化和几何差异统计。")
|
||||
export_diff_button.clicked.connect(self.export_diff_report)
|
||||
export_history_button = QPushButton("导出编辑历史")
|
||||
help_tip(export_history_button, "把本次会话里的所有编辑记录导出为 JSON,方便留档或后续复盘。")
|
||||
export_history_button.clicked.connect(self.export_operation_history)
|
||||
history_layout.addWidget(self.history_list)
|
||||
history_buttons.addWidget(clear_diff_button)
|
||||
history_buttons.addWidget(export_diff_button)
|
||||
history_buttons.addWidget(export_history_button)
|
||||
history_layout.addLayout(history_buttons)
|
||||
panel_layout.addWidget(history_box)
|
||||
|
||||
info_box = QGroupBox("选中对象信息")
|
||||
info_box.setObjectName("infoSection")
|
||||
help_tip(info_box, "显示当前选中对象的几何、拓扑、特征判断和可用操作信息。")
|
||||
info_layout = QVBoxLayout(info_box)
|
||||
info_buttons = QHBoxLayout()
|
||||
copy_id_button = QPushButton("复制 ID")
|
||||
help_tip(copy_id_button, "复制当前选中对象的 ID。Face/Feature 会优先复制逻辑 Face ID。")
|
||||
copy_id_button.clicked.connect(self.copy_selected_id)
|
||||
copy_pick_button = QPushButton("复制坐标")
|
||||
help_tip(copy_pick_button, "复制最近一次鼠标点到模型上的三维坐标。")
|
||||
copy_pick_button.clicked.connect(self.copy_pick_position)
|
||||
copy_info_button = QPushButton("复制信息")
|
||||
help_tip(copy_info_button, "复制当前属性区里的完整文本,方便发给别人或做问题记录。")
|
||||
copy_info_button.clicked.connect(self.copy_current_info)
|
||||
info_buttons.addWidget(copy_id_button)
|
||||
info_buttons.addWidget(copy_pick_button)
|
||||
info_buttons.addWidget(copy_info_button)
|
||||
info_layout.addLayout(info_buttons)
|
||||
|
||||
self.info_tabs = QTabWidget()
|
||||
self.info_tree = QTreeWidget()
|
||||
self.info_tree.setHeaderLabels(["属性", "值"])
|
||||
self.info_tree.setAlternatingRowColors(True)
|
||||
self.info_tree.setTextElideMode(Qt.TextElideMode.ElideMiddle)
|
||||
self.info_tree.setUniformRowHeights(True)
|
||||
help_tip(self.info_tree, "当前选中对象的结构化属性。悬停单元格可以看到完整字段和值。")
|
||||
self.info_text = QPlainTextEdit()
|
||||
self.info_text.setReadOnly(True)
|
||||
help_tip(self.info_text, "当前选中对象信息的原始文本版本,适合复制或排查问题。")
|
||||
self.info_tabs.addTab(self.info_tree, "属性表")
|
||||
self.info_tabs.addTab(self.info_text, "原始文本")
|
||||
help_tip(self.info_tabs, "在表格视图和原始文本视图之间切换当前选中对象信息。")
|
||||
info_layout.addWidget(self.info_tabs)
|
||||
panel_layout.addWidget(info_box, stretch=1)
|
||||
|
||||
self._update_action_states()
|
||||
|
||||
self.vtk_widget = QVTKRenderWindowInteractor(central)
|
||||
self.vtk_widget.setMouseTracking(True)
|
||||
self.vtk_widget.installEventFilter(self)
|
||||
root_layout.addWidget(self.vtk_widget, stretch=1)
|
||||
|
||||
self.statusBar().showMessage("Ready")
|
||||
|
||||
def _set_help_tip(self, widget, text: str) -> None:
|
||||
widget.setToolTip(text)
|
||||
widget.setStatusTip(text)
|
||||
try:
|
||||
widget.setToolTipDuration(14000)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> tuple[Path, bool]:
|
||||
smoke_test = "--smoke-test" in argv
|
||||
paths = [arg for arg in argv[1:] if not arg.startswith("--")]
|
||||
path = Path(paths[0]) if paths else Path("geom_extract.step")
|
||||
return path, smoke_test
|
||||
|
||||
|
||||
def main() -> int:
|
||||
_enable_crash_log()
|
||||
path, smoke_test = _parse_args(sys.argv)
|
||||
app = QApplication(sys.argv)
|
||||
window = StepEditorWindow(path, background_load=not smoke_test)
|
||||
if smoke_test:
|
||||
print("smoke test ok")
|
||||
window.close()
|
||||
app.quit()
|
||||
return 0
|
||||
window.show()
|
||||
window.vtk_widget.Start()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from OCC.Core.GeomAbs import (
|
||||
GeomAbs_BSplineCurve,
|
||||
GeomAbs_BSplineSurface,
|
||||
GeomAbs_BezierCurve,
|
||||
GeomAbs_BezierSurface,
|
||||
GeomAbs_Circle,
|
||||
GeomAbs_Cone,
|
||||
GeomAbs_Cylinder,
|
||||
GeomAbs_Ellipse,
|
||||
GeomAbs_Hyperbola,
|
||||
GeomAbs_Line,
|
||||
GeomAbs_OffsetSurface,
|
||||
GeomAbs_OtherCurve,
|
||||
GeomAbs_OtherSurface,
|
||||
GeomAbs_Parabola,
|
||||
GeomAbs_Plane,
|
||||
GeomAbs_Sphere,
|
||||
GeomAbs_SurfaceOfExtrusion,
|
||||
GeomAbs_SurfaceOfRevolution,
|
||||
GeomAbs_Torus,
|
||||
)
|
||||
from OCC.Core.TopAbs import TopAbs_EXTERNAL, TopAbs_FORWARD, TopAbs_INTERNAL, TopAbs_REVERSED
|
||||
|
||||
|
||||
SURFACE_TYPES = {
|
||||
GeomAbs_Plane: "plane",
|
||||
GeomAbs_Cylinder: "cylinder",
|
||||
GeomAbs_Cone: "cone",
|
||||
GeomAbs_Sphere: "sphere",
|
||||
GeomAbs_Torus: "torus",
|
||||
GeomAbs_BezierSurface: "bezier surface",
|
||||
GeomAbs_BSplineSurface: "b-spline surface",
|
||||
GeomAbs_SurfaceOfRevolution: "surface of revolution",
|
||||
GeomAbs_SurfaceOfExtrusion: "surface of extrusion",
|
||||
GeomAbs_OffsetSurface: "offset surface",
|
||||
GeomAbs_OtherSurface: "other surface",
|
||||
}
|
||||
|
||||
SNAPSHOT_FACE_LOGICAL_IDS_KEY = "__face_logical_ids__"
|
||||
|
||||
CURVE_TYPES = {
|
||||
GeomAbs_Line: "line",
|
||||
GeomAbs_Circle: "circle",
|
||||
GeomAbs_Ellipse: "ellipse",
|
||||
GeomAbs_Hyperbola: "hyperbola",
|
||||
GeomAbs_Parabola: "parabola",
|
||||
GeomAbs_BezierCurve: "bezier curve",
|
||||
GeomAbs_BSplineCurve: "b-spline curve",
|
||||
GeomAbs_OtherCurve: "other curve",
|
||||
}
|
||||
|
||||
ORIENTATION_TYPES = {
|
||||
TopAbs_FORWARD: "forward",
|
||||
TopAbs_REVERSED: "reversed",
|
||||
TopAbs_INTERNAL: "internal",
|
||||
TopAbs_EXTERNAL: "external",
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from OCC.Core.BRep import BRep_Tool
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
||||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||||
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.GeomAbs import (
|
||||
GeomAbs_BSplineCurve,
|
||||
GeomAbs_BSplineSurface,
|
||||
GeomAbs_BezierCurve,
|
||||
GeomAbs_BezierSurface,
|
||||
GeomAbs_Circle,
|
||||
GeomAbs_Cone,
|
||||
GeomAbs_Cylinder,
|
||||
GeomAbs_Ellipse,
|
||||
GeomAbs_Hyperbola,
|
||||
GeomAbs_Line,
|
||||
GeomAbs_OffsetSurface,
|
||||
GeomAbs_OtherCurve,
|
||||
GeomAbs_OtherSurface,
|
||||
GeomAbs_Parabola,
|
||||
GeomAbs_Plane,
|
||||
GeomAbs_Sphere,
|
||||
GeomAbs_SurfaceOfExtrusion,
|
||||
GeomAbs_SurfaceOfRevolution,
|
||||
GeomAbs_Torus,
|
||||
)
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.ShapeFix import ShapeFix_Shape
|
||||
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||||
from OCC.Core.TopAbs import (
|
||||
TopAbs_EDGE,
|
||||
TopAbs_EXTERNAL,
|
||||
TopAbs_FACE,
|
||||
TopAbs_FORWARD,
|
||||
TopAbs_IN,
|
||||
TopAbs_INTERNAL,
|
||||
TopAbs_OUT,
|
||||
TopAbs_REVERSED,
|
||||
TopAbs_SOLID,
|
||||
)
|
||||
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
||||
from OCC.Core.TopLoc import TopLoc_Location
|
||||
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
||||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
||||
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
||||
|
||||
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
|
||||
from .geometry_utils import * # noqa: F403
|
||||
from .step_io import _prepare_shape_for_step_export, _write_step
|
||||
|
||||
|
||||
class ExportMixin:
|
||||
def export_all(self, filename: str | Path) -> None:
|
||||
export_shape = _compound_from_shapes(
|
||||
_prepare_shape_for_step_export(part.shape) for part in self.display_parts()
|
||||
)
|
||||
_write_step(export_shape, Path(filename))
|
||||
|
||||
def export_quality_info(self, scope: str, target_id: int | None = None) -> dict[str, object]:
|
||||
if scope == "all":
|
||||
return _shape_quality_info("当前完整模型", self.shape, expect_solid=False)
|
||||
if scope == "part":
|
||||
if target_id is None:
|
||||
raise ValueError("Part id is required.")
|
||||
part = self.part_by_id(target_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {target_id}")
|
||||
info = _shape_quality_info(f"零件 {part.id}: {part.name}", part.shape, expect_solid=True)
|
||||
info["part_id"] = part.id
|
||||
return info
|
||||
if scope == "solid":
|
||||
if target_id is None or target_id < 0 or target_id >= len(self.solids):
|
||||
raise ValueError(f"Unknown solid id {target_id}")
|
||||
part_id, solid = self.solids[target_id]
|
||||
info = _shape_quality_info(f"Solid {target_id}", solid, expect_solid=True)
|
||||
info["part_id"] = part_id
|
||||
info["solid_id"] = target_id
|
||||
return info
|
||||
if scope == "face":
|
||||
if target_id is None or target_id < 0 or target_id >= len(self.faces):
|
||||
raise ValueError(f"Unknown face id {target_id}")
|
||||
face_ids = self.face_region_ids(target_id)
|
||||
shape = self._face_region_shape(target_id)
|
||||
label = f"Face {target_id}" if len(face_ids) == 1 else f"Face region from face {target_id}"
|
||||
info = _shape_quality_info(label, shape, expect_solid=False)
|
||||
info["part_id"] = self.face_part_ids[target_id]
|
||||
info["solid_id"] = self.face_solid_ids[target_id]
|
||||
info["face_id"] = target_id
|
||||
info["face_region_ids"] = tuple(face_ids)
|
||||
info["face_region_count"] = len(face_ids)
|
||||
return info
|
||||
if scope == "edge":
|
||||
if target_id is None or target_id < 0 or target_id >= len(self.edges):
|
||||
raise ValueError(f"Unknown edge id {target_id}")
|
||||
info = _shape_quality_info(f"Edge {target_id}", self.edges[target_id], expect_solid=False)
|
||||
info["part_id"] = self.edge_part_ids[target_id]
|
||||
info["solid_id"] = self._edge_solid_id(target_id)
|
||||
info["edge_id"] = target_id
|
||||
return info
|
||||
if scope == "feature":
|
||||
if target_id is None or target_id < 0 or target_id >= len(self.faces):
|
||||
raise ValueError(f"Unknown feature source face id {target_id}")
|
||||
feature = self.feature_info(target_id)
|
||||
face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [target_id]
|
||||
shape = _compound_from_shapes(self.faces[face_id] for face_id in face_ids if 0 <= face_id < len(self.faces))
|
||||
info = _shape_quality_info(f"Feature from face {target_id}", shape, expect_solid=False)
|
||||
info["part_id"] = self.face_part_ids[target_id]
|
||||
info["solid_id"] = self.face_solid_ids[target_id]
|
||||
info["face_id"] = target_id
|
||||
info["feature_face_ids"] = tuple(face_ids)
|
||||
return info
|
||||
raise ValueError(f"Unknown export quality scope: {scope}")
|
||||
|
||||
def export_part(self, part_id: int, filename: str | Path) -> None:
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
_write_step(_prepare_shape_for_step_export(part.shape), Path(filename))
|
||||
|
||||
def export_solid(self, solid_id: int, filename: str | Path) -> None:
|
||||
if solid_id < 0 or solid_id >= len(self.solids):
|
||||
raise ValueError(f"Unknown solid id {solid_id}")
|
||||
_write_step(_prepare_shape_for_step_export(self.solids[solid_id][1]), Path(filename))
|
||||
|
||||
def export_face(self, face_id: int, filename: str | Path) -> None:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
raise ValueError(f"Unknown face id {face_id}")
|
||||
_write_step(self._face_region_shape(face_id), Path(filename))
|
||||
|
||||
def face_region_ids(self, face_id: int) -> list[int]:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
raise ValueError(f"Unknown face id {face_id}")
|
||||
return self.connected_same_domain_face_ids(face_id) or [face_id]
|
||||
|
||||
def face_region_boundary_edge_ids(self, face_id: int) -> list[int]:
|
||||
return self._region_boundary_edge_ids(self.face_region_ids(face_id))
|
||||
|
||||
def _face_region_shape(self, face_id: int) -> TopoDS_Shape:
|
||||
face_ids = self.face_region_ids(face_id)
|
||||
shapes = [self.faces[item] for item in face_ids if 0 <= item < len(self.faces)]
|
||||
if not shapes:
|
||||
raise ValueError(f"Face region export did not find any valid faces for face {face_id}.")
|
||||
if len(shapes) == 1:
|
||||
return shapes[0]
|
||||
return _unify_same_domain_shape(_compound_from_shapes(shapes))
|
||||
|
||||
def export_edge(self, edge_id: int, filename: str | Path) -> None:
|
||||
if edge_id < 0 or edge_id >= len(self.edges):
|
||||
raise ValueError(f"Unknown edge id {edge_id}")
|
||||
_write_step(self.edges[edge_id], Path(filename))
|
||||
|
||||
def export_feature(self, face_id: int, filename: str | Path) -> None:
|
||||
if face_id < 0 or face_id >= len(self.faces):
|
||||
raise ValueError(f"Unknown feature source face id {face_id}")
|
||||
feature = self.feature_info(face_id)
|
||||
face_ids = _int_values(feature.get("feature_highlight_face_ids")) or [face_id]
|
||||
shapes = [self.faces[item] for item in face_ids if 0 <= item < len(self.faces)]
|
||||
if not shapes:
|
||||
raise ValueError("Feature export did not find any valid faces.")
|
||||
_write_step(_compound_from_shapes(shapes), Path(filename))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import vtk
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QTableWidgetItem,
|
||||
QTreeWidgetItem,
|
||||
)
|
||||
|
||||
from .model import StepModel
|
||||
from .records import OperationRecord
|
||||
from .ui_helpers import * # noqa: F403
|
||||
from .workers import EditWorker, ScanWorker
|
||||
|
||||
|
||||
class InfoPanelMixin:
|
||||
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 set_measure_point(self, label: str) -> None:
|
||||
point, source = self._current_measure_point()
|
||||
if point is None:
|
||||
QMessageBox.information(self, "没有可测量的点", "请先在模型中选择一个对象,最好用鼠标点击到具体位置。")
|
||||
self.statusBar().showMessage("没有可设为测量点的坐标")
|
||||
return
|
||||
if label.upper() == "A":
|
||||
self.measure_point_a = point
|
||||
self.measure_label_a = source
|
||||
self.statusBar().showMessage(f"已设置测量点 A: {_format_value(point)}")
|
||||
else:
|
||||
self.measure_point_b = point
|
||||
self.measure_label_b = source
|
||||
self.statusBar().showMessage(f"已设置测量点 B: {_format_value(point)}")
|
||||
self._refresh_measurement_panel()
|
||||
|
||||
def clear_measurement(self) -> None:
|
||||
self.measure_point_a = None
|
||||
self.measure_point_b = None
|
||||
self.measure_label_a = ""
|
||||
self.measure_label_b = ""
|
||||
self._clear_measure_actor()
|
||||
self._refresh_measurement_panel()
|
||||
self.statusBar().showMessage("已清除测量")
|
||||
|
||||
def copy_measurement(self) -> None:
|
||||
text = self.measure_text.toPlainText() if hasattr(self, "measure_text") else ""
|
||||
if not text or "尚未设置" in text:
|
||||
self.statusBar().showMessage("没有可复制的测量结果")
|
||||
return
|
||||
QApplication.clipboard().setText(text)
|
||||
self.statusBar().showMessage("已复制测量结果")
|
||||
|
||||
def _current_measure_point(self) -> tuple[tuple[float, float, float] | None, str]:
|
||||
selected_label = self._selected_id_text() or "当前对象"
|
||||
if self.selected_pick_position is not None:
|
||||
return self._tuple3(self.selected_pick_position), f"{selected_label} 拾取点"
|
||||
|
||||
for key, readable in (
|
||||
("pick_position", "拾取点"),
|
||||
("area_center", "面积中心"),
|
||||
("length_center", "长度中心"),
|
||||
("surface_center", "表面积中心"),
|
||||
("center_of_mass", "重心"),
|
||||
("center", "中心"),
|
||||
("rotation_center", "旋转中心"),
|
||||
):
|
||||
point = self._tuple3(self.current_info_values.get(key))
|
||||
if point is not None:
|
||||
return point, f"{selected_label} {readable}"
|
||||
|
||||
bbox_min = self._tuple3(self.current_info_values.get("bbox_min"))
|
||||
bbox_max = self._tuple3(self.current_info_values.get("bbox_max"))
|
||||
if bbox_min is not None and bbox_max is not None:
|
||||
center = (
|
||||
(bbox_min[0] + bbox_max[0]) / 2.0,
|
||||
(bbox_min[1] + bbox_max[1]) / 2.0,
|
||||
(bbox_min[2] + bbox_max[2]) / 2.0,
|
||||
)
|
||||
return center, f"{selected_label} 包围盒中心"
|
||||
return None, ""
|
||||
|
||||
def _refresh_measurement_panel(self) -> None:
|
||||
if not hasattr(self, "measure_text"):
|
||||
return
|
||||
lines = ["两点测量"]
|
||||
if self.measure_point_a is None:
|
||||
lines.append("A: 尚未设置")
|
||||
else:
|
||||
lines.append(f"A: {_format_value(self.measure_point_a)}")
|
||||
if self.measure_label_a:
|
||||
lines.append(f" 来源: {self.measure_label_a}")
|
||||
if self.measure_point_b is None:
|
||||
lines.append("B: 尚未设置")
|
||||
else:
|
||||
lines.append(f"B: {_format_value(self.measure_point_b)}")
|
||||
if self.measure_label_b:
|
||||
lines.append(f" 来源: {self.measure_label_b}")
|
||||
|
||||
if self.measure_point_a is not None and self.measure_point_b is not None:
|
||||
dx = self.measure_point_b[0] - self.measure_point_a[0]
|
||||
dy = self.measure_point_b[1] - self.measure_point_a[1]
|
||||
dz = self.measure_point_b[2] - self.measure_point_a[2]
|
||||
distance = math.sqrt(dx * dx + dy * dy + dz * dz)
|
||||
lines.extend(
|
||||
[
|
||||
f"距离: {_format_value(distance)}",
|
||||
f"ΔX/ΔY/ΔZ: {_format_value((dx, dy, dz))}",
|
||||
]
|
||||
)
|
||||
else:
|
||||
lines.append("距离: 需要同时设置 A 和 B")
|
||||
self.measure_text.setPlainText("\n".join(lines))
|
||||
self._update_measure_actor()
|
||||
|
||||
def _update_measure_actor(self) -> None:
|
||||
self._clear_measure_actor(render=False)
|
||||
if self.measure_point_a is None or self.measure_point_b is None:
|
||||
return
|
||||
if not hasattr(self, "renderer") or not hasattr(self, "render_window"):
|
||||
return
|
||||
line = vtk.vtkLineSource()
|
||||
line.SetPoint1(*self.measure_point_a)
|
||||
line.SetPoint2(*self.measure_point_b)
|
||||
mapper = vtk.vtkPolyDataMapper()
|
||||
mapper.SetInputConnection(line.GetOutputPort())
|
||||
actor = vtk.vtkActor()
|
||||
actor.SetMapper(mapper)
|
||||
actor.GetProperty().SetColor(0.1, 0.95, 1.0)
|
||||
actor.GetProperty().SetLineWidth(4)
|
||||
actor.GetProperty().SetAmbient(0.9)
|
||||
actor.PickableOff()
|
||||
self.measure_actor = actor
|
||||
self.renderer.AddActor(actor)
|
||||
self.render_window.Render()
|
||||
|
||||
def _clear_measure_actor(self, render: bool = True) -> None:
|
||||
actor = getattr(self, "measure_actor", None)
|
||||
if actor is not None and hasattr(self, "renderer"):
|
||||
try:
|
||||
self.renderer.RemoveActor(actor)
|
||||
except Exception:
|
||||
pass
|
||||
self.measure_actor = None
|
||||
if render and hasattr(self, "render_window"):
|
||||
self.render_window.Render()
|
||||
|
||||
def _tuple3(self, value: object) -> tuple[float, float, float] | None:
|
||||
if isinstance(value, (tuple, list)) and len(value) == 3:
|
||||
try:
|
||||
return (float(value[0]), float(value[1]), float(value[2]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
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 ""
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
|
||||
|
||||
@dataclass
|
||||
class PartNode:
|
||||
id: int
|
||||
name: str
|
||||
kind: str
|
||||
shape: TopoDS_Shape
|
||||
parent_id: int | None = None
|
||||
depth: int = 0
|
||||
path: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TopologyStats:
|
||||
parts: int
|
||||
solids: int
|
||||
faces: int
|
||||
edges: int
|
||||
vertices: int
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,313 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from OCC.Core.BRep import BRep_Tool
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
||||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||||
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.GeomAbs import (
|
||||
GeomAbs_BSplineCurve,
|
||||
GeomAbs_BSplineSurface,
|
||||
GeomAbs_BezierCurve,
|
||||
GeomAbs_BezierSurface,
|
||||
GeomAbs_Circle,
|
||||
GeomAbs_Cone,
|
||||
GeomAbs_Cylinder,
|
||||
GeomAbs_Ellipse,
|
||||
GeomAbs_Hyperbola,
|
||||
GeomAbs_Line,
|
||||
GeomAbs_OffsetSurface,
|
||||
GeomAbs_OtherCurve,
|
||||
GeomAbs_OtherSurface,
|
||||
GeomAbs_Parabola,
|
||||
GeomAbs_Plane,
|
||||
GeomAbs_Sphere,
|
||||
GeomAbs_SurfaceOfExtrusion,
|
||||
GeomAbs_SurfaceOfRevolution,
|
||||
GeomAbs_Torus,
|
||||
)
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.ShapeFix import ShapeFix_Shape
|
||||
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||||
from OCC.Core.TopAbs import (
|
||||
TopAbs_EDGE,
|
||||
TopAbs_EXTERNAL,
|
||||
TopAbs_FACE,
|
||||
TopAbs_FORWARD,
|
||||
TopAbs_IN,
|
||||
TopAbs_INTERNAL,
|
||||
TopAbs_OUT,
|
||||
TopAbs_REVERSED,
|
||||
TopAbs_SOLID,
|
||||
)
|
||||
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
||||
from OCC.Core.TopLoc import TopLoc_Location
|
||||
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
||||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
||||
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
||||
|
||||
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
|
||||
from .geometry_utils import * # noqa: F403
|
||||
|
||||
|
||||
def _polydata_id_key(values: Iterable[int] | None) -> tuple[int, ...] | None:
|
||||
if values is None:
|
||||
return None
|
||||
return tuple(sorted({int(value) for value in values}))
|
||||
|
||||
|
||||
class PolydataMixin:
|
||||
def build_face_polydata(
|
||||
self,
|
||||
face_ids: Iterable[int] | None = None,
|
||||
part_ids: Iterable[int] | None = None,
|
||||
deflection: float = 0.8,
|
||||
):
|
||||
import vtk
|
||||
|
||||
face_key = _polydata_id_key(face_ids)
|
||||
part_key = _polydata_id_key(part_ids)
|
||||
cache_key = ("faces", face_key, part_key, float(deflection))
|
||||
cached = self._polydata_cache_get("_face_polydata_cache", cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
selected_faces = set(face_key) if face_key is not None else None
|
||||
selected_parts = set(part_key) if part_key is not None else None
|
||||
self._ensure_mesh(deflection)
|
||||
|
||||
points = vtk.vtkPoints()
|
||||
polys = vtk.vtkCellArray()
|
||||
face_arr = vtk.vtkIntArray()
|
||||
face_arr.SetName("face_id")
|
||||
part_arr = vtk.vtkIntArray()
|
||||
part_arr.SetName("part_id")
|
||||
solid_arr = vtk.vtkIntArray()
|
||||
solid_arr.SetName("solid_id")
|
||||
|
||||
for face_id, face in enumerate(self.faces):
|
||||
part_id = self.face_part_ids[face_id]
|
||||
if selected_faces is not None and face_id not in selected_faces:
|
||||
continue
|
||||
if selected_parts is not None and part_id not in selected_parts:
|
||||
continue
|
||||
|
||||
loc = TopLoc_Location()
|
||||
tri = BRep_Tool.Triangulation(topods.Face(face), loc)
|
||||
if tri is None:
|
||||
continue
|
||||
transform = loc.Transformation()
|
||||
node_offset = points.GetNumberOfPoints()
|
||||
for node_index in range(1, tri.NbNodes() + 1):
|
||||
pnt = tri.Node(node_index).Transformed(transform)
|
||||
points.InsertNextPoint(pnt.X(), pnt.Y(), pnt.Z())
|
||||
|
||||
reversed_face = face.Orientation() == TopAbs_REVERSED
|
||||
for tri_index in range(1, tri.NbTriangles() + 1):
|
||||
n1, n2, n3 = tri.Triangle(tri_index).Get()
|
||||
if reversed_face:
|
||||
n2, n3 = n3, n2
|
||||
vtk_tri = vtk.vtkTriangle()
|
||||
vtk_tri.GetPointIds().SetId(0, node_offset + n1 - 1)
|
||||
vtk_tri.GetPointIds().SetId(1, node_offset + n2 - 1)
|
||||
vtk_tri.GetPointIds().SetId(2, node_offset + n3 - 1)
|
||||
polys.InsertNextCell(vtk_tri)
|
||||
face_arr.InsertNextValue(face_id)
|
||||
part_arr.InsertNextValue(part_id)
|
||||
solid_arr.InsertNextValue(self.face_solid_ids[face_id])
|
||||
|
||||
poly = vtk.vtkPolyData()
|
||||
poly.SetPoints(points)
|
||||
poly.SetPolys(polys)
|
||||
poly.GetCellData().AddArray(face_arr)
|
||||
poly.GetCellData().AddArray(part_arr)
|
||||
poly.GetCellData().AddArray(solid_arr)
|
||||
return self._polydata_cache_remember("_face_polydata_cache", cache_key, poly)
|
||||
|
||||
def _ensure_mesh(self, deflection: float) -> None:
|
||||
requested = max(float(deflection), 1e-9)
|
||||
if self._mesh_deflection is None or requested < self._mesh_deflection * 0.999:
|
||||
BRepMesh_IncrementalMesh(self.shape, requested)
|
||||
self._mesh_deflection = requested
|
||||
|
||||
def build_snapshot_polydata(self, snapshot: dict[object, object], deflection: float = 0.8):
|
||||
shape = _compound_from_shapes(value for value in snapshot.values() if isinstance(value, TopoDS_Shape))
|
||||
BRepMesh_IncrementalMesh(shape, deflection)
|
||||
return _shape_faces_polydata(shape)
|
||||
|
||||
def build_edge_polydata(
|
||||
self,
|
||||
edge_ids: Iterable[int] | None = None,
|
||||
part_ids: Iterable[int] | None = None,
|
||||
deflection: float = 0.8,
|
||||
show_same_domain_internal_edges: bool = False,
|
||||
):
|
||||
import vtk
|
||||
|
||||
edge_key = _polydata_id_key(edge_ids)
|
||||
part_key = _polydata_id_key(part_ids)
|
||||
cache_key = ("edges", edge_key, part_key, float(deflection), bool(show_same_domain_internal_edges))
|
||||
cached = self._polydata_cache_get("_edge_polydata_cache", cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
selected_edges = set(edge_key) if edge_key is not None else None
|
||||
selected_parts = set(part_key) if part_key is not None else None
|
||||
points = vtk.vtkPoints()
|
||||
lines = vtk.vtkCellArray()
|
||||
edge_arr = vtk.vtkIntArray()
|
||||
edge_arr.SetName("edge_id")
|
||||
part_arr = vtk.vtkIntArray()
|
||||
part_arr.SetName("part_id")
|
||||
hidden_edge_ids = (
|
||||
set()
|
||||
if selected_edges is not None or show_same_domain_internal_edges
|
||||
else self._same_domain_internal_edge_ids()
|
||||
)
|
||||
|
||||
for edge_id, edge in enumerate(self.edges):
|
||||
part_id = self.edge_part_ids[edge_id]
|
||||
if selected_edges is not None and edge_id not in selected_edges:
|
||||
continue
|
||||
if selected_parts is not None and part_id not in selected_parts:
|
||||
continue
|
||||
if edge_id in hidden_edge_ids:
|
||||
continue
|
||||
samples = discretize_edge(edge, deflection)
|
||||
if len(samples) < 2:
|
||||
continue
|
||||
polyline = vtk.vtkPolyLine()
|
||||
polyline.GetPointIds().SetNumberOfIds(len(samples))
|
||||
for i, coords in enumerate(samples):
|
||||
point_id = points.InsertNextPoint(float(coords[0]), float(coords[1]), float(coords[2]))
|
||||
polyline.GetPointIds().SetId(i, point_id)
|
||||
lines.InsertNextCell(polyline)
|
||||
edge_arr.InsertNextValue(edge_id)
|
||||
part_arr.InsertNextValue(part_id)
|
||||
|
||||
poly = vtk.vtkPolyData()
|
||||
poly.SetPoints(points)
|
||||
poly.SetLines(lines)
|
||||
poly.GetCellData().AddArray(edge_arr)
|
||||
poly.GetCellData().AddArray(part_arr)
|
||||
return self._polydata_cache_remember("_edge_polydata_cache", cache_key, poly)
|
||||
|
||||
def _polydata_cache_get(self, cache_name: str, key: tuple[object, ...]):
|
||||
cache = getattr(self, cache_name, None)
|
||||
if not isinstance(cache, dict):
|
||||
return None
|
||||
return cache.get(key)
|
||||
|
||||
def _polydata_cache_remember(self, cache_name: str, key: tuple[object, ...], polydata):
|
||||
cache = getattr(self, cache_name, None)
|
||||
if not isinstance(cache, dict):
|
||||
return polydata
|
||||
limit = max(int(getattr(self, "_polydata_cache_limit", 96)), 1)
|
||||
if len(cache) >= limit and key not in cache:
|
||||
try:
|
||||
cache.pop(next(iter(cache)))
|
||||
except StopIteration:
|
||||
pass
|
||||
cache[key] = polydata
|
||||
return polydata
|
||||
|
||||
def _is_same_domain_internal_edge(self, edge_id: int) -> bool:
|
||||
return edge_id in self._same_domain_internal_edge_ids()
|
||||
|
||||
def _same_domain_internal_edge_ids(self) -> set[int]:
|
||||
if self._same_domain_internal_edge_ids_cache is not None:
|
||||
return self._same_domain_internal_edge_ids_cache
|
||||
|
||||
hidden_edge_ids: set[int] = set()
|
||||
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
||||
for edge_id in range(len(self.edges)):
|
||||
if self._is_topological_same_domain_internal_edge(edge_id, tolerance):
|
||||
hidden_edge_ids.add(edge_id)
|
||||
hidden_edge_ids.update(self._same_domain_duplicate_edge_ids(tolerance))
|
||||
self._same_domain_internal_edge_ids_cache = hidden_edge_ids
|
||||
return self._same_domain_internal_edge_ids_cache
|
||||
|
||||
def _is_topological_same_domain_internal_edge(self, edge_id: int, tolerance: float) -> bool:
|
||||
if edge_id < 0 or edge_id >= len(self.edges):
|
||||
return False
|
||||
face_ids = self._edge_adjacent_face_ids(edge_id)
|
||||
if len(face_ids) == 2:
|
||||
left_id, right_id = face_ids
|
||||
if self.face_solid_ids[left_id] == self.face_solid_ids[right_id]:
|
||||
left = BRepAdaptor_Surface(self.faces[left_id])
|
||||
right = BRepAdaptor_Surface(self.faces[right_id])
|
||||
if _surfaces_are_coplanar(left, right, tolerance):
|
||||
return True
|
||||
if _surfaces_are_cocylindrical(left, right, tolerance):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _same_domain_duplicate_edge_ids(self, tolerance: float) -> set[int]:
|
||||
if self._same_domain_duplicate_edge_ids_cache is not None:
|
||||
return self._same_domain_duplicate_edge_ids_cache
|
||||
|
||||
duplicate_edge_ids: set[int] = set()
|
||||
for bucket_edge_ids in self._edge_duplicate_key_ids(tolerance).values():
|
||||
if len(bucket_edge_ids) <= 1:
|
||||
continue
|
||||
for index, left_edge_id in enumerate(bucket_edge_ids):
|
||||
left_face_ids = self._edge_adjacent_face_ids(left_edge_id)
|
||||
if not left_face_ids:
|
||||
continue
|
||||
for right_edge_id in bucket_edge_ids[index + 1 :]:
|
||||
right_face_ids = self._edge_adjacent_face_ids(right_edge_id)
|
||||
if not right_face_ids:
|
||||
continue
|
||||
if self._edge_face_sets_share_same_domain(left_face_ids, right_face_ids):
|
||||
duplicate_edge_ids.add(left_edge_id)
|
||||
duplicate_edge_ids.add(right_edge_id)
|
||||
|
||||
self._same_domain_duplicate_edge_ids_cache = set(duplicate_edge_ids)
|
||||
return self._same_domain_duplicate_edge_ids_cache
|
||||
|
||||
def _edge_duplicate_key_ids(self, tolerance: float) -> dict[tuple[object, ...], list[int]]:
|
||||
if self._edge_duplicate_key_ids_cache is not None:
|
||||
return self._edge_duplicate_key_ids_cache
|
||||
key_tolerance = max(tolerance * 10.0, _shape_diagonal(self.shape) * 1e-7, 1e-6)
|
||||
buckets: dict[tuple[object, ...], list[int]] = {}
|
||||
for edge_id, edge in enumerate(self.edges):
|
||||
key = _edge_duplicate_key(edge, key_tolerance)
|
||||
if key is None:
|
||||
continue
|
||||
buckets.setdefault(key, []).append(edge_id)
|
||||
self._edge_duplicate_key_ids_cache = {key: list(value) for key, value in buckets.items() if len(value) > 1}
|
||||
return self._edge_duplicate_key_ids_cache
|
||||
|
||||
def _edge_face_sets_share_same_domain(self, left_face_ids: list[int], right_face_ids: list[int]) -> bool:
|
||||
tolerance = min(max(_shape_diagonal(self.shape) * 1e-7, 1e-6), 1e-3)
|
||||
for left_face_id in left_face_ids:
|
||||
if left_face_id < 0 or left_face_id >= len(self.faces):
|
||||
continue
|
||||
left_solid_id = self.face_solid_ids[left_face_id]
|
||||
left_surface = BRepAdaptor_Surface(self.faces[left_face_id])
|
||||
for right_face_id in right_face_ids:
|
||||
if right_face_id == left_face_id or right_face_id < 0 or right_face_id >= len(self.faces):
|
||||
continue
|
||||
if left_solid_id != self.face_solid_ids[right_face_id]:
|
||||
continue
|
||||
right_surface = BRepAdaptor_Surface(self.faces[right_face_id])
|
||||
if _surfaces_are_coplanar(left_surface, right_surface, tolerance):
|
||||
return True
|
||||
if _surfaces_are_cocylindrical(left_surface, right_surface, tolerance):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperationRecord:
|
||||
summary: str
|
||||
detail: str
|
||||
target_kind: str | None = None
|
||||
target_id: int | None = None
|
||||
target_logical_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
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCC.Core.IFSelect import IFSelect_RetDone
|
||||
from OCC.Core.Interface import Interface_Static
|
||||
from OCC.Core.STEPCAFControl import STEPCAFControl_Reader
|
||||
from OCC.Core.STEPControl import STEPControl_AsIs, STEPControl_Reader, STEPControl_Writer
|
||||
from OCC.Core.TDF import TDF_Label, TDF_LabelSequence
|
||||
from OCC.Core.TDocStd import TDocStd_Document
|
||||
from OCC.Core.TopLoc import TopLoc_Location
|
||||
from OCC.Core.TopoDS import TopoDS_Shape
|
||||
from OCC.Core.XCAFDoc import XCAFDoc_DocumentTool
|
||||
|
||||
from .geometry_utils import _compound_from_shapes, _repair_shape, _unify_same_domain_shape
|
||||
from .model_types import PartNode
|
||||
|
||||
|
||||
def _load_with_xcaf(path: Path, product_names: list[str]) -> tuple[list[PartNode], TopoDS_Shape]:
|
||||
doc = TDocStd_Document("pythonocc-step-document")
|
||||
shape_tool = XCAFDoc_DocumentTool.ShapeTool(doc.Main())
|
||||
|
||||
reader = STEPCAFControl_Reader()
|
||||
reader.SetColorMode(True)
|
||||
reader.SetLayerMode(True)
|
||||
reader.SetNameMode(True)
|
||||
reader.SetMatMode(True)
|
||||
reader.SetGDTMode(True)
|
||||
status = reader.ReadFile(str(path))
|
||||
if status != IFSelect_RetDone:
|
||||
raise ValueError(f"Could not read STEP file: {path}")
|
||||
if not reader.Transfer(doc):
|
||||
raise ValueError(f"Could not transfer STEP document: {path}")
|
||||
|
||||
parts: list[PartNode] = []
|
||||
free_shapes = TDF_LabelSequence()
|
||||
shape_tool.GetFreeShapes(free_shapes)
|
||||
|
||||
def next_name(label: TDF_Label, index: int) -> str:
|
||||
label_name = str(label.GetLabelName()).strip()
|
||||
if label_name:
|
||||
return label_name
|
||||
if index - 1 < len(product_names):
|
||||
return product_names[index - 1]
|
||||
return f"Part {index}"
|
||||
|
||||
def add_node(
|
||||
name: str,
|
||||
kind: str,
|
||||
shape: TopoDS_Shape,
|
||||
parent_id: int | None,
|
||||
depth: int,
|
||||
path_text: str,
|
||||
) -> PartNode:
|
||||
node = PartNode(len(parts) + 1, name, kind, shape, parent_id, depth, path_text)
|
||||
parts.append(node)
|
||||
return node
|
||||
|
||||
def transformed_shape(label: TDF_Label, locations: list[TopLoc_Location]) -> TopoDS_Shape:
|
||||
shape = shape_tool.GetShape(label)
|
||||
if shape.IsNull() or not locations:
|
||||
return shape
|
||||
location = TopLoc_Location()
|
||||
for loc in locations:
|
||||
location = location.Multiplied(loc)
|
||||
return BRepBuilderAPI_Transform(shape, location.Transformation()).Shape()
|
||||
|
||||
def walk(label: TDF_Label, parent_id: int | None, depth: int, locations: list[TopLoc_Location], path_names: list[str]):
|
||||
name = next_name(label, len(parts) + 1)
|
||||
label_path = " / ".join(path_names + [name])
|
||||
|
||||
if shape_tool.IsAssembly(label):
|
||||
node = add_node(name, "assembly", transformed_shape(label, locations), parent_id, depth, label_path)
|
||||
components = TDF_LabelSequence()
|
||||
shape_tool.GetComponents(label, components)
|
||||
for i in range(1, components.Length() + 1):
|
||||
component = components.Value(i)
|
||||
if shape_tool.IsReference(component):
|
||||
referred = TDF_Label()
|
||||
shape_tool.GetReferredShape(component, referred)
|
||||
loc = shape_tool.GetLocation(component)
|
||||
walk(referred, node.id, depth + 1, locations + [loc], path_names + [name])
|
||||
else:
|
||||
walk(component, node.id, depth + 1, locations, path_names + [name])
|
||||
return
|
||||
|
||||
if shape_tool.IsSimpleShape(label) or shape_tool.IsShape(label):
|
||||
add_node(name, "part", transformed_shape(label, locations), parent_id, depth, label_path)
|
||||
|
||||
for i in range(1, free_shapes.Length() + 1):
|
||||
walk(free_shapes.Value(i), None, 0, [], [])
|
||||
|
||||
display_shapes = [p.shape for p in parts if p.kind == "part" and not p.shape.IsNull()]
|
||||
if not display_shapes:
|
||||
display_shapes = [p.shape for p in parts if not p.shape.IsNull()]
|
||||
return parts, _compound_from_shapes(display_shapes)
|
||||
|
||||
|
||||
def _load_plain_step(path: Path) -> TopoDS_Shape:
|
||||
reader = STEPControl_Reader()
|
||||
status = reader.ReadFile(str(path))
|
||||
if status != IFSelect_RetDone:
|
||||
raise ValueError(f"Could not read STEP file: {path}")
|
||||
if not reader.TransferRoots():
|
||||
raise ValueError(f"Could not transfer STEP roots: {path}")
|
||||
return reader.Shape()
|
||||
|
||||
|
||||
def _parse_product_names(path: Path) -> list[str]:
|
||||
text = path.read_text(errors="ignore")
|
||||
names = re.findall(r"PRODUCT\('((?:''|[^'])*)'", text)
|
||||
return [name.replace("''", "'") for name in names if name.strip()]
|
||||
|
||||
|
||||
def _write_step(shape: TopoDS_Shape, filename: Path) -> None:
|
||||
if shape.IsNull():
|
||||
raise ValueError("Cannot export a null shape.")
|
||||
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
Interface_Static.SetCVal("write.step.schema", "AP214IS")
|
||||
writer = STEPControl_Writer()
|
||||
writer.Transfer(shape, STEPControl_AsIs)
|
||||
status = writer.Write(str(filename))
|
||||
if status != IFSelect_RetDone:
|
||||
raise IOError(f"Could not write STEP file: {filename}")
|
||||
|
||||
|
||||
def _prepare_shape_for_step_export(shape: TopoDS_Shape) -> TopoDS_Shape:
|
||||
if shape.IsNull():
|
||||
return shape
|
||||
try:
|
||||
repaired = _repair_shape(shape)
|
||||
unified = _unify_same_domain_shape(repaired)
|
||||
repaired_unified = _repair_shape(unified)
|
||||
if repaired_unified.IsNull():
|
||||
return shape
|
||||
return repaired_unified
|
||||
except Exception:
|
||||
return shape
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable
|
||||
|
||||
from OCC.Core.BRep import BRep_Tool
|
||||
from OCC.Core.BRepAdaptor import BRepAdaptor_Curve, BRepAdaptor_Surface
|
||||
from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Defeaturing, BRepAlgoAPI_Fuse
|
||||
from OCC.Core.BRepBndLib import brepbndlib
|
||||
from OCC.Core.BOPAlgo import BOPAlgo_GlueFull
|
||||
from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCC.Core.BRepCheck import BRepCheck_Analyzer
|
||||
from OCC.Core.BRepClass3d import BRepClass3d_SolidClassifier
|
||||
from OCC.Core.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||
from OCC.Core.BRepGProp import brepgprop
|
||||
from OCC.Core.BRepMesh import BRepMesh_IncrementalMesh
|
||||
from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism
|
||||
from OCC.Core.Bnd import Bnd_Box
|
||||
from OCC.Core.GeomAbs import (
|
||||
GeomAbs_BSplineCurve,
|
||||
GeomAbs_BSplineSurface,
|
||||
GeomAbs_BezierCurve,
|
||||
GeomAbs_BezierSurface,
|
||||
GeomAbs_Circle,
|
||||
GeomAbs_Cone,
|
||||
GeomAbs_Cylinder,
|
||||
GeomAbs_Ellipse,
|
||||
GeomAbs_Hyperbola,
|
||||
GeomAbs_Line,
|
||||
GeomAbs_OffsetSurface,
|
||||
GeomAbs_OtherCurve,
|
||||
GeomAbs_OtherSurface,
|
||||
GeomAbs_Parabola,
|
||||
GeomAbs_Plane,
|
||||
GeomAbs_Sphere,
|
||||
GeomAbs_SurfaceOfExtrusion,
|
||||
GeomAbs_SurfaceOfRevolution,
|
||||
GeomAbs_Torus,
|
||||
)
|
||||
from OCC.Core.GProp import GProp_GProps
|
||||
from OCC.Core.ShapeFix import ShapeFix_Shape
|
||||
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
|
||||
from OCC.Core.TopAbs import (
|
||||
TopAbs_EDGE,
|
||||
TopAbs_EXTERNAL,
|
||||
TopAbs_FACE,
|
||||
TopAbs_FORWARD,
|
||||
TopAbs_IN,
|
||||
TopAbs_INTERNAL,
|
||||
TopAbs_OUT,
|
||||
TopAbs_REVERSED,
|
||||
TopAbs_SOLID,
|
||||
)
|
||||
from OCC.Core.TopExp import TopExp_Explorer, topexp
|
||||
from OCC.Core.TopLoc import TopLoc_Location
|
||||
from OCC.Core.TopoDS import TopoDS_Compound, TopoDS_Shape, topods
|
||||
from OCC.Core.TopTools import TopTools_IndexedDataMapOfShapeListOfShape, TopTools_IndexedMapOfShape
|
||||
from OCC.Core.gp import gp_Ax1, gp_Ax2, gp_Dir, gp_Pnt, gp_Trsf, gp_Vec
|
||||
from OCC.Extend.TopologyUtils import TopologyExplorer, discretize_edge
|
||||
|
||||
from .constants import CURVE_TYPES, SNAPSHOT_FACE_LOGICAL_IDS_KEY, SURFACE_TYPES
|
||||
from .geometry_utils import * # noqa: F403
|
||||
|
||||
|
||||
class TransformMixin:
|
||||
def translate_part_plan(self, part_id: int, vector: tuple[float, float, float]) -> dict[str, object]:
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
readiness = _translation_readiness(vector, part.shape)
|
||||
return {
|
||||
"status": readiness["translate_status"],
|
||||
"risk": readiness["translate_risk"],
|
||||
"message": readiness["translate_note"],
|
||||
"warnings": readiness["translate_warnings"],
|
||||
"blockers": readiness["translate_blockers"],
|
||||
"target_kind": "part",
|
||||
"part_id": part.id,
|
||||
"name": part.name,
|
||||
"translation_vector": vector,
|
||||
"translation_distance": _vector_length(vector),
|
||||
"bbox_diagonal": _shape_diagonal(part.shape),
|
||||
}
|
||||
|
||||
def translate_solid_plan(self, solid_id: int, vector: tuple[float, float, float]) -> dict[str, object]:
|
||||
if solid_id < 0 or solid_id >= len(self.solids):
|
||||
raise ValueError(f"Unknown solid id {solid_id}")
|
||||
part_id, solid = self.solids[solid_id]
|
||||
readiness = _translation_readiness(vector, solid)
|
||||
part = self.part_by_id(part_id)
|
||||
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
|
||||
warnings = readiness["translate_warnings"]
|
||||
risk = readiness["translate_risk"]
|
||||
status = readiness["translate_status"]
|
||||
if part_solid_count <= 1 and status != "blocked":
|
||||
warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,平移 solid 实际会移动整个 part shape。")
|
||||
if risk == "low":
|
||||
risk = "medium"
|
||||
status = "caution"
|
||||
return {
|
||||
"status": status,
|
||||
"risk": risk,
|
||||
"message": _join_nonempty(readiness["translate_note"], warnings),
|
||||
"warnings": warnings,
|
||||
"blockers": readiness["translate_blockers"],
|
||||
"target_kind": "solid",
|
||||
"part_id": part_id,
|
||||
"solid_id": solid_id,
|
||||
"part_solid_count": part_solid_count,
|
||||
"translation_vector": vector,
|
||||
"translation_distance": _vector_length(vector),
|
||||
"bbox_diagonal": _shape_diagonal(solid),
|
||||
}
|
||||
|
||||
def translate_part(self, part_id: int, vector: tuple[float, float, float]) -> str:
|
||||
plan = self.translate_part_plan(part_id, vector)
|
||||
if plan["status"] == "blocked":
|
||||
raise ValueError(str(plan["message"]))
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
part.shape = _translated_shape_by_vector(part.shape, vector)
|
||||
_ensure_valid_shape(part.shape)
|
||||
self.refresh_topology()
|
||||
return (
|
||||
f"Part translated: part {part_id}, vector={_format_tuple(vector)}, "
|
||||
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
|
||||
)
|
||||
|
||||
def translate_solid(self, solid_id: int, vector: tuple[float, float, float]) -> str:
|
||||
plan = self.translate_solid_plan(solid_id, vector)
|
||||
if plan["status"] == "blocked":
|
||||
raise ValueError(str(plan["message"]))
|
||||
part_id, solid = self.solids[solid_id]
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
part_solids = _explore(part.shape, TopAbs_SOLID)
|
||||
if len(part_solids) <= 1:
|
||||
part.shape = _translated_shape_by_vector(part.shape, vector)
|
||||
else:
|
||||
translated = _translated_shape_by_vector(solid, vector)
|
||||
replaced = False
|
||||
shapes: list[TopoDS_Shape] = []
|
||||
for item in part_solids:
|
||||
if not replaced and _same_shape(item, solid):
|
||||
shapes.append(translated)
|
||||
replaced = True
|
||||
else:
|
||||
shapes.append(item)
|
||||
if not replaced:
|
||||
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
|
||||
part.shape = _compound_from_shapes(shapes)
|
||||
|
||||
_ensure_valid_shape(part.shape)
|
||||
self.refresh_topology()
|
||||
return (
|
||||
f"Solid translated: solid {solid_id}, part {part_id}, vector={_format_tuple(vector)}, "
|
||||
f"distance={float(plan['translation_distance']):g}, risk={plan['risk']}."
|
||||
)
|
||||
|
||||
def rotate_part_plan(self, part_id: int, axis: str, angle_degrees: float) -> dict[str, object]:
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
readiness = _rotation_readiness(axis, angle_degrees)
|
||||
return {
|
||||
"status": readiness["rotate_status"],
|
||||
"risk": readiness["rotate_risk"],
|
||||
"message": readiness["rotate_note"],
|
||||
"warnings": readiness["rotate_warnings"],
|
||||
"blockers": readiness["rotate_blockers"],
|
||||
"target_kind": "part",
|
||||
"part_id": part.id,
|
||||
"name": part.name,
|
||||
"rotation_axis": axis.upper(),
|
||||
"rotation_angle_degrees": angle_degrees,
|
||||
"rotation_center": _shape_center(part.shape),
|
||||
"bbox_diagonal": _shape_diagonal(part.shape),
|
||||
}
|
||||
|
||||
def rotate_solid_plan(self, solid_id: int, axis: str, angle_degrees: float) -> dict[str, object]:
|
||||
if solid_id < 0 or solid_id >= len(self.solids):
|
||||
raise ValueError(f"Unknown solid id {solid_id}")
|
||||
part_id, solid = self.solids[solid_id]
|
||||
readiness = _rotation_readiness(axis, angle_degrees)
|
||||
part = self.part_by_id(part_id)
|
||||
part_solid_count = len(_explore(part.shape, TopAbs_SOLID)) if part is not None else 0
|
||||
warnings = readiness["rotate_warnings"]
|
||||
risk = readiness["rotate_risk"]
|
||||
status = readiness["rotate_status"]
|
||||
if part_solid_count <= 1 and status != "blocked":
|
||||
warnings = _join_nonempty(warnings, "当前 part 只有一个 solid,旋转 solid 实际会旋转整个 part shape。")
|
||||
if risk == "low":
|
||||
risk = "medium"
|
||||
status = "caution"
|
||||
return {
|
||||
"status": status,
|
||||
"risk": risk,
|
||||
"message": _join_nonempty(readiness["rotate_note"], warnings),
|
||||
"warnings": warnings,
|
||||
"blockers": readiness["rotate_blockers"],
|
||||
"target_kind": "solid",
|
||||
"part_id": part_id,
|
||||
"solid_id": solid_id,
|
||||
"part_solid_count": part_solid_count,
|
||||
"rotation_axis": axis.upper(),
|
||||
"rotation_angle_degrees": angle_degrees,
|
||||
"rotation_center": _shape_center(solid),
|
||||
"bbox_diagonal": _shape_diagonal(solid),
|
||||
}
|
||||
|
||||
def rotate_part(self, part_id: int, axis: str, angle_degrees: float) -> str:
|
||||
plan = self.rotate_part_plan(part_id, axis, angle_degrees)
|
||||
if plan["status"] == "blocked":
|
||||
raise ValueError(str(plan["message"]))
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
|
||||
_ensure_valid_shape(part.shape)
|
||||
self.refresh_topology()
|
||||
return (
|
||||
f"Part rotated: part {part_id}, axis={plan['rotation_axis']}, "
|
||||
f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, "
|
||||
f"risk={plan['risk']}."
|
||||
)
|
||||
|
||||
def rotate_solid(self, solid_id: int, axis: str, angle_degrees: float) -> str:
|
||||
plan = self.rotate_solid_plan(solid_id, axis, angle_degrees)
|
||||
if plan["status"] == "blocked":
|
||||
raise ValueError(str(plan["message"]))
|
||||
part_id, solid = self.solids[solid_id]
|
||||
part = self.part_by_id(part_id)
|
||||
if part is None:
|
||||
raise ValueError(f"Unknown part id {part_id}")
|
||||
|
||||
part_solids = _explore(part.shape, TopAbs_SOLID)
|
||||
if len(part_solids) <= 1:
|
||||
part.shape = _rotated_shape(part.shape, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
|
||||
else:
|
||||
rotated = _rotated_shape(solid, str(plan["rotation_axis"]), float(plan["rotation_angle_degrees"]), plan["rotation_center"])
|
||||
replaced = False
|
||||
shapes: list[TopoDS_Shape] = []
|
||||
for item in part_solids:
|
||||
if not replaced and _same_shape(item, solid):
|
||||
shapes.append(rotated)
|
||||
replaced = True
|
||||
else:
|
||||
shapes.append(item)
|
||||
if not replaced:
|
||||
raise RuntimeError(f"Could not locate solid {solid_id} inside part {part_id}.")
|
||||
part.shape = _compound_from_shapes(shapes)
|
||||
|
||||
_ensure_valid_shape(part.shape)
|
||||
self.refresh_topology()
|
||||
return (
|
||||
f"Solid rotated: solid {solid_id}, part {part_id}, axis={plan['rotation_axis']}, "
|
||||
f"angle={float(plan['rotation_angle_degrees']):g}, center={_format_tuple(plan['rotation_center'])}, "
|
||||
f"risk={plan['risk']}."
|
||||
)
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
import vtk
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from .records import OperationRecord
|
||||
|
||||
|
||||
INFO_GROUPS: list[tuple[str, list[str]]] = [
|
||||
(
|
||||
"身份",
|
||||
[
|
||||
"kind",
|
||||
"name",
|
||||
"path",
|
||||
"file",
|
||||
"part_id",
|
||||
"solid_id",
|
||||
"logical_face_id",
|
||||
"face_region_logical_id",
|
||||
"topological_face_id",
|
||||
"face_id",
|
||||
"edge_id",
|
||||
"parent_id",
|
||||
"depth",
|
||||
"feature_mode",
|
||||
"feature_type",
|
||||
"feature_source_face_id",
|
||||
],
|
||||
),
|
||||
(
|
||||
"拓扑",
|
||||
[
|
||||
"parts",
|
||||
"solids",
|
||||
"faces",
|
||||
"edges",
|
||||
"vertices",
|
||||
"boundary_edges",
|
||||
"same_domain_face_ids",
|
||||
"same_domain_face_count",
|
||||
"same_domain_v_range",
|
||||
"same_domain_range_source",
|
||||
"same_domain_note",
|
||||
"orientation",
|
||||
"surface",
|
||||
"curve",
|
||||
"adjacent_face_ids",
|
||||
"adjacent_face_count",
|
||||
],
|
||||
),
|
||||
(
|
||||
"测量",
|
||||
[
|
||||
"volume",
|
||||
"surface_area",
|
||||
"area",
|
||||
"length",
|
||||
"edge_length",
|
||||
"current_length",
|
||||
"target_length",
|
||||
"delta_length",
|
||||
"length_change_ratio",
|
||||
"edge_length_anchor_mode",
|
||||
"edge_length_anchor_label",
|
||||
"circular_edge_current_radius",
|
||||
"circular_edge_target_radius",
|
||||
"circular_edge_length_scale",
|
||||
"cylinder_resize_current_diameter",
|
||||
"cylinder_resize_target_diameter",
|
||||
"cylinder_resize_delta_diameter",
|
||||
"cylinder_resize_delta_ratio",
|
||||
"radius",
|
||||
"diameter",
|
||||
"target_radius",
|
||||
"radius_to_length_ratio",
|
||||
"target_distance",
|
||||
"distance_to_length_ratio",
|
||||
"translation_distance",
|
||||
"rotation_angle_degrees",
|
||||
"current_diameter",
|
||||
"target_diameter",
|
||||
"delta_diameter",
|
||||
"diameter_delta_ratio",
|
||||
"target_to_height_ratio",
|
||||
"major_radius",
|
||||
"minor_radius",
|
||||
"reference_radius",
|
||||
"semi_angle",
|
||||
"angular_span",
|
||||
"height_estimate",
|
||||
"same_domain_height_estimate",
|
||||
"hole_depth_estimate",
|
||||
"slot_chord_width_estimate",
|
||||
"slot_arc_length_estimate",
|
||||
"slot_sagitta_depth_estimate",
|
||||
"existing_fillet_radius_estimate",
|
||||
"existing_fillet_angular_span",
|
||||
"existing_fillet_arc_length_estimate",
|
||||
"current_depth",
|
||||
"target_depth",
|
||||
"delta_depth",
|
||||
"depth_delta_ratio",
|
||||
"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",
|
||||
"translation_vector",
|
||||
"rotation_center",
|
||||
"end_face_id",
|
||||
"end_face_label",
|
||||
"end_face_plane_distance",
|
||||
"push_pull_distance",
|
||||
],
|
||||
),
|
||||
(
|
||||
"方向 / 轴线",
|
||||
[
|
||||
"normal",
|
||||
"oriented_normal",
|
||||
"plane_origin",
|
||||
"axis_point",
|
||||
"axis",
|
||||
"direction",
|
||||
"line_origin",
|
||||
"rotation_axis",
|
||||
"end_face_outward_direction",
|
||||
"desired_movement_vector",
|
||||
"push_pull_outward_direction",
|
||||
"push_pull_inward_direction",
|
||||
"push_pull_plus_side",
|
||||
"push_pull_minus_side",
|
||||
"push_pull_confidence",
|
||||
"push_pull_note",
|
||||
"push_pull_status",
|
||||
"push_pull_risk",
|
||||
"push_pull_message",
|
||||
"push_pull_scope_face_ids",
|
||||
"push_pull_scope_face_count",
|
||||
"push_pull_scope_note",
|
||||
"shell_region_kind",
|
||||
"shell_region_status",
|
||||
"shell_confidence",
|
||||
"shell_source_face_ids",
|
||||
"shell_opposite_face_id",
|
||||
"shell_thickness_estimate",
|
||||
"shell_overlap_ratio_estimate",
|
||||
"shell_opposite_normal_dot",
|
||||
"shell_note",
|
||||
"shell_region_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",
|
||||
"feature_face_ids",
|
||||
"feature_side_face_ids",
|
||||
"feature_end_face_ids",
|
||||
"feature_bottom_face_ids",
|
||||
"feature_opening_face_ids",
|
||||
"feature_start_end_face_ids",
|
||||
"feature_end_end_face_ids",
|
||||
"feature_highlight_face_ids",
|
||||
"feature_adjacent_face_ids",
|
||||
"feature_boundary_edge_ids",
|
||||
"feature_bottom_confidence",
|
||||
"feature_bottom_detection",
|
||||
"feature_bottom_note",
|
||||
"feature_slot_face_ids",
|
||||
"feature_slot_boundary_face_ids",
|
||||
"slot_kind",
|
||||
"slot_status",
|
||||
"slot_angular_span",
|
||||
"slot_open_angle",
|
||||
"slot_chord_width_estimate",
|
||||
"slot_arc_length_estimate",
|
||||
"slot_sagitta_depth_estimate",
|
||||
"slot_note",
|
||||
"feature_existing_fillet_face_ids",
|
||||
"feature_existing_fillet_support_face_ids",
|
||||
"existing_fillet_kind",
|
||||
"existing_fillet_status",
|
||||
"existing_fillet_note",
|
||||
"feature_edit_actions",
|
||||
"resize_status",
|
||||
"resize_strategy",
|
||||
"resize_mode",
|
||||
"resize_risk",
|
||||
"resize_warnings",
|
||||
"resize_blockers",
|
||||
"resize_note",
|
||||
"circular_edge_cylinder_face_id",
|
||||
"circular_edge_cylinder_mode",
|
||||
"circular_edge_cylinder_mode_label",
|
||||
"cylinder_resize_face_id",
|
||||
"cylinder_resize_operation",
|
||||
"cylinder_resize_status",
|
||||
"cylinder_resize_risk",
|
||||
"cylinder_resize_feature_guess",
|
||||
"cylinder_resize_confidence",
|
||||
"cylinder_resize_same_domain_face_ids",
|
||||
"cylinder_resize_same_domain_face_count",
|
||||
"boss_resize_status",
|
||||
"boss_resize_risk",
|
||||
"boss_resize_warnings",
|
||||
"boss_resize_blockers",
|
||||
"boss_resize_note",
|
||||
"suppress_status",
|
||||
"suppress_risk",
|
||||
"suppress_warnings",
|
||||
"suppress_blockers",
|
||||
"suppress_note",
|
||||
"depth_status",
|
||||
"depth_mode",
|
||||
"depth_risk",
|
||||
"depth_warnings",
|
||||
"depth_blockers",
|
||||
"depth_note",
|
||||
"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_bottom_protection",
|
||||
"cutter_protected_bottom_face_ids",
|
||||
"cutter_opening_face_ids",
|
||||
"cutter_bottom_note",
|
||||
"cutter_note",
|
||||
"fill_strategy",
|
||||
"fill_height",
|
||||
"fill_radius",
|
||||
"fill_radius_overlap",
|
||||
"fill_start_point",
|
||||
"fill_note",
|
||||
"boss_tool_strategy",
|
||||
"boss_tool_note",
|
||||
"boss_tool_height",
|
||||
"boss_tool_radius",
|
||||
"boss_tool_old_radius",
|
||||
"boss_tool_outer_radius",
|
||||
"boss_tool_inner_radius",
|
||||
"boss_tool_axial_margin",
|
||||
"boss_tool_radial_overlap",
|
||||
"boss_tool_start_parameter",
|
||||
"boss_tool_end_parameter",
|
||||
"boss_tool_axis_point",
|
||||
"boss_tool_axis_direction",
|
||||
"boss_tool_start_point",
|
||||
"depth_tool_strategy",
|
||||
"depth_tool_role",
|
||||
"depth_tool_note",
|
||||
"depth_tool_height",
|
||||
"depth_tool_radius",
|
||||
"depth_tool_radius_overlap",
|
||||
"depth_tool_start_parameter",
|
||||
"depth_tool_end_parameter",
|
||||
"depth_open_parameter",
|
||||
"depth_bottom_parameter",
|
||||
"depth_nominal_bottom_parameter",
|
||||
"depth_bottom_parameter_source",
|
||||
"depth_current_depth",
|
||||
"depth_current_depth_source",
|
||||
"depth_target_bottom_parameter",
|
||||
"depth_axis_direction",
|
||||
"depth_open_point",
|
||||
"depth_current_bottom_point",
|
||||
"depth_target_bottom_point",
|
||||
"depth_tool_start_point",
|
||||
"fillet_status",
|
||||
"fillet_risk",
|
||||
"fillet_warnings",
|
||||
"fillet_blockers",
|
||||
"fillet_note",
|
||||
"chamfer_status",
|
||||
"chamfer_risk",
|
||||
"chamfer_warnings",
|
||||
"chamfer_blockers",
|
||||
"chamfer_note",
|
||||
"translate_status",
|
||||
"translate_risk",
|
||||
"translate_warnings",
|
||||
"translate_blockers",
|
||||
"translate_note",
|
||||
"part_solid_count",
|
||||
"rotate_status",
|
||||
"rotate_risk",
|
||||
"rotate_warnings",
|
||||
"rotate_blockers",
|
||||
"rotate_note",
|
||||
],
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
INFO_LABELS = {
|
||||
"kind": "类型",
|
||||
"name": "名称",
|
||||
"path": "层级路径",
|
||||
"file": "文件",
|
||||
"part_id": "Part ID",
|
||||
"solid_id": "Solid ID",
|
||||
"logical_face_id": "逻辑 Face ID",
|
||||
"face_region_logical_id": "面区域逻辑 ID",
|
||||
"topological_face_id": "拓扑 Face ID",
|
||||
"face_id": "Face ID",
|
||||
"face_region_ids": "面区域 Face",
|
||||
"face_region_count": "面区域 Face 数",
|
||||
"edge_id": "Edge ID",
|
||||
"parent_id": "父级 ID",
|
||||
"depth": "层级深度",
|
||||
"feature_mode": "特征模式说明",
|
||||
"feature_type": "特征类型",
|
||||
"feature_source_face_id": "特征来源 Face",
|
||||
"parts": "零件数",
|
||||
"solids": "Solid 数",
|
||||
"faces": "Face 数",
|
||||
"edges": "Edge 数",
|
||||
"vertices": "Vertex 数",
|
||||
"boundary_edges": "边界边数",
|
||||
"same_domain_face_ids": "同域区域 Face",
|
||||
"same_domain_face_count": "同域区域 Face 数",
|
||||
"same_domain_v_range": "同域区域 V 范围",
|
||||
"same_domain_height_estimate": "同域区域估算高度",
|
||||
"same_domain_range_source": "同域范围来源",
|
||||
"same_domain_note": "同域区域说明",
|
||||
"orientation": "拓扑方向",
|
||||
"surface": "曲面类型",
|
||||
"curve": "曲线类型",
|
||||
"adjacent_face_ids": "相邻 Face",
|
||||
"adjacent_face_count": "相邻 Face 数",
|
||||
"volume": "体积",
|
||||
"surface_area": "表面积",
|
||||
"area": "面积",
|
||||
"length": "长度",
|
||||
"edge_length": "边长",
|
||||
"current_length": "当前边长",
|
||||
"target_length": "目标边长",
|
||||
"delta_length": "边长变化量",
|
||||
"length_change_ratio": "边长变化比例",
|
||||
"edge_length_anchor_mode": "边长基准模式",
|
||||
"edge_length_anchor_label": "边长基准",
|
||||
"circular_edge_current_radius": "圆边当前半径",
|
||||
"circular_edge_target_radius": "圆边目标半径",
|
||||
"circular_edge_length_scale": "圆边长度比例",
|
||||
"circular_edge_cylinder_face_id": "圆边相邻圆柱 Face",
|
||||
"circular_edge_cylinder_mode": "圆边圆柱模式",
|
||||
"circular_edge_cylinder_mode_label": "圆边编辑模式",
|
||||
"cylinder_resize_face_id": "圆柱编辑 Face",
|
||||
"cylinder_resize_operation": "圆柱编辑操作",
|
||||
"cylinder_resize_current_diameter": "圆柱当前直径",
|
||||
"cylinder_resize_target_diameter": "圆柱目标直径",
|
||||
"cylinder_resize_delta_diameter": "圆柱直径变化量",
|
||||
"cylinder_resize_delta_ratio": "圆柱直径变化比例",
|
||||
"cylinder_resize_status": "圆柱编辑状态",
|
||||
"cylinder_resize_risk": "圆柱编辑风险",
|
||||
"cylinder_resize_feature_guess": "圆柱候选判断",
|
||||
"cylinder_resize_confidence": "圆柱判断置信度",
|
||||
"cylinder_resize_same_domain_face_ids": "圆柱同域 Face",
|
||||
"cylinder_resize_same_domain_face_count": "圆柱同域 Face 数",
|
||||
"radius": "半径",
|
||||
"diameter": "直径",
|
||||
"target_radius": "目标圆角半径",
|
||||
"radius_to_length_ratio": "半径/边长比例",
|
||||
"target_distance": "目标倒角距离",
|
||||
"distance_to_length_ratio": "倒角距离/边长比例",
|
||||
"translation_distance": "平移距离",
|
||||
"rotation_angle_degrees": "旋转角度",
|
||||
"current_diameter": "当前直径",
|
||||
"target_diameter": "目标直径",
|
||||
"delta_diameter": "直径变化量",
|
||||
"diameter_delta_ratio": "直径变化比例",
|
||||
"target_to_height_ratio": "目标直径/估算高度",
|
||||
"major_radius": "主半径",
|
||||
"minor_radius": "小半径",
|
||||
"reference_radius": "参考半径",
|
||||
"semi_angle": "半角",
|
||||
"angular_span": "角度跨度",
|
||||
"height_estimate": "估算高度",
|
||||
"hole_depth_estimate": "孔/槽深度估算",
|
||||
"current_depth": "当前深度",
|
||||
"target_depth": "目标深度",
|
||||
"delta_depth": "深度变化量",
|
||||
"depth_delta_ratio": "深度变化比例",
|
||||
"manual_bottom_face_id": "手动底面 Face",
|
||||
"manual_bottom_face_used": "使用手动底面",
|
||||
"manual_bottom_face_note": "手动底面说明",
|
||||
"depth_open_direction_source": "孔深方向来源",
|
||||
"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": "终点",
|
||||
"translation_vector": "平移向量",
|
||||
"rotation_center": "旋转中心",
|
||||
"end_face_id": "端面 Face",
|
||||
"end_face_label": "端面位置",
|
||||
"end_face_plane_distance": "端面匹配距离",
|
||||
"push_pull_distance": "端面推拉距离",
|
||||
"normal": "几何法向",
|
||||
"oriented_normal": "拓扑修正法向",
|
||||
"plane_origin": "平面原点",
|
||||
"axis_point": "轴线点",
|
||||
"axis": "轴方向",
|
||||
"direction": "方向",
|
||||
"line_origin": "直线原点",
|
||||
"rotation_axis": "旋转轴",
|
||||
"end_face_outward_direction": "端面向外方向",
|
||||
"desired_movement_vector": "目标移动向量",
|
||||
"push_pull_outward_direction": "推拉向外方向",
|
||||
"push_pull_inward_direction": "推拉向内方向",
|
||||
"push_pull_plus_side": "原始法向侧",
|
||||
"push_pull_minus_side": "反向法向侧",
|
||||
"push_pull_confidence": "推拉方向置信度",
|
||||
"push_pull_note": "推拉方向说明",
|
||||
"push_pull_status": "推拉状态",
|
||||
"push_pull_risk": "推拉风险",
|
||||
"push_pull_message": "推拉说明",
|
||||
"push_pull_scope_face_ids": "推拉共面区域 Face",
|
||||
"push_pull_scope_face_count": "推拉共面区域 Face 数",
|
||||
"push_pull_scope_note": "推拉共面区域说明",
|
||||
"shell_region_kind": "壳体/薄壁候选类型",
|
||||
"shell_region_status": "壳体/薄壁识别状态",
|
||||
"shell_confidence": "壳体/薄壁置信度",
|
||||
"shell_source_face_ids": "壳体/薄壁源平面 Face",
|
||||
"shell_opposite_face_id": "相对平面 Face",
|
||||
"shell_thickness_estimate": "薄壁厚度估算",
|
||||
"shell_overlap_ratio_estimate": "相对平面重叠率估算",
|
||||
"shell_opposite_normal_dot": "相对平面法向点积",
|
||||
"shell_note": "壳体/薄壁识别说明",
|
||||
"shell_region_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": "端部采样说明",
|
||||
"end_sample_range_source": "端部采样范围来源",
|
||||
"end_sample_scope_face_ids": "端部采样同域 Face",
|
||||
"end_sample_scope_face_count": "端部采样同域 Face 数",
|
||||
"note": "备注",
|
||||
"feature_face_ids": "特征 Face",
|
||||
"feature_side_face_ids": "特征侧壁 Face",
|
||||
"feature_end_face_ids": "特征端面 Face",
|
||||
"feature_bottom_face_ids": "疑似底面 Face",
|
||||
"feature_opening_face_ids": "开口端相邻 Face",
|
||||
"feature_start_end_face_ids": "起点端 Face",
|
||||
"feature_end_end_face_ids": "终点端 Face",
|
||||
"feature_highlight_face_ids": "特征高亮 Face",
|
||||
"feature_adjacent_face_ids": "相邻 Face",
|
||||
"feature_boundary_edge_ids": "特征边界 Edge",
|
||||
"feature_bottom_confidence": "底面判断置信度",
|
||||
"feature_bottom_detection": "底面识别来源",
|
||||
"feature_bottom_note": "底面判断说明",
|
||||
"feature_slot_face_ids": "槽圆柱 Face",
|
||||
"feature_slot_boundary_face_ids": "槽边界相邻 Face",
|
||||
"slot_kind": "槽类型",
|
||||
"slot_status": "槽识别状态",
|
||||
"slot_angular_span": "槽圆弧角度",
|
||||
"slot_open_angle": "槽开口角度",
|
||||
"slot_chord_width_estimate": "槽宽估算",
|
||||
"slot_arc_length_estimate": "槽圆弧长度估算",
|
||||
"slot_sagitta_depth_estimate": "槽深估算",
|
||||
"slot_note": "槽识别说明",
|
||||
"feature_existing_fillet_face_ids": "已有圆角 Face",
|
||||
"feature_existing_fillet_support_face_ids": "已有圆角支撑 Face",
|
||||
"existing_fillet_kind": "已有圆角类型",
|
||||
"existing_fillet_status": "已有圆角识别状态",
|
||||
"existing_fillet_radius_estimate": "已有圆角半径估算",
|
||||
"existing_fillet_angular_span": "已有圆角圆弧角度",
|
||||
"existing_fillet_arc_length_estimate": "已有圆角圆弧长度估算",
|
||||
"existing_fillet_note": "已有圆角识别说明",
|
||||
"feature_edit_actions": "当前可用操作",
|
||||
"resize_status": "切削状态",
|
||||
"resize_strategy": "编辑策略",
|
||||
"resize_mode": "调整模式",
|
||||
"resize_risk": "切削风险",
|
||||
"resize_warnings": "切削警告",
|
||||
"resize_blockers": "切削阻止原因",
|
||||
"resize_note": "切削说明",
|
||||
"boss_resize_status": "凸台调整状态",
|
||||
"boss_resize_risk": "凸台调整风险",
|
||||
"boss_resize_warnings": "凸台调整警告",
|
||||
"boss_resize_blockers": "凸台调整阻止原因",
|
||||
"boss_resize_note": "凸台调整说明",
|
||||
"suppress_status": "封堵状态",
|
||||
"suppress_risk": "封堵风险",
|
||||
"suppress_warnings": "封堵警告",
|
||||
"suppress_blockers": "封堵阻止原因",
|
||||
"suppress_note": "封堵说明",
|
||||
"depth_status": "孔深状态",
|
||||
"depth_mode": "孔深调整模式",
|
||||
"depth_risk": "孔深风险",
|
||||
"depth_warnings": "孔深警告",
|
||||
"depth_blockers": "孔深阻止原因",
|
||||
"depth_note": "孔深说明",
|
||||
"cutter_strategy": "Cutter 策略",
|
||||
"cutter_scope_face_ids": "Cutter 同域 Face",
|
||||
"cutter_scope_face_count": "Cutter 同域 Face 数",
|
||||
"cutter_range_source": "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_bottom_protection": "Cutter 底面保护",
|
||||
"cutter_protected_bottom_face_ids": "Cutter 保护底面 Face",
|
||||
"cutter_opening_face_ids": "Cutter 开口端 Face",
|
||||
"cutter_bottom_note": "Cutter 底面保护说明",
|
||||
"cutter_note": "Cutter 说明",
|
||||
"fill_strategy": "补料策略",
|
||||
"fill_scope_face_ids": "补料同域 Face",
|
||||
"fill_scope_face_count": "补料同域 Face 数",
|
||||
"fill_range_source": "补料范围来源",
|
||||
"fill_height": "补料高度",
|
||||
"fill_radius": "补料半径",
|
||||
"fill_radius_overlap": "补料重叠量",
|
||||
"fill_start_point": "补料起点",
|
||||
"fill_note": "补料说明",
|
||||
"boss_tool_strategy": "凸台工具策略",
|
||||
"boss_tool_scope_face_ids": "凸台工具同域 Face",
|
||||
"boss_tool_scope_face_count": "凸台工具同域 Face 数",
|
||||
"boss_tool_range_source": "凸台工具范围来源",
|
||||
"boss_tool_note": "凸台工具说明",
|
||||
"boss_tool_height": "凸台工具高度",
|
||||
"boss_tool_radius": "凸台目标半径",
|
||||
"boss_tool_old_radius": "凸台原半径",
|
||||
"boss_tool_outer_radius": "凸台移除包络半径",
|
||||
"boss_tool_inner_radius": "凸台重建目标半径",
|
||||
"boss_tool_axial_margin": "凸台工具轴向余量",
|
||||
"boss_tool_radial_overlap": "凸台工具径向重叠",
|
||||
"boss_tool_start_parameter": "凸台工具起始参数",
|
||||
"boss_tool_end_parameter": "凸台工具结束参数",
|
||||
"boss_tool_axis_point": "凸台工具轴线点",
|
||||
"boss_tool_axis_direction": "凸台工具轴线方向",
|
||||
"boss_tool_start_point": "凸台工具起点",
|
||||
"boss_tool_exact_start_point": "凸台精确重建起点",
|
||||
"boss_tool_exact_height": "凸台精确重建高度",
|
||||
"depth_tool_strategy": "孔深工具策略",
|
||||
"depth_tool_role": "孔深工具类型",
|
||||
"depth_tool_note": "孔深工具说明",
|
||||
"depth_tool_height": "孔深工具高度",
|
||||
"depth_tool_radius": "孔深工具半径",
|
||||
"depth_tool_radius_overlap": "孔深工具半径重叠",
|
||||
"depth_scope_face_ids": "孔深同域 Face",
|
||||
"depth_scope_face_count": "孔深同域 Face 数",
|
||||
"depth_range_source": "孔深范围来源",
|
||||
"depth_tool_start_parameter": "孔深工具起始参数",
|
||||
"depth_tool_end_parameter": "孔深工具结束参数",
|
||||
"depth_open_parameter": "孔开口参数",
|
||||
"depth_bottom_parameter": "当前底面参数",
|
||||
"depth_nominal_bottom_parameter": "圆柱参数底面",
|
||||
"depth_bottom_parameter_source": "底面参数来源",
|
||||
"depth_current_depth": "当前几何深度",
|
||||
"depth_current_depth_source": "当前深度来源",
|
||||
"depth_target_bottom_parameter": "目标底面参数",
|
||||
"depth_axis_direction": "孔深方向",
|
||||
"depth_open_point": "孔开口点",
|
||||
"depth_current_bottom_point": "当前底面点",
|
||||
"depth_target_bottom_point": "目标底面点",
|
||||
"depth_tool_start_point": "孔深工具起点",
|
||||
"fillet_status": "圆角状态",
|
||||
"fillet_risk": "圆角风险",
|
||||
"fillet_warnings": "圆角警告",
|
||||
"fillet_blockers": "圆角阻止原因",
|
||||
"fillet_note": "圆角说明",
|
||||
"chamfer_status": "倒角状态",
|
||||
"chamfer_risk": "倒角风险",
|
||||
"chamfer_warnings": "倒角警告",
|
||||
"chamfer_blockers": "倒角阻止原因",
|
||||
"chamfer_note": "倒角说明",
|
||||
"translate_status": "平移状态",
|
||||
"translate_risk": "平移风险",
|
||||
"translate_warnings": "平移警告",
|
||||
"translate_blockers": "平移阻止原因",
|
||||
"translate_note": "平移说明",
|
||||
"part_solid_count": "Part 内 Solid 数",
|
||||
"rotate_status": "旋转状态",
|
||||
"rotate_risk": "旋转风险",
|
||||
"rotate_warnings": "旋转警告",
|
||||
"rotate_blockers": "旋转阻止原因",
|
||||
"rotate_note": "旋转说明",
|
||||
"scope": "作用范围",
|
||||
"repair_strategy": "修复策略",
|
||||
}
|
||||
|
||||
|
||||
PART_TREE_KIND_ROLE = Qt.UserRole
|
||||
PART_TREE_ID_ROLE = Qt.UserRole + 1
|
||||
PART_TREE_PART_ID_ROLE = Qt.UserRole + 2
|
||||
EDITABLE_TARGET_ID_ROLE = Qt.UserRole
|
||||
EDITABLE_ACTION_ROLE = Qt.UserRole + 1
|
||||
EDITABLE_TARGET_KIND_ROLE = Qt.UserRole + 2
|
||||
|
||||
|
||||
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 _part_tree_kind_label(kind: str) -> str:
|
||||
return {
|
||||
"assembly": "装配",
|
||||
"part": "零件",
|
||||
"solid": "实体",
|
||||
}.get(kind, kind or "对象")
|
||||
|
||||
|
||||
def _enable_overlay_depth_offset(mapper) -> None:
|
||||
"""Draw coplanar overlays in front of the base model to avoid highlight flicker."""
|
||||
if hasattr(mapper, "SetResolveCoincidentTopologyToPolygonOffset"):
|
||||
mapper.SetResolveCoincidentTopologyToPolygonOffset()
|
||||
if hasattr(mapper, "SetRelativeCoincidentTopologyPolygonOffsetParameters"):
|
||||
mapper.SetRelativeCoincidentTopologyPolygonOffsetParameters(-6.0, -6.0)
|
||||
if hasattr(mapper, "SetRelativeCoincidentTopologyLineOffsetParameters"):
|
||||
mapper.SetRelativeCoincidentTopologyLineOffsetParameters(-8.0, -8.0)
|
||||
if hasattr(mapper, "SetRelativeCoincidentTopologyPointOffsetParameter"):
|
||||
mapper.SetRelativeCoincidentTopologyPointOffsetParameter(-8.0)
|
||||
|
||||
|
||||
def _smooth_surface_polydata(polydata):
|
||||
clean = vtk.vtkCleanPolyData()
|
||||
clean.SetInputData(polydata)
|
||||
clean.PointMergingOn()
|
||||
clean.SetTolerance(1e-7)
|
||||
clean.Update()
|
||||
|
||||
normals = vtk.vtkPolyDataNormals()
|
||||
normals.SetInputConnection(clean.GetOutputPort())
|
||||
normals.ComputePointNormalsOn()
|
||||
normals.ComputeCellNormalsOff()
|
||||
normals.ConsistencyOn()
|
||||
normals.SplittingOn()
|
||||
normals.SetFeatureAngle(35.0)
|
||||
if hasattr(normals, "AutoOrientNormalsOn"):
|
||||
normals.AutoOrientNormalsOn()
|
||||
normals.Update()
|
||||
|
||||
smoothed = vtk.vtkPolyData()
|
||||
smoothed.DeepCopy(normals.GetOutput())
|
||||
return smoothed
|
||||
|
||||
|
||||
def _format_percent(value: object) -> str:
|
||||
if value is None or value == "":
|
||||
return ""
|
||||
try:
|
||||
return f"{float(value) * 100.0:.6g}%"
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def _edit_quality_warnings(before_part_stats, after_part_stats) -> list[str]:
|
||||
if before_part_stats is None or after_part_stats is None:
|
||||
return []
|
||||
warnings: list[str] = []
|
||||
if after_part_stats.solids == 0:
|
||||
warnings.append("目标零件编辑后没有检测到 solid,导出前请确认模型是否有效。")
|
||||
elif before_part_stats.solids > 0 and after_part_stats.solids != before_part_stats.solids:
|
||||
warnings.append(
|
||||
"目标零件 solid 数发生变化:"
|
||||
f"{before_part_stats.solids} -> {after_part_stats.solids}。"
|
||||
"如果这是一次局部推拉/孔径修改,请重点检查导出后是否仍是一体实体。"
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _export_quality_text(info: dict[str, object]) -> str:
|
||||
warnings = str(info.get("quality_warnings", ""))
|
||||
lines = [
|
||||
"导出质量检查:",
|
||||
f" 对象: {info.get('quality_label', '')}",
|
||||
f" 状态: {info.get('quality_status', '')}",
|
||||
f" B-Rep 有效: {_format_value(info.get('brep_valid', ''))}",
|
||||
f" solids: {_format_value(info.get('solids', ''))}",
|
||||
f" faces: {_format_value(info.get('faces', ''))}",
|
||||
f" edges: {_format_value(info.get('edges', ''))}",
|
||||
f" vertices: {_format_value(info.get('vertices', ''))}",
|
||||
f" volume: {_format_value(info.get('volume', ''))}",
|
||||
f" bbox_diagonal: {_format_value(info.get('bbox_diagonal', ''))}",
|
||||
]
|
||||
if warnings:
|
||||
lines.extend([" 警告:", f" {warnings}"])
|
||||
else:
|
||||
lines.append(" 警告: 无")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
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 _int_values(value: object) -> list[int]:
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, int):
|
||||
return [value]
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
result: list[int] = []
|
||||
for item in value:
|
||||
try:
|
||||
result.append(int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return result
|
||||
return []
|
||||
|
||||
|
||||
def _int_tuple_or_none(values) -> tuple[int, ...] | None:
|
||||
if values is None:
|
||||
return None
|
||||
if isinstance(values, int):
|
||||
return (values,)
|
||||
return tuple(sorted({int(value) for value in values}))
|
||||
|
||||
|
||||
def _float_or_none(value: object) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _merge_polydata_bounds(*polydatas) -> tuple[float, float, float, float, float, float] | None:
|
||||
merged: list[float] | None = None
|
||||
for polydata in polydatas:
|
||||
if polydata is None or polydata.GetNumberOfPoints() <= 0:
|
||||
continue
|
||||
bounds = polydata.GetBounds()
|
||||
if bounds is None or bounds[0] > bounds[1] or bounds[2] > bounds[3] or bounds[4] > bounds[5]:
|
||||
continue
|
||||
values = [float(item) for item in bounds]
|
||||
if merged is None:
|
||||
merged = values
|
||||
else:
|
||||
merged[0] = min(merged[0], values[0])
|
||||
merged[1] = max(merged[1], values[1])
|
||||
merged[2] = min(merged[2], values[2])
|
||||
merged[3] = max(merged[3], values[3])
|
||||
merged[4] = min(merged[4], values[4])
|
||||
merged[5] = max(merged[5], values[5])
|
||||
return tuple(merged) if merged is not None else None
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
__all__ = [name for name in globals() if not name.startswith("__")]
|
||||
@@ -0,0 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtWidgets import QComboBox
|
||||
|
||||
|
||||
class NoWheelComboBox(QComboBox):
|
||||
def wheelEvent(self, event) -> None:
|
||||
event.accept()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,465 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import vtk
|
||||
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QMessageBox,
|
||||
QTableWidgetItem,
|
||||
QTreeWidgetItem,
|
||||
)
|
||||
|
||||
from .model import StepModel
|
||||
from .records import OperationRecord
|
||||
from .ui_helpers import * # noqa: F403
|
||||
from .workers import EditWorker, LoadWorker, ScanWorker
|
||||
|
||||
|
||||
class WindowStateMixin:
|
||||
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()
|
||||
self._update_action_states()
|
||||
|
||||
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 self.load_in_progress:
|
||||
self.statusBar().showMessage("STEP background loading is still running.")
|
||||
return True
|
||||
if self.operation_in_progress:
|
||||
self.statusBar().showMessage(message)
|
||||
return True
|
||||
if self.scan_in_progress:
|
||||
self.statusBar().showMessage("后台扫描中,请等待扫描完成后再执行该操作。")
|
||||
return True
|
||||
return False
|
||||
|
||||
def _sync_id_picker(self, kind: str, target_id: int) -> None:
|
||||
self.last_id_kind = kind
|
||||
self.id_input.setText(str(target_id))
|
||||
|
||||
def _update_action_states(self) -> None:
|
||||
if not hasattr(self, "export_all_button"):
|
||||
return
|
||||
has_model = (
|
||||
self.model is not None
|
||||
and not self.operation_in_progress
|
||||
and not self.scan_in_progress
|
||||
and not self.load_in_progress
|
||||
)
|
||||
selected_kind = self.selected_kind
|
||||
self.export_all_button.setEnabled(has_model)
|
||||
self.export_check_button.setEnabled(has_model)
|
||||
if hasattr(self, "repair_model_button"):
|
||||
self._set_control_state(
|
||||
self.repair_model_button,
|
||||
has_model,
|
||||
"对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。",
|
||||
"请先加载 STEP 文件,或等待当前后台任务完成。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.repair_selected_button,
|
||||
has_model and (self.selected_solid_id is not None or self.selected_part_id is not None),
|
||||
"优先修复当前选中对象所属 solid;没有 solid 时修复所属零件,并写入撤销历史。",
|
||||
"请先选择 part、solid、face 或 edge,或等待当前后台任务完成。",
|
||||
)
|
||||
self.export_part_button.setEnabled(has_model and selected_kind == "part" and self.selected_part_id is not None)
|
||||
self.export_solid_button.setEnabled(has_model and selected_kind == "solid" and self.selected_solid_id is not None)
|
||||
self.export_face_button.setEnabled(has_model and selected_kind == "face" and self.selected_face_id is not None)
|
||||
self.export_feature_button.setEnabled(has_model and selected_kind == "feature" and self.selected_face_id is not None)
|
||||
self.export_edge_button.setEnabled(has_model and selected_kind == "edge" and self.selected_edge_id is not None)
|
||||
self._update_edit_action_states(has_model)
|
||||
|
||||
def _update_edit_action_states(self, has_model: bool) -> None:
|
||||
if not hasattr(self, "push_button"):
|
||||
return
|
||||
|
||||
action_info = self._selected_action_info() if has_model else {}
|
||||
surface = str(action_info.get("surface", ""))
|
||||
curve = str(action_info.get("curve", ""))
|
||||
feature_guess = str(action_info.get("feature_guess", ""))
|
||||
angular_span = _float_or_none(action_info.get("angular_span"))
|
||||
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
|
||||
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
|
||||
is_plane = has_face and surface == "plane"
|
||||
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
|
||||
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
|
||||
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
|
||||
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
|
||||
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
|
||||
is_slot_or_half_hole = (
|
||||
is_hole_or_groove
|
||||
and angular_span is not None
|
||||
and angular_span < math.tau * 0.92
|
||||
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
|
||||
)
|
||||
is_blind = action_info.get("cylinder_end_type") == "blind"
|
||||
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
|
||||
has_manual_bottom = bool(
|
||||
hasattr(self, "hole_bottom_face_input")
|
||||
and self.hole_bottom_face_input.text().strip()
|
||||
)
|
||||
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
|
||||
is_line_edge = has_edge and curve == "line"
|
||||
|
||||
self._set_control_state(
|
||||
self.push_button,
|
||||
has_model and is_plane,
|
||||
"对当前平面 face 执行推拉。",
|
||||
"请先选择一个平面 face,或在 Feature 模式下选择可推拉平面候选。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.resize_button,
|
||||
has_model and is_hole_or_groove,
|
||||
"调整当前圆柱孔/槽候选的直径。",
|
||||
"请先选择被识别为孔/槽候选的圆柱 face。",
|
||||
)
|
||||
if hasattr(self, "resize_slot_button"):
|
||||
self._set_control_state(
|
||||
self.resize_slot_button,
|
||||
has_model and is_slot_or_half_hole,
|
||||
"按目标槽宽调整当前槽/半孔候选;第一版会换算为对应圆柱直径后执行。",
|
||||
"请先选择被识别为槽/半孔的部分圆柱 face。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.resize_boss_button,
|
||||
has_model and is_boss and is_full_cylinder,
|
||||
"调整当前完整圆柱凸台候选的直径。",
|
||||
"请先选择被识别为完整凸台/外圆候选的圆柱 face。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.suppress_button,
|
||||
has_model and is_hole_or_groove and is_full_cylinder,
|
||||
"封堵当前完整圆柱孔候选。",
|
||||
"请先选择接近完整圆柱的孔候选;半孔/槽不会放行。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.resize_depth_button,
|
||||
has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom),
|
||||
"调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。",
|
||||
"请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.fillet_edge_button,
|
||||
has_model and is_line_edge,
|
||||
"给当前直线 edge 添加新圆角。",
|
||||
"请先选择一条直线 edge。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.resize_existing_fillet_button,
|
||||
has_model and is_existing_fillet and has_fillet_support,
|
||||
"尝试修改当前已有圆角/倒圆候选的半径。",
|
||||
"请先选择一个已有圆角/倒圆候选 face;第一版需要识别到至少两个支撑 face。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.chamfer_edge_button,
|
||||
has_model and is_line_edge,
|
||||
"给当前直线 edge 添加倒角。",
|
||||
"请先选择一条直线 edge。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.resize_edge_length_button,
|
||||
has_model and has_edge,
|
||||
"直接修改当前 edge 的长度;直线优先端面推拉,其他 edge 使用高风险几何 fallback。",
|
||||
"请先选择一条 edge。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.translate_part_button,
|
||||
has_model and self.selected_part_id is not None,
|
||||
"平移当前选中对象所属零件。",
|
||||
"请先选择一个零件,或选择属于某个零件的对象。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.rotate_part_button,
|
||||
has_model and self.selected_part_id is not None,
|
||||
"旋转当前选中对象所属零件。",
|
||||
"请先选择一个零件,或选择属于某个零件的对象。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.translate_solid_button,
|
||||
has_model and self.selected_solid_id is not None,
|
||||
"平移当前选中对象所属 solid。",
|
||||
"请先选择一个 solid,或选择属于某个 solid 的 face/edge。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.rotate_solid_button,
|
||||
has_model and self.selected_solid_id is not None,
|
||||
"旋转当前选中对象所属 solid。",
|
||||
"请先选择一个 solid,或选择属于某个 solid 的 face/edge。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.cylinders_button,
|
||||
has_model,
|
||||
"扫描当前模型中的圆柱候选。",
|
||||
"请先加载 STEP 文件。",
|
||||
)
|
||||
if hasattr(self, "editable_refresh_button"):
|
||||
self._set_control_state(
|
||||
self.editable_refresh_button,
|
||||
has_model,
|
||||
"扫描第一版可编辑对象。",
|
||||
"请先加载 STEP 文件,或等待当前后台任务完成。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.editable_deep_scan_button,
|
||||
has_model,
|
||||
"深度扫描更多第一版可编辑对象。",
|
||||
"请先加载 STEP 文件,或等待当前后台任务完成。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.undo_button,
|
||||
has_model and bool(self.undo_stack),
|
||||
"撤销上一步编辑。",
|
||||
"当前没有可撤销的编辑。",
|
||||
)
|
||||
self._set_control_state(
|
||||
self.redo_button,
|
||||
has_model and bool(self.redo_stack),
|
||||
"重做刚撤销的编辑。",
|
||||
"当前没有可重做的编辑。",
|
||||
)
|
||||
|
||||
def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None:
|
||||
widget.setEnabled(enabled)
|
||||
tip = enabled_tip if enabled else disabled_tip
|
||||
if hasattr(self, "_set_help_tip"):
|
||||
self._set_help_tip(widget, tip)
|
||||
else:
|
||||
widget.setToolTip(tip)
|
||||
|
||||
def _selected_action_info(self) -> dict[str, object]:
|
||||
if self.model is None:
|
||||
return {}
|
||||
try:
|
||||
if self.selected_kind == "feature" and self.selected_face_id is not None:
|
||||
return self.model.feature_info(self.selected_face_id)
|
||||
if self.selected_kind == "face" and self.selected_face_id is not None:
|
||||
info = self.model.face_info(self.selected_face_id)
|
||||
if info.get("surface") == "cylinder":
|
||||
return self.model.feature_info(self.selected_face_id)
|
||||
return info
|
||||
if self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||||
return self.model.edge_info(self.selected_edge_id)
|
||||
except Exception:
|
||||
return dict(self.current_info_values)
|
||||
return dict(self.current_info_values)
|
||||
|
||||
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 in {"face", "feature"} and record.target_id is not None:
|
||||
lookup_id = record.target_logical_id if record.target_logical_id is not None else record.target_id
|
||||
resolved_face_id = self.model.resolve_face_selection_id(lookup_id)
|
||||
if resolved_face_id is not None:
|
||||
info = self.model.feature_info(resolved_face_id) if record.target_kind == "feature" else self.model.face_info(resolved_face_id)
|
||||
self.selected_kind = "feature" if record.target_kind == "feature" else "face"
|
||||
self.selected_face_id = resolved_face_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("Feature" if record.target_kind == "feature" else "Face")
|
||||
current_logical_id = self.model.face_region_logical_id(resolved_face_id)
|
||||
self._sync_id_picker("Feature" if record.target_kind == "feature" else "Face", current_logical_id)
|
||||
face_ids = (
|
||||
_int_values(info.get("feature_highlight_face_ids"))
|
||||
if record.target_kind == "feature"
|
||||
else self.model.face_region_ids(resolved_face_id)
|
||||
)
|
||||
if not face_ids:
|
||||
face_ids = self.model.face_region_ids(resolved_face_id)
|
||||
self._highlight_faces(face_ids=face_ids)
|
||||
located = True
|
||||
region_note = f";同域面区域 {len(face_ids)} 个 face" if len(face_ids) > 1 else ""
|
||||
id_note = (
|
||||
f"逻辑 Face ID {current_logical_id}"
|
||||
if record.target_logical_id is not None
|
||||
else f"face {record.target_id}"
|
||||
)
|
||||
if resolved_face_id != record.target_id:
|
||||
id_note += f"(当前拓扑 face {resolved_face_id})"
|
||||
locator_note = (
|
||||
f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。"
|
||||
"布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。"
|
||||
)
|
||||
else:
|
||||
if record.target_logical_id is not None:
|
||||
locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。"
|
||||
else:
|
||||
locator_note = f"定位: 原目标 face {record.target_id} 在当前模型索引中已经不存在。"
|
||||
|
||||
elif record.target_kind == "edge" and record.target_id is not None:
|
||||
if 0 <= record.target_id < len(self.model.edges):
|
||||
info = self.model.edge_info(record.target_id)
|
||||
self.selected_kind = "edge"
|
||||
self.selected_edge_id = record.target_id
|
||||
self.selected_part_id = int(info["part_id"])
|
||||
self.selected_solid_id = int(info.get("solid_id", -1)) if int(info.get("solid_id", -1)) >= 0 else None
|
||||
self.selected_pick_position = record.pick_position
|
||||
self.mode_combo.setCurrentText("Edge")
|
||||
self._sync_id_picker("Edge", record.target_id)
|
||||
self._highlight_edge(record.target_id)
|
||||
located = True
|
||||
locator_note = (
|
||||
f"定位: 已尝试高亮当前模型中的 edge {record.target_id}。"
|
||||
"布尔/倒圆编辑后 edge ID 可能发生语义变化,请结合拾取点确认。"
|
||||
)
|
||||
else:
|
||||
locator_note = f"定位: 原目标 edge {record.target_id} 在当前模型索引中已经不存在。"
|
||||
|
||||
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._update_action_states()
|
||||
self.statusBar().showMessage(locator_note.splitlines()[0])
|
||||
return locator_note
|
||||
|
||||
def undo_edit(self) -> None:
|
||||
if self.model is None:
|
||||
return
|
||||
if self._edit_busy("编辑计算中,暂时不能撤销。"):
|
||||
return
|
||||
if not self.undo_stack:
|
||||
self.statusBar().showMessage("没有可撤销的编辑")
|
||||
return
|
||||
current = self.model.snapshot()
|
||||
snapshot = self.undo_stack[-1]
|
||||
undone = self.operation_history[-1] if self.operation_history else OperationRecord("编辑", "编辑")
|
||||
try:
|
||||
self._restore_snapshot(snapshot)
|
||||
except Exception as exc:
|
||||
rollback_message = self._restore_after_failed_undo_redo(current)
|
||||
QMessageBox.critical(self, "撤销失败", f"{exc}\n\n{rollback_message}")
|
||||
self.statusBar().showMessage("撤销失败,模型已尽量恢复到撤销前状态")
|
||||
return
|
||||
self.undo_stack.pop()
|
||||
if self.operation_history:
|
||||
self.operation_history.pop()
|
||||
self.redo_stack.append(current)
|
||||
self.redo_history.append(undone)
|
||||
self._refresh_history_list()
|
||||
self._update_action_states()
|
||||
self.statusBar().showMessage(f"已撤销:{undone.summary}")
|
||||
|
||||
def redo_edit(self) -> None:
|
||||
if self.model is None:
|
||||
return
|
||||
if self._edit_busy("编辑计算中,暂时不能重做。"):
|
||||
return
|
||||
if not self.redo_stack:
|
||||
self.statusBar().showMessage("没有可重做的编辑")
|
||||
return
|
||||
current = self.model.snapshot()
|
||||
snapshot = self.redo_stack[-1]
|
||||
redone = self.redo_history[-1] if self.redo_history else OperationRecord("编辑", "编辑")
|
||||
try:
|
||||
self._restore_snapshot(snapshot)
|
||||
except Exception as exc:
|
||||
rollback_message = self._restore_after_failed_undo_redo(current)
|
||||
QMessageBox.critical(self, "重做失败", f"{exc}\n\n{rollback_message}")
|
||||
self.statusBar().showMessage("重做失败,模型已尽量恢复到重做前状态")
|
||||
return
|
||||
self.redo_stack.pop()
|
||||
if self.redo_history:
|
||||
self.redo_history.pop()
|
||||
self.undo_stack.append(current)
|
||||
self.operation_history.append(redone)
|
||||
self._refresh_history_list()
|
||||
self._update_action_states()
|
||||
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 _restore_after_failed_undo_redo(self, snapshot: dict[int, object]) -> str:
|
||||
try:
|
||||
self._restore_snapshot(snapshot)
|
||||
except Exception as rollback_exc:
|
||||
return f"恢复原状态也失败:{rollback_exc}。建议重新加载 STEP 文件。"
|
||||
return "模型已恢复到操作前状态,历史记录未移动。"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import QObject, Signal, Slot
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
class ScanWorker(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))
|
||||
|
||||
|
||||
class LoadWorker(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))
|
||||
Reference in New Issue
Block a user