feat: 拆分 STEP 编辑器并完善最小系统

将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
This commit is contained in:
2026-07-27 18:28:26 +08:00
parent 1ab595f82e
commit 26216064e0
24 changed files with 13683 additions and 9688 deletions
+6
View File
@@ -0,0 +1,6 @@
from __future__ import annotations
from .app import StepEditorWindow, main
from .model import StepModel
__all__ = ["StepEditorWindow", "StepModel", "main"]
+826
View File
@@ -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())
+59
View File
@@ -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",
}
+176
View File
@@ -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
+242
View File
@@ -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 ""
+1379
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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
+313
View File
@@ -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
+16
View File
@@ -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
+140
View File
@@ -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
+264
View File
@@ -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']}."
)
+856
View File
@@ -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("__")]
+8
View File
@@ -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
+465
View File
@@ -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 "模型已恢复到操作前状态,历史记录未移动。"
+51
View File
@@ -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))