1ab595f82e
中文化模型结构树,补充选择交互、局部编辑、导出质量检查、历史记录、拓扑缓存和中文文档。
5033 lines
219 KiB
Python
5033 lines
219 KiB
Python
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
import math
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import vtk
|
||
from PySide6.QtCore import QObject, Qt, QThread, QTimer, Signal, Slot
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QApplication,
|
||
QComboBox,
|
||
QFileDialog,
|
||
QGridLayout,
|
||
QGroupBox,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QListWidget,
|
||
QMainWindow,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QPlainTextEdit,
|
||
QProgressDialog,
|
||
QScrollArea,
|
||
QTableWidget,
|
||
QTableWidgetItem,
|
||
QTabWidget,
|
||
QTreeWidget,
|
||
QTreeWidgetItem,
|
||
QVBoxLayout,
|
||
QWidget,
|
||
)
|
||
from vtkmodules.qt.QVTKRenderWindowInteractor import QVTKRenderWindowInteractor
|
||
|
||
from step_model import StepModel
|
||
|
||
|
||
@dataclass
|
||
class OperationRecord:
|
||
summary: str
|
||
detail: str
|
||
target_kind: str | None = None
|
||
target_id: int | None = None
|
||
pick_position: tuple[float, float, float] | None = None
|
||
before_snapshot: dict[int, object] | None = None
|
||
after_snapshot: dict[int, object] | None = None
|
||
diff_stats: dict[str, object] | None = None
|
||
|
||
|
||
class EditWorker(QObject):
|
||
finished = Signal(object)
|
||
failed = Signal(str)
|
||
|
||
def __init__(self, action):
|
||
super().__init__()
|
||
self.action = action
|
||
|
||
@Slot()
|
||
def run(self) -> None:
|
||
try:
|
||
self.finished.emit(self.action())
|
||
except Exception as exc:
|
||
self.failed.emit(str(exc))
|
||
|
||
|
||
class NoWheelComboBox(QComboBox):
|
||
def wheelEvent(self, event) -> None:
|
||
event.accept()
|
||
|
||
|
||
INFO_GROUPS: list[tuple[str, list[str]]] = [
|
||
(
|
||
"身份",
|
||
[
|
||
"kind",
|
||
"name",
|
||
"path",
|
||
"file",
|
||
"part_id",
|
||
"solid_id",
|
||
"face_id",
|
||
"edge_id",
|
||
"parent_id",
|
||
"depth",
|
||
"feature_mode",
|
||
"feature_type",
|
||
"feature_source_face_id",
|
||
],
|
||
),
|
||
(
|
||
"拓扑",
|
||
[
|
||
"parts",
|
||
"solids",
|
||
"faces",
|
||
"edges",
|
||
"vertices",
|
||
"boundary_edges",
|
||
"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",
|
||
"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",
|
||
"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",
|
||
],
|
||
),
|
||
(
|
||
"参数",
|
||
[
|
||
"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_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_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_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",
|
||
"face_id": "Face ID",
|
||
"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": "边界边数",
|
||
"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": "边长变化比例",
|
||
"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": "深度变化比例",
|
||
"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": "推拉共面区域说明",
|
||
"u_range": "U 参数范围",
|
||
"v_range": "V 参数范围",
|
||
"first_parameter": "起始参数",
|
||
"last_parameter": "结束参数",
|
||
"param_height": "参数高度",
|
||
"feature_guess": "候选判断",
|
||
"confidence": "置信度",
|
||
"material_vote_summary": "材料投票",
|
||
"material_sample_count": "采样数量",
|
||
"material_toward_axis": "轴侧材料",
|
||
"material_away_axis": "外侧材料",
|
||
"cylinder_end_type": "端部类型",
|
||
"start_end_state": "起点端状态",
|
||
"end_end_state": "终点端状态",
|
||
"start_end_open": "起点端开口",
|
||
"end_end_open": "终点端开口",
|
||
"open_end_count": "开口端数量",
|
||
"closed_end_count": "封闭端数量",
|
||
"end_sample_offset": "端部采样偏移",
|
||
"end_sample_note": "端部采样说明",
|
||
"note": "备注",
|
||
"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_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_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_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": "凸台环形 cutter 外半径",
|
||
"boss_tool_inner_radius": "凸台环形 cutter 内半径",
|
||
"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": "Part 内 Solid 数",
|
||
"rotate_status": "旋转状态",
|
||
"rotate_risk": "旋转风险",
|
||
"rotate_warnings": "旋转警告",
|
||
"rotate_blockers": "旋转阻止原因",
|
||
"rotate_note": "旋转说明",
|
||
}
|
||
|
||
|
||
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
|
||
|
||
|
||
class StepEditorWindow(QMainWindow):
|
||
def __init__(self, step_path: str | Path):
|
||
super().__init__()
|
||
self.setWindowTitle("STEP 零件查看与编辑原型")
|
||
self.resize(1280, 820)
|
||
|
||
self.model: StepModel | None = None
|
||
self.step_path = Path(step_path)
|
||
self.selected_kind: str | None = None
|
||
self.selected_part_id: int | None = None
|
||
self.selected_solid_id: int | None = None
|
||
self.selected_face_id: int | None = None
|
||
self.selected_edge_id: int | None = None
|
||
self.selected_pick_position: tuple[float, float, float] | None = None
|
||
|
||
self.model_actor = None
|
||
self.edge_actor = None
|
||
self.highlight_actor = None
|
||
self.edge_highlight_actor = None
|
||
self.hover_face_actor = None
|
||
self.hover_edge_actor = None
|
||
self.hover_signature: tuple[str, int] | None = None
|
||
self.pending_hover_position: tuple[int, int] | None = None
|
||
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.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.current_info_text = ""
|
||
self.current_info_values: dict[str, object] = {}
|
||
self.cylinder_candidate_cache: list[dict[str, object]] = []
|
||
self.cylinder_candidates_loaded = False
|
||
self.operation_in_progress = False
|
||
self.edit_thread: QThread | None = None
|
||
self.edit_worker: EditWorker | None = None
|
||
self.edit_progress: QProgressDialog | None = None
|
||
self.pending_edit_context: dict[str, object] | None = None
|
||
self.main_content_enabled_before_edit = True
|
||
self.last_id_kind = "Face"
|
||
|
||
self._build_ui()
|
||
self._build_vtk()
|
||
self.load_step(self.step_path)
|
||
|
||
def _build_ui(self) -> None:
|
||
central = QWidget()
|
||
root_layout = QHBoxLayout(central)
|
||
root_layout.setContentsMargins(10, 10, 10, 10)
|
||
root_layout.setSpacing(10)
|
||
self.setCentralWidget(central)
|
||
|
||
panel_scroll = QScrollArea()
|
||
panel_scroll.setWidgetResizable(True)
|
||
panel_scroll.setFixedWidth(440)
|
||
panel_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||
root_layout.addWidget(panel_scroll)
|
||
|
||
panel = QWidget()
|
||
panel.setFixedWidth(420)
|
||
panel_layout = QVBoxLayout(panel)
|
||
panel_layout.setContentsMargins(0, 0, 0, 0)
|
||
panel_layout.setSpacing(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")
|
||
file_layout = QVBoxLayout(file_box)
|
||
self.path_label = QLabel("")
|
||
self.path_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||
self.path_label.setWordWrap(True)
|
||
file_layout.addWidget(self.path_label)
|
||
|
||
file_buttons = QHBoxLayout()
|
||
open_button = QPushButton("打开")
|
||
open_button.clicked.connect(self.open_step)
|
||
reload_button = QPushButton("重新加载")
|
||
reload_button.clicked.connect(self.reload_step)
|
||
file_buttons.addWidget(open_button)
|
||
file_buttons.addWidget(reload_button)
|
||
file_layout.addLayout(file_buttons)
|
||
panel_layout.addWidget(file_box)
|
||
|
||
tree_box = QGroupBox("模型结构树")
|
||
tree_box.setObjectName("treeSection")
|
||
tree_layout = QVBoxLayout(tree_box)
|
||
self.part_tree = QTreeWidget()
|
||
self.part_tree.setHeaderLabels(["对象", "内容"])
|
||
self.part_tree.setMinimumHeight(180)
|
||
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")
|
||
mode_layout = QVBoxLayout(mode_box)
|
||
self.mode_combo = NoWheelComboBox()
|
||
self.mode_combo.addItems(["Part", "Solid", "Face", "Edge", "Feature"])
|
||
self.mode_combo.setCurrentText("Face")
|
||
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")
|
||
select_layout = QGridLayout(select_box)
|
||
self.id_input = QLineEdit("")
|
||
self.id_input.setPlaceholderText("输入 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)
|
||
self.id_mode_display.setToolTip("当前按这个鼠标选择模式解释输入的 ID。")
|
||
select_button = QPushButton("选择")
|
||
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")
|
||
view_layout = QHBoxLayout(view_box)
|
||
isolate_button = QPushButton("只显示选中")
|
||
isolate_button.clicked.connect(self.isolate_selected)
|
||
fit_button = QPushButton("对准选中")
|
||
fit_button.clicked.connect(self.fit_selected)
|
||
show_all_button = QPushButton("显示全部")
|
||
show_all_button.clicked.connect(self.show_all_geometry)
|
||
view_layout.addWidget(isolate_button)
|
||
view_layout.addWidget(fit_button)
|
||
view_layout.addWidget(show_all_button)
|
||
panel_layout.addWidget(view_box)
|
||
|
||
export_box = QGroupBox("导出")
|
||
export_box.setObjectName("exportSection")
|
||
export_layout = QVBoxLayout(export_box)
|
||
self.export_all_button = QPushButton("导出当前完整 STEP")
|
||
self.export_all_button.clicked.connect(self.export_all)
|
||
self.export_part_button = QPushButton("导出选中零件")
|
||
self.export_part_button.clicked.connect(self.export_selected_part)
|
||
self.export_solid_button = QPushButton("导出选中 solid")
|
||
self.export_solid_button.clicked.connect(self.export_selected_solid)
|
||
self.export_face_button = QPushButton("导出选中 face")
|
||
self.export_face_button.clicked.connect(self.export_selected_face)
|
||
self.export_feature_button = QPushButton("导出选中特征区域")
|
||
self.export_feature_button.clicked.connect(self.export_selected_feature)
|
||
self.export_edge_button = QPushButton("导出选中 edge")
|
||
self.export_edge_button.clicked.connect(self.export_selected_edge)
|
||
self.export_check_button = QPushButton("检查导出质量")
|
||
self.export_check_button.clicked.connect(self.check_export_quality)
|
||
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)
|
||
panel_layout.addWidget(export_box)
|
||
|
||
edit_box = QGroupBox("实验性编辑")
|
||
edit_box.setObjectName("editSection")
|
||
edit_layout = QGridLayout(edit_box)
|
||
edit_layout.addWidget(QLabel("面偏移"), 0, 0)
|
||
self.offset_input = QLineEdit("5.0")
|
||
edit_layout.addWidget(self.offset_input, 0, 1)
|
||
self.push_button = QPushButton("推拉平面")
|
||
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("")
|
||
edit_layout.addWidget(self.hole_diameter_input, 2, 1)
|
||
self.resize_button = QPushButton("调整圆柱孔径")
|
||
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.boss_diameter_input = QLineEdit("")
|
||
edit_layout.addWidget(self.boss_diameter_input, 4, 1)
|
||
self.resize_boss_button = QPushButton("调整圆柱凸台直径")
|
||
self.resize_boss_button.clicked.connect(self.resize_boss)
|
||
edit_layout.addWidget(self.resize_boss_button, 5, 0, 1, 2)
|
||
|
||
self.suppress_button = QPushButton("封堵圆柱孔")
|
||
self.suppress_button.clicked.connect(self.suppress_hole)
|
||
edit_layout.addWidget(self.suppress_button, 6, 0, 1, 2)
|
||
|
||
edit_layout.addWidget(QLabel("孔深度"), 7, 0)
|
||
self.hole_depth_input = QLineEdit("")
|
||
edit_layout.addWidget(self.hole_depth_input, 7, 1)
|
||
self.resize_depth_button = QPushButton("调整盲孔深度")
|
||
self.resize_depth_button.clicked.connect(self.resize_hole_depth)
|
||
edit_layout.addWidget(self.resize_depth_button, 8, 0, 1, 2)
|
||
|
||
edit_layout.addWidget(QLabel("圆角半径"), 9, 0)
|
||
self.edge_fillet_radius_input = QLineEdit("")
|
||
edit_layout.addWidget(self.edge_fillet_radius_input, 9, 1)
|
||
self.fillet_edge_button = QPushButton("给边添加圆角")
|
||
self.fillet_edge_button.clicked.connect(self.fillet_edge)
|
||
edit_layout.addWidget(self.fillet_edge_button, 10, 0, 1, 2)
|
||
|
||
self.resize_existing_fillet_button = QPushButton("修改已有圆角半径")
|
||
self.resize_existing_fillet_button.clicked.connect(self.resize_existing_fillet)
|
||
edit_layout.addWidget(self.resize_existing_fillet_button, 11, 0, 1, 2)
|
||
|
||
edit_layout.addWidget(QLabel("倒角距离"), 12, 0)
|
||
self.edge_chamfer_distance_input = QLineEdit("")
|
||
edit_layout.addWidget(self.edge_chamfer_distance_input, 12, 1)
|
||
self.chamfer_edge_button = QPushButton("给边添加倒角")
|
||
self.chamfer_edge_button.clicked.connect(self.chamfer_edge)
|
||
edit_layout.addWidget(self.chamfer_edge_button, 13, 0, 1, 2)
|
||
|
||
edit_layout.addWidget(QLabel("边目标长度"), 14, 0)
|
||
self.edge_target_length_input = QLineEdit("")
|
||
edit_layout.addWidget(self.edge_target_length_input, 14, 1)
|
||
self.resize_edge_length_button = QPushButton("调整直线边长度")
|
||
self.resize_edge_length_button.clicked.connect(self.resize_edge_length)
|
||
edit_layout.addWidget(self.resize_edge_length_button, 15, 0, 1, 2)
|
||
|
||
edit_layout.addWidget(QLabel("平移 X/Y/Z"), 16, 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")
|
||
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, 16, 1)
|
||
self.translate_part_button = QPushButton("平移选中零件")
|
||
self.translate_part_button.clicked.connect(self.translate_selected_part)
|
||
self.translate_solid_button = QPushButton("平移选中 solid")
|
||
self.translate_solid_button.clicked.connect(self.translate_selected_solid)
|
||
edit_layout.addWidget(self.translate_part_button, 17, 0, 1, 2)
|
||
edit_layout.addWidget(self.translate_solid_button, 18, 0, 1, 2)
|
||
|
||
edit_layout.addWidget(QLabel("旋转轴/角度"), 19, 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")
|
||
self.rotate_angle_input = QLineEdit("90")
|
||
self.rotate_angle_input.setPlaceholderText("度")
|
||
rotate_layout.addWidget(self.rotate_axis_combo)
|
||
rotate_layout.addWidget(self.rotate_angle_input)
|
||
edit_layout.addWidget(rotate_inputs, 19, 1)
|
||
self.rotate_part_button = QPushButton("旋转选中零件")
|
||
self.rotate_part_button.clicked.connect(self.rotate_selected_part)
|
||
self.rotate_solid_button = QPushButton("旋转选中 solid")
|
||
self.rotate_solid_button.clicked.connect(self.rotate_selected_solid)
|
||
edit_layout.addWidget(self.rotate_part_button, 20, 0, 1, 2)
|
||
edit_layout.addWidget(self.rotate_solid_button, 21, 0, 1, 2)
|
||
|
||
self.cylinders_button = QPushButton("列出圆柱候选")
|
||
self.cylinders_button.clicked.connect(self.list_cylinders)
|
||
edit_layout.addWidget(self.cylinders_button, 22, 0, 1, 2)
|
||
|
||
self.undo_button = QPushButton("撤销")
|
||
self.undo_button.clicked.connect(self.undo_edit)
|
||
self.redo_button = QPushButton("重做")
|
||
self.redo_button.clicked.connect(self.redo_edit)
|
||
edit_layout.addWidget(self.undo_button, 23, 0)
|
||
edit_layout.addWidget(self.redo_button, 23, 1)
|
||
panel_layout.addWidget(edit_box)
|
||
|
||
editable_box = QGroupBox("第一版可编辑对象")
|
||
editable_box.setObjectName("editableSection")
|
||
editable_layout = QVBoxLayout(editable_box)
|
||
editable_button_row = QHBoxLayout()
|
||
editable_refresh_button = QPushButton("扫描可编辑对象")
|
||
editable_refresh_button.clicked.connect(lambda _checked=False: self.refresh_editable_candidates(show_info=True))
|
||
editable_deep_scan_button = QPushButton("深度扫描")
|
||
editable_deep_scan_button.clicked.connect(
|
||
lambda _checked=False: self.refresh_editable_candidates(show_info=True, deep_scan=True)
|
||
)
|
||
editable_button_row.addWidget(editable_refresh_button)
|
||
editable_button_row.addWidget(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)
|
||
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")
|
||
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",
|
||
]
|
||
)
|
||
self.candidate_filter_combo.currentTextChanged.connect(
|
||
lambda _text: self._filter_cached_cylinder_candidates(show_info=True)
|
||
)
|
||
filter_layout.addWidget(self.candidate_filter_combo)
|
||
candidate_layout.addLayout(filter_layout)
|
||
self.cylinder_table = QTableWidget(0, 8)
|
||
self.cylinder_table.setHorizontalHeaderLabels(
|
||
["face", "guess", "diameter", "span", "height", "confidence", "risk", "part"]
|
||
)
|
||
self.cylinder_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||
self.cylinder_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||
self.cylinder_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||
self.cylinder_table.setMinimumHeight(140)
|
||
self.cylinder_table.cellClicked.connect(self.on_cylinder_row_clicked)
|
||
candidate_layout.addWidget(self.cylinder_table)
|
||
panel_layout.addWidget(candidate_box)
|
||
|
||
history_box = QGroupBox("操作历史")
|
||
history_box.setObjectName("historySection")
|
||
history_layout = QVBoxLayout(history_box)
|
||
self.history_list = QListWidget()
|
||
self.history_list.setMinimumHeight(110)
|
||
self.history_list.currentRowChanged.connect(self.on_history_row_changed)
|
||
history_buttons = QHBoxLayout()
|
||
clear_diff_button = QPushButton("清除差异预览")
|
||
clear_diff_button.clicked.connect(lambda _checked=False: self.clear_diff_preview())
|
||
export_diff_button = QPushButton("导出差异报告")
|
||
export_diff_button.clicked.connect(self.export_diff_report)
|
||
history_layout.addWidget(self.history_list)
|
||
history_buttons.addWidget(clear_diff_button)
|
||
history_buttons.addWidget(export_diff_button)
|
||
history_layout.addLayout(history_buttons)
|
||
panel_layout.addWidget(history_box)
|
||
|
||
info_box = QGroupBox("选中对象信息")
|
||
info_box.setObjectName("infoSection")
|
||
info_layout = QVBoxLayout(info_box)
|
||
info_buttons = QHBoxLayout()
|
||
copy_id_button = QPushButton("复制 ID")
|
||
copy_id_button.clicked.connect(self.copy_selected_id)
|
||
copy_pick_button = QPushButton("复制坐标")
|
||
copy_pick_button.clicked.connect(self.copy_pick_position)
|
||
copy_info_button = QPushButton("复制信息")
|
||
copy_info_button.clicked.connect(self.copy_current_info)
|
||
info_buttons.addWidget(copy_id_button)
|
||
info_buttons.addWidget(copy_pick_button)
|
||
info_buttons.addWidget(copy_info_button)
|
||
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)
|
||
self.info_text = QPlainTextEdit()
|
||
self.info_text.setReadOnly(True)
|
||
self.info_tabs.addTab(self.info_tree, "属性表")
|
||
self.info_tabs.addTab(self.info_text, "原始文本")
|
||
info_layout.addWidget(self.info_tabs)
|
||
panel_layout.addWidget(info_box, stretch=1)
|
||
|
||
self._update_action_states()
|
||
|
||
self.vtk_widget = QVTKRenderWindowInteractor(central)
|
||
root_layout.addWidget(self.vtk_widget, stretch=1)
|
||
|
||
self.statusBar().showMessage("Ready")
|
||
|
||
def _build_vtk(self) -> None:
|
||
self.renderer = vtk.vtkRenderer()
|
||
self.renderer.SetBackground(0.11, 0.13, 0.15)
|
||
self.render_window = self.vtk_widget.GetRenderWindow()
|
||
self.render_window.AddRenderer(self.renderer)
|
||
|
||
self.interactor = self.render_window.GetInteractor()
|
||
self.interactor.SetInteractorStyle(vtk.vtkInteractorStyleTrackballCamera())
|
||
self.picker = vtk.vtkCellPicker()
|
||
self.picker.SetTolerance(0.003)
|
||
self.interactor.AddObserver("LeftButtonPressEvent", self.on_left_click)
|
||
self.interactor.AddObserver("MouseMoveEvent", self.on_mouse_move)
|
||
|
||
light = vtk.vtkLight()
|
||
light.SetLightTypeToSceneLight()
|
||
light.SetPosition(1, 1, 1)
|
||
light.SetIntensity(0.9)
|
||
self.renderer.AddLight(light)
|
||
|
||
self.interactor.Initialize()
|
||
|
||
def closeEvent(self, event) -> None:
|
||
if self.operation_in_progress:
|
||
QMessageBox.information(self, "编辑进行中", "请等待当前编辑计算完成后再关闭窗口。")
|
||
event.ignore()
|
||
return
|
||
super().closeEvent(event)
|
||
|
||
def load_step(self, path: str | Path) -> None:
|
||
new_path = Path(path)
|
||
self.statusBar().showMessage(f"正在加载 {new_path.name}...")
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
QApplication.processEvents()
|
||
load_error: Exception | None = None
|
||
try:
|
||
new_model = StepModel.load(new_path)
|
||
stats = new_model.stats()
|
||
model_polydata = new_model.build_face_polydata()
|
||
edge_polydata = new_model.build_edge_polydata()
|
||
except Exception as exc:
|
||
load_error = exc
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
if load_error is not None:
|
||
QMessageBox.critical(self, "加载失败", str(load_error))
|
||
self.statusBar().showMessage("STEP 加载失败,当前模型保持不变")
|
||
return
|
||
|
||
self.model = new_model
|
||
self.step_path = new_path
|
||
self._clear_history()
|
||
self.path_label.setText(str(self.step_path))
|
||
self._populate_part_tree()
|
||
self._reset_selection()
|
||
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True)
|
||
self._clear_editable_candidates()
|
||
self._clear_cylinder_candidates()
|
||
self.set_info(
|
||
{
|
||
"file": str(self.step_path),
|
||
"parts": stats.parts,
|
||
"solids": stats.solids,
|
||
"faces": stats.faces,
|
||
"edges": stats.edges,
|
||
"vertices": stats.vertices,
|
||
}
|
||
)
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(f"已加载 {self.step_path.name}")
|
||
|
||
def open_step(self) -> None:
|
||
if self._edit_busy("请等待当前编辑完成后再打开文件。"):
|
||
return
|
||
path, _ = QFileDialog.getOpenFileName(
|
||
self,
|
||
"打开 STEP 文件",
|
||
str(self.step_path.parent if self.step_path else Path.cwd()),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if path:
|
||
self.load_step(path)
|
||
|
||
def reload_step(self) -> None:
|
||
if self._edit_busy("请等待当前编辑完成后再重新加载。"):
|
||
return
|
||
self.load_step(self.step_path)
|
||
|
||
def _clear_history(self) -> None:
|
||
self.undo_stack.clear()
|
||
self.redo_stack.clear()
|
||
self.operation_history.clear()
|
||
self.redo_history.clear()
|
||
self.clear_diff_preview(render=False)
|
||
if hasattr(self, "history_list"):
|
||
self.history_list.clear()
|
||
|
||
def _refresh_history_list(self) -> None:
|
||
was_blocked = self.history_list.blockSignals(True)
|
||
try:
|
||
self.history_list.clear()
|
||
for index, entry in enumerate(self.operation_history, start=1):
|
||
self.history_list.addItem(f"{index}. {entry.summary}")
|
||
finally:
|
||
self.history_list.blockSignals(was_blocked)
|
||
|
||
def on_history_row_changed(self, row: int) -> None:
|
||
if self._edit_busy("编辑计算中,暂时不能查看历史记录详情。"):
|
||
return
|
||
if 0 <= row < len(self.operation_history):
|
||
record = self.operation_history[row]
|
||
locate_message = self._locate_operation_record(record)
|
||
diff_message = self._show_operation_diff(record)
|
||
detail = record.detail
|
||
if locate_message:
|
||
detail = f"{detail}\n\n{locate_message}"
|
||
if diff_message:
|
||
detail = f"{detail}\n\n{diff_message}"
|
||
self.set_plain_info(detail)
|
||
|
||
def export_diff_report(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能导出差异报告。"):
|
||
return
|
||
row = self.history_list.currentRow()
|
||
if row < 0 or row >= len(self.operation_history):
|
||
QMessageBox.information(self, "未选择历史记录", "请先选择一条操作历史。")
|
||
return
|
||
record = self.operation_history[row]
|
||
self._ensure_record_diff_stats(record)
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出差异报告",
|
||
str(self.step_path.parent / f"{self.step_path.stem}_diff_{row + 1}.txt"),
|
||
"文本文件 (*.txt);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
report = self._diff_report_text(record, row + 1)
|
||
try:
|
||
Path(target).write_text(report, encoding="utf-8")
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "导出失败", str(exc))
|
||
self.statusBar().showMessage("差异报告导出失败")
|
||
return
|
||
self.statusBar().showMessage(f"已导出差异报告 {Path(target).name}")
|
||
self.set_plain_info(f"已导出差异报告:{target}\n\n{report}")
|
||
|
||
def _populate_part_tree(self) -> None:
|
||
was_blocked = self.part_tree.blockSignals(True)
|
||
try:
|
||
self.part_tree.clear()
|
||
if self.model is None:
|
||
return
|
||
inserted: dict[int, QTreeWidgetItem] = {}
|
||
for part in self.model.parts:
|
||
item = QTreeWidgetItem(
|
||
[
|
||
self._part_tree_part_name(part.id, part.name, part.kind),
|
||
self._part_tree_part_detail(part.id, part.kind),
|
||
]
|
||
)
|
||
item.setData(0, PART_TREE_KIND_ROLE, part.kind)
|
||
item.setData(0, PART_TREE_ID_ROLE, part.id)
|
||
item.setToolTip(0, self._part_tree_part_tooltip(part.id))
|
||
item.setToolTip(1, self._part_tree_part_tooltip(part.id))
|
||
parent = inserted.get(part.parent_id)
|
||
if parent is None:
|
||
self.part_tree.addTopLevelItem(item)
|
||
else:
|
||
parent.addChild(item)
|
||
inserted[part.id] = item
|
||
|
||
for solid_id, (part_id, _solid) in enumerate(self.model.solids):
|
||
parent = inserted.get(part_id)
|
||
if parent is None:
|
||
continue
|
||
item = QTreeWidgetItem([f"实体 {solid_id}", self._part_tree_solid_detail(solid_id)])
|
||
item.setData(0, PART_TREE_KIND_ROLE, "solid")
|
||
item.setData(0, PART_TREE_ID_ROLE, solid_id)
|
||
item.setData(0, PART_TREE_PART_ID_ROLE, part_id)
|
||
item.setToolTip(0, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}")
|
||
item.setToolTip(1, f"Solid ID: {solid_id}\n所属零件 ID: {part_id}")
|
||
parent.addChild(item)
|
||
|
||
self.part_tree.expandAll()
|
||
self.part_tree.resizeColumnToContents(0)
|
||
self.part_tree.resizeColumnToContents(1)
|
||
finally:
|
||
self.part_tree.blockSignals(was_blocked)
|
||
|
||
def _part_tree_part_name(self, part_id: int, name: str, kind: str) -> str:
|
||
kind_label = _part_tree_kind_label(kind)
|
||
clean_name = str(name).strip()
|
||
if clean_name:
|
||
return f"{kind_label} {part_id}:{clean_name}"
|
||
return f"{kind_label} {part_id}"
|
||
|
||
def _part_tree_part_detail(self, part_id: int, kind: str) -> str:
|
||
if self.model is None:
|
||
return _part_tree_kind_label(kind)
|
||
try:
|
||
info = self.model.part_info(part_id)
|
||
except Exception:
|
||
return _part_tree_kind_label(kind)
|
||
return (
|
||
f"{_part_tree_kind_label(kind)} | "
|
||
f"实体 {info.get('solids', 0)} 个 | "
|
||
f"面 {info.get('faces', 0)} 个 | "
|
||
f"边 {info.get('edges', 0)} 条"
|
||
)
|
||
|
||
def _part_tree_part_tooltip(self, part_id: int) -> str:
|
||
if self.model is None:
|
||
return f"Part ID: {part_id}"
|
||
try:
|
||
info = self.model.part_info(part_id)
|
||
except Exception:
|
||
return f"Part ID: {part_id}"
|
||
lines = [
|
||
f"ID: {part_id}",
|
||
f"类型: {_part_tree_kind_label(str(info.get('kind', '')))}",
|
||
f"名称: {info.get('name', '')}",
|
||
]
|
||
path = str(info.get("path", "")).strip()
|
||
if path:
|
||
lines.append(f"路径: {path}")
|
||
return "\n".join(lines)
|
||
|
||
def _part_tree_solid_detail(self, solid_id: int) -> str:
|
||
if self.model is None:
|
||
return "实体"
|
||
try:
|
||
info = self.model.solid_info(solid_id)
|
||
except Exception:
|
||
return "实体"
|
||
return f"面 {info.get('faces', 0)} 个 | 边 {info.get('edges', 0)} 条"
|
||
|
||
def _rebuild_scene(self, reset_camera: bool = False) -> None:
|
||
if self.model is None:
|
||
return
|
||
model_polydata = self.model.build_face_polydata()
|
||
edge_polydata = self.model.build_edge_polydata()
|
||
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=reset_camera)
|
||
|
||
def isolate_selected(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
|
||
return
|
||
face_ids: list[int] | None = None
|
||
edge_ids: list[int] | None = None
|
||
part_ids: list[int] | None = None
|
||
label = ""
|
||
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
part_ids = [self.selected_part_id]
|
||
label = f"part {self.selected_part_id}"
|
||
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
||
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
|
||
label = f"solid {self.selected_solid_id}"
|
||
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
||
info = self.model.feature_info(self.selected_face_id)
|
||
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
|
||
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
|
||
label = f"feature face {self.selected_face_id}"
|
||
elif self.selected_kind == "face" and self.selected_face_id is not None:
|
||
face_ids = [self.selected_face_id]
|
||
edge_ids = _int_values(self.model.face_info(self.selected_face_id).get("feature_boundary_edge_ids"))
|
||
label = f"face {self.selected_face_id}"
|
||
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||
info = self.model.edge_info(self.selected_edge_id)
|
||
face_ids = _int_values(info.get("adjacent_face_ids"))
|
||
edge_ids = [self.selected_edge_id]
|
||
label = f"edge {self.selected_edge_id}"
|
||
else:
|
||
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
|
||
return
|
||
|
||
model_polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
|
||
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids, part_ids=part_ids)
|
||
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=True)
|
||
self.scene_isolated = True
|
||
self._refresh_selection_highlight()
|
||
self.statusBar().showMessage(f"已只显示选中对象:{label}")
|
||
|
||
def show_all_geometry(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能切换显示范围。"):
|
||
return
|
||
self._rebuild_scene(reset_camera=True)
|
||
self._refresh_selection_highlight()
|
||
self.statusBar().showMessage("已显示完整模型")
|
||
|
||
def fit_selected(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能调整视角。"):
|
||
return
|
||
bounds = self._selected_focus_bounds()
|
||
if bounds is None:
|
||
QMessageBox.information(self, "未选择对象", "请先选择 part、solid、face、edge 或 feature。")
|
||
return
|
||
self._fit_camera_to_bounds(bounds)
|
||
self.render_window.Render()
|
||
self.statusBar().showMessage("已对准选中对象")
|
||
|
||
def _selected_focus_bounds(self) -> tuple[float, float, float, float, float, float] | None:
|
||
if self.model is None:
|
||
return None
|
||
face_polydata = None
|
||
edge_polydata = None
|
||
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
face_polydata = self.model.build_face_polydata(part_ids=[self.selected_part_id])
|
||
edge_polydata = self.model.build_edge_polydata(part_ids=[self.selected_part_id])
|
||
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
||
edge_ids = self.model.edge_ids_for_solid(self.selected_solid_id)
|
||
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
|
||
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
|
||
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
||
info = self.model.feature_info(self.selected_face_id)
|
||
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
|
||
edge_ids = _int_values(info.get("feature_boundary_edge_ids"))
|
||
face_polydata = self.model.build_face_polydata(face_ids=face_ids)
|
||
edge_polydata = self.model.build_edge_polydata(edge_ids=edge_ids)
|
||
elif self.selected_kind == "face" and self.selected_face_id is not None:
|
||
face_polydata = self.model.build_face_polydata(face_ids=[self.selected_face_id])
|
||
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||
edge_polydata = self.model.build_edge_polydata(edge_ids=[self.selected_edge_id])
|
||
|
||
return _merge_polydata_bounds(face_polydata, edge_polydata)
|
||
|
||
def _fit_camera_to_bounds(self, bounds: tuple[float, float, float, float, float, float]) -> None:
|
||
x0, x1, y0, y1, z0, z1 = bounds
|
||
dx = max(x1 - x0, 0.0)
|
||
dy = max(y1 - y0, 0.0)
|
||
dz = max(z1 - z0, 0.0)
|
||
diagonal = max(math.sqrt(dx * dx + dy * dy + dz * dz), 1.0)
|
||
pad = diagonal * 0.18
|
||
padded_bounds = (x0 - pad, x1 + pad, y0 - pad, y1 + pad, z0 - pad, z1 + pad)
|
||
try:
|
||
self.renderer.ResetCamera(padded_bounds)
|
||
except TypeError:
|
||
self.renderer.ResetCamera(*padded_bounds)
|
||
self.renderer.ResetCameraClippingRange()
|
||
|
||
def _refresh_selection_highlight(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
self._highlight_faces(part_ids=[self.selected_part_id])
|
||
elif self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == self.selected_solid_id]
|
||
self._highlight_faces(face_ids=face_ids)
|
||
elif self.selected_kind == "feature" and self.selected_face_id is not None:
|
||
info = self.model.feature_info(self.selected_face_id)
|
||
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id]
|
||
self._highlight_faces(face_ids=face_ids)
|
||
elif self.selected_kind == "face" and self.selected_face_id is not None:
|
||
self._highlight_faces(face_ids=[self.selected_face_id])
|
||
elif self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||
self._highlight_edge(self.selected_edge_id)
|
||
|
||
def _rebuild_scene_from_polydata(self, model_polydata, edge_polydata, reset_camera: bool = False) -> None:
|
||
self.renderer.RemoveAllViewProps()
|
||
self.renderer.SetBackground(0.11, 0.13, 0.15)
|
||
self.scene_isolated = False
|
||
|
||
self.model_polydata = model_polydata
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(self.model_polydata)
|
||
self.model_actor = vtk.vtkActor()
|
||
self.model_actor.SetMapper(mapper)
|
||
self.model_actor.GetProperty().SetColor(0.68, 0.72, 0.73)
|
||
self.model_actor.GetProperty().SetDiffuse(0.82)
|
||
self.model_actor.GetProperty().SetSpecular(0.25)
|
||
self.model_actor.GetProperty().SetSpecularPower(18)
|
||
self.renderer.AddActor(self.model_actor)
|
||
|
||
self.edge_polydata = edge_polydata
|
||
edge_mapper = vtk.vtkPolyDataMapper()
|
||
edge_mapper.SetInputData(self.edge_polydata)
|
||
self.edge_actor = vtk.vtkActor()
|
||
self.edge_actor.SetMapper(edge_mapper)
|
||
self.edge_actor.GetProperty().SetColor(0.08, 0.09, 0.1)
|
||
self.edge_actor.GetProperty().SetLineWidth(1.0)
|
||
self.renderer.AddActor(self.edge_actor)
|
||
|
||
self.highlight_actor = None
|
||
self.edge_highlight_actor = None
|
||
self.hover_face_actor = None
|
||
self.hover_edge_actor = None
|
||
self.hover_signature = None
|
||
self.pending_hover_position = None
|
||
self.pick_marker_actor = None
|
||
self.edit_preview_actor = None
|
||
self.edit_preview_actors = []
|
||
self.diff_actors = []
|
||
if reset_camera:
|
||
self.renderer.ResetCamera()
|
||
self.render_window.Render()
|
||
|
||
def _on_mode_changed(self, mode: str) -> None:
|
||
self._clear_hover(render=True)
|
||
if hasattr(self, "id_mode_display"):
|
||
self.id_mode_display.setText(mode)
|
||
|
||
def on_left_click(self, _obj, _event) -> None:
|
||
if self.operation_in_progress:
|
||
self.statusBar().showMessage("编辑计算中,请等待当前操作完成")
|
||
return
|
||
if self.model is None:
|
||
return
|
||
mode = self.mode_combo.currentText()
|
||
x, y = self.interactor.GetEventPosition()
|
||
target = self._pick_selection_target(mode, x, y)
|
||
if target is None:
|
||
self.statusBar().showMessage("未选中对象")
|
||
return
|
||
|
||
self._clear_hover(render=False)
|
||
self._select_pick_target(target)
|
||
|
||
def on_mouse_move(self, _obj, _event) -> None:
|
||
if self.operation_in_progress or self.model is None:
|
||
self._clear_hover(render=True)
|
||
return
|
||
x, y = self.interactor.GetEventPosition()
|
||
self.pending_hover_position = (int(x), int(y))
|
||
if not self.hover_timer.isActive():
|
||
self.hover_timer.start(35)
|
||
|
||
def _update_hover_target(self) -> None:
|
||
if self.operation_in_progress or self.model is None or self.pending_hover_position is None:
|
||
self._clear_hover(render=True)
|
||
return
|
||
x, y = self.pending_hover_position
|
||
target = self._pick_selection_target(self.mode_combo.currentText(), x, y)
|
||
self._show_hover_target(target)
|
||
|
||
def _pick_selection_target(self, mode: str, x: int, y: int) -> dict[str, object] | None:
|
||
if self.model is None:
|
||
return None
|
||
if mode == "Edge":
|
||
edge_hit = self._pick_edge_cell(x, y)
|
||
if edge_hit is not None:
|
||
return self._edge_target_from_cell_hit(edge_hit)
|
||
face_hit = self._pick_face_cell(x, y)
|
||
if face_hit is not None:
|
||
return self._edge_target_from_face_hit(face_hit)
|
||
return None
|
||
|
||
face_hit = self._pick_face_cell(x, y)
|
||
if face_hit is not None:
|
||
return self._target_from_face_hit(face_hit, mode)
|
||
|
||
edge_hit = self._pick_edge_cell(x, y)
|
||
if edge_hit is not None:
|
||
edge_target = self._edge_target_from_cell_hit(edge_hit)
|
||
if edge_target is not None:
|
||
return self._target_from_edge_id(int(edge_target["target_id"]), mode, edge_target["pick_position"])
|
||
return None
|
||
|
||
def _pick_actor_cell(self, actor, x: int, y: int) -> dict[str, object] | None:
|
||
if actor is None:
|
||
return None
|
||
self.picker.InitializePickList()
|
||
self.picker.PickFromListOn()
|
||
self.picker.AddPickList(actor)
|
||
picked = self.picker.Pick(int(x), int(y), 0, self.renderer)
|
||
self.picker.PickFromListOff()
|
||
if not picked or self.picker.GetCellId() < 0:
|
||
return None
|
||
return {
|
||
"cell_id": int(self.picker.GetCellId()),
|
||
"pick_position": _vector_tuple(self.picker.GetPickPosition()),
|
||
}
|
||
|
||
def _pick_face_cell(self, x: int, y: int) -> dict[str, object] | None:
|
||
hit = self._pick_actor_cell(self.model_actor, x, y)
|
||
if hit is None or self.model_polydata is None:
|
||
return None
|
||
cell_data = self._face_cell_data(int(hit["cell_id"]))
|
||
if cell_data is None:
|
||
return None
|
||
hit.update(cell_data)
|
||
return hit
|
||
|
||
def _pick_edge_cell(self, x: int, y: int) -> dict[str, object] | None:
|
||
hit = self._pick_actor_cell(self.edge_actor, x, y)
|
||
if hit is None or self.edge_polydata is None:
|
||
return None
|
||
edge_id = self._edge_id_from_cell(int(hit["cell_id"]))
|
||
if edge_id is None:
|
||
return None
|
||
hit["edge_id"] = edge_id
|
||
return hit
|
||
|
||
def _face_cell_data(self, cell_id: int) -> dict[str, int] | None:
|
||
if self.model_polydata is None or cell_id < 0:
|
||
return None
|
||
face_arr = self.model_polydata.GetCellData().GetArray("face_id")
|
||
part_arr = self.model_polydata.GetCellData().GetArray("part_id")
|
||
solid_arr = self.model_polydata.GetCellData().GetArray("solid_id")
|
||
if face_arr is None or part_arr is None or solid_arr is None:
|
||
return None
|
||
return {
|
||
"face_id": int(face_arr.GetValue(cell_id)),
|
||
"part_id": int(part_arr.GetValue(cell_id)),
|
||
"solid_id": int(solid_arr.GetValue(cell_id)),
|
||
}
|
||
|
||
def _edge_id_from_cell(self, cell_id: int) -> int | None:
|
||
if self.edge_polydata is None or cell_id < 0:
|
||
return None
|
||
edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id")
|
||
if edge_arr is None:
|
||
return None
|
||
return int(edge_arr.GetValue(cell_id))
|
||
|
||
def _target_from_face_hit(self, hit: dict[str, object], mode: str) -> dict[str, object] | None:
|
||
face_id = int(hit["face_id"])
|
||
part_id = int(hit["part_id"])
|
||
solid_id = int(hit["solid_id"])
|
||
pick_position = hit["pick_position"]
|
||
if mode == "Part":
|
||
if part_id < 0:
|
||
return None
|
||
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
|
||
if mode == "Solid":
|
||
if solid_id < 0:
|
||
return None
|
||
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
|
||
if mode == "Feature":
|
||
return {"kind": "feature", "target_id": face_id, "pick_position": pick_position}
|
||
return {"kind": "face", "target_id": face_id, "pick_position": pick_position}
|
||
|
||
def _edge_target_from_cell_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
|
||
edge_id = hit.get("edge_id")
|
||
if edge_id is None:
|
||
return None
|
||
return {"kind": "edge", "target_id": int(edge_id), "pick_position": hit["pick_position"]}
|
||
|
||
def _edge_target_from_face_hit(self, hit: dict[str, object]) -> dict[str, object] | None:
|
||
if self.model is None:
|
||
return None
|
||
face_id = int(hit["face_id"])
|
||
pick_position = hit["pick_position"]
|
||
edge_ids = self.model.face_boundary_edge_ids(face_id)
|
||
edge_id = self.model.nearest_edge_id_to_point(edge_ids, pick_position)
|
||
if edge_id is None:
|
||
return None
|
||
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
|
||
|
||
def _target_from_edge_id(
|
||
self,
|
||
edge_id: int,
|
||
mode: str,
|
||
pick_position: tuple[float, float, float] | None,
|
||
) -> dict[str, object] | None:
|
||
if self.model is None or edge_id < 0 or edge_id >= len(self.model.edges):
|
||
return None
|
||
info = self.model.edge_info(edge_id)
|
||
part_id = int(info.get("part_id", -1))
|
||
solid_id = int(info.get("solid_id", -1))
|
||
adjacent_face_ids = _int_values(info.get("adjacent_face_ids"))
|
||
if mode == "Part" and part_id >= 0:
|
||
return {"kind": "part", "target_id": part_id, "pick_position": pick_position}
|
||
if mode == "Solid" and solid_id >= 0:
|
||
return {"kind": "solid", "target_id": solid_id, "part_id": part_id, "pick_position": pick_position}
|
||
if mode == "Feature" and adjacent_face_ids:
|
||
return {"kind": "feature", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
|
||
if mode == "Face" and adjacent_face_ids:
|
||
return {"kind": "face", "target_id": adjacent_face_ids[0], "pick_position": pick_position}
|
||
if mode == "Edge":
|
||
return {"kind": "edge", "target_id": edge_id, "pick_position": pick_position}
|
||
return None
|
||
|
||
def _select_pick_target(self, target: dict[str, object]) -> None:
|
||
kind = str(target["kind"])
|
||
target_id = int(target["target_id"])
|
||
pick_position = target.get("pick_position")
|
||
if kind == "part":
|
||
self.select_part(target_id, pick_position=pick_position)
|
||
elif kind == "solid":
|
||
self.select_solid(target_id, int(target["part_id"]), pick_position=pick_position)
|
||
elif kind == "feature":
|
||
self.select_feature(target_id, pick_position=pick_position)
|
||
elif kind == "edge":
|
||
self.select_edge(target_id, pick_position=pick_position)
|
||
else:
|
||
self.select_face(target_id, pick_position=pick_position)
|
||
|
||
def _select_face_cell(
|
||
self,
|
||
cell_id: int,
|
||
mode: str,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
) -> None:
|
||
if self.model is None or self.model_polydata is None:
|
||
return
|
||
face_arr = self.model_polydata.GetCellData().GetArray("face_id")
|
||
part_arr = self.model_polydata.GetCellData().GetArray("part_id")
|
||
solid_arr = self.model_polydata.GetCellData().GetArray("solid_id")
|
||
face_id = int(face_arr.GetValue(cell_id))
|
||
part_id = int(part_arr.GetValue(cell_id))
|
||
solid_id = int(solid_arr.GetValue(cell_id))
|
||
|
||
if mode == "Part":
|
||
self.select_part(part_id, pick_position=pick_position)
|
||
elif mode == "Solid":
|
||
self.select_solid(solid_id, part_id, pick_position=pick_position)
|
||
elif mode == "Feature":
|
||
self.select_feature(face_id, pick_position=pick_position)
|
||
else:
|
||
self.select_face(face_id, pick_position=pick_position)
|
||
|
||
def _select_edge_from_cell(
|
||
self,
|
||
cell_id: int,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
) -> None:
|
||
if self.model is None or self.edge_polydata is None:
|
||
return
|
||
edge_arr = self.edge_polydata.GetCellData().GetArray("edge_id")
|
||
edge_id = int(edge_arr.GetValue(cell_id))
|
||
self.select_edge(edge_id, pick_position=pick_position)
|
||
|
||
def on_part_tree_select(self, current: QTreeWidgetItem | None, _previous: QTreeWidgetItem | None) -> None:
|
||
if self._edit_busy("编辑计算中,暂时不能切换零件树选择。"):
|
||
return
|
||
if current is None:
|
||
return
|
||
node_kind = current.data(0, PART_TREE_KIND_ROLE)
|
||
target_id = current.data(0, PART_TREE_ID_ROLE)
|
||
if node_kind == "solid" and target_id is not None:
|
||
part_id = current.data(0, PART_TREE_PART_ID_ROLE)
|
||
if part_id is not None:
|
||
self.mode_combo.setCurrentText("Solid")
|
||
self.select_solid(int(target_id), int(part_id))
|
||
return
|
||
if node_kind in {"part", "assembly"} and target_id is not None:
|
||
self.mode_combo.setCurrentText("Part")
|
||
self.select_part(int(target_id))
|
||
|
||
def on_cylinder_row_clicked(self, row: int, _column: int) -> None:
|
||
if self._edit_busy("编辑计算中,暂时不能切换圆柱候选。"):
|
||
return
|
||
item = self.cylinder_table.item(row, 0)
|
||
if item is None:
|
||
return
|
||
face_id = item.data(Qt.UserRole)
|
||
if face_id is None:
|
||
return
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(int(face_id))
|
||
|
||
def on_editable_row_clicked(self, row: int, _column: int) -> None:
|
||
if self._edit_busy("编辑计算中,暂时不能切换可编辑对象。"):
|
||
return
|
||
item = self.editable_table.item(row, 0)
|
||
if item is None:
|
||
return
|
||
target_id = item.data(EDITABLE_TARGET_ID_ROLE)
|
||
target_kind = item.data(EDITABLE_TARGET_KIND_ROLE)
|
||
action = item.data(EDITABLE_ACTION_ROLE)
|
||
if target_id is None:
|
||
return
|
||
if action == "resize_cylinder":
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可调整孔径候选 face {target_id}")
|
||
elif action == "resize_boss":
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可调整凸台直径候选 face {target_id}")
|
||
elif action == "suppress_cylinder":
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可封堵圆柱孔 face {target_id}")
|
||
elif action == "resize_depth":
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可调整盲孔深度候选 face {target_id}")
|
||
elif action == "inspect_existing_fillet":
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(int(target_id))
|
||
self.statusBar().showMessage(f"已选择已有圆角/倒圆候选 face {target_id}")
|
||
elif action == "fillet_edge":
|
||
self.mode_combo.setCurrentText("Edge")
|
||
self.select_edge(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可添加圆角 edge {target_id}")
|
||
elif action == "chamfer_edge":
|
||
self.mode_combo.setCurrentText("Edge")
|
||
self.select_edge(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可添加倒角 edge {target_id}")
|
||
elif action == "resize_edge_length":
|
||
self.mode_combo.setCurrentText("Edge")
|
||
self.select_edge(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可尝试调整长度的直线 edge {target_id}")
|
||
elif target_kind == "edge":
|
||
self.mode_combo.setCurrentText("Edge")
|
||
self.select_edge(int(target_id))
|
||
self.statusBar().showMessage(f"已选择 edge {target_id}")
|
||
else:
|
||
self.mode_combo.setCurrentText("Face")
|
||
self.select_face(int(target_id))
|
||
self.statusBar().showMessage(f"已选择可推拉平面 face {target_id}")
|
||
|
||
def select_by_id(self, kind: str | None = None) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能切换选择对象。"):
|
||
return
|
||
try:
|
||
target_id = int(self.id_input.text())
|
||
except ValueError:
|
||
QMessageBox.information(self, "ID 无效", "请输入整数 ID。Part ID 从 1 开始,Solid/Face/Edge ID 从 0 开始。")
|
||
return
|
||
|
||
kind = kind or self.mode_combo.currentText()
|
||
try:
|
||
if kind == "Part":
|
||
if self.model.part_by_id(target_id) is None:
|
||
raise ValueError(f"不存在 Part ID {target_id}")
|
||
self.mode_combo.setCurrentText("Part")
|
||
self.select_part(target_id)
|
||
elif kind == "Solid":
|
||
if target_id < 0 or target_id >= len(self.model.solids):
|
||
raise ValueError(f"不存在 Solid ID {target_id}")
|
||
part_id = self.model.solids[target_id][0]
|
||
self.mode_combo.setCurrentText("Solid")
|
||
self.select_solid(target_id, part_id)
|
||
elif kind == "Face":
|
||
if target_id < 0 or target_id >= len(self.model.faces):
|
||
raise ValueError(f"不存在 Face ID {target_id}")
|
||
self.mode_combo.setCurrentText("Face")
|
||
self.select_face(target_id)
|
||
elif kind == "Feature":
|
||
if target_id < 0 or target_id >= len(self.model.faces):
|
||
raise ValueError(f"不存在 Feature 来源 Face ID {target_id}")
|
||
self.mode_combo.setCurrentText("Feature")
|
||
self.select_feature(target_id)
|
||
elif kind == "Edge":
|
||
if target_id < 0 or target_id >= len(self.model.edges):
|
||
raise ValueError(f"不存在 Edge ID {target_id}")
|
||
self.mode_combo.setCurrentText("Edge")
|
||
self.select_edge(target_id)
|
||
self.last_id_kind = kind
|
||
except ValueError as exc:
|
||
QMessageBox.information(self, "未找到对象", str(exc))
|
||
|
||
def select_part(self, part_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能选择零件。"):
|
||
return
|
||
info = self.model.part_info(part_id)
|
||
self._reset_selection(clear_highlight=False)
|
||
self.selected_kind = "part"
|
||
self.selected_part_id = part_id
|
||
self.selected_pick_position = pick_position
|
||
self._highlight_faces(part_ids=[part_id])
|
||
self._show_pick_marker(pick_position)
|
||
self._sync_id_picker("Part", part_id)
|
||
self.set_info(self._with_pick_info(info, pick_position))
|
||
self._update_action_states()
|
||
kind_label = _part_tree_kind_label(str(info.get("kind", "part")))
|
||
self.statusBar().showMessage(self._selection_status(f"已选择{kind_label} {part_id}", pick_position))
|
||
|
||
def select_solid(
|
||
self,
|
||
solid_id: int,
|
||
part_id: int,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能选择 solid。"):
|
||
return
|
||
info = self.model.solid_info(solid_id)
|
||
self._reset_selection(clear_highlight=False)
|
||
self.selected_kind = "solid"
|
||
self.selected_part_id = part_id
|
||
self.selected_solid_id = solid_id
|
||
self.selected_pick_position = pick_position
|
||
face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id]
|
||
self._highlight_faces(face_ids=face_ids)
|
||
self._show_pick_marker(pick_position)
|
||
self._sync_id_picker("Solid", solid_id)
|
||
self.set_info(self._with_pick_info(info, pick_position))
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(self._selection_status(f"已选择 solid {solid_id}", pick_position))
|
||
|
||
def select_face(
|
||
self,
|
||
face_id: int,
|
||
feature_mode: bool = False,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能选择 face。"):
|
||
return
|
||
self._reset_selection(clear_highlight=False)
|
||
self.selected_kind = "feature" if feature_mode else "face"
|
||
self.selected_face_id = face_id
|
||
self.selected_pick_position = pick_position
|
||
info = self.model.face_info(face_id)
|
||
if feature_mode:
|
||
info["feature_mode"] = "当前是几何候选判断,不等同于 CAD 历史特征"
|
||
self._sync_cylindrical_edit_inputs(info)
|
||
self.selected_part_id = int(info["part_id"])
|
||
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
|
||
self._highlight_faces(face_ids=[face_id])
|
||
self._show_pick_marker(pick_position)
|
||
self._sync_id_picker("Feature" if feature_mode else "Face", face_id)
|
||
self.set_info(self._with_pick_info(info, pick_position))
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(self._selection_status(f"已选择 face {face_id}", pick_position))
|
||
|
||
def select_feature(self, face_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能选择特征。"):
|
||
return
|
||
info = self.model.feature_info(face_id)
|
||
self._reset_selection(clear_highlight=False)
|
||
self.selected_kind = "feature"
|
||
self.selected_face_id = face_id
|
||
self.selected_pick_position = pick_position
|
||
self._sync_cylindrical_edit_inputs(info)
|
||
self.selected_part_id = int(info["part_id"])
|
||
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
|
||
highlight_face_ids = _int_values(info.get("feature_highlight_face_ids"))
|
||
self._highlight_faces(face_ids=highlight_face_ids or [face_id])
|
||
self._show_pick_marker(pick_position)
|
||
self._sync_id_picker("Feature", face_id)
|
||
self.set_info(self._with_pick_info(info, pick_position))
|
||
feature_type = str(info.get("feature_type", "局部特征候选"))
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(self._selection_status(f"已选择 {feature_type},来源 face {face_id}", pick_position))
|
||
|
||
def _sync_cylindrical_edit_inputs(self, info: dict[str, object]) -> None:
|
||
fillet_radius_suggestion: str | None = None
|
||
if "diameter" in info:
|
||
feature_guess = str(info.get("feature_guess", ""))
|
||
if feature_guess == "boss/outer-round candidate":
|
||
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
|
||
self.hole_diameter_input.clear()
|
||
self.boss_diameter_input.setText(suggested_diameter)
|
||
elif feature_guess == "round/fillet candidate":
|
||
radius = _float_or_none(info.get("existing_fillet_radius_estimate"))
|
||
if radius is None:
|
||
radius = _float_or_none(info.get("radius"))
|
||
if radius is not None:
|
||
fillet_radius_suggestion = _format_float(max(radius * 1.2, 0.01))
|
||
self.hole_diameter_input.clear()
|
||
self.boss_diameter_input.clear()
|
||
else:
|
||
suggested_diameter = _format_float(float(info["diameter"]) * 1.2)
|
||
self.hole_diameter_input.setText(suggested_diameter)
|
||
self.boss_diameter_input.clear()
|
||
else:
|
||
self.hole_diameter_input.clear()
|
||
self.boss_diameter_input.clear()
|
||
if info.get("cylinder_end_type") == "blind" and "hole_depth_estimate" in info:
|
||
self.hole_depth_input.setText(_format_float(float(info["hole_depth_estimate"]) * 1.2))
|
||
else:
|
||
self.hole_depth_input.clear()
|
||
if hasattr(self, "edge_fillet_radius_input"):
|
||
if fillet_radius_suggestion is None:
|
||
self.edge_fillet_radius_input.clear()
|
||
else:
|
||
self.edge_fillet_radius_input.setText(fillet_radius_suggestion)
|
||
if hasattr(self, "edge_chamfer_distance_input"):
|
||
self.edge_chamfer_distance_input.clear()
|
||
if hasattr(self, "edge_target_length_input"):
|
||
self.edge_target_length_input.clear()
|
||
|
||
def _sync_edge_edit_inputs(self, info: dict[str, object]) -> None:
|
||
self.hole_diameter_input.clear()
|
||
self.boss_diameter_input.clear()
|
||
self.hole_depth_input.clear()
|
||
if info.get("curve") == "line" and "length" in info:
|
||
length = float(info["length"])
|
||
self.edge_fillet_radius_input.setText(_format_float(max(length * 0.05, 0.01)))
|
||
self.edge_chamfer_distance_input.setText(_format_float(max(length * 0.04, 0.01)))
|
||
self.edge_target_length_input.setText(_format_float(length))
|
||
else:
|
||
self.edge_fillet_radius_input.clear()
|
||
self.edge_chamfer_distance_input.clear()
|
||
self.edge_target_length_input.clear()
|
||
|
||
def select_edge(self, edge_id: int, pick_position: tuple[float, float, float] | None = None) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能选择 edge。"):
|
||
return
|
||
self._reset_selection(clear_highlight=False)
|
||
self.selected_kind = "edge"
|
||
self.selected_edge_id = edge_id
|
||
self.selected_pick_position = pick_position
|
||
info = self.model.edge_info(edge_id)
|
||
self._sync_edge_edit_inputs(info)
|
||
self.selected_part_id = int(info["part_id"])
|
||
self.selected_solid_id = int(info["solid_id"]) if int(info.get("solid_id", -1)) >= 0 else None
|
||
self._highlight_edge(edge_id)
|
||
self._show_pick_marker(pick_position)
|
||
self._sync_id_picker("Edge", edge_id)
|
||
self.set_info(self._with_pick_info(info, pick_position))
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(self._selection_status(f"已选择 edge {edge_id}", pick_position))
|
||
|
||
def _highlight_faces(self, face_ids=None, part_ids=None) -> None:
|
||
if self.model is None:
|
||
return
|
||
self._clear_highlight()
|
||
polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(polydata)
|
||
_enable_overlay_depth_offset(mapper)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(1.0, 0.72, 0.08)
|
||
actor.GetProperty().SetOpacity(0.82)
|
||
actor.GetProperty().SetAmbient(0.45)
|
||
actor.GetProperty().SetDiffuse(0.65)
|
||
actor.GetProperty().SetSpecular(0.35)
|
||
actor.GetProperty().SetLineWidth(2)
|
||
self.highlight_actor = actor
|
||
self.renderer.AddActor(actor)
|
||
self.render_window.Render()
|
||
|
||
def _highlight_edge(self, edge_id: int) -> None:
|
||
if self.model is None:
|
||
return
|
||
self._clear_highlight()
|
||
polydata = self.model.build_edge_polydata(edge_ids=[edge_id])
|
||
mapper = vtk.vtkDataSetMapper()
|
||
mapper.SetInputData(polydata)
|
||
_enable_overlay_depth_offset(mapper)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(1.0, 0.78, 0.0)
|
||
actor.GetProperty().SetAmbient(0.7)
|
||
actor.GetProperty().SetDiffuse(0.8)
|
||
actor.GetProperty().SetLineWidth(5)
|
||
self.edge_highlight_actor = actor
|
||
self.renderer.AddActor(actor)
|
||
self.render_window.Render()
|
||
|
||
def _show_hover_target(self, target: dict[str, object] | None) -> None:
|
||
if self.model is None:
|
||
self._clear_hover(render=True)
|
||
return
|
||
signature = None if target is None else (str(target["kind"]), int(target["target_id"]))
|
||
if signature == self.hover_signature:
|
||
return
|
||
|
||
self._clear_hover(render=False)
|
||
if target is None:
|
||
self.render_window.Render()
|
||
return
|
||
|
||
kind = str(target["kind"])
|
||
target_id = int(target["target_id"])
|
||
if kind == "part":
|
||
self._highlight_hover_faces(part_ids=[target_id])
|
||
elif kind == "solid":
|
||
face_ids = [index for index, solid_id in enumerate(self.model.face_solid_ids) if solid_id == target_id]
|
||
self._highlight_hover_faces(face_ids=face_ids)
|
||
elif kind == "feature":
|
||
info = self.model.feature_info(target_id)
|
||
face_ids = _int_values(info.get("feature_highlight_face_ids")) or [target_id]
|
||
self._highlight_hover_faces(face_ids=face_ids)
|
||
elif kind == "edge":
|
||
self._highlight_hover_edge(target_id)
|
||
else:
|
||
self._highlight_hover_faces(face_ids=[target_id])
|
||
self.hover_signature = signature
|
||
self.render_window.Render()
|
||
|
||
def _highlight_hover_faces(self, face_ids=None, part_ids=None) -> None:
|
||
if self.model is None:
|
||
return
|
||
polydata = self.model.build_face_polydata(face_ids=face_ids, part_ids=part_ids)
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(polydata)
|
||
_enable_overlay_depth_offset(mapper)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(1.0, 0.12, 0.06)
|
||
actor.GetProperty().SetOpacity(0.58)
|
||
actor.GetProperty().SetAmbient(0.5)
|
||
actor.GetProperty().SetDiffuse(0.7)
|
||
actor.GetProperty().SetSpecular(0.35)
|
||
actor.GetProperty().SetLineWidth(2)
|
||
self.hover_face_actor = actor
|
||
self.renderer.AddActor(actor)
|
||
|
||
def _highlight_hover_edge(self, edge_id: int) -> None:
|
||
if self.model is None:
|
||
return
|
||
polydata = self.model.build_edge_polydata(edge_ids=[edge_id])
|
||
mapper = vtk.vtkDataSetMapper()
|
||
mapper.SetInputData(polydata)
|
||
_enable_overlay_depth_offset(mapper)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(1.0, 0.08, 0.02)
|
||
actor.GetProperty().SetAmbient(0.75)
|
||
actor.GetProperty().SetDiffuse(0.8)
|
||
actor.GetProperty().SetLineWidth(4)
|
||
self.hover_edge_actor = actor
|
||
self.renderer.AddActor(actor)
|
||
|
||
def _clear_hover(self, render: bool = False) -> None:
|
||
if not hasattr(self, "renderer"):
|
||
return
|
||
removed = False
|
||
if self.hover_face_actor is not None:
|
||
self.renderer.RemoveActor(self.hover_face_actor)
|
||
self.hover_face_actor = None
|
||
removed = True
|
||
if self.hover_edge_actor is not None:
|
||
self.renderer.RemoveActor(self.hover_edge_actor)
|
||
self.hover_edge_actor = None
|
||
removed = True
|
||
self.hover_signature = None
|
||
if render and removed and hasattr(self, "render_window"):
|
||
self.render_window.Render()
|
||
|
||
def _clear_highlight(self) -> None:
|
||
if self.highlight_actor is not None:
|
||
self.renderer.RemoveActor(self.highlight_actor)
|
||
self.highlight_actor = None
|
||
if self.edge_highlight_actor is not None:
|
||
self.renderer.RemoveActor(self.edge_highlight_actor)
|
||
self.edge_highlight_actor = None
|
||
if self.pick_marker_actor is not None:
|
||
self.renderer.RemoveActor(self.pick_marker_actor)
|
||
self.pick_marker_actor = None
|
||
|
||
def clear_edit_preview(self, render: bool = True) -> None:
|
||
if self.edit_preview_timer is not None:
|
||
self.edit_preview_timer.stop()
|
||
if hasattr(self, "renderer"):
|
||
for actor in self.edit_preview_actors:
|
||
self.renderer.RemoveActor(actor)
|
||
if self.edit_preview_actor is not None and self.edit_preview_actor not in self.edit_preview_actors:
|
||
self.renderer.RemoveActor(self.edit_preview_actor)
|
||
self.edit_preview_actors = []
|
||
self.edit_preview_actor = None
|
||
if render and hasattr(self, "render_window"):
|
||
self.render_window.Render()
|
||
|
||
def _add_edit_preview_actor(
|
||
self,
|
||
polydata,
|
||
color: tuple[float, float, float],
|
||
opacity: float | None = None,
|
||
) -> None:
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(polydata)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(*color)
|
||
actor.GetProperty().SetOpacity(opacity if opacity is not None else self.edit_preview_base_opacity)
|
||
actor.GetProperty().SetSpecular(0.28)
|
||
actor.GetProperty().SetLineWidth(1)
|
||
self.edit_preview_actors.append(actor)
|
||
self.edit_preview_actor = self.edit_preview_actor or actor
|
||
self.renderer.AddActor(actor)
|
||
|
||
def _show_push_pull_preview(self, face_id: int, distance: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
polydata = self.model.push_pull_preview_polydata(face_id, distance)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"推拉预览不可用:{exc}")
|
||
return
|
||
if distance >= 0:
|
||
color = (0.0, 0.86, 0.34)
|
||
else:
|
||
color = (1.0, 0.18, 0.06)
|
||
self._add_edit_preview_actor(polydata, color)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_resize_preview(self, face_id: int, diameter: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_resize_preview_polydata(face_id, diameter)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"孔径调整预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
role = str(preview["role"])
|
||
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
||
opacity = 0.28 if role == "fill" else 0.32
|
||
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_boss_resize_preview(self, face_id: int, diameter: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_boss_resize_preview_polydata(face_id, diameter)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"凸台直径调整预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
role = str(preview["role"])
|
||
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
||
opacity = 0.3 if role == "fill" else 0.34
|
||
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_suppress_preview(self, face_id: int) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_suppress_preview_polydata(face_id)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"封堵圆柱孔预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
self._add_edit_preview_actor(preview["polydata"], (0.0, 0.86, 0.34), opacity=0.3)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_cylinder_depth_preview(self, face_id: int, target_depth: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.cylindrical_depth_preview_polydata(face_id, target_depth)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"孔深调整预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
role = str(preview["role"])
|
||
color = (0.0, 0.86, 0.34) if role == "fill" else (1.0, 0.18, 0.06)
|
||
opacity = 0.3 if role == "fill" else 0.34
|
||
self._add_edit_preview_actor(preview["polydata"], color, opacity=opacity)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_existing_fillet_resize_preview(self, face_id: int, target_radius: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
previews = self.model.existing_fillet_resize_preview_polydata(face_id, target_radius)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"已有圆角半径修改预览不可用:{exc}")
|
||
return
|
||
for preview in previews:
|
||
self._add_edit_preview_actor(preview["polydata"], (0.35, 0.45, 1.0), opacity=0.36)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_edge_fillet_preview(self, edge_id: int, radius: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self._show_edge_tube_preview(edge_id, radius, (0.1, 0.62, 1.0), "圆角预览")
|
||
|
||
def _show_edge_chamfer_preview(self, edge_id: int, distance: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self._show_edge_tube_preview(edge_id, distance, (1.0, 0.55, 0.08), "倒角预览")
|
||
|
||
def _show_edge_length_preview(self, edge_id: int, target_length: float) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
try:
|
||
polydata = self.model.straight_edge_length_preview_polydata(edge_id, target_length)
|
||
except Exception as exc:
|
||
self.statusBar().showMessage(f"直线边长度调整预览不可用:{exc}")
|
||
return
|
||
self._add_edit_preview_actor(polydata, (0.0, 0.72, 0.78), opacity=0.34)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _show_edge_tube_preview(
|
||
self,
|
||
edge_id: int,
|
||
radius: float,
|
||
color: tuple[float, float, float],
|
||
label: str,
|
||
) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.clear_edit_preview(render=False)
|
||
polydata = self.model.build_edge_polydata()
|
||
edge_arr = polydata.GetCellData().GetArray("edge_id")
|
||
ids = vtk.vtkIdList()
|
||
for cell_id in range(polydata.GetNumberOfCells()):
|
||
if int(edge_arr.GetValue(cell_id)) == edge_id:
|
||
ids.InsertNextId(cell_id)
|
||
break
|
||
if ids.GetNumberOfIds() == 0:
|
||
self.statusBar().showMessage(f"{label}不可用:没有找到选中的 edge。")
|
||
return
|
||
extract = vtk.vtkExtractCells()
|
||
extract.SetInputData(polydata)
|
||
extract.SetCellList(ids)
|
||
extract.Update()
|
||
geometry = vtk.vtkGeometryFilter()
|
||
geometry.SetInputConnection(extract.GetOutputPort())
|
||
tube = vtk.vtkTubeFilter()
|
||
tube.SetInputConnection(geometry.GetOutputPort())
|
||
tube.SetRadius(radius)
|
||
tube.SetNumberOfSides(24)
|
||
tube.CappingOn()
|
||
tube.Update()
|
||
self._add_edit_preview_actor(tube.GetOutput(), color, opacity=0.34)
|
||
self._start_edit_preview_pulse()
|
||
self.render_window.Render()
|
||
|
||
def _start_edit_preview_pulse(self) -> None:
|
||
if self.edit_preview_timer is None:
|
||
self.edit_preview_timer = QTimer(self)
|
||
self.edit_preview_timer.timeout.connect(self._pulse_edit_preview)
|
||
self.edit_preview_phase = 0.0
|
||
self.edit_preview_timer.start(120)
|
||
|
||
def _pulse_edit_preview(self) -> None:
|
||
if not self.edit_preview_actors:
|
||
if self.edit_preview_timer is not None:
|
||
self.edit_preview_timer.stop()
|
||
return
|
||
self.edit_preview_phase += 0.35
|
||
opacity = self.edit_preview_base_opacity * (0.75 + 0.25 * (math.sin(self.edit_preview_phase) + 1.0) / 2.0)
|
||
for actor in self.edit_preview_actors:
|
||
actor.GetProperty().SetOpacity(opacity)
|
||
self.render_window.Render()
|
||
|
||
def clear_diff_preview(self, render: bool = True) -> None:
|
||
if not hasattr(self, "renderer"):
|
||
self.diff_actors.clear()
|
||
return
|
||
for actor in self.diff_actors:
|
||
self.renderer.RemoveViewProp(actor)
|
||
self.diff_actors.clear()
|
||
if render and hasattr(self, "render_window"):
|
||
self.render_window.Render()
|
||
self.statusBar().showMessage("已清除差异预览")
|
||
|
||
def _show_operation_diff(self, record: OperationRecord) -> str:
|
||
if self.model is None:
|
||
return ""
|
||
self.clear_diff_preview(render=False)
|
||
if record.before_snapshot is None or record.after_snapshot is None:
|
||
return "差异预览: 这条历史记录没有可显示的前后模型快照。"
|
||
|
||
before_polydata = self.model.build_snapshot_polydata(record.before_snapshot)
|
||
after_polydata = self.model.build_snapshot_polydata(record.after_snapshot)
|
||
before_actor = self._make_diff_actor(before_polydata, color=(1.0, 0.16, 0.08), opacity=0.24)
|
||
after_actor = self._make_diff_actor(after_polydata, color=(0.0, 0.9, 0.28), opacity=0.16)
|
||
heatmap_actor, scalar_bar, heatmap_stats = self._make_distance_heatmap_props(before_polydata, after_polydata)
|
||
self.diff_actors = [before_actor, after_actor]
|
||
if heatmap_actor is not None:
|
||
self.diff_actors.append(heatmap_actor)
|
||
if scalar_bar is not None:
|
||
self.diff_actors.append(scalar_bar)
|
||
for actor in self.diff_actors:
|
||
self.renderer.AddViewProp(actor)
|
||
self.render_window.Render()
|
||
if heatmap_stats:
|
||
record.diff_stats = heatmap_stats
|
||
return (
|
||
"差异预览: 红色半透明为编辑前,绿色半透明为编辑后;"
|
||
"热力图覆盖在编辑后模型上,蓝色接近无变化,黄色/红色表示变化更大。\n"
|
||
f"热力图统计: max_distance={_format_value(heatmap_stats['max_distance'])}, "
|
||
f"mean_distance={_format_value(heatmap_stats['mean_distance'])}, "
|
||
f"changed_points={heatmap_stats['changed_points']}/{heatmap_stats['points']} "
|
||
f"({_format_value(heatmap_stats['changed_ratio'] * 100.0)}%)."
|
||
)
|
||
return "差异预览: 红色半透明为编辑前,绿色半透明为编辑后;热力图无法生成。"
|
||
|
||
def _ensure_record_diff_stats(self, record: OperationRecord) -> dict[str, object]:
|
||
if record.diff_stats is not None:
|
||
return record.diff_stats
|
||
if self.model is None or record.before_snapshot is None or record.after_snapshot is None:
|
||
record.diff_stats = {}
|
||
return record.diff_stats
|
||
before_polydata = self.model.build_snapshot_polydata(record.before_snapshot)
|
||
after_polydata = self.model.build_snapshot_polydata(record.after_snapshot)
|
||
_heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata)
|
||
record.diff_stats = stats
|
||
return stats
|
||
|
||
def _diff_report_text(self, record: OperationRecord, history_index: int) -> str:
|
||
lines = [
|
||
"STEP 编辑差异报告",
|
||
"",
|
||
f"generated_at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
||
f"source_file: {self.step_path}",
|
||
f"history_index: {history_index}",
|
||
f"summary: {record.summary}",
|
||
"",
|
||
"操作详情:",
|
||
record.detail,
|
||
"",
|
||
"距离热力图统计:",
|
||
]
|
||
stats = record.diff_stats or {}
|
||
if stats:
|
||
lines.extend(
|
||
[
|
||
f" points: {stats.get('points', '')}",
|
||
f" max_distance: {_format_value(stats.get('max_distance', ''))}",
|
||
f" mean_distance: {_format_value(stats.get('mean_distance', ''))}",
|
||
f" changed_points: {stats.get('changed_points', '')}",
|
||
f" changed_ratio: {_format_value(float(stats.get('changed_ratio', 0.0)) * 100.0)}%",
|
||
f" changed_threshold: {_format_value(stats.get('changed_threshold', ''))}",
|
||
]
|
||
)
|
||
else:
|
||
lines.append(" unavailable")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"说明:",
|
||
" 热力图使用“编辑后模型顶点到编辑前模型表面”的距离估算。",
|
||
" 它适合做可视化诊断和修改留档,不等同于完整 CAD 公差报告。",
|
||
]
|
||
)
|
||
return "\n".join(lines)
|
||
|
||
def _make_diff_actor(self, polydata, color: tuple[float, float, float], opacity: float):
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(polydata)
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetColor(*color)
|
||
actor.GetProperty().SetOpacity(opacity)
|
||
actor.GetProperty().SetSpecular(0.15)
|
||
actor.GetProperty().SetLineWidth(1)
|
||
return actor
|
||
|
||
def _make_distance_heatmap_props(self, before_polydata, after_polydata):
|
||
heat_polydata, stats = self._build_distance_heatmap_polydata(before_polydata, after_polydata)
|
||
if heat_polydata is None:
|
||
return None, None, {}
|
||
max_distance = float(stats["max_distance"])
|
||
|
||
lut = self._make_heatmap_lookup_table(max_distance)
|
||
mapper = vtk.vtkPolyDataMapper()
|
||
mapper.SetInputData(heat_polydata)
|
||
mapper.SetLookupTable(lut)
|
||
mapper.SetScalarRange(0.0, max(max_distance, 1e-9))
|
||
mapper.SetScalarModeToUsePointData()
|
||
mapper.ScalarVisibilityOn()
|
||
|
||
actor = vtk.vtkActor()
|
||
actor.SetMapper(mapper)
|
||
actor.GetProperty().SetOpacity(0.82)
|
||
actor.GetProperty().SetSpecular(0.22)
|
||
actor.GetProperty().SetSpecularPower(16)
|
||
|
||
scalar_bar = vtk.vtkScalarBarActor()
|
||
scalar_bar.SetLookupTable(lut)
|
||
scalar_bar.SetTitle("distance")
|
||
scalar_bar.SetNumberOfLabels(4)
|
||
scalar_bar.SetWidth(0.08)
|
||
scalar_bar.SetHeight(0.32)
|
||
scalar_bar.SetPosition(0.89, 0.05)
|
||
scalar_bar.GetTitleTextProperty().SetColor(1.0, 1.0, 1.0)
|
||
scalar_bar.GetLabelTextProperty().SetColor(1.0, 1.0, 1.0)
|
||
|
||
return actor, scalar_bar, stats
|
||
|
||
def _build_distance_heatmap_polydata(self, before_polydata, after_polydata):
|
||
point_count = after_polydata.GetNumberOfPoints()
|
||
if before_polydata.GetNumberOfPoints() == 0 or point_count == 0:
|
||
return None, {}
|
||
heat_polydata = vtk.vtkPolyData()
|
||
heat_polydata.DeepCopy(after_polydata)
|
||
distance = vtk.vtkImplicitPolyDataDistance()
|
||
distance.SetInput(before_polydata)
|
||
|
||
values = vtk.vtkFloatArray()
|
||
values.SetName("edit_distance")
|
||
values.SetNumberOfValues(point_count)
|
||
max_distance = 0.0
|
||
total_distance = 0.0
|
||
raw_values: list[float] = []
|
||
for point_id in range(point_count):
|
||
point = heat_polydata.GetPoint(point_id)
|
||
value = abs(float(distance.EvaluateFunction(point)))
|
||
raw_values.append(value)
|
||
values.SetValue(point_id, value)
|
||
total_distance += value
|
||
max_distance = max(max_distance, value)
|
||
|
||
heat_polydata.GetPointData().SetScalars(values)
|
||
changed_threshold = max(max_distance * 0.01, 1e-6)
|
||
changed_points = sum(1 for value in raw_values if value > changed_threshold)
|
||
stats = {
|
||
"points": point_count,
|
||
"max_distance": max_distance,
|
||
"mean_distance": total_distance / point_count,
|
||
"changed_points": changed_points,
|
||
"changed_ratio": changed_points / point_count,
|
||
"changed_threshold": changed_threshold,
|
||
}
|
||
return heat_polydata, stats
|
||
|
||
def _make_heatmap_lookup_table(self, max_distance: float):
|
||
lut = vtk.vtkLookupTable()
|
||
lut.SetNumberOfTableValues(256)
|
||
lut.SetRange(0.0, max(max_distance, 1e-9))
|
||
lut.Build()
|
||
stops = [
|
||
(0.0, (0.08, 0.16, 0.85)),
|
||
(0.35, (0.0, 0.72, 1.0)),
|
||
(0.7, (1.0, 0.9, 0.08)),
|
||
(1.0, (1.0, 0.08, 0.02)),
|
||
]
|
||
for index in range(256):
|
||
t = index / 255.0
|
||
left = stops[0]
|
||
right = stops[-1]
|
||
for stop_index in range(len(stops) - 1):
|
||
if stops[stop_index][0] <= t <= stops[stop_index + 1][0]:
|
||
left = stops[stop_index]
|
||
right = stops[stop_index + 1]
|
||
break
|
||
span = max(right[0] - left[0], 1e-9)
|
||
local_t = (t - left[0]) / span
|
||
color = tuple(left[1][axis] + (right[1][axis] - left[1][axis]) * local_t for axis in range(3))
|
||
lut.SetTableValue(index, color[0], color[1], color[2], 1.0)
|
||
return lut
|
||
|
||
def _reset_selection(self, clear_highlight: bool = True) -> None:
|
||
self.selected_kind = None
|
||
self.selected_part_id = None
|
||
self.selected_solid_id = None
|
||
self.selected_face_id = None
|
||
self.selected_edge_id = None
|
||
self.selected_pick_position = None
|
||
if clear_highlight:
|
||
self._clear_highlight()
|
||
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 not self.operation_in_progress:
|
||
return False
|
||
self.statusBar().showMessage(message)
|
||
return True
|
||
|
||
def _sync_id_picker(self, kind: str, target_id: int) -> None:
|
||
self.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
|
||
selected_kind = self.selected_kind
|
||
self.export_all_button.setEnabled(has_model)
|
||
self.export_check_button.setEnabled(has_model)
|
||
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()
|
||
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_blind = action_info.get("cylinder_end_type") == "blind"
|
||
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
|
||
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。",
|
||
)
|
||
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,
|
||
"调整当前已识别底面的盲孔/盲槽深度。",
|
||
"请先选择已识别出疑似底面的盲孔/盲槽候选。",
|
||
)
|
||
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 is_line_edge,
|
||
"通过移动端面尝试调整当前直线 edge 的长度。",
|
||
"请先选择一条直线 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 文件。",
|
||
)
|
||
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)
|
||
widget.setToolTip(enabled_tip if enabled else disabled_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 == "face" and record.target_id is not None:
|
||
if 0 <= record.target_id < len(self.model.faces):
|
||
info = self.model.face_info(record.target_id)
|
||
self.selected_kind = "face"
|
||
self.selected_face_id = record.target_id
|
||
self.selected_part_id = int(info["part_id"])
|
||
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
|
||
self.selected_pick_position = record.pick_position
|
||
self.mode_combo.setCurrentText("Face")
|
||
self._sync_id_picker("Face", record.target_id)
|
||
self._highlight_faces(face_ids=[record.target_id])
|
||
located = True
|
||
locator_note = (
|
||
f"定位: 已尝试高亮当前模型中的 face {record.target_id}。"
|
||
"布尔编辑后 face ID 可能发生语义变化,请结合拾取点确认。"
|
||
)
|
||
else:
|
||
locator_note = f"定位: 原目标 face {record.target_id} 在当前模型索引中已经不存在。"
|
||
|
||
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 "模型已恢复到操作前状态,历史记录未移动。"
|
||
|
||
def export_all(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
||
return
|
||
if not self._confirm_export_quality("all"):
|
||
return
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出当前完整 STEP",
|
||
str(self.step_path.parent / f"{self.step_path.stem}_edited.step"),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
self._run_action(lambda: self.model.export_all(target), f"已导出 {Path(target).name}")
|
||
|
||
def export_selected_part(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
||
return
|
||
if self.selected_part_id is None:
|
||
QMessageBox.information(self, "未选择零件", "请先选择一个零件。")
|
||
return
|
||
if not self._confirm_export_quality("part", self.selected_part_id):
|
||
return
|
||
part = self.model.part_by_id(self.selected_part_id)
|
||
default_name = f"{part.name if part else 'part'}_export.step".replace(" ", "_")
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出选中零件",
|
||
str(self.step_path.parent / default_name),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
self._run_action(
|
||
lambda: self.model.export_part(self.selected_part_id, target),
|
||
f"已导出选中零件到 {Path(target).name}",
|
||
)
|
||
|
||
def export_selected_solid(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
||
return
|
||
if self.selected_solid_id is None:
|
||
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择一个属于 solid 的 face。")
|
||
return
|
||
if not self._confirm_export_quality("solid", self.selected_solid_id):
|
||
return
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出选中 solid",
|
||
str(self.step_path.parent / f"solid_{self.selected_solid_id}_export.step"),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
self._run_action(
|
||
lambda: self.model.export_solid(self.selected_solid_id, target),
|
||
f"已导出选中 solid 到 {Path(target).name}",
|
||
)
|
||
|
||
def export_selected_face(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择 face", "请先切换到 Face 或 Feature 模式并选择一个 face。")
|
||
return
|
||
if not self._confirm_export_quality("face", self.selected_face_id):
|
||
return
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出选中 face",
|
||
str(self.step_path.parent / f"face_{self.selected_face_id}_export.step"),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
self._run_action(
|
||
lambda: self.model.export_face(self.selected_face_id, target),
|
||
f"已导出选中 face 到 {Path(target).name}",
|
||
)
|
||
|
||
def export_selected_feature(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。")
|
||
return
|
||
if not self._confirm_export_quality("feature", self.selected_face_id):
|
||
return
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出选中特征区域",
|
||
str(self.step_path.parent / f"feature_face_{self.selected_face_id}_export.step"),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
self._run_action(
|
||
lambda: self.model.export_feature(self.selected_face_id, target),
|
||
f"已导出选中特征区域到 {Path(target).name}",
|
||
)
|
||
|
||
def export_selected_edge(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再导出。"):
|
||
return
|
||
if self.selected_edge_id is None:
|
||
QMessageBox.information(self, "未选择 edge", "请先切换到 Edge 模式并选择一个 edge。")
|
||
return
|
||
if not self._confirm_export_quality("edge", self.selected_edge_id):
|
||
return
|
||
target, _ = QFileDialog.getSaveFileName(
|
||
self,
|
||
"导出选中 edge",
|
||
str(self.step_path.parent / f"edge_{self.selected_edge_id}_export.step"),
|
||
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
|
||
)
|
||
if not target:
|
||
return
|
||
self._run_action(
|
||
lambda: self.model.export_edge(self.selected_edge_id, target),
|
||
f"已导出选中 edge 到 {Path(target).name}",
|
||
)
|
||
|
||
def check_export_quality(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能检查导出质量。"):
|
||
return
|
||
scope, target_id = self._current_export_quality_target()
|
||
try:
|
||
info = self.model.export_quality_info(scope, target_id)
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "质量检查失败", str(exc))
|
||
self.statusBar().showMessage("导出质量检查失败")
|
||
return
|
||
report = _export_quality_text(info)
|
||
self.set_plain_info(report)
|
||
if info.get("quality_status") == "ok":
|
||
self.statusBar().showMessage("导出质量检查通过")
|
||
else:
|
||
self.statusBar().showMessage("导出质量检查发现警告")
|
||
|
||
def _current_export_quality_target(self) -> tuple[str, int | None]:
|
||
if self.selected_kind == "feature" and self.selected_face_id is not None:
|
||
return "feature", self.selected_face_id
|
||
if self.selected_kind == "face" and self.selected_face_id is not None:
|
||
return "face", self.selected_face_id
|
||
if self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||
return "edge", self.selected_edge_id
|
||
if self.selected_kind == "edge":
|
||
if self.selected_solid_id is not None:
|
||
return "solid", self.selected_solid_id
|
||
if self.selected_part_id is not None:
|
||
return "part", self.selected_part_id
|
||
if self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||
return "solid", self.selected_solid_id
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
return "part", self.selected_part_id
|
||
return "all", None
|
||
|
||
def _confirm_export_quality(self, scope: str, target_id: int | None = None) -> bool:
|
||
if self.model is None:
|
||
return False
|
||
try:
|
||
info = self.model.export_quality_info(scope, target_id)
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "导出质量检查失败", str(exc))
|
||
self.statusBar().showMessage("导出质量检查失败")
|
||
return False
|
||
report = _export_quality_text(info)
|
||
self.set_plain_info(report)
|
||
if info.get("quality_status") == "ok":
|
||
return True
|
||
result = QMessageBox.question(
|
||
self,
|
||
"导出质量警告",
|
||
f"{report}\n\n仍然继续导出吗?",
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
return result == QMessageBox.StandardButton.Yes
|
||
|
||
def push_pull_face(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再执行新的推拉。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择面", "请先选择一个平面 face。")
|
||
return
|
||
try:
|
||
distance = float(self.offset_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。")
|
||
return
|
||
face_id = self.selected_face_id
|
||
plan = self.model.push_pull_plan(face_id, distance)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能推拉平面", str(plan["message"]))
|
||
self.statusBar().showMessage("推拉平面已阻止")
|
||
return
|
||
if plan["risk"] != "low":
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认推拉平面",
|
||
(
|
||
f"face: {plan['face_id']}\n"
|
||
f"推拉距离: {_format_value(plan['distance'])}\n"
|
||
f"共面推拉范围: {plan.get('push_pull_scope_face_count', 1)} 个 face\n"
|
||
f"{plan.get('push_pull_scope_note', '')}\n"
|
||
f"风险: {plan['risk']}\n"
|
||
f"方向置信度: {plan['direction_confidence']}\n"
|
||
f"方向说明: {plan['direction_note']}\n"
|
||
f"face 尺寸参考: bbox_diagonal={_format_value(plan.get('bbox_diagonal', ''))}\n\n"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会对当前零件执行实验性布尔修改。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消推拉平面")
|
||
return
|
||
self._show_push_pull_preview(face_id, distance)
|
||
|
||
def action():
|
||
return self.model.push_pull_face(face_id, distance)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="推拉平面",
|
||
target=f"face {face_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"semantic_distance": distance,
|
||
"distance_rule": "positive=outward fuse, negative=inward cut",
|
||
"surface": plan.get("surface"),
|
||
"outward_direction": plan.get("outward_direction"),
|
||
"direction_confidence": plan.get("direction_confidence"),
|
||
"direction_note": plan.get("direction_note"),
|
||
"push_pull_risk": plan.get("risk"),
|
||
"push_pull_status": plan.get("status"),
|
||
"push_pull_message": plan.get("message"),
|
||
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
|
||
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
|
||
"push_pull_scope_note": plan.get("push_pull_scope_note"),
|
||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||
},
|
||
target_kind="face",
|
||
target_id=face_id,
|
||
)
|
||
|
||
def resize_hole(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再调整孔径。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱面。")
|
||
return
|
||
try:
|
||
diameter = float(self.hole_diameter_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。")
|
||
return
|
||
info = self.model.face_info(self.selected_face_id)
|
||
if "diameter" not in info:
|
||
QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱孔径。")
|
||
return
|
||
plan = self.model.cylindrical_resize_plan(self.selected_face_id, diameter)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能调整孔径", str(plan["message"]))
|
||
self.statusBar().showMessage("圆柱孔径调整已阻止")
|
||
return
|
||
guess = str(info.get("feature_guess", "cylindrical face"))
|
||
if plan["risk"] != "low":
|
||
target_to_height_ratio = plan.get("target_to_height_ratio", "")
|
||
target_to_height_line = (
|
||
""
|
||
if target_to_height_ratio is None or target_to_height_ratio == ""
|
||
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
|
||
)
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认圆柱孔径调整",
|
||
(
|
||
f"face: {plan['face_id']}\n"
|
||
f"当前直径: {_format_value(plan['current_diameter'])}\n"
|
||
f"目标直径: {_format_value(plan['target_diameter'])}\n"
|
||
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
|
||
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
|
||
f"{target_to_height_line}"
|
||
f"调整模式: {plan['resize_mode']}\n"
|
||
f"候选判断: {plan['feature_guess']}\n"
|
||
f"置信度: {plan['confidence']}\n"
|
||
f"材料投票: {plan['material_vote_summary']}\n"
|
||
f"端部类型: {plan['cylinder_end_type']}\n"
|
||
f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n"
|
||
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
|
||
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"Cutter: {plan['cutter_strategy']}\n"
|
||
f"Cutter 高度: {_format_value(plan['cutter_height'])}\n"
|
||
f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / "
|
||
f"{_format_value(plan['cutter_end_margin'])}\n\n"
|
||
f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n"
|
||
f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n"
|
||
f"补料策略: {plan.get('fill_strategy', '')}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会对当前零件执行实验性布尔修改。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消圆柱孔径调整")
|
||
return
|
||
face_id = self.selected_face_id
|
||
self._show_cylinder_resize_preview(face_id, diameter)
|
||
|
||
def action():
|
||
return self.model.resize_cylindrical_hole(face_id, diameter)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="调整圆柱孔径",
|
||
target=f"face {face_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"new_diameter": diameter,
|
||
"old_diameter": info.get("diameter"),
|
||
"delta_diameter": plan.get("delta_diameter"),
|
||
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
|
||
"target_to_height_ratio": plan.get("target_to_height_ratio"),
|
||
"resize_mode": plan.get("resize_mode"),
|
||
"feature_type": plan.get("feature_type"),
|
||
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
|
||
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
|
||
"feature_bottom_note": plan.get("feature_bottom_note"),
|
||
"feature_guess": guess,
|
||
"confidence": plan.get("confidence"),
|
||
"resize_status": plan.get("status"),
|
||
"resize_risk": plan.get("risk"),
|
||
"resize_message": plan.get("message"),
|
||
"resize_warnings": plan.get("warnings"),
|
||
"resize_blockers": plan.get("blockers"),
|
||
"material_vote_summary": plan.get("material_vote_summary"),
|
||
"cylinder_end_type": plan.get("cylinder_end_type"),
|
||
"hole_depth_estimate": plan.get("hole_depth_estimate"),
|
||
"start_end_state": plan.get("start_end_state"),
|
||
"end_end_state": plan.get("end_end_state"),
|
||
"cutter_strategy": plan.get("cutter_strategy"),
|
||
"cutter_height": plan.get("cutter_height"),
|
||
"cutter_margin": plan.get("cutter_margin"),
|
||
"cutter_start_margin": plan.get("cutter_start_margin"),
|
||
"cutter_end_margin": plan.get("cutter_end_margin"),
|
||
"cutter_bottom_protection": plan.get("cutter_bottom_protection"),
|
||
"cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"),
|
||
"cutter_opening_face_ids": plan.get("cutter_opening_face_ids"),
|
||
"cutter_bottom_note": plan.get("cutter_bottom_note"),
|
||
"cutter_note": plan.get("cutter_note"),
|
||
"fill_strategy": plan.get("fill_strategy"),
|
||
"fill_height": plan.get("fill_height"),
|
||
"fill_radius": plan.get("fill_radius"),
|
||
"fill_radius_overlap": plan.get("fill_radius_overlap"),
|
||
"fill_note": plan.get("fill_note"),
|
||
},
|
||
target_kind="face",
|
||
target_id=face_id,
|
||
)
|
||
|
||
def resize_boss(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再调整凸台直径。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择圆柱凸台", "请先选择一个圆柱凸台候选 face。")
|
||
return
|
||
try:
|
||
diameter = float(self.boss_diameter_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标凸台直径。")
|
||
return
|
||
info = self.model.face_info(self.selected_face_id)
|
||
if "diameter" not in info:
|
||
QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。")
|
||
return
|
||
plan = self.model.cylindrical_boss_resize_plan(self.selected_face_id, diameter)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能调整凸台直径", str(plan["message"]))
|
||
self.statusBar().showMessage("圆柱凸台直径调整已阻止")
|
||
return
|
||
if plan["risk"] != "low":
|
||
target_to_height_ratio = plan.get("target_to_height_ratio", "")
|
||
target_to_height_line = (
|
||
""
|
||
if target_to_height_ratio is None or target_to_height_ratio == ""
|
||
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
|
||
)
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认圆柱凸台直径调整",
|
||
(
|
||
f"face: {plan['face_id']}\n"
|
||
f"当前凸台直径: {_format_value(plan['current_diameter'])}\n"
|
||
f"目标凸台直径: {_format_value(plan['target_diameter'])}\n"
|
||
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
|
||
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
|
||
f"{target_to_height_line}"
|
||
f"调整模式: {plan['resize_mode']}\n"
|
||
f"候选判断: {plan['feature_guess']}\n"
|
||
f"置信度: {plan['confidence']}\n"
|
||
f"材料投票: {plan['material_vote_summary']}\n"
|
||
f"工具策略: {plan['boss_tool_strategy']}\n"
|
||
f"工具高度: {_format_value(plan['boss_tool_height'])}\n"
|
||
f"轴向重叠余量: {_format_value(plan['boss_tool_axial_margin'])}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会对当前零件执行实验性布尔修改。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消圆柱凸台直径调整")
|
||
return
|
||
|
||
face_id = self.selected_face_id
|
||
self._show_cylinder_boss_resize_preview(face_id, diameter)
|
||
|
||
def action():
|
||
return self.model.resize_cylindrical_boss(face_id, diameter)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="调整圆柱凸台直径",
|
||
target=f"face {face_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"new_diameter": diameter,
|
||
"old_diameter": info.get("diameter"),
|
||
"delta_diameter": plan.get("delta_diameter"),
|
||
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
|
||
"target_to_height_ratio": plan.get("target_to_height_ratio"),
|
||
"resize_mode": plan.get("resize_mode"),
|
||
"feature_type": plan.get("feature_type"),
|
||
"feature_guess": plan.get("feature_guess"),
|
||
"confidence": plan.get("confidence"),
|
||
"boss_resize_status": plan.get("status"),
|
||
"boss_resize_risk": plan.get("risk"),
|
||
"boss_resize_message": plan.get("message"),
|
||
"boss_resize_warnings": plan.get("warnings"),
|
||
"boss_resize_blockers": plan.get("blockers"),
|
||
"material_vote_summary": plan.get("material_vote_summary"),
|
||
"boss_tool_strategy": plan.get("boss_tool_strategy"),
|
||
"boss_tool_height": plan.get("boss_tool_height"),
|
||
"boss_tool_axial_margin": plan.get("boss_tool_axial_margin"),
|
||
"boss_tool_radius": plan.get("boss_tool_radius"),
|
||
"boss_tool_outer_radius": plan.get("boss_tool_outer_radius"),
|
||
"boss_tool_inner_radius": plan.get("boss_tool_inner_radius"),
|
||
"boss_tool_radial_overlap": plan.get("boss_tool_radial_overlap"),
|
||
"boss_tool_note": plan.get("boss_tool_note"),
|
||
"feature_adjacent_face_ids": plan.get("feature_adjacent_face_ids"),
|
||
"feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"),
|
||
},
|
||
target_kind="face",
|
||
target_id=face_id,
|
||
)
|
||
|
||
def suppress_hole(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再封堵圆柱孔。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱孔 face。")
|
||
return
|
||
|
||
info = self.model.face_info(self.selected_face_id)
|
||
if info.get("surface") != "cylinder" or "diameter" not in info:
|
||
QMessageBox.information(self, "不是圆柱孔", "当前选中的 face 不是可封堵的圆柱孔。")
|
||
return
|
||
plan = self.model.cylindrical_suppress_plan(self.selected_face_id)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能封堵圆柱孔", str(plan["message"]))
|
||
self.statusBar().showMessage("封堵圆柱孔已阻止")
|
||
return
|
||
|
||
if plan["risk"] != "low":
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认封堵圆柱孔",
|
||
(
|
||
f"face: {plan['face_id']}\n"
|
||
f"直径: {_format_value(plan['diameter'])}\n"
|
||
f"估算高度: {_format_value(plan['height_estimate'])}\n"
|
||
f"候选判断: {plan['feature_guess']}\n"
|
||
f"置信度: {plan['confidence']}\n"
|
||
f"材料投票: {plan['material_vote_summary']}\n"
|
||
f"端部类型: {plan['cylinder_end_type']}\n"
|
||
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"补料策略: {plan['fill_strategy']}\n"
|
||
f"补料半径: {_format_value(plan['fill_radius'])}\n"
|
||
f"补料高度: {_format_value(plan['fill_height'])}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会用补料体封堵当前孔,并对当前零件执行实验性 Fuse。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消封堵圆柱孔")
|
||
return
|
||
|
||
face_id = self.selected_face_id
|
||
self._show_cylinder_suppress_preview(face_id)
|
||
|
||
def action():
|
||
return self.model.suppress_cylindrical_hole(face_id)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="封堵圆柱孔",
|
||
target=f"face {face_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"diameter": plan.get("diameter"),
|
||
"height_estimate": plan.get("height_estimate"),
|
||
"feature_type": plan.get("feature_type"),
|
||
"feature_guess": plan.get("feature_guess"),
|
||
"confidence": plan.get("confidence"),
|
||
"suppress_status": plan.get("status"),
|
||
"suppress_risk": plan.get("risk"),
|
||
"suppress_message": plan.get("message"),
|
||
"suppress_warnings": plan.get("warnings"),
|
||
"suppress_blockers": plan.get("blockers"),
|
||
"cylinder_end_type": plan.get("cylinder_end_type"),
|
||
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
|
||
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
|
||
"feature_bottom_note": plan.get("feature_bottom_note"),
|
||
"fill_strategy": plan.get("fill_strategy"),
|
||
"fill_height": plan.get("fill_height"),
|
||
"fill_radius": plan.get("fill_radius"),
|
||
"fill_radius_overlap": plan.get("fill_radius_overlap"),
|
||
"fill_note": plan.get("fill_note"),
|
||
},
|
||
target_kind="face",
|
||
target_id=face_id,
|
||
)
|
||
|
||
def resize_hole_depth(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再调整孔深。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择圆柱面", "请先选择一个盲孔或盲槽的圆柱面。")
|
||
return
|
||
try:
|
||
target_depth = float(self.hole_depth_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "深度无效", "请输入数字形式的目标孔深。")
|
||
return
|
||
|
||
info = self.model.face_info(self.selected_face_id)
|
||
if info.get("surface") != "cylinder" or "hole_depth_estimate" not in info:
|
||
QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 face 不是可调整孔深的圆柱孔/槽。")
|
||
return
|
||
plan = self.model.cylindrical_depth_plan(self.selected_face_id, target_depth)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能调整孔深", str(plan["message"]))
|
||
self.statusBar().showMessage("盲孔深度调整已阻止")
|
||
return
|
||
|
||
if plan["risk"] != "low":
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认盲孔深度调整",
|
||
(
|
||
f"face: {plan['face_id']}\n"
|
||
f"当前深度: {_format_value(plan['current_depth'])}\n"
|
||
f"目标深度: {_format_value(plan['target_depth'])}\n"
|
||
f"深度变化量: {_format_value(plan['delta_depth'])}\n"
|
||
f"深度变化比例: {_format_percent(plan['depth_delta_ratio'])}\n"
|
||
f"调整模式: {plan['depth_mode']}\n"
|
||
f"候选判断: {plan['feature_guess']}\n"
|
||
f"置信度: {plan['confidence']}\n"
|
||
f"材料投票: {plan['material_vote_summary']}\n"
|
||
f"端部类型: {plan['cylinder_end_type']}\n"
|
||
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
|
||
f"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n"
|
||
f"当前深度来源: {_format_value(plan.get('depth_current_depth_source', ''))}\n"
|
||
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"工具策略: {plan['depth_tool_strategy']}\n"
|
||
f"工具类型: {plan['depth_tool_role']}\n"
|
||
f"工具高度: {_format_value(plan['depth_tool_height'])}\n"
|
||
f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会对当前零件执行实验性布尔修改。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消盲孔深度调整")
|
||
return
|
||
|
||
face_id = self.selected_face_id
|
||
self._show_cylinder_depth_preview(face_id, target_depth)
|
||
|
||
def action():
|
||
return self.model.resize_cylindrical_depth(face_id, target_depth)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="调整盲孔深度",
|
||
target=f"face {face_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"old_depth": plan.get("current_depth"),
|
||
"new_depth": target_depth,
|
||
"delta_depth": plan.get("delta_depth"),
|
||
"depth_delta_ratio": plan.get("depth_delta_ratio"),
|
||
"depth_mode": plan.get("depth_mode"),
|
||
"feature_type": plan.get("feature_type"),
|
||
"feature_guess": plan.get("feature_guess"),
|
||
"confidence": plan.get("confidence"),
|
||
"depth_status": plan.get("status"),
|
||
"depth_risk": plan.get("risk"),
|
||
"depth_message": plan.get("message"),
|
||
"depth_warnings": plan.get("warnings"),
|
||
"depth_blockers": plan.get("blockers"),
|
||
"cylinder_end_type": plan.get("cylinder_end_type"),
|
||
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
|
||
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
|
||
"feature_bottom_confidence": plan.get("feature_bottom_confidence"),
|
||
"feature_bottom_detection": plan.get("feature_bottom_detection"),
|
||
"feature_bottom_note": plan.get("feature_bottom_note"),
|
||
"depth_tool_strategy": plan.get("depth_tool_strategy"),
|
||
"depth_tool_role": plan.get("depth_tool_role"),
|
||
"depth_tool_height": plan.get("depth_tool_height"),
|
||
"depth_tool_radius": plan.get("depth_tool_radius"),
|
||
"depth_tool_radius_overlap": plan.get("depth_tool_radius_overlap"),
|
||
"depth_current_depth": plan.get("depth_current_depth"),
|
||
"depth_current_depth_source": plan.get("depth_current_depth_source"),
|
||
"depth_bottom_parameter_source": plan.get("depth_bottom_parameter_source"),
|
||
"depth_open_point": plan.get("depth_open_point"),
|
||
"depth_current_bottom_point": plan.get("depth_current_bottom_point"),
|
||
"depth_target_bottom_point": plan.get("depth_target_bottom_point"),
|
||
},
|
||
target_kind="face",
|
||
target_id=face_id,
|
||
)
|
||
|
||
def resize_existing_fillet(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再修改已有圆角。"):
|
||
return
|
||
if self.selected_face_id is None:
|
||
QMessageBox.information(self, "未选择已有圆角", "请先选择一个已有圆角/倒圆候选 face。")
|
||
return
|
||
try:
|
||
target_radius = float(self.edge_fillet_radius_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "半径无效", "请输入数字形式的目标圆角半径。")
|
||
return
|
||
|
||
face_id = self.selected_face_id
|
||
plan = self.model.existing_fillet_resize_plan(face_id, target_radius)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能修改已有圆角", str(plan["message"]))
|
||
self.statusBar().showMessage("已有圆角半径修改已阻止")
|
||
return
|
||
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认修改已有圆角半径",
|
||
(
|
||
f"face: {plan['face_id']}\n"
|
||
f"当前估算半径: {_format_value(plan['current_radius'])}\n"
|
||
f"目标半径: {_format_value(plan['target_radius'])}\n"
|
||
f"半径变化量: {_format_value(plan['delta_radius'])}\n"
|
||
f"半径变化比例: {_format_percent(plan['radius_delta_ratio'])}\n"
|
||
f"圆弧跨度: {_format_value(plan.get('angular_span', ''))}\n"
|
||
f"长度估算: {_format_value(plan.get('height_estimate', ''))}\n"
|
||
f"支撑 Face: {_format_value(plan.get('feature_existing_fillet_support_face_ids', ''))}\n"
|
||
f"边界 Edge: {_format_value(plan.get('feature_boundary_edge_ids', ''))}\n"
|
||
f"策略: {plan['resize_strategy']}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会先尝试移除当前圆角面,再对恢复出的锐边重新倒圆。\n"
|
||
"这不是 CAD 历史特征参数编辑,复杂圆角可能失败。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消修改已有圆角半径")
|
||
return
|
||
|
||
self._show_existing_fillet_resize_preview(face_id, target_radius)
|
||
|
||
def action():
|
||
return self.model.resize_existing_fillet(face_id, target_radius)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="修改已有圆角半径",
|
||
target=f"face {face_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"old_radius": plan.get("current_radius"),
|
||
"new_radius": target_radius,
|
||
"delta_radius": plan.get("delta_radius"),
|
||
"radius_delta_ratio": plan.get("radius_delta_ratio"),
|
||
"feature_type": plan.get("feature_type"),
|
||
"feature_guess": plan.get("feature_guess"),
|
||
"confidence": plan.get("confidence"),
|
||
"existing_fillet_status": plan.get("status"),
|
||
"existing_fillet_risk": plan.get("risk"),
|
||
"existing_fillet_message": plan.get("message"),
|
||
"existing_fillet_warnings": plan.get("warnings"),
|
||
"existing_fillet_blockers": plan.get("blockers"),
|
||
"feature_existing_fillet_support_face_ids": plan.get("feature_existing_fillet_support_face_ids"),
|
||
"feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"),
|
||
"resize_strategy": plan.get("resize_strategy"),
|
||
"resize_note": plan.get("resize_note"),
|
||
"axis_point": plan.get("axis_point"),
|
||
"axis": plan.get("axis"),
|
||
"height_estimate": plan.get("height_estimate"),
|
||
"angular_span": plan.get("angular_span"),
|
||
},
|
||
target_kind="face",
|
||
target_id=face_id,
|
||
)
|
||
|
||
def fillet_edge(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再添加圆角。"):
|
||
return
|
||
if self.selected_edge_id is None:
|
||
QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。")
|
||
return
|
||
try:
|
||
radius = float(self.edge_fillet_radius_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "半径无效", "请输入数字形式的圆角半径。")
|
||
return
|
||
|
||
edge_id = self.selected_edge_id
|
||
plan = self.model.edge_fillet_plan(edge_id, radius)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能添加圆角", str(plan["message"]))
|
||
self.statusBar().showMessage("添加圆角已阻止")
|
||
return
|
||
|
||
if plan["risk"] != "low":
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认给边添加圆角",
|
||
(
|
||
f"edge: {plan['edge_id']}\n"
|
||
f"边长: {_format_value(plan['edge_length'])}\n"
|
||
f"目标圆角半径: {_format_value(plan['target_radius'])}\n"
|
||
f"半径/边长比例: {_format_percent(plan['radius_to_length_ratio'])}\n"
|
||
f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n"
|
||
f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会对当前零件执行实验性 OCCT 倒圆。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消添加圆角")
|
||
return
|
||
|
||
self._show_edge_fillet_preview(edge_id, radius)
|
||
|
||
def action():
|
||
return self.model.fillet_edge(edge_id, radius)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="给边添加圆角",
|
||
target=f"edge {edge_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"edge_length": plan.get("edge_length"),
|
||
"fillet_radius": radius,
|
||
"radius_to_length_ratio": plan.get("radius_to_length_ratio"),
|
||
"curve": plan.get("curve"),
|
||
"adjacent_face_ids": plan.get("adjacent_face_ids"),
|
||
"adjacent_face_count": plan.get("adjacent_face_count"),
|
||
"fillet_status": plan.get("status"),
|
||
"fillet_risk": plan.get("risk"),
|
||
"fillet_message": plan.get("message"),
|
||
"fillet_warnings": plan.get("warnings"),
|
||
"fillet_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="edge",
|
||
target_id=edge_id,
|
||
)
|
||
|
||
def chamfer_edge(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再添加倒角。"):
|
||
return
|
||
if self.selected_edge_id is None:
|
||
QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。")
|
||
return
|
||
try:
|
||
distance = float(self.edge_chamfer_distance_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "距离无效", "请输入数字形式的倒角距离。")
|
||
return
|
||
|
||
edge_id = self.selected_edge_id
|
||
plan = self.model.edge_chamfer_plan(edge_id, distance)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能添加倒角", str(plan["message"]))
|
||
self.statusBar().showMessage("添加倒角已阻止")
|
||
return
|
||
|
||
if plan["risk"] != "low":
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认给边添加倒角",
|
||
(
|
||
f"edge: {plan['edge_id']}\n"
|
||
f"边长: {_format_value(plan['edge_length'])}\n"
|
||
f"目标倒角距离: {_format_value(plan['target_distance'])}\n"
|
||
f"倒角距离/边长比例: {_format_percent(plan['distance_to_length_ratio'])}\n"
|
||
f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n"
|
||
f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会对当前零件执行实验性 OCCT 倒角。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消添加倒角")
|
||
return
|
||
|
||
self._show_edge_chamfer_preview(edge_id, distance)
|
||
|
||
def action():
|
||
return self.model.chamfer_edge(edge_id, distance)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="给边添加倒角",
|
||
target=f"edge {edge_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"edge_length": plan.get("edge_length"),
|
||
"chamfer_distance": distance,
|
||
"distance_to_length_ratio": plan.get("distance_to_length_ratio"),
|
||
"curve": plan.get("curve"),
|
||
"adjacent_face_ids": plan.get("adjacent_face_ids"),
|
||
"adjacent_face_count": plan.get("adjacent_face_count"),
|
||
"chamfer_status": plan.get("status"),
|
||
"chamfer_risk": plan.get("risk"),
|
||
"chamfer_message": plan.get("message"),
|
||
"chamfer_warnings": plan.get("warnings"),
|
||
"chamfer_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="edge",
|
||
target_id=edge_id,
|
||
)
|
||
|
||
def resize_edge_length(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再调整直线边长度。"):
|
||
return
|
||
if self.selected_edge_id is None:
|
||
QMessageBox.information(self, "未选择边", "请先选择一条直线 edge。")
|
||
return
|
||
try:
|
||
target_length = float(self.edge_target_length_input.text())
|
||
except ValueError:
|
||
QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。")
|
||
return
|
||
|
||
edge_id = self.selected_edge_id
|
||
plan = self.model.straight_edge_length_plan(edge_id, target_length)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能调整直线边长度", str(plan["message"]))
|
||
self.statusBar().showMessage("直线边长度调整已阻止")
|
||
return
|
||
|
||
if plan["risk"] != "low":
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
"确认调整直线边长度",
|
||
(
|
||
f"edge: {plan['edge_id']}\n"
|
||
f"当前边长: {_format_value(plan['current_length'])}\n"
|
||
f"目标边长: {_format_value(plan['target_length'])}\n"
|
||
f"变化量: {_format_value(plan['delta_length'])}\n"
|
||
f"端面 face: {_format_value(plan.get('end_face_id', ''))}\n"
|
||
f"端面位置: {_format_value(plan.get('end_face_label', ''))}\n"
|
||
f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"第一版会通过移动端点附近的平面端面来改变边长,不是通用参数化边长编辑。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
if result != QMessageBox.StandardButton.Yes:
|
||
self.statusBar().showMessage("已取消调整直线边长度")
|
||
return
|
||
|
||
self._show_edge_length_preview(edge_id, target_length)
|
||
|
||
def action():
|
||
return self.model.resize_straight_edge_length(edge_id, target_length)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="调整直线边长度",
|
||
target=f"edge {edge_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": plan.get("solid_id"),
|
||
"current_length": plan.get("current_length"),
|
||
"target_length": plan.get("target_length"),
|
||
"delta_length": plan.get("delta_length"),
|
||
"length_change_ratio": plan.get("length_change_ratio"),
|
||
"end_face_id": plan.get("end_face_id"),
|
||
"end_face_label": plan.get("end_face_label"),
|
||
"push_pull_distance": plan.get("push_pull_distance"),
|
||
"resize_strategy": plan.get("resize_strategy"),
|
||
"resize_status": plan.get("status"),
|
||
"resize_risk": plan.get("risk"),
|
||
"resize_message": plan.get("message"),
|
||
"resize_warnings": plan.get("warnings"),
|
||
"resize_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="edge",
|
||
target_id=edge_id,
|
||
)
|
||
|
||
def translate_selected_part(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再平移零件。"):
|
||
return
|
||
if self.selected_part_id is None:
|
||
QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。")
|
||
return
|
||
try:
|
||
vector = self._translation_vector_from_inputs()
|
||
except ValueError as exc:
|
||
QMessageBox.critical(self, "平移向量无效", str(exc))
|
||
return
|
||
part_id = self.selected_part_id
|
||
plan = self.model.translate_part_plan(part_id, vector)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能平移零件", str(plan["message"]))
|
||
self.statusBar().showMessage("平移零件已阻止")
|
||
return
|
||
if not self._confirm_translation_plan("确认平移零件", plan):
|
||
self.statusBar().showMessage("已取消平移零件")
|
||
return
|
||
|
||
def action():
|
||
return self.model.translate_part(part_id, vector)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="平移零件",
|
||
target=f"part {part_id}",
|
||
parameters={
|
||
"part_id": part_id,
|
||
"translation_vector": vector,
|
||
"translation_distance": plan.get("translation_distance"),
|
||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||
"translate_status": plan.get("status"),
|
||
"translate_risk": plan.get("risk"),
|
||
"translate_message": plan.get("message"),
|
||
"translate_warnings": plan.get("warnings"),
|
||
"translate_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="part",
|
||
target_id=part_id,
|
||
)
|
||
|
||
def translate_selected_solid(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再平移 solid。"):
|
||
return
|
||
if self.selected_solid_id is None:
|
||
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。")
|
||
return
|
||
try:
|
||
vector = self._translation_vector_from_inputs()
|
||
except ValueError as exc:
|
||
QMessageBox.critical(self, "平移向量无效", str(exc))
|
||
return
|
||
solid_id = self.selected_solid_id
|
||
plan = self.model.translate_solid_plan(solid_id, vector)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能平移 solid", str(plan["message"]))
|
||
self.statusBar().showMessage("平移 solid 已阻止")
|
||
return
|
||
if not self._confirm_translation_plan("确认平移 solid", plan):
|
||
self.statusBar().showMessage("已取消平移 solid")
|
||
return
|
||
|
||
def action():
|
||
return self.model.translate_solid(solid_id, vector)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="平移 solid",
|
||
target=f"solid {solid_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": solid_id,
|
||
"part_solid_count": plan.get("part_solid_count"),
|
||
"translation_vector": vector,
|
||
"translation_distance": plan.get("translation_distance"),
|
||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||
"translate_status": plan.get("status"),
|
||
"translate_risk": plan.get("risk"),
|
||
"translate_message": plan.get("message"),
|
||
"translate_warnings": plan.get("warnings"),
|
||
"translate_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="solid",
|
||
target_id=solid_id,
|
||
)
|
||
|
||
def _translation_vector_from_inputs(self) -> tuple[float, float, float]:
|
||
try:
|
||
return (
|
||
float(self.translate_x_input.text()),
|
||
float(self.translate_y_input.text()),
|
||
float(self.translate_z_input.text()),
|
||
)
|
||
except ValueError as exc:
|
||
raise ValueError("请输入数字形式的 X/Y/Z 平移量。") from exc
|
||
|
||
def _confirm_translation_plan(self, title: str, plan: dict[str, object]) -> bool:
|
||
if plan["risk"] == "low":
|
||
return True
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
title,
|
||
(
|
||
f"对象: {plan.get('target_kind', '')}\n"
|
||
f"part: {_format_value(plan.get('part_id', ''))}\n"
|
||
f"solid: {_format_value(plan.get('solid_id', ''))}\n"
|
||
f"平移向量: {_format_value(plan['translation_vector'])}\n"
|
||
f"平移距离: {_format_value(plan['translation_distance'])}\n"
|
||
f"对象 bbox_diagonal: {_format_value(plan.get('bbox_diagonal', ''))}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会移动当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
return result == QMessageBox.StandardButton.Yes
|
||
|
||
def rotate_selected_part(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再旋转零件。"):
|
||
return
|
||
if self.selected_part_id is None:
|
||
QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。")
|
||
return
|
||
try:
|
||
axis, angle = self._rotation_values_from_inputs()
|
||
except ValueError as exc:
|
||
QMessageBox.critical(self, "旋转参数无效", str(exc))
|
||
return
|
||
part_id = self.selected_part_id
|
||
plan = self.model.rotate_part_plan(part_id, axis, angle)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能旋转零件", str(plan["message"]))
|
||
self.statusBar().showMessage("旋转零件已阻止")
|
||
return
|
||
if not self._confirm_rotation_plan("确认旋转零件", plan):
|
||
self.statusBar().showMessage("已取消旋转零件")
|
||
return
|
||
|
||
def action():
|
||
return self.model.rotate_part(part_id, axis, angle)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="旋转零件",
|
||
target=f"part {part_id}",
|
||
parameters={
|
||
"part_id": part_id,
|
||
"rotation_axis": plan.get("rotation_axis"),
|
||
"rotation_angle_degrees": angle,
|
||
"rotation_center": plan.get("rotation_center"),
|
||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||
"rotate_status": plan.get("status"),
|
||
"rotate_risk": plan.get("risk"),
|
||
"rotate_message": plan.get("message"),
|
||
"rotate_warnings": plan.get("warnings"),
|
||
"rotate_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="part",
|
||
target_id=part_id,
|
||
)
|
||
|
||
def rotate_selected_solid(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("请等待当前编辑完成后再旋转 solid。"):
|
||
return
|
||
if self.selected_solid_id is None:
|
||
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。")
|
||
return
|
||
try:
|
||
axis, angle = self._rotation_values_from_inputs()
|
||
except ValueError as exc:
|
||
QMessageBox.critical(self, "旋转参数无效", str(exc))
|
||
return
|
||
solid_id = self.selected_solid_id
|
||
plan = self.model.rotate_solid_plan(solid_id, axis, angle)
|
||
if plan["status"] == "blocked":
|
||
QMessageBox.information(self, "不能旋转 solid", str(plan["message"]))
|
||
self.statusBar().showMessage("旋转 solid 已阻止")
|
||
return
|
||
if not self._confirm_rotation_plan("确认旋转 solid", plan):
|
||
self.statusBar().showMessage("已取消旋转 solid")
|
||
return
|
||
|
||
def action():
|
||
return self.model.rotate_solid(solid_id, axis, angle)
|
||
|
||
self._run_edit_action(
|
||
action,
|
||
operation_name="旋转 solid",
|
||
target=f"solid {solid_id}",
|
||
parameters={
|
||
"part_id": plan.get("part_id"),
|
||
"solid_id": solid_id,
|
||
"part_solid_count": plan.get("part_solid_count"),
|
||
"rotation_axis": plan.get("rotation_axis"),
|
||
"rotation_angle_degrees": angle,
|
||
"rotation_center": plan.get("rotation_center"),
|
||
"bbox_diagonal": plan.get("bbox_diagonal"),
|
||
"rotate_status": plan.get("status"),
|
||
"rotate_risk": plan.get("risk"),
|
||
"rotate_message": plan.get("message"),
|
||
"rotate_warnings": plan.get("warnings"),
|
||
"rotate_blockers": plan.get("blockers"),
|
||
},
|
||
target_kind="solid",
|
||
target_id=solid_id,
|
||
)
|
||
|
||
def _rotation_values_from_inputs(self) -> tuple[str, float]:
|
||
try:
|
||
angle = float(self.rotate_angle_input.text())
|
||
except ValueError as exc:
|
||
raise ValueError("请输入数字形式的旋转角度。") from exc
|
||
return self.rotate_axis_combo.currentText(), angle
|
||
|
||
def _confirm_rotation_plan(self, title: str, plan: dict[str, object]) -> bool:
|
||
if plan["risk"] == "low":
|
||
return True
|
||
warnings = str(plan.get("warnings", ""))
|
||
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
|
||
result = QMessageBox.question(
|
||
self,
|
||
title,
|
||
(
|
||
f"对象: {plan.get('target_kind', '')}\n"
|
||
f"part: {_format_value(plan.get('part_id', ''))}\n"
|
||
f"solid: {_format_value(plan.get('solid_id', ''))}\n"
|
||
f"旋转轴: {_format_value(plan['rotation_axis'])}\n"
|
||
f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n"
|
||
f"旋转中心: {_format_value(plan['rotation_center'])}\n"
|
||
f"风险: {plan['risk']}\n\n"
|
||
f"{warnings_line}"
|
||
f"{plan['message']}\n\n"
|
||
"继续操作会旋转当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n"
|
||
"确定继续吗?"
|
||
),
|
||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||
QMessageBox.StandardButton.No,
|
||
)
|
||
return result == QMessageBox.StandardButton.Yes
|
||
|
||
def list_cylinders(self) -> None:
|
||
self.refresh_cylinder_candidates(show_info=True)
|
||
|
||
def _clear_editable_candidates(self) -> None:
|
||
if hasattr(self, "editable_table"):
|
||
self.editable_table.setRowCount(0)
|
||
|
||
def refresh_editable_candidates(self, show_info: bool = True, deep_scan: bool = False) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能扫描可编辑对象。"):
|
||
return
|
||
limit = 48 if deep_scan else 24
|
||
scan_label = "深度扫描" if deep_scan else "扫描"
|
||
self.statusBar().showMessage(f"正在{scan_label}第一版可编辑对象...")
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
QApplication.processEvents()
|
||
try:
|
||
candidates = self.model.editable_feature_candidates(
|
||
limit=limit,
|
||
progress_callback=QApplication.processEvents,
|
||
)
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "扫描失败", str(exc))
|
||
self.statusBar().showMessage("第一版可编辑对象扫描失败")
|
||
return
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
self.editable_table.setRowCount(len(candidates))
|
||
lines = [f"第一版可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit})"]
|
||
for row, item in enumerate(candidates):
|
||
target_kind = str(item.get("target_kind", "face"))
|
||
target_id = int(item.get("target_id", item.get("face_id", item.get("edge_id", -1))))
|
||
operation_item = QTableWidgetItem(str(item["operation"]))
|
||
operation_item.setData(EDITABLE_TARGET_ID_ROLE, target_id)
|
||
operation_item.setData(EDITABLE_TARGET_KIND_ROLE, target_kind)
|
||
operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"]))
|
||
id_item = QTableWidgetItem(f"{target_kind} {target_id}")
|
||
object_item = QTableWidgetItem(str(item["feature_guess"]))
|
||
current_item = QTableWidgetItem(
|
||
f"{item['current_value_label']}={_format_value(item['current_value'])}"
|
||
)
|
||
status_item = QTableWidgetItem(str(item["status"]))
|
||
risk_item = QTableWidgetItem(str(item["risk"]))
|
||
confidence_item = QTableWidgetItem(str(item["confidence"]))
|
||
note_item = QTableWidgetItem(str(item["note"]))
|
||
row_items = [
|
||
operation_item,
|
||
id_item,
|
||
object_item,
|
||
current_item,
|
||
status_item,
|
||
risk_item,
|
||
confidence_item,
|
||
note_item,
|
||
]
|
||
for column, table_item in enumerate(row_items):
|
||
table_item.setToolTip(table_item.text())
|
||
self.editable_table.setItem(row, column, table_item)
|
||
lines.append(
|
||
f"{target_kind} {target_id}: {item['operation']}, "
|
||
f"part={item['part_id']}, solid={item['solid_id']}, "
|
||
f"object={item['feature_guess']}, "
|
||
f"{item['current_value_label']}={_format_value(item['current_value'])}, "
|
||
f"status={item['status']}, risk={item['risk']}, "
|
||
f"confidence={item['confidence']}, note={item['note']}"
|
||
)
|
||
self.editable_table.resizeColumnsToContents()
|
||
self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个")
|
||
if show_info:
|
||
self.set_plain_info("\n".join(lines))
|
||
|
||
def _clear_cylinder_candidates(self) -> None:
|
||
self.cylinder_candidate_cache = []
|
||
self.cylinder_candidates_loaded = False
|
||
if hasattr(self, "cylinder_table"):
|
||
self.cylinder_table.setRowCount(0)
|
||
|
||
def refresh_cylinder_candidates(self, show_info: bool = True) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能扫描圆柱候选。"):
|
||
return
|
||
self.statusBar().showMessage("正在扫描圆柱候选...")
|
||
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
|
||
QApplication.processEvents()
|
||
try:
|
||
self.cylinder_candidate_cache = self.model.cylindrical_feature_candidates(
|
||
limit=60,
|
||
progress_callback=QApplication.processEvents,
|
||
)
|
||
self.cylinder_candidates_loaded = True
|
||
except Exception as exc:
|
||
self._clear_cylinder_candidates()
|
||
QMessageBox.critical(self, "扫描失败", str(exc))
|
||
self.statusBar().showMessage("圆柱候选扫描失败")
|
||
return
|
||
finally:
|
||
QApplication.restoreOverrideCursor()
|
||
self._filter_cached_cylinder_candidates(show_info=show_info)
|
||
|
||
def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None:
|
||
if not self.cylinder_candidates_loaded:
|
||
self.cylinder_table.setRowCount(0)
|
||
if show_info:
|
||
self.set_plain_info("圆柱候选尚未扫描。点击 `列出圆柱候选` 后再切换筛选类型。")
|
||
return
|
||
candidates = [
|
||
item
|
||
for item in self.cylinder_candidate_cache
|
||
if self._candidate_matches_filter(str(item["feature_guess"]))
|
||
]
|
||
self._populate_cylinder_table(candidates, show_info=show_info)
|
||
|
||
def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None:
|
||
self.cylinder_table.setRowCount(len(candidates))
|
||
lines = [f"圆柱候选:显示 {len(candidates)} 个"]
|
||
for row, item in enumerate(candidates):
|
||
face_item = QTableWidgetItem(str(item["face_id"]))
|
||
face_item.setData(Qt.UserRole, int(item["face_id"]))
|
||
guess_item = QTableWidgetItem(str(item["feature_guess"]))
|
||
diameter_item = QTableWidgetItem(_format_float(float(item["diameter"])))
|
||
span_item = QTableWidgetItem(_format_float(float(item["angular_span"])))
|
||
height_item = QTableWidgetItem(_format_float(float(item["height_estimate"])))
|
||
confidence_item = QTableWidgetItem(str(item["confidence"]))
|
||
risk_item = QTableWidgetItem(str(item["resize_risk"]))
|
||
part_item = QTableWidgetItem(str(item["part_id"]))
|
||
self.cylinder_table.setItem(row, 0, face_item)
|
||
self.cylinder_table.setItem(row, 1, guess_item)
|
||
self.cylinder_table.setItem(row, 2, diameter_item)
|
||
self.cylinder_table.setItem(row, 3, span_item)
|
||
self.cylinder_table.setItem(row, 4, height_item)
|
||
self.cylinder_table.setItem(row, 5, confidence_item)
|
||
self.cylinder_table.setItem(row, 6, risk_item)
|
||
self.cylinder_table.setItem(row, 7, part_item)
|
||
lines.append(
|
||
f"face {item['face_id']}: part {item['part_id']}, "
|
||
f"guess={item['feature_guess']}, "
|
||
f"diameter={_format_float(float(item['diameter']))}, "
|
||
f"height={_format_float(float(item['height_estimate']))}, "
|
||
f"confidence={item['confidence']}, "
|
||
f"risk={item['resize_risk']}"
|
||
)
|
||
self.cylinder_table.resizeColumnsToContents()
|
||
self.statusBar().showMessage(f"已显示圆柱候选:{len(candidates)} 个")
|
||
if show_info:
|
||
self.set_plain_info("\n".join(lines))
|
||
|
||
def _candidate_matches_filter(self, feature_guess: str) -> bool:
|
||
current = self.candidate_filter_combo.currentText()
|
||
if current == "All":
|
||
return True
|
||
if current == "Hole/Groove":
|
||
return feature_guess == "hole/groove candidate"
|
||
if current == "Round/Fillet":
|
||
return feature_guess == "round/fillet candidate"
|
||
if current == "Boss/Outer":
|
||
return feature_guess == "boss/outer-round candidate"
|
||
if current == "Unclear":
|
||
return feature_guess == "cylindrical face"
|
||
return True
|
||
|
||
def _run_action(self, action, success_message: str | None) -> None:
|
||
try:
|
||
result = action()
|
||
except Exception as exc:
|
||
QMessageBox.critical(self, "操作失败", str(exc))
|
||
self.statusBar().showMessage("操作失败")
|
||
return
|
||
message = success_message or str(result)
|
||
self.statusBar().showMessage(message)
|
||
self.set_plain_info(message)
|
||
|
||
def _run_edit_action(
|
||
self,
|
||
action,
|
||
operation_name: str,
|
||
target: str,
|
||
parameters: dict[str, object],
|
||
target_kind: str | None = None,
|
||
target_id: int | None = None,
|
||
) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self.operation_in_progress:
|
||
QMessageBox.information(self, "编辑进行中", "请等待当前编辑计算完成。")
|
||
return
|
||
context = {
|
||
"operation_name": operation_name,
|
||
"target": target,
|
||
"parameters": parameters,
|
||
"target_kind": target_kind,
|
||
"target_id": target_id,
|
||
"pick_position": self.selected_pick_position,
|
||
}
|
||
|
||
self._begin_edit_task(operation_name)
|
||
self.pending_edit_context = context
|
||
thread = QThread(self)
|
||
worker = EditWorker(self._make_edit_job(action, context))
|
||
worker.moveToThread(thread)
|
||
thread.started.connect(worker.run)
|
||
worker.finished.connect(self._finish_edit_action)
|
||
worker.failed.connect(self._fail_edit_action)
|
||
worker.finished.connect(thread.quit)
|
||
worker.failed.connect(thread.quit)
|
||
thread.finished.connect(worker.deleteLater)
|
||
thread.finished.connect(thread.deleteLater)
|
||
thread.finished.connect(self._forget_edit_thread)
|
||
self.edit_thread = thread
|
||
self.edit_worker = worker
|
||
thread.start()
|
||
|
||
def _make_edit_job(self, action, context: dict[str, object]):
|
||
def job():
|
||
if self.model is None:
|
||
raise RuntimeError("Model is not loaded.")
|
||
snapshot = self.model.snapshot()
|
||
target_part_id = self._edit_context_part_id(context)
|
||
before_stats = self.model.stats()
|
||
before_part_stats = self._part_stats_or_none(target_part_id)
|
||
before_geometry = self.model.geometry_stats()
|
||
try:
|
||
result = action()
|
||
after_snapshot = self.model.snapshot()
|
||
after_stats = self.model.stats()
|
||
after_part_stats = self._part_stats_or_none(target_part_id)
|
||
after_geometry = self.model.geometry_stats()
|
||
model_polydata = self.model.build_face_polydata()
|
||
edge_polydata = self.model.build_edge_polydata()
|
||
except Exception as exc:
|
||
try:
|
||
self.model.restore_snapshot(snapshot)
|
||
except Exception as rollback_exc:
|
||
raise RuntimeError(
|
||
f"编辑失败,且回滚到操作前状态也失败:{rollback_exc}\n原始错误:{exc}"
|
||
) from exc
|
||
raise RuntimeError(f"编辑失败,模型已恢复到操作前状态:{exc}") from exc
|
||
return {
|
||
"message": str(result),
|
||
"snapshot": snapshot,
|
||
"before_stats": before_stats,
|
||
"before_part_stats": before_part_stats,
|
||
"before_geometry": before_geometry,
|
||
"after_snapshot": after_snapshot,
|
||
"after_stats": after_stats,
|
||
"after_part_stats": after_part_stats,
|
||
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
|
||
"after_geometry": after_geometry,
|
||
"model_polydata": model_polydata,
|
||
"edge_polydata": edge_polydata,
|
||
}
|
||
|
||
return job
|
||
|
||
def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
|
||
if self.model is None:
|
||
return None
|
||
parameters = context.get("parameters")
|
||
if isinstance(parameters, dict):
|
||
part_id = parameters.get("part_id")
|
||
if part_id not in {"", None}:
|
||
try:
|
||
return int(part_id)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
target_kind = context.get("target_kind")
|
||
target_id = context.get("target_id")
|
||
if target_id is None:
|
||
return None
|
||
try:
|
||
numeric_id = int(target_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
if target_kind == "part":
|
||
return numeric_id
|
||
if target_kind in {"face", "feature"} and 0 <= numeric_id < len(self.model.face_part_ids):
|
||
return int(self.model.face_part_ids[numeric_id])
|
||
if target_kind == "edge" and 0 <= numeric_id < len(self.model.edge_part_ids):
|
||
return int(self.model.edge_part_ids[numeric_id])
|
||
if target_kind == "solid" and 0 <= numeric_id < len(self.model.solids):
|
||
return int(self.model.solids[numeric_id][0])
|
||
return None
|
||
|
||
def _part_stats_or_none(self, part_id: int | None):
|
||
if self.model is None or part_id is None:
|
||
return None
|
||
try:
|
||
return self.model.part_topology_stats(part_id)
|
||
except Exception:
|
||
return None
|
||
|
||
def _begin_edit_task(self, operation_name: str) -> None:
|
||
self.operation_in_progress = True
|
||
self._set_main_content_busy(True)
|
||
self.statusBar().showMessage(f"{operation_name} 正在后台计算...")
|
||
if "推拉" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,半透明预览表示推拉方向和大致范围..."
|
||
elif "孔径" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,红色预览表示切削范围,绿色预览表示补料范围..."
|
||
elif "凸台" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,绿色预览表示扩大补料范围,红色预览表示缩小切削范围..."
|
||
elif "封堵" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,绿色预览表示封堵补料范围..."
|
||
elif "孔深" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,红色预览表示加深切削范围,绿色预览表示变浅补料范围..."
|
||
elif "圆角" in operation_name:
|
||
if "已有" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,蓝色预览表示将移除并重建的已有圆角面..."
|
||
else:
|
||
progress_text = f"{operation_name} 正在计算,蓝色预览表示目标边和半径范围..."
|
||
elif "倒角" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,橙色预览表示目标边和倒角距离范围..."
|
||
elif "边长度" in operation_name:
|
||
progress_text = f"{operation_name} 正在计算,蓝绿色预览表示将移动的端面推拉范围..."
|
||
elif "平移" in operation_name:
|
||
progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型位置和拓扑索引..."
|
||
elif "旋转" in operation_name:
|
||
progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型姿态和拓扑索引..."
|
||
else:
|
||
progress_text = f"{operation_name} 正在后台计算..."
|
||
self.edit_progress = QProgressDialog(progress_text, "", 0, 0, self)
|
||
self.edit_progress.setWindowTitle("编辑计算中")
|
||
self.edit_progress.setCancelButton(None)
|
||
self.edit_progress.setAutoClose(False)
|
||
self.edit_progress.setAutoReset(False)
|
||
self.edit_progress.setMinimumDuration(0)
|
||
self.edit_progress.setWindowModality(Qt.WindowModality.WindowModal)
|
||
self.edit_progress.show()
|
||
QApplication.processEvents()
|
||
|
||
def _set_edit_progress_text(self, text: str) -> None:
|
||
if self.edit_progress is not None:
|
||
self.edit_progress.setLabelText(text)
|
||
QApplication.processEvents()
|
||
|
||
@Slot(object)
|
||
def _finish_edit_action(self, result: object) -> None:
|
||
context = self.pending_edit_context
|
||
if context is None:
|
||
self._end_edit_task(clear_preview=True)
|
||
self.statusBar().showMessage("编辑已完成,但上下文丢失")
|
||
return
|
||
if self.model is None:
|
||
self._end_edit_task(clear_preview=True)
|
||
return
|
||
self._set_edit_progress_text("布尔计算已完成,正在刷新模型显示和历史记录...")
|
||
if not isinstance(result, dict):
|
||
self._end_edit_task(clear_preview=True)
|
||
QMessageBox.critical(self, "操作失败", "后台编辑返回了无法识别的结果。")
|
||
self.statusBar().showMessage("编辑结果无法识别")
|
||
return
|
||
message = str(result["message"])
|
||
try:
|
||
record = self._make_operation_record(
|
||
operation_name=str(context["operation_name"]),
|
||
target=str(context["target"]),
|
||
parameters=dict(context["parameters"]),
|
||
result_message=message,
|
||
before_stats=result["before_stats"],
|
||
after_stats=result["after_stats"],
|
||
before_part_stats=result.get("before_part_stats"),
|
||
after_part_stats=result.get("after_part_stats"),
|
||
quality_warnings=list(result.get("quality_warnings", [])),
|
||
before_geometry=dict(result["before_geometry"]),
|
||
after_geometry=dict(result["after_geometry"]),
|
||
target_kind=context["target_kind"],
|
||
target_id=context["target_id"],
|
||
pick_position=context["pick_position"],
|
||
before_snapshot=result["snapshot"],
|
||
after_snapshot=result["after_snapshot"],
|
||
)
|
||
self.clear_edit_preview(render=False)
|
||
self._reset_selection()
|
||
self._populate_part_tree()
|
||
self._rebuild_scene_from_polydata(result["model_polydata"], result["edge_polydata"], reset_camera=False)
|
||
except Exception as exc:
|
||
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
|
||
self._end_edit_task(clear_preview=True)
|
||
QMessageBox.critical(self, "操作失败", f"编辑结果刷新失败:{exc}\n\n{rollback_message}")
|
||
self.statusBar().showMessage("编辑已完成,但刷新结果时失败")
|
||
return
|
||
|
||
self.undo_stack.append(result["snapshot"])
|
||
self.redo_stack.clear()
|
||
self.operation_history.append(record)
|
||
self.redo_history.clear()
|
||
self._clear_editable_candidates()
|
||
self._clear_cylinder_candidates()
|
||
self._refresh_history_list()
|
||
self._end_edit_task(clear_preview=False)
|
||
if result.get("quality_warnings"):
|
||
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
|
||
else:
|
||
self.statusBar().showMessage(message)
|
||
self.set_plain_info(record.detail)
|
||
|
||
@Slot(str)
|
||
def _fail_edit_action(self, message: str) -> None:
|
||
self._end_edit_task(clear_preview=True)
|
||
QMessageBox.critical(self, "操作失败", message)
|
||
self._clear_editable_candidates()
|
||
self._clear_cylinder_candidates()
|
||
self.statusBar().showMessage("操作失败,模型已保持在编辑前状态")
|
||
|
||
def _restore_failed_edit_snapshot(self, snapshot: object) -> str:
|
||
if self.model is None or not isinstance(snapshot, dict):
|
||
return "未找到可用的编辑前快照,请重新加载 STEP 文件确认状态。"
|
||
try:
|
||
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()
|
||
except Exception as rollback_exc:
|
||
return f"尝试回滚到编辑前状态失败:{rollback_exc}。建议重新加载 STEP 文件。"
|
||
return "模型已回滚到编辑前状态。"
|
||
|
||
def _end_edit_task(self, clear_preview: bool = True) -> None:
|
||
self.operation_in_progress = False
|
||
self.pending_edit_context = None
|
||
if clear_preview:
|
||
self.clear_edit_preview(render=False)
|
||
self._set_main_content_busy(False)
|
||
if self.edit_progress is not None:
|
||
self.edit_progress.close()
|
||
self.edit_progress.deleteLater()
|
||
self.edit_progress = None
|
||
QApplication.processEvents()
|
||
|
||
def _set_main_content_busy(self, busy: bool) -> None:
|
||
content = self.centralWidget()
|
||
if content is None:
|
||
return
|
||
if busy:
|
||
self.main_content_enabled_before_edit = content.isEnabled()
|
||
content.setEnabled(False)
|
||
else:
|
||
content.setEnabled(self.main_content_enabled_before_edit)
|
||
self._update_action_states()
|
||
|
||
def _forget_edit_thread(self) -> None:
|
||
self.edit_thread = None
|
||
self.edit_worker = None
|
||
|
||
def _make_operation_record(
|
||
self,
|
||
operation_name: str,
|
||
target: str,
|
||
parameters: dict[str, object],
|
||
result_message: str,
|
||
before_stats,
|
||
after_stats,
|
||
before_geometry: dict[str, object],
|
||
after_geometry: dict[str, object],
|
||
before_part_stats=None,
|
||
after_part_stats=None,
|
||
quality_warnings: list[str] | None = None,
|
||
target_kind: str | None = None,
|
||
target_id: int | None = None,
|
||
pick_position: tuple[float, float, float] | None = None,
|
||
before_snapshot: dict[int, object] | None = None,
|
||
after_snapshot: dict[int, object] | None = None,
|
||
) -> OperationRecord:
|
||
summary_parts = [operation_name, target]
|
||
if "distance" in parameters:
|
||
summary_parts.append(f"distance={_format_value(parameters['distance'])}")
|
||
if "semantic_distance" in parameters:
|
||
summary_parts.append(f"distance={_format_value(parameters['semantic_distance'])}")
|
||
if "new_diameter" in parameters:
|
||
summary_parts.append(f"diameter={_format_value(parameters['new_diameter'])}")
|
||
summary = " | ".join(summary_parts)
|
||
|
||
lines = [
|
||
f"operation: {operation_name}",
|
||
f"target: {target}",
|
||
f"target_kind: {target_kind or ''}",
|
||
f"target_id: {target_id if target_id is not None else ''}",
|
||
"parameters:",
|
||
]
|
||
if pick_position is not None:
|
||
lines.insert(4, f"pick_position: {_format_value(pick_position)}")
|
||
for key, value in parameters.items():
|
||
lines.append(f" {key}: {_format_value(value)}")
|
||
if before_part_stats is not None and after_part_stats is not None:
|
||
lines.extend(
|
||
[
|
||
"target part topology before:",
|
||
f" solids: {before_part_stats.solids}",
|
||
f" faces: {before_part_stats.faces}",
|
||
f" edges: {before_part_stats.edges}",
|
||
"target part topology after:",
|
||
f" solids: {after_part_stats.solids} ({_signed_delta(after_part_stats.solids - before_part_stats.solids)})",
|
||
f" faces: {after_part_stats.faces} ({_signed_delta(after_part_stats.faces - before_part_stats.faces)})",
|
||
f" edges: {after_part_stats.edges} ({_signed_delta(after_part_stats.edges - before_part_stats.edges)})",
|
||
]
|
||
)
|
||
if quality_warnings:
|
||
lines.append("quality warnings:")
|
||
for warning in quality_warnings:
|
||
lines.append(f" {warning}")
|
||
lines.extend(
|
||
[
|
||
"topology before:",
|
||
f" solids: {before_stats.solids}",
|
||
f" faces: {before_stats.faces}",
|
||
f" edges: {before_stats.edges}",
|
||
"topology after:",
|
||
f" solids: {after_stats.solids} ({_signed_delta(after_stats.solids - before_stats.solids)})",
|
||
f" faces: {after_stats.faces} ({_signed_delta(after_stats.faces - before_stats.faces)})",
|
||
f" edges: {after_stats.edges} ({_signed_delta(after_stats.edges - before_stats.edges)})",
|
||
"geometry before:",
|
||
f" volume: {_format_value(before_geometry.get('volume', ''))}",
|
||
f" surface_area: {_format_value(before_geometry.get('surface_area', ''))}",
|
||
f" bbox_size: {_format_value(before_geometry.get('bbox_size', ''))}",
|
||
f" bbox_diagonal: {_format_value(before_geometry.get('bbox_diagonal', ''))}",
|
||
"geometry after:",
|
||
f" volume: {_format_after_delta(before_geometry, after_geometry, 'volume')}",
|
||
f" surface_area: {_format_after_delta(before_geometry, after_geometry, 'surface_area')}",
|
||
f" bbox_size: {_format_after_delta(before_geometry, after_geometry, 'bbox_size')}",
|
||
f" bbox_diagonal: {_format_after_delta(before_geometry, after_geometry, 'bbox_diagonal')}",
|
||
"result:",
|
||
f" {result_message}",
|
||
]
|
||
)
|
||
return OperationRecord(
|
||
summary=summary,
|
||
detail="\n".join(lines),
|
||
target_kind=target_kind,
|
||
target_id=target_id,
|
||
pick_position=pick_position,
|
||
before_snapshot=before_snapshot,
|
||
after_snapshot=after_snapshot,
|
||
)
|
||
|
||
def set_info(self, info: dict[str, object]) -> None:
|
||
self.current_info_values = dict(info)
|
||
self.current_info_text = _info_to_text(info)
|
||
self.info_text.setPlainText(self.current_info_text)
|
||
self._populate_info_tree(info)
|
||
self.info_tabs.setCurrentWidget(self.info_tree)
|
||
|
||
def set_plain_info(self, text: str) -> None:
|
||
self.current_info_values = {}
|
||
self.current_info_text = text
|
||
self.info_tree.clear()
|
||
self.info_text.setPlainText(text)
|
||
self.info_tabs.setCurrentWidget(self.info_text)
|
||
|
||
def _populate_info_tree(self, info: dict[str, object]) -> None:
|
||
self.info_tree.clear()
|
||
emitted: set[str] = set()
|
||
for group_name, keys in INFO_GROUPS:
|
||
items = [(key, info[key]) for key in keys if key in info]
|
||
if not items:
|
||
continue
|
||
self._add_info_group(group_name, items)
|
||
emitted.update(key for key, _value in items)
|
||
|
||
remaining = [(key, value) for key, value in info.items() if key not in emitted]
|
||
if remaining:
|
||
self._add_info_group("其他", remaining)
|
||
|
||
self.info_tree.expandAll()
|
||
self.info_tree.resizeColumnToContents(0)
|
||
|
||
def _add_info_group(self, group_name: str, items: list[tuple[str, object]]) -> None:
|
||
group = QTreeWidgetItem([group_name, ""])
|
||
group.setFirstColumnSpanned(True)
|
||
self.info_tree.addTopLevelItem(group)
|
||
for key, value in items:
|
||
child = QTreeWidgetItem([INFO_LABELS.get(key, key), _format_value(value)])
|
||
child.setData(0, Qt.UserRole, key)
|
||
child.setToolTip(0, key)
|
||
child.setToolTip(1, _format_value(value))
|
||
group.addChild(child)
|
||
|
||
def copy_selected_id(self) -> None:
|
||
text = self._selected_id_text()
|
||
if not text:
|
||
self.statusBar().showMessage("没有可复制的对象 ID")
|
||
return
|
||
QApplication.clipboard().setText(text)
|
||
self.statusBar().showMessage(f"已复制 {text}")
|
||
|
||
def copy_pick_position(self) -> None:
|
||
pick_position = self.selected_pick_position
|
||
if pick_position is None and "pick_position" in self.current_info_values:
|
||
value = self.current_info_values["pick_position"]
|
||
if isinstance(value, tuple) and len(value) == 3:
|
||
pick_position = (float(value[0]), float(value[1]), float(value[2]))
|
||
if pick_position is None:
|
||
self.statusBar().showMessage("没有可复制的拾取坐标")
|
||
return
|
||
text = _format_value(pick_position)
|
||
QApplication.clipboard().setText(text)
|
||
self.statusBar().showMessage(f"已复制拾取坐标 {text}")
|
||
|
||
def copy_current_info(self) -> None:
|
||
if not self.current_info_text:
|
||
self.statusBar().showMessage("没有可复制的信息")
|
||
return
|
||
QApplication.clipboard().setText(self.current_info_text)
|
||
self.statusBar().showMessage("已复制当前信息")
|
||
|
||
def _selected_id_text(self) -> str:
|
||
if self.selected_kind == "part" and self.selected_part_id is not None:
|
||
return f"part {self.selected_part_id}"
|
||
if self.selected_kind == "solid" and self.selected_solid_id is not None:
|
||
return f"solid {self.selected_solid_id}"
|
||
if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None:
|
||
return f"face {self.selected_face_id}"
|
||
if self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||
return f"edge {self.selected_edge_id}"
|
||
for kind, key in (("face", "face_id"), ("edge", "edge_id"), ("solid", "solid_id"), ("part", "part_id")):
|
||
if key in self.current_info_values:
|
||
return f"{kind} {self.current_info_values[key]}"
|
||
return ""
|
||
|
||
|
||
def _format_float(value: float) -> str:
|
||
return f"{value:.6g}"
|
||
|
||
|
||
def _info_to_text(info: dict[str, object]) -> str:
|
||
return "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
|
||
|
||
|
||
def _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 _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 _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)
|
||
|
||
|
||
def _parse_args(argv: list[str]) -> tuple[Path, bool]:
|
||
smoke_test = "--smoke-test" in argv
|
||
paths = [arg for arg in argv[1:] if not arg.startswith("--")]
|
||
path = Path(paths[0]) if paths else Path("geom_extract.step")
|
||
return path, smoke_test
|
||
|
||
|
||
def main() -> int:
|
||
path, smoke_test = _parse_args(sys.argv)
|
||
app = QApplication(sys.argv)
|
||
window = StepEditorWindow(path)
|
||
if smoke_test:
|
||
print("smoke test ok")
|
||
window.close()
|
||
app.quit()
|
||
return 0
|
||
window.show()
|
||
window.vtk_widget.Start()
|
||
return app.exec()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|