2026-07-27 18:28:26 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
import math
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
import vtk
|
|
|
|
|
|
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
|
|
|
|
|
from PySide6.QtWidgets import (
|
|
|
|
|
|
QApplication,
|
|
|
|
|
|
QFileDialog,
|
|
|
|
|
|
QMessageBox,
|
2026-07-28 18:30:58 +08:00
|
|
|
|
QPushButton,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
QTableWidgetItem,
|
|
|
|
|
|
QTreeWidgetItem,
|
|
|
|
|
|
)
|
2026-07-28 18:30:58 +08:00
|
|
|
|
from PySide6.QtGui import QColor
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
from .model import StepModel
|
|
|
|
|
|
from .records import OperationRecord
|
|
|
|
|
|
from .ui_helpers import * # noqa: F403
|
|
|
|
|
|
from .workers import EditWorker, LoadWorker, ScanWorker
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
PROPERTY_VALUE_TOLERANCE = 1e-9
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
class WindowStateMixin:
|
|
|
|
|
|
def _reset_selection(self, clear_highlight: bool = True) -> None:
|
|
|
|
|
|
self.selected_kind = None
|
|
|
|
|
|
self.selected_part_id = None
|
|
|
|
|
|
self.selected_solid_id = None
|
|
|
|
|
|
self.selected_face_id = None
|
|
|
|
|
|
self.selected_edge_id = None
|
|
|
|
|
|
self.selected_pick_position = None
|
|
|
|
|
|
if clear_highlight:
|
|
|
|
|
|
self._clear_highlight()
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._update_selected_object_title()
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._update_action_states()
|
|
|
|
|
|
|
|
|
|
|
|
def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None:
|
|
|
|
|
|
if pick_position is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
radius = self._pick_marker_radius()
|
|
|
|
|
|
sphere = vtk.vtkSphereSource()
|
|
|
|
|
|
sphere.SetCenter(*pick_position)
|
|
|
|
|
|
sphere.SetRadius(radius)
|
|
|
|
|
|
sphere.SetThetaResolution(20)
|
|
|
|
|
|
sphere.SetPhiResolution(12)
|
|
|
|
|
|
|
|
|
|
|
|
mapper = vtk.vtkPolyDataMapper()
|
|
|
|
|
|
mapper.SetInputConnection(sphere.GetOutputPort())
|
|
|
|
|
|
actor = vtk.vtkActor()
|
|
|
|
|
|
actor.SetMapper(mapper)
|
|
|
|
|
|
actor.GetProperty().SetColor(0.1, 0.95, 0.95)
|
|
|
|
|
|
actor.GetProperty().SetSpecular(0.4)
|
|
|
|
|
|
actor.GetProperty().SetSpecularPower(18)
|
|
|
|
|
|
self.pick_marker_actor = actor
|
|
|
|
|
|
self.renderer.AddActor(actor)
|
|
|
|
|
|
self.render_window.Render()
|
|
|
|
|
|
|
|
|
|
|
|
def _pick_marker_radius(self) -> float:
|
|
|
|
|
|
bounds = self.model_actor.GetBounds() if self.model_actor is not None else None
|
|
|
|
|
|
if bounds is None:
|
|
|
|
|
|
return 1.0
|
|
|
|
|
|
dx = bounds[1] - bounds[0]
|
|
|
|
|
|
dy = bounds[3] - bounds[2]
|
|
|
|
|
|
dz = bounds[5] - bounds[4]
|
|
|
|
|
|
diagonal = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
|
|
|
|
return max(diagonal * 0.004, 0.1)
|
|
|
|
|
|
|
|
|
|
|
|
def _with_pick_info(
|
|
|
|
|
|
self,
|
|
|
|
|
|
info: dict[str, object],
|
|
|
|
|
|
pick_position: tuple[float, float, float] | None,
|
|
|
|
|
|
) -> dict[str, object]:
|
|
|
|
|
|
if pick_position is None:
|
|
|
|
|
|
return info
|
|
|
|
|
|
enriched = dict(info)
|
|
|
|
|
|
enriched["pick_position"] = pick_position
|
|
|
|
|
|
return enriched
|
|
|
|
|
|
|
|
|
|
|
|
def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str:
|
|
|
|
|
|
if pick_position is None:
|
|
|
|
|
|
return message
|
|
|
|
|
|
return f"{message},拾取点 {_format_value(pick_position)}"
|
|
|
|
|
|
|
|
|
|
|
|
def _edit_busy(self, message: str = "编辑计算中,请等待当前操作完成。") -> bool:
|
|
|
|
|
|
if self.load_in_progress:
|
|
|
|
|
|
self.statusBar().showMessage("STEP background loading is still running.")
|
|
|
|
|
|
return True
|
|
|
|
|
|
if self.operation_in_progress:
|
|
|
|
|
|
self.statusBar().showMessage(message)
|
|
|
|
|
|
return True
|
|
|
|
|
|
if self.scan_in_progress:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self.statusBar().showMessage("扫描中,请等待扫描完成后再执行该操作。")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
def _sync_id_picker(self, kind: str, target_id: int) -> None:
|
|
|
|
|
|
self.last_id_kind = kind
|
|
|
|
|
|
self.id_input.setText(str(target_id))
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._update_id_select_title(kind)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _update_action_states(self) -> None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._update_selected_object_title()
|
|
|
|
|
|
self._update_id_select_title()
|
|
|
|
|
|
if not hasattr(self, "mode_combo"):
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return
|
2026-07-28 14:05:14 +08:00
|
|
|
|
has_loaded_model = self.model is not None
|
|
|
|
|
|
busy = self.operation_in_progress or self.scan_in_progress or self.load_in_progress
|
|
|
|
|
|
has_model = has_loaded_model and not busy
|
2026-07-27 18:28:26 +08:00
|
|
|
|
selected_kind = self.selected_kind
|
2026-07-28 14:05:14 +08:00
|
|
|
|
has_selection = any(
|
|
|
|
|
|
item is not None
|
|
|
|
|
|
for item in (
|
|
|
|
|
|
self.selected_part_id,
|
|
|
|
|
|
self.selected_solid_id,
|
|
|
|
|
|
self.selected_face_id,
|
|
|
|
|
|
self.selected_edge_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
has_part = self.selected_part_id is not None
|
|
|
|
|
|
has_solid = self.selected_solid_id is not None
|
|
|
|
|
|
has_face = self.selected_face_id is not None and selected_kind in {"face", "feature"}
|
|
|
|
|
|
has_edge = self.selected_edge_id is not None and selected_kind == "edge"
|
|
|
|
|
|
id_text = self.id_input.text().strip() if hasattr(self, "id_input") else ""
|
|
|
|
|
|
history_row = self.history_list.currentRow() if hasattr(self, "history_list") else -1
|
|
|
|
|
|
has_history = has_model and bool(getattr(self, "operation_history", []))
|
|
|
|
|
|
has_history_selection = has_history and 0 <= history_row < len(self.operation_history)
|
|
|
|
|
|
has_diff_preview = has_model and bool(getattr(self, "diff_actors", []))
|
|
|
|
|
|
has_measure_points = bool(getattr(self, "measure_point_a", None) or getattr(self, "measure_point_b", None))
|
|
|
|
|
|
has_measure_result = bool(getattr(self, "measure_point_a", None) and getattr(self, "measure_point_b", None))
|
|
|
|
|
|
pick_value = getattr(self, "current_info_values", {}).get("pick_position")
|
|
|
|
|
|
has_pick_position = self.selected_pick_position is not None or (
|
|
|
|
|
|
isinstance(pick_value, (tuple, list)) and len(pick_value) == 3
|
|
|
|
|
|
)
|
|
|
|
|
|
selected_id_text = self._selected_id_text() if hasattr(self, "_selected_id_text") else ""
|
|
|
|
|
|
has_current_info = bool(getattr(self, "current_info_text", ""))
|
|
|
|
|
|
wait_or_load_tip = "请先加载 STEP 文件,或等待当前后台任务完成。"
|
|
|
|
|
|
|
|
|
|
|
|
if hasattr(self, "open_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.open_button,
|
|
|
|
|
|
not busy,
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"导入一个 .step 或 .stp 几何模型。",
|
|
|
|
|
|
"请等待当前后台任务完成后再导入其他几何模型。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.reload_button,
|
|
|
|
|
|
has_loaded_model and not busy,
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"按当前显示路径读取 STEP 模型。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
wait_or_load_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "part_tree"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.part_tree,
|
|
|
|
|
|
has_model,
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"从结构树中选择零件、Solid 或装配节点。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
wait_or_load_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "mode_combo"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.mode_combo,
|
|
|
|
|
|
not busy,
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"选择模式决定鼠标点击模型时按什么对象类型选择。",
|
|
|
|
|
|
"请等待当前后台任务完成后再切换选择模式。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "id_input"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.id_input,
|
2026-07-28 18:30:58 +08:00
|
|
|
|
True,
|
|
|
|
|
|
"输入要选中的对象 ID;加载模型后可点击选择按钮跳转。",
|
|
|
|
|
|
"输入要选中的对象 ID;加载模型后可点击选择按钮跳转。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.select_id_button,
|
|
|
|
|
|
has_model and bool(id_text),
|
|
|
|
|
|
"按当前选择模式跳转到输入的 ID。",
|
|
|
|
|
|
"请先加载模型并输入整数 ID。",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if hasattr(self, "export_all_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_all_button,
|
|
|
|
|
|
has_model,
|
|
|
|
|
|
"导出当前完整 STEP 模型。",
|
|
|
|
|
|
wait_or_load_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_check_button,
|
|
|
|
|
|
has_model,
|
|
|
|
|
|
"检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。",
|
|
|
|
|
|
wait_or_load_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_part_button,
|
|
|
|
|
|
has_model and has_part,
|
|
|
|
|
|
"导出当前选中对象所属零件。",
|
|
|
|
|
|
"请先选择零件,或选择属于某个零件的 Solid、Face 或 Edge。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_solid_button,
|
|
|
|
|
|
has_model and has_solid,
|
|
|
|
|
|
"导出当前选中对象所属 Solid。",
|
|
|
|
|
|
"请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_face_button,
|
|
|
|
|
|
has_model and has_face,
|
|
|
|
|
|
"导出当前选中的 Face 或同域面区域。",
|
|
|
|
|
|
"请先在 Face 或特征模式下选择一个 Face。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_feature_button,
|
|
|
|
|
|
has_model and selected_kind == "feature" and self.selected_face_id is not None,
|
|
|
|
|
|
"导出特征模式识别到的区域。",
|
|
|
|
|
|
"请先切换到特征模式并选择孔、槽、圆角或凸台候选。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_edge_button,
|
|
|
|
|
|
has_model and has_edge,
|
|
|
|
|
|
"导出当前选中的 Edge。",
|
|
|
|
|
|
"请先切换到 Edge 模式并选择一条 Edge。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if hasattr(self, "repair_model_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.repair_model_button,
|
|
|
|
|
|
has_model,
|
|
|
|
|
|
"对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
wait_or_load_tip,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.repair_selected_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
has_model and (has_solid or has_part),
|
|
|
|
|
|
"优先修复当前选中对象所属Solid;没有Solid时修复所属零件,并写入撤销历史。",
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"请先选择零件、Solid、Face 或 Edge,或等待当前后台任务完成。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "isolate_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.isolate_button,
|
|
|
|
|
|
has_model and has_selection,
|
|
|
|
|
|
"只显示当前选中的对象或区域。",
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"请先加载模型并选择零件、Solid、Face、Edge 或特征。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.fit_button,
|
|
|
|
|
|
has_model and has_selection,
|
|
|
|
|
|
"把相机对准当前选中对象。",
|
|
|
|
|
|
"请先加载模型并选择一个对象。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.show_all_button,
|
|
|
|
|
|
has_model,
|
|
|
|
|
|
"恢复显示完整模型。",
|
|
|
|
|
|
wait_or_load_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.show_internal_edges_checkbox,
|
|
|
|
|
|
has_model,
|
|
|
|
|
|
"显示或隐藏同域内部拓扑边。",
|
|
|
|
|
|
wait_or_load_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "set_measure_a_button"):
|
|
|
|
|
|
measure_point_tip = "把当前选中对象的拾取点或中心设为测量点。"
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.set_measure_a_button,
|
|
|
|
|
|
has_model and has_selection,
|
|
|
|
|
|
measure_point_tip,
|
|
|
|
|
|
"请先加载模型并选择一个对象。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.set_measure_b_button,
|
|
|
|
|
|
has_model and has_selection,
|
|
|
|
|
|
measure_point_tip,
|
|
|
|
|
|
"请先加载模型并选择一个对象。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.copy_measure_button,
|
|
|
|
|
|
has_measure_result,
|
|
|
|
|
|
"复制 A/B 两点距离和 X/Y/Z 差值。",
|
|
|
|
|
|
"请先设置测量点 A 和 B。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.clear_measure_button,
|
|
|
|
|
|
has_measure_points,
|
|
|
|
|
|
"清除 A/B 测量点和 3D 测量线。",
|
|
|
|
|
|
"当前没有可清除的测量点。",
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "history_list"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.history_list,
|
|
|
|
|
|
has_history,
|
|
|
|
|
|
"点击历史记录可查看参数、定位目标并显示差异预览。",
|
|
|
|
|
|
"当前还没有编辑历史。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.clear_diff_button,
|
|
|
|
|
|
has_diff_preview,
|
|
|
|
|
|
"清除当前显示的差异预览。",
|
|
|
|
|
|
"当前没有正在显示的差异预览。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_diff_button,
|
|
|
|
|
|
has_history_selection,
|
|
|
|
|
|
"导出当前选中历史记录的差异报告。",
|
|
|
|
|
|
"请先在操作历史中选择一条记录。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.export_history_button,
|
|
|
|
|
|
has_history,
|
|
|
|
|
|
"导出本次会话的完整编辑历史 JSON。",
|
|
|
|
|
|
"当前还没有可导出的编辑历史。",
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "copy_id_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.copy_id_button,
|
|
|
|
|
|
bool(selected_id_text),
|
|
|
|
|
|
"复制当前选中对象的 ID。",
|
|
|
|
|
|
"请先选择一个对象。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.copy_pick_button,
|
|
|
|
|
|
has_pick_position,
|
|
|
|
|
|
"复制最近一次鼠标拾取到的三维坐标。",
|
|
|
|
|
|
"当前没有可复制的拾取坐标。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.copy_info_button,
|
|
|
|
|
|
has_current_info,
|
|
|
|
|
|
"复制当前属性区里的完整文本。",
|
|
|
|
|
|
"当前属性区还没有可复制的信息。",
|
|
|
|
|
|
)
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if hasattr(self, "property_table"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.property_table,
|
|
|
|
|
|
has_current_info,
|
|
|
|
|
|
"查看当前选中对象的属性;可修改的行可以输入目标值。",
|
|
|
|
|
|
"请先选择一个对象。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._update_edit_action_states(has_model)
|
|
|
|
|
|
|
|
|
|
|
|
def _update_edit_action_states(self, has_model: bool) -> None:
|
|
|
|
|
|
if not hasattr(self, "push_button"):
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
action_info = self._selected_action_info() if has_model else {}
|
|
|
|
|
|
surface = str(action_info.get("surface", ""))
|
|
|
|
|
|
curve = str(action_info.get("curve", ""))
|
|
|
|
|
|
feature_guess = str(action_info.get("feature_guess", ""))
|
|
|
|
|
|
angular_span = _float_or_none(action_info.get("angular_span"))
|
|
|
|
|
|
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
|
|
|
|
|
|
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
|
|
|
|
|
|
is_plane = has_face and surface == "plane"
|
2026-07-28 14:05:14 +08:00
|
|
|
|
is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
|
|
|
|
|
|
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
|
|
|
|
|
|
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
|
|
|
|
|
|
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
|
|
|
|
|
|
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
|
|
|
|
|
|
is_slot_or_half_hole = (
|
|
|
|
|
|
is_hole_or_groove
|
|
|
|
|
|
and angular_span is not None
|
|
|
|
|
|
and angular_span < math.tau * 0.92
|
|
|
|
|
|
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
is_blind = action_info.get("cylinder_end_type") == "blind"
|
|
|
|
|
|
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
|
|
|
|
|
|
has_manual_bottom = bool(
|
|
|
|
|
|
hasattr(self, "hole_bottom_face_input")
|
|
|
|
|
|
and self.hole_bottom_face_input.text().strip()
|
|
|
|
|
|
)
|
|
|
|
|
|
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
|
|
|
|
|
|
is_line_edge = has_edge and curve == "line"
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_push = has_model and is_plane
|
|
|
|
|
|
can_resize_shell = has_model and is_shell_candidate
|
|
|
|
|
|
can_resize_hole = has_model and is_hole_or_groove
|
|
|
|
|
|
can_resize_slot = has_model and is_slot_or_half_hole
|
|
|
|
|
|
can_resize_boss = has_model and is_boss and is_full_cylinder
|
|
|
|
|
|
can_suppress_hole = has_model and is_hole_or_groove and is_full_cylinder
|
|
|
|
|
|
can_resize_depth = has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom)
|
|
|
|
|
|
can_fillet_edge = has_model and is_line_edge
|
|
|
|
|
|
can_resize_existing_fillet = has_model and is_existing_fillet and has_fillet_support
|
|
|
|
|
|
can_chamfer_edge = has_model and is_line_edge
|
|
|
|
|
|
can_resize_edge_length = has_model and has_edge
|
|
|
|
|
|
can_transform_part = has_model and self.selected_part_id is not None
|
|
|
|
|
|
can_transform_solid = has_model and self.selected_solid_id is not None
|
|
|
|
|
|
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.offset_input,
|
|
|
|
|
|
can_push,
|
|
|
|
|
|
"输入平面 Face 的推拉距离。",
|
|
|
|
|
|
"请先选择一个可推拉的平面 Face。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.push_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_push,
|
|
|
|
|
|
"对当前平面 Face 执行推拉。",
|
2026-07-28 18:30:58 +08:00
|
|
|
|
"请先选择一个平面 Face,或在特征模式下选择可推拉平面候选。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "resize_shell_thickness_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.shell_thickness_input,
|
|
|
|
|
|
can_resize_shell,
|
|
|
|
|
|
"输入薄壁/壳体局部区域的目标厚度。",
|
|
|
|
|
|
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_shell_thickness_button,
|
|
|
|
|
|
can_resize_shell,
|
|
|
|
|
|
"按目标厚度推拉当前薄壁/壳体平面区域。",
|
|
|
|
|
|
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.hole_diameter_input,
|
|
|
|
|
|
can_resize_hole,
|
|
|
|
|
|
"输入圆柱孔/槽的目标直径。",
|
|
|
|
|
|
"请先选择被识别为孔/槽候选的圆柱 Face。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_resize_hole,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"调整当前圆柱孔/槽候选的直径。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"请先选择被识别为孔/槽候选的圆柱 Face。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "resize_slot_button"):
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.slot_width_input,
|
|
|
|
|
|
can_resize_slot,
|
|
|
|
|
|
"输入槽/半孔候选的目标宽度。",
|
|
|
|
|
|
"请先选择被识别为槽/半孔的部分圆柱 Face。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_slot_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_resize_slot,
|
|
|
|
|
|
"按目标槽宽调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。",
|
|
|
|
|
|
"请先选择被识别为槽/半孔的部分圆柱 Face。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.boss_diameter_input,
|
|
|
|
|
|
can_resize_boss,
|
|
|
|
|
|
"输入完整圆柱凸台候选的目标直径。",
|
|
|
|
|
|
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_boss_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_resize_boss,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"调整当前完整圆柱凸台候选的直径。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.suppress_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_suppress_hole,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"封堵当前完整圆柱孔候选。",
|
|
|
|
|
|
"请先选择接近完整圆柱的孔候选;半孔/槽不会放行。",
|
|
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.hole_depth_input,
|
|
|
|
|
|
can_resize_hole,
|
|
|
|
|
|
"输入盲孔/盲槽的目标深度。",
|
|
|
|
|
|
"请先选择孔/槽圆柱面。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.hole_bottom_face_input,
|
|
|
|
|
|
can_resize_hole,
|
|
|
|
|
|
"必要时输入底面 Face ID,用来明确盲孔/盲槽深度。",
|
|
|
|
|
|
"请先选择孔/槽圆柱面。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_depth_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_resize_depth,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。",
|
|
|
|
|
|
"请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。",
|
|
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.edge_fillet_radius_input,
|
|
|
|
|
|
can_fillet_edge or can_resize_existing_fillet,
|
|
|
|
|
|
"输入新圆角或已有圆角的目标半径。",
|
|
|
|
|
|
"请先选择直线Edge,或选择可修改的已有圆角 Face。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.fillet_edge_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_fillet_edge,
|
|
|
|
|
|
"给当前直线Edge添加新圆角。",
|
|
|
|
|
|
"请先选择一条直线Edge。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_existing_fillet_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_resize_existing_fillet,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"尝试修改当前已有圆角/倒圆候选的半径。",
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"请先选择一个已有圆角/倒圆候选 Face;当前版本需要识别到至少两个支撑 Face。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.edge_chamfer_distance_input,
|
|
|
|
|
|
can_chamfer_edge,
|
|
|
|
|
|
"输入当前直线Edge的倒角距离。",
|
|
|
|
|
|
"请先选择一条直线Edge。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.chamfer_edge_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_chamfer_edge,
|
|
|
|
|
|
"给当前直线Edge添加倒角。",
|
|
|
|
|
|
"请先选择一条直线Edge。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.edge_target_length_input,
|
|
|
|
|
|
can_resize_edge_length,
|
|
|
|
|
|
"输入当前Edge的目标长度。",
|
|
|
|
|
|
"请先选择一条Edge。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.edge_length_anchor_combo,
|
|
|
|
|
|
can_resize_edge_length,
|
|
|
|
|
|
"选择修改Edge长度时尽量固定哪一端。",
|
|
|
|
|
|
"请先选择一条Edge。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.resize_edge_length_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_resize_edge_length,
|
|
|
|
|
|
"修改当前Edge的长度;直线Edge优先局部形变,圆/椭圆/平面曲线优先用局部或径向策略。",
|
|
|
|
|
|
"请先选择一条Edge。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
for widget in (self.translate_x_input, self.translate_y_input, self.translate_z_input):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
widget,
|
|
|
|
|
|
can_transform_part or can_transform_solid,
|
|
|
|
|
|
"输入选中零件或 Solid 的平移距离。",
|
|
|
|
|
|
"请先选择一个零件、Solid,或其所属 Face/Edge。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.translate_part_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_transform_part,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"平移当前选中对象所属零件。",
|
|
|
|
|
|
"请先选择一个零件,或选择属于某个零件的对象。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.rotate_part_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_transform_part,
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"旋转当前选中对象所属零件。",
|
|
|
|
|
|
"请先选择一个零件,或选择属于某个零件的对象。",
|
|
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.rotate_axis_combo,
|
|
|
|
|
|
can_transform_part or can_transform_solid,
|
|
|
|
|
|
"选择选中零件或 Solid 的旋转轴。",
|
|
|
|
|
|
"请先选择一个零件、Solid,或其所属 Face/Edge。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.rotate_angle_input,
|
|
|
|
|
|
can_transform_part or can_transform_solid,
|
|
|
|
|
|
"输入选中零件或 Solid 的旋转角度。",
|
|
|
|
|
|
"请先选择一个零件、Solid,或其所属 Face/Edge。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.translate_solid_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_transform_solid,
|
|
|
|
|
|
"平移当前选中对象所属 Solid。",
|
|
|
|
|
|
"请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.rotate_solid_button,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
can_transform_solid,
|
|
|
|
|
|
"旋转当前选中对象所属 Solid。",
|
|
|
|
|
|
"请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if hasattr(self, "cylinders_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.cylinders_button,
|
|
|
|
|
|
has_model,
|
|
|
|
|
|
"扫描当前模型中的圆柱面候选。",
|
|
|
|
|
|
"请先加载 STEP 文件。",
|
|
|
|
|
|
)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if hasattr(self, "editable_refresh_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.editable_refresh_button,
|
|
|
|
|
|
has_model,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"扫描可编辑对象。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"请先加载 STEP 文件,或等待当前后台任务完成。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.editable_deep_scan_button,
|
|
|
|
|
|
has_model,
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"深度扫描更多可编辑对象。",
|
2026-07-27 18:28:26 +08:00
|
|
|
|
"请先加载 STEP 文件,或等待当前后台任务完成。",
|
|
|
|
|
|
)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.editable_table,
|
|
|
|
|
|
has_model and self.editable_table.rowCount() > 0,
|
|
|
|
|
|
"点击一行可选中对应对象并填入相关编辑参数。",
|
|
|
|
|
|
"当前没有可编辑对象列表;请先点击扫描对象。",
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "candidate_filter_combo"):
|
|
|
|
|
|
has_cylinder_candidates = has_model and bool(getattr(self, "cylinder_candidates_loaded", False))
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.candidate_filter_combo,
|
|
|
|
|
|
has_cylinder_candidates,
|
|
|
|
|
|
"筛选已扫描的圆柱面候选。",
|
|
|
|
|
|
"请先点击扫描圆柱面。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.cylinder_table,
|
|
|
|
|
|
has_model and self.cylinder_table.rowCount() > 0,
|
|
|
|
|
|
"点击一行可选中对应圆柱 Face。",
|
|
|
|
|
|
"当前没有圆柱面候选列表;请先点击扫描圆柱面。",
|
|
|
|
|
|
)
|
2026-07-28 18:30:58 +08:00
|
|
|
|
if hasattr(self, "undo_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.undo_button,
|
|
|
|
|
|
has_model and bool(self.undo_stack),
|
|
|
|
|
|
"撤销上一步编辑。",
|
|
|
|
|
|
"当前没有可撤销的编辑。",
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "redo_button"):
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.redo_button,
|
|
|
|
|
|
has_model and bool(self.redo_stack),
|
|
|
|
|
|
"重做刚撤销的编辑。",
|
|
|
|
|
|
"当前没有可重做的编辑。",
|
|
|
|
|
|
)
|
|
|
|
|
|
self._update_property_apply_state(has_model)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None:
|
|
|
|
|
|
widget.setEnabled(enabled)
|
|
|
|
|
|
tip = enabled_tip if enabled else disabled_tip
|
|
|
|
|
|
if hasattr(self, "_set_help_tip"):
|
|
|
|
|
|
self._set_help_tip(widget, tip)
|
|
|
|
|
|
else:
|
|
|
|
|
|
widget.setToolTip(tip)
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
def _current_selection_mode(self) -> str:
|
|
|
|
|
|
if not hasattr(self, "mode_combo"):
|
|
|
|
|
|
return getattr(self, "last_id_kind", "Face")
|
|
|
|
|
|
data = self.mode_combo.currentData()
|
|
|
|
|
|
if data is not None:
|
|
|
|
|
|
return _selection_mode_value(data)
|
|
|
|
|
|
return _selection_mode_value(self.mode_combo.currentText())
|
|
|
|
|
|
|
|
|
|
|
|
def _set_selection_mode(self, mode: str) -> None:
|
|
|
|
|
|
if not hasattr(self, "mode_combo"):
|
|
|
|
|
|
self.last_id_kind = _selection_mode_value(mode)
|
|
|
|
|
|
return
|
|
|
|
|
|
value = _selection_mode_value(mode)
|
|
|
|
|
|
index = self.mode_combo.findData(value)
|
|
|
|
|
|
if index >= 0:
|
|
|
|
|
|
self.mode_combo.setCurrentIndex(index)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.mode_combo.setCurrentText(_selection_mode_label(value))
|
|
|
|
|
|
self.last_id_kind = value
|
|
|
|
|
|
self._update_id_select_title(value)
|
|
|
|
|
|
|
|
|
|
|
|
def _update_id_select_title(self, kind: str | None = None) -> None:
|
|
|
|
|
|
current_kind = kind
|
|
|
|
|
|
if current_kind is None and hasattr(self, "mode_combo"):
|
|
|
|
|
|
current_kind = self._current_selection_mode()
|
|
|
|
|
|
current_kind = current_kind or getattr(self, "last_id_kind", "Face")
|
|
|
|
|
|
if hasattr(self, "id_select_label"):
|
|
|
|
|
|
self.id_select_label.setText(f"按ID:{_selection_mode_label(current_kind)}")
|
|
|
|
|
|
|
|
|
|
|
|
def _update_selected_object_title(self) -> None:
|
|
|
|
|
|
if not hasattr(self, "object_edit_box"):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.object_edit_box.setTitle(f"当前选中对象:{self._selected_object_title_suffix()}")
|
|
|
|
|
|
|
|
|
|
|
|
def _selected_object_title_suffix(self) -> str:
|
|
|
|
|
|
if self.selected_kind == "part" and self.selected_part_id is not None:
|
|
|
|
|
|
return f"零件 {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 == "feature" and self.selected_face_id is not None:
|
|
|
|
|
|
return f"特征 Face {self.selected_face_id}"
|
|
|
|
|
|
if self.selected_kind == "face" and self.selected_face_id is not None:
|
|
|
|
|
|
logical_id = self.current_info_values.get("face_region_logical_id")
|
|
|
|
|
|
if logical_id is None:
|
|
|
|
|
|
logical_id = self.current_info_values.get("logical_face_id")
|
|
|
|
|
|
if logical_id not in {None, "", self.selected_face_id}:
|
|
|
|
|
|
return f"Face {logical_id}(拓扑 {self.selected_face_id})"
|
|
|
|
|
|
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}"
|
|
|
|
|
|
return "未选择"
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_property_editor(self) -> None:
|
|
|
|
|
|
if not hasattr(self, "property_table"):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.property_editor_specs = []
|
|
|
|
|
|
self.property_table_expanded = False
|
|
|
|
|
|
was_blocked = self.property_table.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.property_table.setRowCount(0)
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.property_table.blockSignals(was_blocked)
|
|
|
|
|
|
self._resize_property_table_height()
|
|
|
|
|
|
self._update_property_apply_state(False)
|
|
|
|
|
|
|
|
|
|
|
|
def _refresh_property_editor(self) -> None:
|
|
|
|
|
|
if not hasattr(self, "property_table"):
|
|
|
|
|
|
return
|
|
|
|
|
|
info = dict(getattr(self, "current_info_values", {}) or {})
|
|
|
|
|
|
action_info = self._selected_action_info()
|
|
|
|
|
|
for key, value in action_info.items():
|
|
|
|
|
|
info.setdefault(key, value)
|
|
|
|
|
|
|
|
|
|
|
|
specs = self._property_editor_specs(info, action_info)
|
|
|
|
|
|
self.property_editor_specs = specs
|
|
|
|
|
|
self.property_table_expanded = False
|
|
|
|
|
|
self.property_editor_updating = True
|
|
|
|
|
|
was_blocked = self.property_table.blockSignals(True)
|
|
|
|
|
|
try:
|
|
|
|
|
|
self.property_table.setRowCount(len(specs))
|
|
|
|
|
|
for row, spec in enumerate(specs):
|
|
|
|
|
|
editable = bool(spec.get("editable") and spec.get("enabled"))
|
|
|
|
|
|
label_item = self._property_table_item(str(spec.get("label", "")), editable=False)
|
|
|
|
|
|
current_item = self._property_table_item(str(spec.get("current_text", "")), editable=False)
|
|
|
|
|
|
target_item = self._property_table_item(str(spec.get("target_text", "")), editable=editable)
|
|
|
|
|
|
status_text = "" if editable else str(spec.get("status_text", ""))
|
|
|
|
|
|
status_item = self._property_table_item(status_text, editable=False)
|
|
|
|
|
|
target_item.setToolTip(self._property_target_tooltip(spec, editable=editable))
|
|
|
|
|
|
if editable:
|
|
|
|
|
|
target_item.setBackground(QColor("#fff3d9"))
|
|
|
|
|
|
status_item.setForeground(QColor("#166534"))
|
|
|
|
|
|
else:
|
|
|
|
|
|
target_item.setBackground(QColor("#eef2f6"))
|
|
|
|
|
|
target_item.setForeground(QColor("#8f99a8"))
|
|
|
|
|
|
status_item.setForeground(QColor("#64748b"))
|
|
|
|
|
|
for column, item in enumerate((label_item, current_item, target_item, status_item)):
|
|
|
|
|
|
item.setToolTip(item.toolTip() or item.text())
|
|
|
|
|
|
self.property_table.setItem(row, column, item)
|
|
|
|
|
|
if editable:
|
|
|
|
|
|
self._set_property_row_button(row, spec)
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.property_table.removeCellWidget(row, 3)
|
|
|
|
|
|
self._resize_property_table_height()
|
|
|
|
|
|
finally:
|
|
|
|
|
|
self.property_table.blockSignals(was_blocked)
|
|
|
|
|
|
self.property_editor_updating = False
|
|
|
|
|
|
self._update_property_apply_state()
|
|
|
|
|
|
|
|
|
|
|
|
def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None:
|
|
|
|
|
|
button = QPushButton("可修改")
|
|
|
|
|
|
button.setObjectName("propertyRowEditButton")
|
|
|
|
|
|
tooltip = f"只应用“{spec.get('label', '当前属性')}”这一行的目标值。"
|
|
|
|
|
|
range_hint = self._property_range_hint(spec)
|
|
|
|
|
|
if range_hint:
|
|
|
|
|
|
tooltip = f"{tooltip}\n\n{range_hint}"
|
|
|
|
|
|
button.setToolTip(tooltip)
|
|
|
|
|
|
button.setCursor(Qt.CursorShape.PointingHandCursor)
|
|
|
|
|
|
button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row))
|
|
|
|
|
|
self.property_table.setCellWidget(row, 3, button)
|
|
|
|
|
|
|
|
|
|
|
|
def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str:
|
|
|
|
|
|
tip_key = "enabled_tip" if editable else "disabled_tip"
|
|
|
|
|
|
parts = [str(spec.get(tip_key, "")).strip()]
|
|
|
|
|
|
if editable:
|
|
|
|
|
|
parts.append(self._property_range_hint(spec))
|
|
|
|
|
|
return "\n\n".join(part for part in parts if part)
|
|
|
|
|
|
|
|
|
|
|
|
def _property_range_hint(self, spec: dict[str, object]) -> str:
|
|
|
|
|
|
parts: list[str] = []
|
|
|
|
|
|
hard_range = self._property_hard_range_text(spec)
|
|
|
|
|
|
if hard_range:
|
|
|
|
|
|
parts.append(f"有效范围:{hard_range}")
|
|
|
|
|
|
range_hint = str(spec.get("range_hint", "")).strip()
|
|
|
|
|
|
if range_hint:
|
|
|
|
|
|
parts.append(f"建议范围:{range_hint}")
|
|
|
|
|
|
return "\n".join(parts)
|
|
|
|
|
|
|
|
|
|
|
|
def _property_hard_range_text(self, spec: dict[str, object]) -> str:
|
|
|
|
|
|
chunks: list[str] = []
|
|
|
|
|
|
min_value = _float_or_none(spec.get("min_value"))
|
|
|
|
|
|
max_value = _float_or_none(spec.get("max_value"))
|
|
|
|
|
|
if min_value is not None:
|
|
|
|
|
|
operator = ">" if spec.get("min_exclusive") else ">="
|
|
|
|
|
|
chunks.append(f"{operator} {_format_float(min_value)}")
|
|
|
|
|
|
if max_value is not None:
|
|
|
|
|
|
operator = "<" if spec.get("max_exclusive") else "<="
|
|
|
|
|
|
chunks.append(f"{operator} {_format_float(max_value)}")
|
|
|
|
|
|
return " 且 ".join(chunks)
|
|
|
|
|
|
|
|
|
|
|
|
def _resize_property_table_height(self) -> None:
|
|
|
|
|
|
if not hasattr(self, "property_table"):
|
|
|
|
|
|
return
|
|
|
|
|
|
row_count = self.property_table.rowCount()
|
|
|
|
|
|
collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6)
|
|
|
|
|
|
expanded = bool(getattr(self, "property_table_expanded", False))
|
|
|
|
|
|
visible_rows = row_count if expanded else min(row_count, collapsed_rows)
|
|
|
|
|
|
visible_rows = max(1, visible_rows)
|
|
|
|
|
|
row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22)
|
|
|
|
|
|
header_height = int(self.property_table.horizontalHeader().height())
|
|
|
|
|
|
frame = int(self.property_table.frameWidth()) * 2
|
|
|
|
|
|
height = header_height + frame + visible_rows * row_height + 8
|
|
|
|
|
|
self.property_table.setMinimumHeight(height)
|
|
|
|
|
|
self.property_table.setMaximumHeight(height)
|
|
|
|
|
|
has_hidden_rows = row_count > collapsed_rows
|
|
|
|
|
|
self.property_table.setVerticalScrollBarPolicy(
|
|
|
|
|
|
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
|
|
|
|
|
if expanded or not has_hidden_rows
|
|
|
|
|
|
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
|
|
|
|
|
)
|
|
|
|
|
|
if hasattr(self, "property_expand_button"):
|
|
|
|
|
|
self.property_expand_button.setVisible(has_hidden_rows)
|
|
|
|
|
|
if expanded:
|
|
|
|
|
|
self.property_expand_button.setText(f"收起,只显示前 {collapsed_rows} 项")
|
|
|
|
|
|
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几行。")
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.property_expand_button.setText(f"展开全部属性({row_count} 项)")
|
|
|
|
|
|
self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。")
|
|
|
|
|
|
|
|
|
|
|
|
def toggle_property_table_expanded(self) -> None:
|
|
|
|
|
|
if not hasattr(self, "property_table"):
|
|
|
|
|
|
return
|
|
|
|
|
|
self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False))
|
|
|
|
|
|
self._resize_property_table_height()
|
|
|
|
|
|
|
|
|
|
|
|
def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem:
|
|
|
|
|
|
item = QTableWidgetItem(text)
|
|
|
|
|
|
flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
|
|
|
|
|
|
if editable:
|
|
|
|
|
|
flags |= Qt.ItemFlag.ItemIsEditable
|
|
|
|
|
|
item.setFlags(flags)
|
|
|
|
|
|
return item
|
|
|
|
|
|
|
|
|
|
|
|
def _property_editor_specs(
|
|
|
|
|
|
self,
|
|
|
|
|
|
info: dict[str, object],
|
|
|
|
|
|
action_info: dict[str, object],
|
|
|
|
|
|
) -> list[dict[str, object]]:
|
|
|
|
|
|
editable_specs, used_keys = self._editable_property_specs(action_info)
|
|
|
|
|
|
specs = list(editable_specs)
|
|
|
|
|
|
for key, value in self._ordered_info_items(info):
|
|
|
|
|
|
if key in used_keys:
|
|
|
|
|
|
continue
|
|
|
|
|
|
specs.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": key,
|
|
|
|
|
|
"label": INFO_LABELS.get(key, key),
|
|
|
|
|
|
"current_text": _format_value(value),
|
|
|
|
|
|
"current_raw": value,
|
|
|
|
|
|
"target_text": "",
|
|
|
|
|
|
"editable": False,
|
|
|
|
|
|
"enabled": False,
|
|
|
|
|
|
"status_text": "只读",
|
|
|
|
|
|
"disabled_tip": "这个属性来自当前 STEP/B-Rep 几何或识别结果,当前版本只能查看,不能直接修改。",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
if not specs:
|
|
|
|
|
|
specs.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": "empty",
|
|
|
|
|
|
"label": "当前对象",
|
|
|
|
|
|
"current_text": "尚未选择",
|
|
|
|
|
|
"target_text": "",
|
|
|
|
|
|
"editable": False,
|
|
|
|
|
|
"enabled": False,
|
|
|
|
|
|
"status_text": "只读",
|
|
|
|
|
|
"disabled_tip": "请先在模型中选择零件、Solid、Face、Edge 或特征。",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return specs
|
|
|
|
|
|
|
|
|
|
|
|
def _ordered_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
|
|
|
|
|
|
ordered: list[tuple[str, object]] = []
|
|
|
|
|
|
emitted: set[str] = set()
|
|
|
|
|
|
for _group_name, keys in INFO_GROUPS:
|
|
|
|
|
|
for key in keys:
|
|
|
|
|
|
if key in info and key not in emitted:
|
|
|
|
|
|
ordered.append((key, info[key]))
|
|
|
|
|
|
emitted.add(key)
|
|
|
|
|
|
for key in sorted(key for key in info if key not in emitted):
|
|
|
|
|
|
ordered.append((key, info[key]))
|
|
|
|
|
|
return ordered
|
|
|
|
|
|
|
|
|
|
|
|
def _editable_property_specs(self, action_info: dict[str, object]) -> tuple[list[dict[str, object]], set[str]]:
|
|
|
|
|
|
has_model = self.model is not None and not (self.operation_in_progress or self.scan_in_progress or self.load_in_progress)
|
|
|
|
|
|
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_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
|
|
|
|
|
|
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
|
|
|
|
|
|
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
|
|
|
|
|
|
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
|
|
|
|
|
|
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
|
|
|
|
|
|
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
|
|
|
|
|
|
is_slot_or_half_hole = (
|
|
|
|
|
|
is_hole_or_groove
|
|
|
|
|
|
and angular_span is not None
|
|
|
|
|
|
and angular_span < math.tau * 0.92
|
|
|
|
|
|
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
|
|
|
|
|
|
)
|
|
|
|
|
|
is_blind = action_info.get("cylinder_end_type") == "blind"
|
|
|
|
|
|
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
|
|
|
|
|
|
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
|
|
|
|
|
|
is_line_edge = has_edge and curve == "line"
|
|
|
|
|
|
specs: list[dict[str, object]] = []
|
|
|
|
|
|
used_keys: set[str] = set()
|
|
|
|
|
|
|
|
|
|
|
|
def numeric_text(value: object, fallback: str = "") -> str:
|
|
|
|
|
|
number = _float_or_none(value)
|
|
|
|
|
|
return _format_float(number) if number is not None else fallback
|
|
|
|
|
|
|
|
|
|
|
|
def relative_range_hint(current: object, caution_ratio: float, high_ratio: float) -> str:
|
|
|
|
|
|
number = _float_or_none(current)
|
|
|
|
|
|
if number is None or number <= 0:
|
|
|
|
|
|
return "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。"
|
|
|
|
|
|
lower = max(number * (1.0 - caution_ratio), 0.0)
|
|
|
|
|
|
upper = number * (1.0 + caution_ratio)
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改"
|
|
|
|
|
|
f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);"
|
|
|
|
|
|
f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def positive_minimum() -> dict[str, object]:
|
|
|
|
|
|
return {"min_value": 0.0, "min_exclusive": True}
|
|
|
|
|
|
|
|
|
|
|
|
def push_pull_hint() -> str:
|
|
|
|
|
|
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
|
|
|
|
|
|
if diagonal is not None and diagonal > 0:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"可输入正数或负数,单位同模型;"
|
|
|
|
|
|
f"建议单次绝对值不超过 {_format_float(diagonal * 0.08)},"
|
|
|
|
|
|
f"超过 {_format_float(diagonal * 0.2)} 时风险较高。"
|
|
|
|
|
|
)
|
|
|
|
|
|
return "可输入正数或负数,单位同模型;建议先小幅试改并查看预览。"
|
|
|
|
|
|
|
|
|
|
|
|
def translation_hint() -> str:
|
|
|
|
|
|
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
|
|
|
|
|
|
if diagonal is not None and diagonal > 0:
|
|
|
|
|
|
return (
|
|
|
|
|
|
"格式为 X, Y, Z;"
|
|
|
|
|
|
f"建议单次平移距离不超过 {_format_float(diagonal * 0.5)},"
|
|
|
|
|
|
f"超过 {_format_float(diagonal * 2.0)} 时请重点确认单位和方向。"
|
|
|
|
|
|
)
|
|
|
|
|
|
return "格式为 X, Y, Z;建议先小幅试改并查看预览。"
|
|
|
|
|
|
|
|
|
|
|
|
def hole_diameter_upper_limit(current: object) -> float | None:
|
|
|
|
|
|
current_number = _float_or_none(current)
|
|
|
|
|
|
height = _float_or_none(action_info.get("height_estimate"))
|
|
|
|
|
|
limits: list[float] = []
|
|
|
|
|
|
if current_number is not None and current_number > 0:
|
|
|
|
|
|
limits.append(current_number * 2.0)
|
|
|
|
|
|
if height is not None and height > 0:
|
|
|
|
|
|
limits.append(height * 2.0)
|
|
|
|
|
|
return min(limits) if limits else None
|
|
|
|
|
|
|
|
|
|
|
|
def hole_diameter_hint(current: object) -> str:
|
|
|
|
|
|
hint = relative_range_hint(current, 0.35, 1.0)
|
|
|
|
|
|
upper = hole_diameter_upper_limit(current)
|
|
|
|
|
|
if upper is not None:
|
|
|
|
|
|
hint = (
|
|
|
|
|
|
f"{hint} 当前版本会阻止超过 {_format_float(upper)} 的目标直径,"
|
|
|
|
|
|
"避免布尔返回成功但孔壁没有真正变成目标直径。"
|
|
|
|
|
|
)
|
|
|
|
|
|
return hint
|
|
|
|
|
|
|
|
|
|
|
|
def add_spec(
|
|
|
|
|
|
*,
|
|
|
|
|
|
key: str,
|
|
|
|
|
|
label: str,
|
|
|
|
|
|
current_raw: object,
|
|
|
|
|
|
target_text: str,
|
|
|
|
|
|
action: str,
|
|
|
|
|
|
target_attr: str | None = None,
|
|
|
|
|
|
target_attrs: tuple[str, ...] | None = None,
|
|
|
|
|
|
enabled: bool,
|
|
|
|
|
|
enabled_tip: str,
|
|
|
|
|
|
disabled_tip: str,
|
|
|
|
|
|
value_type: str = "number",
|
|
|
|
|
|
used: tuple[str, ...] = (),
|
|
|
|
|
|
range_hint: str = "",
|
|
|
|
|
|
min_value: float | None = None,
|
|
|
|
|
|
max_value: float | None = None,
|
|
|
|
|
|
min_exclusive: bool = False,
|
|
|
|
|
|
max_exclusive: bool = False,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
specs.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"key": key,
|
|
|
|
|
|
"label": label,
|
|
|
|
|
|
"current_text": _format_value(current_raw),
|
|
|
|
|
|
"current_raw": current_raw,
|
|
|
|
|
|
"target_text": target_text,
|
|
|
|
|
|
"action": action,
|
|
|
|
|
|
"target_attr": target_attr,
|
|
|
|
|
|
"target_attrs": target_attrs,
|
|
|
|
|
|
"editable": True,
|
|
|
|
|
|
"enabled": has_model and enabled,
|
|
|
|
|
|
"status_text": "可修改" if has_model and enabled else "不可修改",
|
|
|
|
|
|
"enabled_tip": enabled_tip,
|
|
|
|
|
|
"disabled_tip": disabled_tip,
|
|
|
|
|
|
"value_type": value_type,
|
|
|
|
|
|
"range_hint": range_hint,
|
|
|
|
|
|
"min_value": min_value,
|
|
|
|
|
|
"max_value": max_value,
|
|
|
|
|
|
"min_exclusive": min_exclusive,
|
|
|
|
|
|
"max_exclusive": max_exclusive,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
used_keys.update(used or (key,))
|
|
|
|
|
|
|
|
|
|
|
|
if is_plane:
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="push_pull_distance",
|
|
|
|
|
|
label="Face偏移",
|
|
|
|
|
|
current_raw=0.0,
|
|
|
|
|
|
target_text="0",
|
|
|
|
|
|
action="push_pull_face",
|
|
|
|
|
|
target_attr="offset_input",
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
enabled_tip="输入平面 Face 的推拉距离,正数通常向外加料,负数通常向内切削。",
|
|
|
|
|
|
disabled_tip="当前对象不是可推拉的平面 Face。",
|
|
|
|
|
|
range_hint=push_pull_hint(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_shell_candidate:
|
|
|
|
|
|
current = _float_or_none(action_info.get("shell_thickness_estimate"))
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="shell_thickness_estimate",
|
|
|
|
|
|
label="薄壁厚度",
|
|
|
|
|
|
current_raw=current if current is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current),
|
|
|
|
|
|
action="resize_shell_thickness",
|
|
|
|
|
|
target_attr="shell_thickness_input",
|
|
|
|
|
|
enabled=current is not None,
|
|
|
|
|
|
enabled_tip="输入薄壁/壳体局部区域的目标厚度。",
|
|
|
|
|
|
disabled_tip="当前平面没有稳定识别到可修改的薄壁厚度。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=relative_range_hint(current, 0.35, 0.8),
|
|
|
|
|
|
used=("shell_thickness_estimate", "shell_current_thickness"),
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_hole_or_groove:
|
|
|
|
|
|
current_diameter = _float_or_none(action_info.get("diameter"))
|
|
|
|
|
|
diameter_upper = hole_diameter_upper_limit(current_diameter)
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="diameter",
|
|
|
|
|
|
label="孔/圆柱直径",
|
|
|
|
|
|
current_raw=current_diameter if current_diameter is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current_diameter),
|
|
|
|
|
|
action="resize_hole",
|
|
|
|
|
|
target_attr="hole_diameter_input",
|
|
|
|
|
|
enabled=current_diameter is not None,
|
|
|
|
|
|
enabled_tip="输入当前孔/槽圆柱面的目标直径。",
|
|
|
|
|
|
disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=hole_diameter_hint(current_diameter),
|
|
|
|
|
|
max_value=diameter_upper,
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_slot_or_half_hole:
|
|
|
|
|
|
current_slot = _float_or_none(action_info.get("slot_chord_width_estimate"))
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="slot_chord_width_estimate",
|
|
|
|
|
|
label="槽/半孔宽度",
|
|
|
|
|
|
current_raw=current_slot if current_slot is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current_slot),
|
|
|
|
|
|
action="resize_slot_width",
|
|
|
|
|
|
target_attr="slot_width_input",
|
|
|
|
|
|
enabled=current_slot is not None,
|
|
|
|
|
|
enabled_tip="输入槽或半孔的目标宽度。",
|
|
|
|
|
|
disabled_tip="当前对象不是稳定的槽/半孔候选。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=relative_range_hint(current_slot, 0.35, 1.0),
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
current_depth = _float_or_none(action_info.get("hole_depth_estimate"))
|
|
|
|
|
|
can_depth = bool(is_blind and has_bottom and current_depth is not None)
|
|
|
|
|
|
if is_blind or current_depth is not None:
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="hole_depth_estimate",
|
|
|
|
|
|
label="盲孔/盲槽深度",
|
|
|
|
|
|
current_raw=current_depth if current_depth is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current_depth),
|
|
|
|
|
|
action="resize_hole_depth",
|
|
|
|
|
|
target_attr="hole_depth_input",
|
|
|
|
|
|
enabled=can_depth,
|
|
|
|
|
|
enabled_tip="输入盲孔或盲槽的目标深度。",
|
|
|
|
|
|
disabled_tip="需要识别到盲孔/盲槽底面后才能直接修改深度。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=relative_range_hint(current_depth, 0.35, 1.0),
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_boss:
|
|
|
|
|
|
current_boss = _float_or_none(action_info.get("diameter"))
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="boss_diameter",
|
|
|
|
|
|
label="凸台直径",
|
|
|
|
|
|
current_raw=current_boss if current_boss is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current_boss),
|
|
|
|
|
|
action="resize_boss",
|
|
|
|
|
|
target_attr="boss_diameter_input",
|
|
|
|
|
|
enabled=bool(is_full_cylinder and current_boss is not None),
|
|
|
|
|
|
enabled_tip="输入完整圆柱凸台的目标直径。",
|
|
|
|
|
|
disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行直径修改。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=relative_range_hint(current_boss, 0.3, 0.8),
|
|
|
|
|
|
used=("diameter",),
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_existing_fillet:
|
|
|
|
|
|
current_radius = _float_or_none(action_info.get("existing_fillet_radius_estimate"))
|
|
|
|
|
|
if current_radius is None:
|
|
|
|
|
|
current_radius = _float_or_none(action_info.get("radius"))
|
|
|
|
|
|
fillet_hint = relative_range_hint(current_radius, 0.35, 1.0)
|
|
|
|
|
|
arc_length = _float_or_none(action_info.get("existing_fillet_arc_length_estimate"))
|
|
|
|
|
|
if arc_length is not None and arc_length > 0:
|
|
|
|
|
|
fillet_hint = (
|
|
|
|
|
|
f"{fillet_hint} 目标半径接近 {_format_float(arc_length * 0.5)} "
|
|
|
|
|
|
"(圆角圆弧长度估算的一半)时比例会很异常。"
|
|
|
|
|
|
)
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="existing_fillet_radius_estimate",
|
|
|
|
|
|
label="已有圆角半径",
|
|
|
|
|
|
current_raw=current_radius if current_radius is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current_radius),
|
|
|
|
|
|
action="resize_existing_fillet",
|
|
|
|
|
|
target_attr="edge_fillet_radius_input",
|
|
|
|
|
|
enabled=bool(current_radius is not None and has_fillet_support),
|
|
|
|
|
|
enabled_tip="输入已有圆角/倒圆面的目标半径。",
|
|
|
|
|
|
disabled_tip="当前圆角候选缺少稳定支撑面,暂不放行修改。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=fillet_hint,
|
|
|
|
|
|
used=("existing_fillet_radius_estimate", "radius"),
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if has_edge:
|
|
|
|
|
|
current_length = _float_or_none(action_info.get("length"))
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="length",
|
|
|
|
|
|
label="Edge长度",
|
|
|
|
|
|
current_raw=current_length if current_length is not None else "",
|
|
|
|
|
|
target_text=numeric_text(current_length),
|
|
|
|
|
|
action="resize_any_edge_length",
|
|
|
|
|
|
target_attr="edge_target_length_input",
|
|
|
|
|
|
enabled=current_length is not None,
|
|
|
|
|
|
enabled_tip="输入当前Edge的目标长度。",
|
|
|
|
|
|
disabled_tip="当前Edge没有稳定长度信息。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=relative_range_hint(current_length, 0.25, 0.5),
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if is_line_edge:
|
|
|
|
|
|
edge_limit = current_length * 0.45 if current_length is not None and current_length > 0 else None
|
|
|
|
|
|
edge_soft = current_length * 0.12 if current_length is not None and current_length > 0 else None
|
|
|
|
|
|
edge_warning = current_length * 0.25 if current_length is not None and current_length > 0 else None
|
|
|
|
|
|
fillet_edge_hint = "建议半径先用较小值试改;过大容易导致倒圆失败。"
|
|
|
|
|
|
chamfer_edge_hint = "建议距离先用较小值试改;过大容易导致倒角失败。"
|
|
|
|
|
|
if edge_soft is not None and edge_warning is not None:
|
|
|
|
|
|
fillet_edge_hint = (
|
|
|
|
|
|
f"建议半径先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});"
|
|
|
|
|
|
f"超过 25%(约 {_format_float(edge_warning)})时风险较高。"
|
|
|
|
|
|
)
|
|
|
|
|
|
chamfer_edge_hint = (
|
|
|
|
|
|
f"建议距离先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});"
|
|
|
|
|
|
f"超过 25%(约 {_format_float(edge_warning)})时风险较高。"
|
|
|
|
|
|
)
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="new_fillet_radius",
|
|
|
|
|
|
label="新增圆角半径",
|
|
|
|
|
|
current_raw="未添加",
|
|
|
|
|
|
target_text="",
|
|
|
|
|
|
action="fillet_edge",
|
|
|
|
|
|
target_attr="edge_fillet_radius_input",
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
enabled_tip="输入半径后给当前直线Edge添加新圆角。",
|
|
|
|
|
|
disabled_tip="只有直线Edge才能直接添加圆角。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=fillet_edge_hint,
|
|
|
|
|
|
max_value=edge_limit,
|
|
|
|
|
|
max_exclusive=edge_limit is not None,
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="new_chamfer_distance",
|
|
|
|
|
|
label="新增倒角距离",
|
|
|
|
|
|
current_raw="未添加",
|
|
|
|
|
|
target_text="",
|
|
|
|
|
|
action="chamfer_edge",
|
|
|
|
|
|
target_attr="edge_chamfer_distance_input",
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
enabled_tip="输入距离后给当前直线Edge添加新倒角。",
|
|
|
|
|
|
disabled_tip="只有直线Edge才能直接添加倒角。",
|
|
|
|
|
|
value_type="positive",
|
|
|
|
|
|
range_hint=chamfer_edge_hint,
|
|
|
|
|
|
max_value=edge_limit,
|
|
|
|
|
|
max_exclusive=edge_limit is not None,
|
|
|
|
|
|
**positive_minimum(),
|
|
|
|
|
|
)
|
|
|
|
|
|
if self.selected_kind in {"part", "solid"}:
|
|
|
|
|
|
target_is_part = self.selected_kind == "part"
|
|
|
|
|
|
translate_action = "translate_selected_part" if target_is_part else "translate_selected_solid"
|
|
|
|
|
|
rotate_action = "rotate_selected_part" if target_is_part else "rotate_selected_solid"
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="translation_vector",
|
|
|
|
|
|
label="平移 X/Y/Z",
|
|
|
|
|
|
current_raw=(0.0, 0.0, 0.0),
|
|
|
|
|
|
target_text="0, 0, 0",
|
|
|
|
|
|
action=translate_action,
|
|
|
|
|
|
target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"),
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
enabled_tip="输入三个数,例如 10, 0, 0,平移当前对象。",
|
|
|
|
|
|
disabled_tip="请先选择零件或 Solid。",
|
|
|
|
|
|
value_type="vector3",
|
|
|
|
|
|
range_hint=translation_hint(),
|
|
|
|
|
|
)
|
|
|
|
|
|
axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z"
|
|
|
|
|
|
add_spec(
|
|
|
|
|
|
key="rotation_angle_degrees",
|
|
|
|
|
|
label=f"旋转角度({axis}轴)",
|
|
|
|
|
|
current_raw=0.0,
|
|
|
|
|
|
target_text="0",
|
|
|
|
|
|
action=rotate_action,
|
|
|
|
|
|
target_attr="rotate_angle_input",
|
|
|
|
|
|
enabled=True,
|
|
|
|
|
|
enabled_tip=f"输入旋转角度,当前使用 {axis} 轴。",
|
|
|
|
|
|
disabled_tip="请先选择零件或 Solid。",
|
|
|
|
|
|
range_hint="角度可正可负;建议单次输入 -360 到 360 度,超过后请确认旋转方向和单位。",
|
|
|
|
|
|
)
|
|
|
|
|
|
return specs, used_keys
|
|
|
|
|
|
|
|
|
|
|
|
def _on_property_table_item_changed(self, _item: QTableWidgetItem) -> None:
|
|
|
|
|
|
if getattr(self, "property_editor_updating", False):
|
|
|
|
|
|
return
|
|
|
|
|
|
self._update_property_apply_state()
|
|
|
|
|
|
|
|
|
|
|
|
def _update_property_apply_state(self, has_model: bool | None = None) -> None:
|
|
|
|
|
|
if not hasattr(self, "property_table"):
|
|
|
|
|
|
return
|
|
|
|
|
|
if has_model is None:
|
|
|
|
|
|
has_model = self.model is not None and not (
|
|
|
|
|
|
self.operation_in_progress or self.scan_in_progress or self.load_in_progress
|
|
|
|
|
|
)
|
|
|
|
|
|
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
|
|
|
|
|
|
widget = self.property_table.cellWidget(row, 3)
|
|
|
|
|
|
if isinstance(widget, QPushButton):
|
|
|
|
|
|
widget.setEnabled(bool(has_model and spec.get("enabled") and spec.get("action")))
|
|
|
|
|
|
if not hasattr(self, "apply_property_button"):
|
|
|
|
|
|
return
|
|
|
|
|
|
changed = self._changed_property_rows()
|
|
|
|
|
|
enabled = bool(has_model and changed)
|
|
|
|
|
|
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
|
|
|
|
|
|
if changed and len(changed) > 1:
|
|
|
|
|
|
disabled_tip = "请使用每一行状态列里的修改按钮,单独应用某个特征。"
|
|
|
|
|
|
self._set_control_state(
|
|
|
|
|
|
self.apply_property_button,
|
|
|
|
|
|
enabled and len(changed) == 1,
|
|
|
|
|
|
"应用属性表中被修改的一行目标值。",
|
|
|
|
|
|
disabled_tip,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _changed_property_rows(self) -> list[tuple[int, dict[str, object], str]]:
|
|
|
|
|
|
if not hasattr(self, "property_table"):
|
|
|
|
|
|
return []
|
|
|
|
|
|
changed: list[tuple[int, dict[str, object], str]] = []
|
|
|
|
|
|
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
|
|
|
|
|
|
if not spec.get("action") or not spec.get("enabled"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
item = self.property_table.item(row, 2)
|
|
|
|
|
|
if item is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
text = item.text().strip()
|
|
|
|
|
|
if self._property_target_changed(spec, text):
|
|
|
|
|
|
changed.append((row, spec, text))
|
|
|
|
|
|
return changed
|
|
|
|
|
|
|
|
|
|
|
|
def _property_target_changed(self, spec: dict[str, object], text: str) -> bool:
|
|
|
|
|
|
if not text:
|
|
|
|
|
|
return False
|
|
|
|
|
|
value_type = str(spec.get("value_type", "number"))
|
|
|
|
|
|
if value_type == "vector3":
|
|
|
|
|
|
try:
|
|
|
|
|
|
values = self._parse_property_vector3(text)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return any(abs(value) > PROPERTY_VALUE_TOLERANCE for value in values)
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = float(text)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if value_type == "positive" and value <= 0:
|
|
|
|
|
|
return True
|
|
|
|
|
|
if self._property_scalar_range_error(spec, value):
|
|
|
|
|
|
return True
|
|
|
|
|
|
current = _float_or_none(spec.get("current_raw"))
|
|
|
|
|
|
if current is None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
return abs(value - current) > PROPERTY_VALUE_TOLERANCE
|
|
|
|
|
|
|
|
|
|
|
|
def apply_current_property_edit(self) -> None:
|
|
|
|
|
|
changed = self._changed_property_rows()
|
|
|
|
|
|
if not changed:
|
|
|
|
|
|
self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。")
|
|
|
|
|
|
return
|
|
|
|
|
|
if len(changed) > 1:
|
|
|
|
|
|
QMessageBox.information(self, "一次应用一个修改", "请只修改一行目标值,然后再点击应用当前修改。")
|
|
|
|
|
|
return
|
|
|
|
|
|
row, _spec, _text = changed[0]
|
|
|
|
|
|
self.apply_property_row_edit(row)
|
|
|
|
|
|
|
|
|
|
|
|
def apply_property_row_edit(self, row: int) -> None:
|
|
|
|
|
|
specs = getattr(self, "property_editor_specs", [])
|
|
|
|
|
|
if row < 0 or row >= len(specs):
|
|
|
|
|
|
self.statusBar().showMessage("当前属性行已经失效,请重新选择对象。")
|
|
|
|
|
|
return
|
|
|
|
|
|
spec = specs[row]
|
|
|
|
|
|
if not spec.get("action") or not spec.get("enabled"):
|
|
|
|
|
|
self.statusBar().showMessage("这一行当前不能直接修改。")
|
|
|
|
|
|
return
|
|
|
|
|
|
item = self.property_table.item(row, 2) if hasattr(self, "property_table") else None
|
|
|
|
|
|
text = item.text().strip() if item is not None else ""
|
|
|
|
|
|
if not self._property_target_changed(spec, text):
|
|
|
|
|
|
self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。")
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
self._sync_property_edit_target(spec, text)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
QMessageBox.information(self, "目标值无效", str(exc))
|
|
|
|
|
|
return
|
|
|
|
|
|
action_name = str(spec.get("action", ""))
|
|
|
|
|
|
action = getattr(self, action_name, None)
|
|
|
|
|
|
if action is None:
|
|
|
|
|
|
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
|
|
|
|
|
|
return
|
|
|
|
|
|
action()
|
|
|
|
|
|
|
|
|
|
|
|
def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None:
|
|
|
|
|
|
value_type = str(spec.get("value_type", "number"))
|
|
|
|
|
|
if value_type == "vector3":
|
|
|
|
|
|
values = self._parse_property_vector3(text)
|
|
|
|
|
|
target_attrs = tuple(spec.get("target_attrs") or ())
|
|
|
|
|
|
if len(target_attrs) != 3:
|
|
|
|
|
|
raise ValueError("这个属性缺少 X/Y/Z 输入绑定。")
|
|
|
|
|
|
for attr, value in zip(target_attrs, values):
|
|
|
|
|
|
getattr(self, str(attr)).setText(_format_float(value))
|
|
|
|
|
|
return
|
|
|
|
|
|
try:
|
|
|
|
|
|
value = float(text)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise ValueError("请输入数字形式的目标值。") from exc
|
|
|
|
|
|
if value_type == "positive" and value <= 0:
|
|
|
|
|
|
raise ValueError("请输入大于 0 的目标值。")
|
|
|
|
|
|
range_error = self._property_scalar_range_error(spec, value)
|
|
|
|
|
|
if range_error:
|
|
|
|
|
|
raise ValueError(range_error)
|
|
|
|
|
|
target_attr = spec.get("target_attr")
|
|
|
|
|
|
if not target_attr:
|
|
|
|
|
|
raise ValueError("这个属性缺少目标输入绑定。")
|
|
|
|
|
|
getattr(self, str(target_attr)).setText(_format_float(value))
|
|
|
|
|
|
|
|
|
|
|
|
def _property_scalar_range_error(self, spec: dict[str, object], value: float) -> str:
|
|
|
|
|
|
label = str(spec.get("label", "目标值"))
|
|
|
|
|
|
min_value = _float_or_none(spec.get("min_value"))
|
|
|
|
|
|
max_value = _float_or_none(spec.get("max_value"))
|
|
|
|
|
|
if min_value is not None:
|
|
|
|
|
|
min_exclusive = bool(spec.get("min_exclusive"))
|
|
|
|
|
|
if (min_exclusive and value <= min_value) or (not min_exclusive and value < min_value):
|
|
|
|
|
|
operator = "大于" if min_exclusive else "大于或等于"
|
|
|
|
|
|
return f"{label} 必须{operator} {_format_float(min_value)}。"
|
|
|
|
|
|
if max_value is not None:
|
|
|
|
|
|
max_exclusive = bool(spec.get("max_exclusive"))
|
|
|
|
|
|
if (max_exclusive and value >= max_value) or (not max_exclusive and value > max_value):
|
|
|
|
|
|
operator = "小于" if max_exclusive else "小于或等于"
|
|
|
|
|
|
return f"{label} 必须{operator} {_format_float(max_value)}。"
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_property_vector3(self, text: str) -> tuple[float, float, float]:
|
|
|
|
|
|
chunks = text.replace(",", ",").replace(";", ",").replace(";", ",").replace(" ", ",").split(",")
|
|
|
|
|
|
values = [chunk for chunk in chunks if chunk.strip()]
|
|
|
|
|
|
if len(values) != 3:
|
|
|
|
|
|
raise ValueError("请输入三个数字,例如 10, 0, 0。")
|
|
|
|
|
|
try:
|
|
|
|
|
|
return (float(values[0]), float(values[1]), float(values[2]))
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise ValueError("平移 X/Y/Z 必须都是数字。") from exc
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
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)
|
2026-07-28 14:05:14 +08:00
|
|
|
|
if info.get("surface") in {"cylinder", "plane"}:
|
2026-07-27 18:28:26 +08:00
|
|
|
|
return self.model.feature_info(self.selected_face_id)
|
|
|
|
|
|
return info
|
|
|
|
|
|
if self.selected_kind == "edge" and self.selected_edge_id is not None:
|
|
|
|
|
|
return self.model.edge_info(self.selected_edge_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return dict(self.current_info_values)
|
|
|
|
|
|
return dict(self.current_info_values)
|
|
|
|
|
|
|
|
|
|
|
|
def _locate_operation_record(self, record: OperationRecord) -> str:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
self._reset_selection(clear_highlight=True)
|
|
|
|
|
|
located = False
|
|
|
|
|
|
locator_note = ""
|
|
|
|
|
|
|
|
|
|
|
|
if record.target_kind in {"face", "feature"} and record.target_id is not None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
resolved_face_id = self._resolve_record_face_id(record)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
if resolved_face_id is not None:
|
|
|
|
|
|
info = self.model.feature_info(resolved_face_id) if record.target_kind == "feature" else self.model.face_info(resolved_face_id)
|
|
|
|
|
|
self.selected_kind = "feature" if record.target_kind == "feature" else "face"
|
|
|
|
|
|
self.selected_face_id = resolved_face_id
|
|
|
|
|
|
self.selected_part_id = int(info["part_id"])
|
|
|
|
|
|
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
|
|
|
|
|
|
self.selected_pick_position = record.pick_position
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Feature" if record.target_kind == "feature" else "Face")
|
2026-07-27 18:28:26 +08:00
|
|
|
|
current_logical_id = self.model.face_region_logical_id(resolved_face_id)
|
|
|
|
|
|
self._sync_id_picker("Feature" if record.target_kind == "feature" else "Face", current_logical_id)
|
|
|
|
|
|
face_ids = (
|
|
|
|
|
|
_int_values(info.get("feature_highlight_face_ids"))
|
|
|
|
|
|
if record.target_kind == "feature"
|
|
|
|
|
|
else self.model.face_region_ids(resolved_face_id)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not face_ids:
|
|
|
|
|
|
face_ids = self.model.face_region_ids(resolved_face_id)
|
|
|
|
|
|
self._highlight_faces(face_ids=face_ids)
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self.set_info(self._with_pick_info(info, record.pick_position))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
located = True
|
2026-07-28 14:05:14 +08:00
|
|
|
|
region_note = f";同域面区域 {len(face_ids)} 个Face" if len(face_ids) > 1 else ""
|
2026-07-27 18:28:26 +08:00
|
|
|
|
id_note = (
|
|
|
|
|
|
f"逻辑 Face ID {current_logical_id}"
|
|
|
|
|
|
if record.target_logical_id is not None
|
2026-07-28 14:05:14 +08:00
|
|
|
|
else f"Face {record.target_id}"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
if resolved_face_id != record.target_id:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
id_note += f"(当前拓扑Face {resolved_face_id})"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
locator_note = (
|
|
|
|
|
|
f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。"
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"布尔编辑后Face ID可能发生语义变化,请结合拾取点确认。"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
if record.target_logical_id is not None:
|
|
|
|
|
|
locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。"
|
|
|
|
|
|
else:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
locator_note = f"定位: 原目标Face {record.target_id} 在当前模型索引中已经不存在。"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
|
|
|
|
|
elif record.target_kind == "edge" and record.target_id is not None:
|
2026-07-28 18:30:58 +08:00
|
|
|
|
resolved_edge_id = self._resolve_record_edge_id(record)
|
|
|
|
|
|
if resolved_edge_id is not None:
|
|
|
|
|
|
info = self.model.edge_info(resolved_edge_id)
|
2026-07-27 18:28:26 +08:00
|
|
|
|
self.selected_kind = "edge"
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self.selected_edge_id = resolved_edge_id
|
2026-07-27 18:28:26 +08:00
|
|
|
|
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
|
2026-07-28 18:30:58 +08:00
|
|
|
|
self._set_selection_mode("Edge")
|
|
|
|
|
|
self._sync_id_picker("Edge", resolved_edge_id)
|
|
|
|
|
|
self._highlight_edge(resolved_edge_id)
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, record.pick_position))
|
2026-07-27 18:28:26 +08:00
|
|
|
|
located = True
|
|
|
|
|
|
locator_note = (
|
2026-07-28 18:30:58 +08:00
|
|
|
|
f"定位: 已尝试高亮当前模型中的Edge {resolved_edge_id}。"
|
2026-07-28 14:05:14 +08:00
|
|
|
|
"布尔/倒圆编辑后Edge ID可能发生语义变化,请结合拾取点确认。"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-28 14:05:14 +08:00
|
|
|
|
locator_note = f"定位: 原目标Edge {record.target_id} 在当前模型索引中已经不存在。"
|
2026-07-27 18:28:26 +08:00
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
elif record.target_kind == "part" and record.target_id is not None:
|
|
|
|
|
|
part_id = int(record.target_id)
|
|
|
|
|
|
if self.model.part_by_id(part_id) is not None:
|
|
|
|
|
|
info = self.model.part_info(part_id)
|
|
|
|
|
|
self.selected_kind = "part"
|
|
|
|
|
|
self.selected_part_id = part_id
|
|
|
|
|
|
self.selected_pick_position = record.pick_position
|
|
|
|
|
|
self._set_selection_mode("Part")
|
|
|
|
|
|
self._sync_id_picker("Part", part_id)
|
|
|
|
|
|
self._highlight_faces(part_ids=[part_id])
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, record.pick_position))
|
|
|
|
|
|
located = True
|
|
|
|
|
|
locator_note = f"定位: 已尝试高亮当前模型中的零件 {part_id}。"
|
|
|
|
|
|
else:
|
|
|
|
|
|
locator_note = f"定位: 原目标零件 {part_id} 在当前模型索引中已经不存在。"
|
|
|
|
|
|
|
|
|
|
|
|
elif record.target_kind == "solid" and record.target_id is not None:
|
|
|
|
|
|
solid_id = int(record.target_id)
|
|
|
|
|
|
if 0 <= solid_id < len(self.model.solids):
|
|
|
|
|
|
info = self.model.solid_info(solid_id)
|
|
|
|
|
|
self.selected_kind = "solid"
|
|
|
|
|
|
self.selected_solid_id = solid_id
|
|
|
|
|
|
self.selected_part_id = int(info["part_id"])
|
|
|
|
|
|
self.selected_pick_position = record.pick_position
|
|
|
|
|
|
face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id]
|
|
|
|
|
|
self._set_selection_mode("Solid")
|
|
|
|
|
|
self._sync_id_picker("Solid", solid_id)
|
|
|
|
|
|
self._highlight_faces(face_ids=face_ids)
|
|
|
|
|
|
self.set_info(self._with_pick_info(info, record.pick_position))
|
|
|
|
|
|
located = True
|
|
|
|
|
|
locator_note = f"定位: 已尝试高亮当前模型中的Solid {solid_id}。"
|
|
|
|
|
|
else:
|
|
|
|
|
|
locator_note = f"定位: 原目标Solid {solid_id} 在当前模型索引中已经不存在。"
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-28 18:30:58 +08:00
|
|
|
|
def _resolve_record_face_id(self, record: OperationRecord) -> int | None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if record.pick_position is not None:
|
|
|
|
|
|
nearest = self.model.nearest_face_id_to_point(self._record_candidate_face_ids(record), record.pick_position)
|
|
|
|
|
|
if nearest is not None:
|
|
|
|
|
|
return nearest
|
|
|
|
|
|
lookup_id = record.target_logical_id if record.target_logical_id is not None else record.target_id
|
|
|
|
|
|
if lookup_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return self.model.resolve_face_selection_id(int(lookup_id))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_record_edge_id(self, record: OperationRecord) -> int | None:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
if record.pick_position is not None:
|
|
|
|
|
|
nearest = self.model.nearest_edge_id_to_point(self._record_candidate_edge_ids(record), record.pick_position)
|
|
|
|
|
|
if nearest is not None:
|
|
|
|
|
|
return nearest
|
|
|
|
|
|
if record.target_id is None:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
edge_id = int(record.target_id)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
return edge_id if 0 <= edge_id < len(self.model.edges) else None
|
|
|
|
|
|
|
|
|
|
|
|
def _record_candidate_face_ids(self, record: OperationRecord) -> list[int]:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
part_id = self._record_int_parameter(record, "part_id")
|
|
|
|
|
|
solid_id = self._record_int_parameter(record, "solid_id")
|
|
|
|
|
|
face_ids = list(range(len(self.model.faces)))
|
|
|
|
|
|
if part_id is not None:
|
|
|
|
|
|
face_ids = [face_id for face_id in face_ids if self.model.face_part_ids[face_id] == part_id]
|
|
|
|
|
|
if solid_id is not None and solid_id >= 0:
|
|
|
|
|
|
face_ids = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id]
|
|
|
|
|
|
target_diameter = (
|
|
|
|
|
|
self._record_float_parameter(record, "new_diameter")
|
|
|
|
|
|
or self._record_float_parameter(record, "derived_new_diameter")
|
|
|
|
|
|
or self._record_float_parameter(record, "target_diameter")
|
|
|
|
|
|
)
|
|
|
|
|
|
if target_diameter is not None and target_diameter > 0:
|
|
|
|
|
|
diameter_tolerance = max(target_diameter * 0.02, 1e-5)
|
|
|
|
|
|
cylindrical_face_ids: list[int] = []
|
|
|
|
|
|
for face_id in face_ids:
|
|
|
|
|
|
try:
|
|
|
|
|
|
info = self.model.face_info(face_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
current_diameter = _float_or_none(info.get("diameter"))
|
|
|
|
|
|
if info.get("surface") == "cylinder" and current_diameter is not None:
|
|
|
|
|
|
if abs(current_diameter - target_diameter) <= diameter_tolerance:
|
|
|
|
|
|
cylindrical_face_ids.append(face_id)
|
|
|
|
|
|
if cylindrical_face_ids:
|
|
|
|
|
|
face_ids = cylindrical_face_ids
|
|
|
|
|
|
return face_ids or list(range(len(self.model.faces)))
|
|
|
|
|
|
|
|
|
|
|
|
def _record_candidate_edge_ids(self, record: OperationRecord) -> list[int]:
|
|
|
|
|
|
if self.model is None:
|
|
|
|
|
|
return []
|
|
|
|
|
|
part_id = self._record_int_parameter(record, "part_id")
|
|
|
|
|
|
solid_id = self._record_int_parameter(record, "solid_id")
|
|
|
|
|
|
edge_ids = list(range(len(self.model.edges)))
|
|
|
|
|
|
if part_id is not None:
|
|
|
|
|
|
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_part_ids[edge_id] == part_id]
|
|
|
|
|
|
if solid_id is not None and solid_id >= 0:
|
|
|
|
|
|
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_solid_ids[edge_id] == solid_id]
|
|
|
|
|
|
return edge_ids or list(range(len(self.model.edges)))
|
|
|
|
|
|
|
|
|
|
|
|
def _record_int_parameter(self, record: OperationRecord, key: str) -> int | None:
|
|
|
|
|
|
parameters = record.parameters if isinstance(record.parameters, dict) else {}
|
|
|
|
|
|
value = parameters.get(key)
|
|
|
|
|
|
if value is None or value == "":
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(value)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
def _record_float_parameter(self, record: OperationRecord, key: str) -> float | None:
|
|
|
|
|
|
parameters = record.parameters if isinstance(record.parameters, dict) else {}
|
|
|
|
|
|
return _float_or_none(parameters.get(key))
|
|
|
|
|
|
|
2026-07-27 18:28:26 +08:00
|
|
|
|
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 "模型已恢复到操作前状态,历史记录未移动。"
|
|
|
|
|
|
|