Files
pythonocc-step-editor/step_editor/window_actions.py
T

2716 lines
128 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import datetime
import math
from pathlib import Path
import vtk
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QMessageBox,
QTableWidgetItem,
QTreeWidgetItem,
)
from .model import StepModel
from .records import OperationRecord
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
def _message_field(message: str, key: str) -> str | None:
marker = f"{key}="
start = message.find(marker)
if start < 0:
return None
start += len(marker)
end = message.find(",", start)
if end < 0:
end = len(message)
value = message[start:end].strip().rstrip(".")
return value or None
class WindowActionMixin:
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 或特征模式并选择一个 Face。")
return
if not self._confirm_export_quality("face", self.selected_face_id):
return
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中面区域",
str(self.step_path.parent / f"face_region_{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"已导出选中面区域到 {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, "未选择特征", "请先切换到特征模式并选择一个特征。")
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 repair_model(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修复模型。"):
return
def action():
return self.model.repair_model()
self._run_edit_action(
action,
operation_name="修复当前模型",
target="当前完整模型",
parameters={"scope": "all", "repair_strategy": "ShapeFix + UnifySameDomain"},
target_kind=None,
target_id=None,
)
def repair_selected_shape(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修复选中对象。"):
return
if self.selected_solid_id is not None:
solid_id = self.selected_solid_id
def action():
return self.model.repair_solid(solid_id)
self._run_edit_action(
action,
operation_name="修复选中Solid",
target=f"Solid {solid_id}",
parameters={
"scope": "solid",
"solid_id": solid_id,
"part_id": self.selected_part_id,
"repair_strategy": "ShapeFix + UnifySameDomain",
},
target_kind="solid",
target_id=solid_id,
)
return
if self.selected_part_id is not None:
part_id = self.selected_part_id
def action():
return self.model.repair_part(part_id)
self._run_edit_action(
action,
operation_name="修复选中零件",
target=f"零件 {part_id}",
parameters={
"scope": "part",
"part_id": part_id,
"repair_strategy": "ShapeFix + UnifySameDomain",
},
target_kind="part",
target_id=part_id,
)
return
QMessageBox.information(self, "未选择对象", "请先选择零件、Solid、Face 或 Edge。")
self.statusBar().showMessage("未选择可修复对象")
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", "请先选择一个平面 Face。")
return
try:
distance = float(self.offset_input.text())
except ValueError:
QMessageBox.critical(self, "距离无效", "请输入数字形式的Face偏移距离。")
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
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_shell_thickness(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再调整薄壁厚度。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择平面", "请先选择一个已识别到相对面的薄壁/壳体平面候选。")
return
if not hasattr(self, "shell_thickness_input"):
return
try:
target_thickness = float(self.shell_thickness_input.text())
except ValueError:
QMessageBox.critical(self, "目标厚度无效", "请输入数字形式的目标薄壁厚度。")
return
face_id = self.selected_face_id
plan = self.model.shell_thickness_plan(face_id, target_thickness)
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['shell_current_thickness'])}\n"
f"目标厚度: {_format_value(plan['shell_target_thickness'])}\n"
f"厚度变化量: {_format_value(plan['shell_delta_thickness'])}\n"
f"厚度变化比例: {_format_percent(plan['shell_delta_ratio'])}\n"
f"相对平面 Face: {_format_value(plan.get('shell_opposite_face_id', ''))}\n"
f"相对平面重叠率: {_format_percent(plan.get('shell_overlap_ratio_estimate'))}\n"
f"置信度: {_format_value(plan.get('shell_confidence', ''))}\n"
f"当前面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"当前版本会移动当前平面区域,让它和相对平面的距离接近目标厚度;这不是 CAD 壳命令参数编辑。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消薄壁厚度调整")
return
self._show_shell_thickness_preview(face_id, target_thickness)
def action():
return self.model.resize_shell_thickness(face_id, target_thickness)
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"),
"shell_region_kind": plan.get("shell_region_kind"),
"shell_confidence": plan.get("shell_confidence"),
"shell_source_face_ids": plan.get("shell_source_face_ids"),
"shell_opposite_face_id": plan.get("shell_opposite_face_id"),
"shell_current_thickness": plan.get("shell_current_thickness"),
"shell_target_thickness": plan.get("shell_target_thickness"),
"shell_delta_thickness": plan.get("shell_delta_thickness"),
"shell_delta_ratio": plan.get("shell_delta_ratio"),
"shell_signed_thickness": plan.get("shell_signed_thickness"),
"shell_overlap_ratio_estimate": plan.get("shell_overlap_ratio_estimate"),
"shell_opposite_normal_dot": plan.get("shell_opposite_normal_dot"),
"shell_desired_movement_vector": plan.get("shell_desired_movement_vector"),
"shell_movement_alignment": plan.get("shell_movement_alignment"),
"push_pull_distance": plan.get("push_pull_distance"),
"outward_direction": plan.get("outward_direction"),
"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"),
"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="feature" if self.selected_kind == "feature" else "face",
target_id=face_id,
)
@staticmethod
def _max_quick_risk(current: str, candidate: str) -> str:
order = {"low": 0, "medium": 1, "high": 2, "blocked": 3}
return current if order.get(current, 0) >= order.get(candidate, 0) else candidate
def _selected_face_info_snapshot(self) -> dict[str, object]:
info = dict(getattr(self, "current_info_values", {}) or {})
if self.model is None or self.selected_face_id is None:
return info
selected_face_id = int(self.selected_face_id)
possible_ids = (
info.get("face_id"),
info.get("topological_face_id"),
info.get("feature_source_face_id"),
info.get("face_region_source_face_id"),
)
has_selected_id = False
for value in possible_ids:
try:
if value is not None and int(value) == selected_face_id:
has_selected_id = True
break
except (TypeError, ValueError):
pass
has_any_face_id = any(value is not None for value in possible_ids)
if info and has_any_face_id and not has_selected_id:
info = {}
if info and str(info.get("kind", "")) in {"edge", "part", "solid"} and not has_selected_id:
info = {}
if "surface" not in info and "diameter" not in info:
try:
info = dict(self.model.face_info(selected_face_id))
except Exception:
pass
return info
def _quick_cylinder_resize_plan(
self,
face_id: int,
target_diameter: float,
info: dict[str, object] | None = None,
) -> dict[str, object]:
info = dict(info or self._selected_face_info_snapshot())
warnings: list[str] = []
blockers: list[str] = []
risk = "low"
status = "ready"
surface = str(info.get("surface", ""))
current_diameter = _float_or_none(info.get("diameter"))
height_estimate = _float_or_none(info.get("height_estimate"))
if height_estimate is None:
height_estimate = _float_or_none(info.get("same_domain_height_estimate"))
if height_estimate is None:
height_estimate = _float_or_none(info.get("hole_depth_estimate"))
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = _float_or_none(info.get("angular_span"))
if surface != "cylinder" or current_diameter is None or current_diameter <= 1e-9:
blockers.append("当前选中对象不是可识别的圆柱面,不能调整孔/槽直径。")
if target_diameter <= 0:
blockers.append("目标直径必须大于 0。")
if guess == "round/fillet candidate":
risk = self._max_quick_risk(risk, "high")
warnings.append("当前圆柱面更像圆角/倒圆,调整孔径可能误切圆角。")
elif guess == "boss/outer-round candidate":
risk = self._max_quick_risk(risk, "high")
warnings.append("当前圆柱面更像凸台或外圆,调整孔径可能切掉外部结构。")
elif guess != "hole/groove candidate":
risk = self._max_quick_risk(risk, "high")
warnings.append("当前圆柱面还没有被稳定识别为孔/槽候选。")
if guess == "hole/groove candidate" and confidence == "low":
risk = self._max_quick_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if (
guess == "hole/groove candidate"
and angular_span is not None
and angular_span < math.tau * 0.92
):
risk = self._max_quick_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,不是完整圆孔。")
delta_diameter = None
delta_ratio = None
target_to_height_ratio = None
resize_mode = "unknown"
if current_diameter is not None and current_diameter > 1e-9:
delta_diameter = target_diameter - current_diameter
delta_ratio = abs(delta_diameter) / current_diameter
resize_mode = "expand" if delta_diameter > 0 else "shrink"
if abs(delta_diameter) <= max(current_diameter * 1e-5, 1e-6):
blockers.append("目标直径与当前直径几乎相同,不需要修改。")
elif target_diameter > current_diameter * 2.0:
blockers.append(
"目标直径超过当前直径的 2 倍;当前版本会阻止这种极端放大,避免布尔计算长时间卡死。"
)
elif delta_ratio > 1.0:
risk = self._max_quick_risk(risk, "high")
warnings.append("目标直径变化超过当前直径的 100%,很可能导致大范围误切或布尔失败。")
elif delta_ratio > 0.35:
risk = self._max_quick_risk(risk, "medium")
warnings.append("目标直径变化超过当前直径的 35%。")
if target_diameter < current_diameter:
if guess != "hole/groove candidate":
blockers.append("缩小孔径当前版本只支持明确的孔/槽候选。")
else:
risk = self._max_quick_risk(risk, "high")
warnings.append("缩小孔径需要先补料再重切,属于高风险实验路径。")
if height_estimate is not None and height_estimate > 1e-9:
target_to_height_ratio = target_diameter / height_estimate
if target_diameter > height_estimate * 2.0:
blockers.append("目标直径超过圆柱面估算高度的 2 倍,当前版本暂不放行。")
elif target_diameter > height_estimate:
risk = self._max_quick_risk(risk, "medium")
warnings.append("目标直径大于圆柱面估算高度,请确认单位和目标。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
message = "".join(blockers + warnings) if blockers or warnings else "可以尝试调整孔/槽直径。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"current_diameter": current_diameter,
"target_diameter": target_diameter,
"delta_diameter": delta_diameter,
"diameter_delta_ratio": delta_ratio,
"target_to_height_ratio": target_to_height_ratio,
"resize_mode": resize_mode,
"feature_type": info.get("feature_type"),
"feature_guess": guess,
"confidence": confidence,
"angular_span": angular_span,
"height_estimate": height_estimate,
"material_vote_summary": info.get("material_vote_summary"),
"cylinder_end_type": info.get("cylinder_end_type"),
"hole_depth_estimate": info.get("hole_depth_estimate"),
"feature_bottom_face_ids": info.get("feature_bottom_face_ids"),
"feature_opening_face_ids": info.get("feature_opening_face_ids"),
"quick_preflight": True,
}
def _confirm_quick_edit_plan(
self,
title: str,
plan: dict[str, object],
lines: list[str],
blocked_status: str,
cancelled_status: str,
) -> bool:
if plan.get("status") == "blocked":
QMessageBox.information(self, f"不能{title}", str(plan.get("message", "")))
self.statusBar().showMessage(blocked_status)
return False
if self._block_unisolated_high_risk_operation(f"已阻止高风险{title}", plan):
return False
if plan.get("risk") == "low":
return True
warnings = str(plan.get("warnings", ""))
warnings_line = f"\n警告: {warnings}\n" if warnings else ""
result = QMessageBox.question(
self,
f"确认{title}",
(
"\n".join(lines)
+ f"\n风险: {plan.get('risk')}\n"
+ warnings_line
+ f"\n{plan.get('message', '')}\n\n"
"为避免复杂 STEP 在界面线程卡死,本次不会先生成红/绿预览;"
"详细几何方案会进入后台计算,完成后自动刷新模型。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage(cancelled_status)
return False
return True
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
face_id = self.selected_face_id
info = self._selected_face_info_snapshot()
plan = self._quick_cylinder_resize_plan(face_id, diameter, info)
lines = [
f"Face: {face_id}",
f"当前直径: {_format_value(plan.get('current_diameter'))}",
f"目标直径: {_format_value(plan.get('target_diameter'))}",
f"直径变化量: {_format_value(plan.get('delta_diameter'))}",
f"直径变化比例: {_format_percent(plan.get('diameter_delta_ratio'))}",
f"候选判断: {plan.get('feature_guess')}",
f"置信度: {plan.get('confidence')}",
]
if plan.get("target_to_height_ratio") not in {None, ""}:
lines.append(f"目标直径/估算高度: {_format_value(plan.get('target_to_height_ratio'))}")
if not self._confirm_quick_edit_plan(
"圆柱孔径调整",
plan,
lines,
"圆柱孔径调整已阻止",
"已取消圆柱孔径调整",
):
return
self.clear_edit_preview(render=False)
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": plan.get("current_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_guess": plan.get("feature_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"),
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
target_kind="face",
target_id=face_id,
)
def resize_slot_width(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
if not hasattr(self, "slot_width_input"):
return
try:
target_width = float(self.slot_width_input.text())
except ValueError:
QMessageBox.critical(self, "目标槽宽无效", "请输入数字形式的目标槽/半孔宽度。")
return
if target_width <= 0:
QMessageBox.critical(self, "目标槽宽无效", "目标槽/半孔宽度必须大于 0。")
return
face_id = self.selected_face_id
info = self._selected_face_info_snapshot()
if info.get("surface") != "cylinder" or info.get("slot_kind") != "partial-cylindrical-groove":
QMessageBox.information(self, "不是槽/半孔候选", "当前选中Face没有被识别为槽/半孔候选。")
return
angular_span = _float_or_none(info.get("slot_angular_span"))
if angular_span is None:
angular_span = _float_or_none(info.get("angular_span"))
if angular_span is None or angular_span <= 1e-6 or angular_span >= math.tau * 0.92:
QMessageBox.information(self, "不能调整槽宽", "当前候选没有稳定的部分圆柱角度,不能换算槽宽。")
return
sin_half_span = math.sin(min(max(angular_span, 1e-6), math.tau - 1e-6) / 2.0)
if abs(sin_half_span) <= 1e-6:
QMessageBox.information(self, "不能调整槽宽", "当前槽圆弧角度过小,不能可靠换算为圆柱直径。")
return
target_diameter = target_width / sin_half_span
current_width = _float_or_none(info.get("slot_chord_width_estimate"))
plan = self._quick_cylinder_resize_plan(face_id, target_diameter, info)
lines = [
f"Face: {face_id}",
f"当前槽宽估算: {_format_value(current_width)}",
f"目标槽宽: {_format_value(target_width)}",
f"槽圆弧角度: {_format_value(angular_span)}",
f"换算目标圆柱直径: {_format_value(target_diameter)}",
"当前版本会把槽宽换算成圆柱直径后执行孔/槽重建,不是完整 CAD 槽参数编辑。",
]
if not self._confirm_quick_edit_plan(
"槽/半孔宽度调整",
plan,
lines,
"槽/半孔宽度调整已阻止",
"已取消槽/半孔宽度调整",
):
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_cylindrical_hole(face_id, target_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_slot_width": target_width,
"old_slot_width": current_width,
"slot_width_delta": None if current_width is None else target_width - current_width,
"slot_angular_span": angular_span,
"derived_new_diameter": target_diameter,
"old_diameter": plan.get("current_diameter"),
"delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"resize_mode": plan.get("resize_mode"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"slot_status": info.get("slot_status"),
"slot_kind": info.get("slot_kind"),
"feature_slot_boundary_face_ids": info.get("feature_slot_boundary_face_ids"),
"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"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
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"
"继续操作会对当前零件的局部几何执行受限B-Rep布尔修改。\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"
"继续操作会用补料体封堵当前孔,并对当前零件执行受限OCCT 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 _hole_bottom_face_override(self) -> int | None:
if not hasattr(self, "hole_bottom_face_input"):
return None
text = self.hole_bottom_face_input.text().strip()
if not text:
return None
try:
bottom_face_id = int(text)
except ValueError as exc:
raise ValueError("底面 Face ID 必须是整数。") from exc
if self.model is not None and (bottom_face_id < 0 or bottom_face_id >= len(self.model.faces)):
raise ValueError(f"底面 Face ID {bottom_face_id} 不存在。")
return bottom_face_id
def _quick_cylinder_depth_plan(
self,
face_id: int,
target_depth: float,
bottom_face_id: int | None = None,
info: dict[str, object] | None = None,
) -> dict[str, object]:
info = dict(info or self._selected_face_info_snapshot())
warnings: list[str] = []
blockers: list[str] = []
risk = "low"
status = "ready"
surface = str(info.get("surface", ""))
guess = str(info.get("feature_guess", "cylindrical face"))
confidence = str(info.get("confidence", "low"))
angular_span = _float_or_none(info.get("angular_span"))
end_type = str(info.get("cylinder_end_type", "unknown"))
current_depth = _float_or_none(info.get("hole_depth_estimate"))
if current_depth is None:
current_depth = _float_or_none(info.get("same_domain_height_estimate"))
manual_bottom = bottom_face_id is not None
if surface != "cylinder":
blockers.append("当前选中对象不是圆柱孔/槽,不能调整孔深。")
if guess != "hole/groove candidate":
blockers.append("孔深调整当前版本只支持明确的孔/槽候选。")
if end_type != "blind" and not manual_bottom:
blockers.append("孔深调整需要识别到盲孔/盲槽底面,或手动填写底面 Face ID。")
elif end_type != "blind" and manual_bottom:
risk = self._max_quick_risk(risk, "medium")
warnings.append("端部类型不是明确 blind,当前会按手动底面 Face ID 尝试推断孔深方向。")
if current_depth is None or current_depth <= 1e-9:
blockers.append("当前圆柱面没有可靠的深度估算。")
if target_depth <= 0:
blockers.append("目标深度必须大于 0。")
if guess == "hole/groove candidate" and confidence == "low":
risk = self._max_quick_risk(risk, "medium")
warnings.append("孔/槽判断置信度较低。")
if (
guess == "hole/groove candidate"
and angular_span is not None
and angular_span < math.tau * 0.92
):
risk = self._max_quick_risk(risk, "medium")
warnings.append("这是局部圆柱面,更像槽或半孔,孔深调整会按局部槽处理。")
delta_depth = None
delta_ratio = None
depth_mode = "unknown"
if current_depth is not None and current_depth > 1e-9:
delta_depth = target_depth - current_depth
delta_ratio = abs(delta_depth) / current_depth
depth_mode = "deepen" if delta_depth > 0 else "shallow"
if abs(delta_depth) <= max(current_depth * 1e-5, 1e-6):
blockers.append("目标深度与当前深度几乎相同,不需要修改。")
elif delta_ratio > 1.0:
risk = self._max_quick_risk(risk, "high")
warnings.append("目标深度变化超过当前深度的 100%,很可能贯穿、误切或布尔失败。")
elif delta_ratio > 0.35:
risk = self._max_quick_risk(risk, "medium")
warnings.append("目标深度变化超过当前深度的 35%。")
if target_depth < current_depth * 0.08:
risk = self._max_quick_risk(risk, "high")
warnings.append("目标深度非常浅,补料后可能生成很薄的局部面。")
if blockers:
status = "blocked"
risk = "blocked"
elif risk in {"medium", "high"}:
status = "caution"
message = "".join(blockers + warnings) if blockers or warnings else "可以尝试调整盲孔/盲槽深度。"
return {
"status": status,
"risk": risk,
"message": message,
"warnings": "".join(warnings),
"blockers": "".join(blockers),
"face_id": face_id,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"current_depth": current_depth,
"target_depth": target_depth,
"delta_depth": delta_depth,
"depth_delta_ratio": delta_ratio,
"depth_mode": depth_mode,
"diameter": info.get("diameter"),
"radius": info.get("radius"),
"feature_type": info.get("feature_type"),
"feature_guess": guess,
"confidence": confidence,
"angular_span": angular_span,
"cylinder_end_type": end_type,
"feature_bottom_face_ids": info.get("feature_bottom_face_ids"),
"manual_bottom_face_id": "" if bottom_face_id is None else bottom_face_id,
"manual_bottom_face_used": manual_bottom,
"feature_opening_face_ids": info.get("feature_opening_face_ids"),
"feature_bottom_confidence": info.get("feature_bottom_confidence"),
"feature_bottom_detection": info.get("feature_bottom_detection"),
"feature_bottom_note": info.get("feature_bottom_note"),
"quick_preflight": True,
}
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
try:
bottom_face_id = self._hole_bottom_face_override()
except ValueError as exc:
QMessageBox.critical(self, "底面 Face ID 无效", str(exc))
return
face_id = self.selected_face_id
info = self._selected_face_info_snapshot()
plan = self._quick_cylinder_depth_plan(face_id, target_depth, bottom_face_id=bottom_face_id, info=info)
lines = [
f"Face: {face_id}",
f"当前深度: {_format_value(plan.get('current_depth'))}",
f"目标深度: {_format_value(plan.get('target_depth'))}",
f"深度变化量: {_format_value(plan.get('delta_depth'))}",
f"深度变化比例: {_format_percent(plan.get('depth_delta_ratio'))}",
f"候选判断: {plan.get('feature_guess')}",
f"置信度: {plan.get('confidence')}",
f"端部类型: {plan.get('cylinder_end_type')}",
]
if bottom_face_id is not None:
lines.append(f"手动底面 Face: {bottom_face_id}")
if not self._confirm_quick_edit_plan(
"盲孔深度调整",
plan,
lines,
"盲孔深度调整已阻止",
"已取消盲孔深度调整",
):
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_cylindrical_depth(
face_id,
target_depth,
bottom_face_id=bottom_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"),
"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"),
"manual_bottom_face_id": plan.get("manual_bottom_face_id"),
"manual_bottom_face_used": plan.get("manual_bottom_face_used"),
"manual_bottom_face_note": plan.get("manual_bottom_face_note"),
"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"),
"quick_preflight": True,
"ui_preview": "skipped-to-avoid-ui-freeze",
},
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 self._block_unisolated_high_risk_operation("已阻止高风险已有圆角修改", plan):
return
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", "请先选择一条直线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 self._block_unisolated_high_risk_operation("已阻止高风险边圆角", plan):
return
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,
"确认给Edge添加圆角",
(
f"Edge: {plan['edge_id']}\n"
f"Edge长度: {_format_value(plan['edge_length'])}\n"
f"目标圆角半径: {_format_value(plan['target_radius'])}\n"
f"半径/Edge长度比例: {_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="给Edge添加圆角",
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", "请先选择一条直线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 self._block_unisolated_high_risk_operation("已阻止高风险边倒角", plan):
return
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,
"确认给Edge添加倒角",
(
f"Edge: {plan['edge_id']}\n"
f"Edge长度: {_format_value(plan['edge_length'])}\n"
f"目标倒角距离: {_format_value(plan['target_distance'])}\n"
f"倒角距离/Edge长度比例: {_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="给Edge添加倒角",
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("请等待当前编辑完成后再修改Edge长度。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择Edge", "请先选择一条Edge。")
return
try:
target_length = float(self.edge_target_length_input.text())
except ValueError:
QMessageBox.critical(self, "目标长度无效", "请输入数字形式的Edge目标长度。")
return
edge_id = self.selected_edge_id
anchor_mode = self._edge_length_anchor_mode_from_ui()
plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改Edge长度", str(plan["message"]))
self.statusBar().showMessage("Edge长度修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认修改Edge长度",
(
f"Edge: {plan['edge_id']}\n"
f"当前Edge长度: {_format_value(plan['current_length'])}\n"
f"目标Edge长度: {_format_value(plan['target_length'])}\n"
f"变化量: {_format_value(plan['delta_length'])}\n"
f"Edge长度基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n"
f"策略: {_format_value(plan.get('resize_strategy', ''))}\n"
f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n"
f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n"
f"重建面数: {_format_value(plan.get('local_edge_deform_face_count', ''))}\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"圆柱Face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n"
f"圆形Edge模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n"
f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n"
f"缩放类型: {_format_value(plan.get('affine_transform_label') or plan.get('affine_transform_kind', ''))}\n"
f"缩放说明: {_format_value(plan.get('affine_transform_note', ''))}\n"
f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n"
f"预估Edge长度: {_format_value(plan.get('affine_predicted_edge_length', ''))}\n"
f"缩放校正: {_format_value(plan.get('affine_scale_correction', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"当前会优先只移动被选直线Edge的端点并重建周边平面;没有明确局部路径时会尝试端面推拉、相邻圆柱直径编辑或几何缩放 fallback。"
"这不是通用参数化Edge长度编辑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消Edge长度修改")
return
self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode)
def action():
return self.model.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode)
self._run_edit_action(
action,
operation_name="修改Edge长度",
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"),
"edge_length_anchor_mode": plan.get("edge_length_anchor_mode"),
"edge_length_anchor_label": plan.get("edge_length_anchor_label"),
"local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"),
"local_edge_deform_anchor": plan.get("local_edge_deform_anchor"),
"local_edge_deform_face_count": plan.get("local_edge_deform_face_count"),
"local_edge_deform_start_move": plan.get("local_edge_deform_start_move"),
"local_edge_deform_end_move": plan.get("local_edge_deform_end_move"),
"local_edge_deform_moved_endpoint_count": plan.get("local_edge_deform_moved_endpoint_count"),
"local_edge_deform_risk": plan.get("local_edge_deform_risk"),
"local_edge_deform_note": plan.get("local_edge_deform_note"),
"end_face_id": plan.get("end_face_id"),
"end_face_endpoint_role": plan.get("end_face_endpoint_role"),
"end_face_label": plan.get("end_face_label"),
"push_pull_distance": plan.get("push_pull_distance"),
"resize_strategy": plan.get("resize_strategy"),
"affine_scale": plan.get("affine_scale"),
"affine_initial_scale": plan.get("affine_initial_scale"),
"affine_scale_correction": plan.get("affine_scale_correction"),
"affine_predicted_edge_length": plan.get("affine_predicted_edge_length"),
"affine_scale_refine_iterations": plan.get("affine_scale_refine_iterations"),
"affine_scale_refine_note": plan.get("affine_scale_refine_note"),
"affine_transform_kind": plan.get("affine_transform_kind"),
"affine_transform_label": plan.get("affine_transform_label"),
"affine_transform_note": plan.get("affine_transform_note"),
"affine_transform_warning": plan.get("affine_transform_warning"),
"affine_transform_risk": plan.get("affine_transform_risk"),
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_axis_source": plan.get("affine_axis_source"),
"affine_anchor_source": plan.get("affine_anchor_source"),
"affine_target_kind": plan.get("affine_target_kind"),
"planar_edge_scale_sample_count": plan.get("planar_edge_scale_sample_count"),
"planar_edge_scale_plane_deviation": plan.get("planar_edge_scale_plane_deviation"),
"circular_edge_current_radius": plan.get("circular_edge_current_radius"),
"circular_edge_target_radius": plan.get("circular_edge_target_radius"),
"circular_edge_length_scale": plan.get("circular_edge_length_scale"),
"circular_edge_cylinder_face_id": plan.get("circular_edge_cylinder_face_id"),
"circular_edge_cylinder_mode": plan.get("circular_edge_cylinder_mode"),
"circular_edge_cylinder_mode_label": plan.get("circular_edge_cylinder_mode_label"),
"cylinder_resize_face_id": plan.get("cylinder_resize_face_id"),
"cylinder_resize_operation": plan.get("cylinder_resize_operation"),
"cylinder_resize_current_diameter": plan.get("cylinder_resize_current_diameter"),
"cylinder_resize_target_diameter": plan.get("cylinder_resize_target_diameter"),
"cylinder_resize_delta_diameter": plan.get("cylinder_resize_delta_diameter"),
"cylinder_resize_delta_ratio": plan.get("cylinder_resize_delta_ratio"),
"cylinder_resize_status": plan.get("cylinder_resize_status"),
"cylinder_resize_risk": plan.get("cylinder_resize_risk"),
"cylinder_resize_feature_guess": plan.get("cylinder_resize_feature_guess"),
"cylinder_resize_confidence": plan.get("cylinder_resize_confidence"),
"cylinder_resize_same_domain_face_ids": plan.get("cylinder_resize_same_domain_face_ids"),
"cylinder_resize_same_domain_face_count": plan.get("cylinder_resize_same_domain_face_count"),
"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 resize_any_edge_length(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改Edge长度。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择Edge", "请先选择一条Edge。")
return
try:
target_length = float(self.edge_target_length_input.text())
except ValueError:
QMessageBox.critical(self, "目标长度无效", "请输入数字形式的Edge目标长度。")
return
edge_id = self.selected_edge_id
anchor_mode = self._edge_length_anchor_mode_from_ui()
plan = self.model.general_edge_length_plan(edge_id, target_length, anchor_mode=anchor_mode)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改Edge长度", str(plan["message"]))
self.statusBar().showMessage("Edge长度修改已阻止")
return
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
strategy = str(plan.get("resize_strategy", ""))
if plan["risk"] != "low":
result = QMessageBox.question(
self,
"确认修改Edge长度",
(
f"Edge: {plan['edge_id']}\n"
f"曲线类型: {_format_value(plan.get('curve', ''))}\n"
f"当前Edge长度: {_format_value(plan['current_length'])}\n"
f"目标Edge长度: {_format_value(plan['target_length'])}\n"
f"变化量: {_format_value(plan['delta_length'])}\n"
f"Edge长度基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n"
f"策略: {_format_value(strategy)}\n"
f"局部形变目标: {_format_value(plan.get('local_edge_deform_target_kind', ''))}\n"
f"局部形变: {_format_value(plan.get('local_edge_deform_anchor', ''))}\n"
f"重建面数: {_format_value(plan.get('local_edge_deform_face_count', ''))}\n"
f"端面Face: {_format_value(plan.get('end_face_id', ''))}\n"
f"端面推拉距离: {_format_value(plan.get('push_pull_distance', ''))}\n"
f"圆柱Face: {_format_value(plan.get('cylinder_resize_face_id', ''))}\n"
f"圆形Edge模式: {_format_value(plan.get('circular_edge_cylinder_mode_label', ''))}\n"
f"换算目标直径: {_format_value(plan.get('cylinder_resize_target_diameter', ''))}\n"
f"缩放类型: {_format_value(plan.get('affine_transform_label') or plan.get('affine_transform_kind', ''))}\n"
f"缩放说明: {_format_value(plan.get('affine_transform_note', ''))}\n"
f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n"
f"缩放比例: {_format_value(plan.get('affine_scale', ''))}\n"
f"预估Edge长度: {_format_value(plan.get('affine_predicted_edge_length', ''))}\n"
f"缩放校正: {_format_value(plan.get('affine_scale_correction', ''))}\n"
f"轴线来源: {_format_value(plan.get('affine_axis_source', ''))}\n"
f"平面采样偏差: {_format_value(plan.get('planar_edge_scale_plane_deviation', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消Edge长度修改")
return
self._show_edge_length_preview(edge_id, target_length, anchor_mode=anchor_mode)
def action():
return self.model.resize_general_edge_length(edge_id, target_length, anchor_mode=anchor_mode)
self._run_edit_action(
action,
operation_name="修改Edge长度",
target=f"Edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"curve": plan.get("curve"),
"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"),
"edge_length_anchor_mode": plan.get("edge_length_anchor_mode"),
"edge_length_anchor_label": plan.get("edge_length_anchor_label"),
"local_edge_deform_target_kind": plan.get("local_edge_deform_target_kind"),
"local_edge_deform_anchor": plan.get("local_edge_deform_anchor"),
"local_edge_deform_face_count": plan.get("local_edge_deform_face_count"),
"local_edge_deform_start_move": plan.get("local_edge_deform_start_move"),
"local_edge_deform_end_move": plan.get("local_edge_deform_end_move"),
"local_edge_deform_moved_endpoint_count": plan.get("local_edge_deform_moved_endpoint_count"),
"local_edge_deform_risk": plan.get("local_edge_deform_risk"),
"local_edge_deform_note": plan.get("local_edge_deform_note"),
"end_face_id": plan.get("end_face_id"),
"end_face_endpoint_role": plan.get("end_face_endpoint_role"),
"end_face_label": plan.get("end_face_label"),
"push_pull_distance": plan.get("push_pull_distance"),
"resize_strategy": plan.get("resize_strategy"),
"affine_scale": plan.get("affine_scale"),
"affine_initial_scale": plan.get("affine_initial_scale"),
"affine_scale_correction": plan.get("affine_scale_correction"),
"affine_predicted_edge_length": plan.get("affine_predicted_edge_length"),
"affine_scale_refine_iterations": plan.get("affine_scale_refine_iterations"),
"affine_scale_refine_note": plan.get("affine_scale_refine_note"),
"affine_transform_kind": plan.get("affine_transform_kind"),
"affine_transform_label": plan.get("affine_transform_label"),
"affine_transform_note": plan.get("affine_transform_note"),
"affine_transform_warning": plan.get("affine_transform_warning"),
"affine_transform_risk": plan.get("affine_transform_risk"),
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_axis_source": plan.get("affine_axis_source"),
"affine_anchor_source": plan.get("affine_anchor_source"),
"affine_target_kind": plan.get("affine_target_kind"),
"planar_edge_scale_sample_count": plan.get("planar_edge_scale_sample_count"),
"planar_edge_scale_plane_deviation": plan.get("planar_edge_scale_plane_deviation"),
"circular_edge_current_radius": plan.get("circular_edge_current_radius"),
"circular_edge_target_radius": plan.get("circular_edge_target_radius"),
"circular_edge_length_scale": plan.get("circular_edge_length_scale"),
"circular_edge_cylinder_face_id": plan.get("circular_edge_cylinder_face_id"),
"circular_edge_cylinder_mode": plan.get("circular_edge_cylinder_mode"),
"circular_edge_cylinder_mode_label": plan.get("circular_edge_cylinder_mode_label"),
"cylinder_resize_face_id": plan.get("cylinder_resize_face_id"),
"cylinder_resize_operation": plan.get("cylinder_resize_operation"),
"cylinder_resize_current_diameter": plan.get("cylinder_resize_current_diameter"),
"cylinder_resize_target_diameter": plan.get("cylinder_resize_target_diameter"),
"cylinder_resize_delta_diameter": plan.get("cylinder_resize_delta_diameter"),
"cylinder_resize_delta_ratio": plan.get("cylinder_resize_delta_ratio"),
"cylinder_resize_status": plan.get("cylinder_resize_status"),
"cylinder_resize_risk": plan.get("cylinder_resize_risk"),
"cylinder_resize_feature_guess": plan.get("cylinder_resize_feature_guess"),
"cylinder_resize_confidence": plan.get("cylinder_resize_confidence"),
"cylinder_resize_same_domain_face_ids": plan.get("cylinder_resize_same_domain_face_ids"),
"cylinder_resize_same_domain_face_count": plan.get("cylinder_resize_same_domain_face_count"),
"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 _edge_length_anchor_mode_from_ui(self) -> str:
combo = getattr(self, "edge_length_anchor_combo", None)
if combo is None:
return "auto"
data = combo.currentData()
return str(data or "auto")
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, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。")
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_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"零件: {_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, "未选择零件", "请先选择一个零件,或选择属于某个零件的对象。")
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_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"零件: {_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 _run_scan_task(self, scan_kind: str, action, context: dict[str, object] | None = None) -> None:
if self.scan_in_progress:
self.statusBar().showMessage("扫描中,请等待当前扫描完成。")
return
if self.operation_in_progress:
self.statusBar().showMessage("编辑计算中,请等待当前操作完成后再扫描。")
return
self.scan_in_progress = True
self.pending_scan_kind = scan_kind
self.pending_scan_context = dict(context or {})
self._clear_hover(render=True)
self._update_action_states()
self.scan_thread = None
self.scan_worker = None
QTimer.singleShot(0, lambda: self._run_scan_task_sync(action))
def _run_scan_task_sync(self, action) -> None:
if not self.scan_in_progress:
return
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = action()
except Exception as exc:
self._fail_scan_task_result(str(exc))
else:
self._finish_scan_task_result(result)
finally:
QApplication.restoreOverrideCursor()
@Slot(object)
def _finish_scan_task_result(self, result: object) -> None:
scan_kind = self.pending_scan_kind
context = dict(self.pending_scan_context or {})
if scan_kind == "editable":
self._finish_editable_scan(
result,
bool(context.get("show_info", True)),
str(context.get("scan_label", "扫描")),
int(context.get("limit", 0)),
)
return
if scan_kind == "cylinder":
self._finish_cylinder_scan(result, bool(context.get("show_info", True)))
return
self._end_scan_task()
self.statusBar().showMessage("扫描已完成,但扫描类型已丢失。")
@Slot(str)
def _fail_scan_task_result(self, message: str) -> None:
scan_kind = self.pending_scan_kind
if scan_kind == "editable":
self._fail_editable_scan(message)
return
if scan_kind == "cylinder":
self._fail_cylinder_scan(message)
return
self._end_scan_task()
QMessageBox.critical(self, "扫描失败", message)
self.statusBar().showMessage("扫描失败")
def _end_scan_task(self) -> None:
self.scan_in_progress = False
self.pending_scan_kind = None
self.pending_scan_context = None
self._update_action_states()
self._request_thread_quit(self.scan_thread)
def _forget_scan_thread(self) -> None:
self.scan_thread = None
self.scan_worker = None
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
max_scan_faces = 1600 if deep_scan else 700
max_scan_edges = 2500 if deep_scan else 1200
scan_label = "深度扫描" if deep_scan else "扫描"
self.statusBar().showMessage(f"正在{scan_label}可编辑对象...")
def action():
if self.model is None:
return []
return self.model.editable_feature_candidates(
limit=limit,
max_scan_faces=max_scan_faces,
max_scan_edges=max_scan_edges,
)
self._run_scan_task(
"editable",
action,
{
"show_info": show_info,
"scan_label": scan_label,
"limit": limit,
},
)
def _finish_editable_scan(self, result: object, show_info: bool, scan_label: str, limit: int) -> None:
try:
candidates = list(result) if isinstance(result, (list, tuple)) else []
self._populate_editable_candidates(candidates, show_info, scan_label, limit)
finally:
self._end_scan_task()
def _fail_editable_scan(self, message: str) -> None:
self._end_scan_task()
QMessageBox.critical(self, "扫描失败", message)
self.statusBar().showMessage("可编辑对象扫描失败")
def _populate_editable_candidates(
self,
candidates: list[dict[str, object]],
show_info: bool,
scan_label: str,
limit: int,
) -> None:
if not hasattr(self, "editable_table"):
if show_info:
self.set_plain_info("可编辑对象面板当前未启用。")
return
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"零件={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("正在扫描圆柱面候选...")
def action():
if self.model is None:
return []
return self.model.cylindrical_feature_candidates(limit=60, max_scan_faces=1200)
self._run_scan_task(
"cylinder",
action,
{"show_info": show_info},
)
def _finish_cylinder_scan(self, result: object, show_info: bool) -> None:
try:
self.cylinder_candidate_cache = list(result) if isinstance(result, (list, tuple)) else []
self.cylinder_candidates_loaded = True
self._filter_cached_cylinder_candidates(show_info=show_info)
finally:
self._end_scan_task()
def _fail_cylinder_scan(self, message: str) -> None:
self._end_scan_task()
self._clear_cylinder_candidates()
QMessageBox.critical(self, "扫描失败", message)
self.statusBar().showMessage("圆柱面候选扫描失败")
def _filter_cached_cylinder_candidates(self, show_info: bool = False) -> None:
if not hasattr(self, "cylinder_table"):
if show_info:
self.set_plain_info("圆柱面候选面板当前未启用。")
return
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:
if not hasattr(self, "cylinder_table"):
if show_info:
self.set_plain_info("圆柱面候选面板当前未启用。")
return
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']}: 零件 {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:
if not hasattr(self, "candidate_filter_combo"):
return True
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 _block_unisolated_high_risk_operation(self, title: str, plan: dict[str, object]) -> bool:
if str(plan.get("risk", "")) != "high":
return False
QMessageBox.information(
self,
title,
(
"该操作被稳定性保护阻止。\n\n"
"当前计划被判定为 high risk;这类 OCCT 布尔、倒圆或局部重建在复杂 STEP 上"
"可能不是普通失败,而是让进程卡死或直接退出。请先尝试更小的参数、修复模型、"
"选择更明确的面/边,或等后续子进程隔离执行通道实现后再开放。"
),
)
self.statusBar().showMessage("高风险操作已被稳定性保护阻止")
return True
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:
self.statusBar().showMessage("后台编辑正在计算,请等待当前操作完成。")
return
target_logical_id = self._edit_target_logical_id(target_kind, target_id)
context = {
"operation_name": operation_name,
"target": target,
"parameters": parameters,
"target_kind": target_kind,
"target_id": target_id,
"target_logical_id": target_logical_id,
"pick_position": self.selected_pick_position,
"show_same_domain_internal_edges": self._show_same_domain_internal_edges(),
}
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, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_edit_action, Qt.ConnectionType.QueuedConnection)
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 _edit_target_logical_id(self, target_kind: str | None, target_id: int | None) -> int | None:
if self.model is None or target_id is None or target_kind not in {"face", "feature"}:
return None
try:
numeric_id = int(target_id)
except (TypeError, ValueError):
return None
if 0 <= numeric_id < len(self.model.faces):
try:
return self.model.face_region_logical_id(numeric_id)
except Exception:
return None
return None
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()
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
model_polydata = None
edge_polydata = None
try:
model_polydata = self.model.build_face_polydata()
edge_polydata = self.model.build_edge_polydata(
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
except Exception:
model_polydata = None
edge_polydata = None
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._update_action_states()
if "推拉" in operation_name:
progress_text = f"{operation_name} 正在后台计算,半透明预览会持续渲染,3D 视图仍可旋转查看。"
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:
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 or "Edge长度" 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.statusBar().showMessage(progress_text)
if self.edit_preview_timer is not None:
self.edit_preview_timer.stop()
QApplication.processEvents()
def _set_edit_status_text(self, text: str) -> None:
self.statusBar().showMessage(text)
QApplication.processEvents()
@Slot(object)
def _finish_edit_action(self, result: object) -> None:
if hasattr(self, "_is_ui_thread") and not self._is_ui_thread():
self._invoke_on_ui_thread(lambda result=result: self._finish_edit_action(result))
return
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_status_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"],
target_logical_id=context.get("target_logical_id"),
pick_position=context["pick_position"],
before_snapshot=result["snapshot"],
after_snapshot=result["after_snapshot"],
)
model_polydata = result.get("model_polydata")
edge_polydata = result.get("edge_polydata")
if model_polydata is None or edge_polydata is None:
model_polydata = self.model.build_face_polydata()
edge_polydata = self.model.build_edge_polydata(
show_same_domain_internal_edges=bool(context.get("show_same_domain_internal_edges", False))
)
self.clear_edit_preview(render=False)
self._populate_part_tree()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False)
locator_note = self._locate_operation_record(record)
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:
selection_note = ";已保持当前选择" if self.selected_kind is not None else ""
self.statusBar().showMessage(f"{message}{selection_note}")
if self.selected_kind is None:
self.set_plain_info(f"{record.detail}\n\n{locator_note}")
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
if hasattr(self, "_is_ui_thread") and not self._is_ui_thread():
self._invoke_on_ui_thread(lambda message=message: self._fail_edit_action(message))
return
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._update_action_states()
self._request_thread_quit(self.edit_thread)
QApplication.processEvents()
def _forget_edit_thread(self) -> None:
self.edit_thread = None
self.edit_worker = None
def _make_operation_record(
self,
operation_name: str,
target: str,
parameters: dict[str, object],
result_message: str,
before_stats,
after_stats,
before_geometry: dict[str, object],
after_geometry: dict[str, object],
before_part_stats=None,
after_part_stats=None,
quality_warnings: list[str] | None = None,
target_kind: str | None = None,
target_id: int | None = None,
target_logical_id: int | None = None,
pick_position: tuple[float, float, float] | None = None,
before_snapshot: dict[int, object] | None = None,
after_snapshot: dict[int, object] | None = None,
) -> OperationRecord:
target_summary = target
if target_kind in {"face", "feature"} and target_logical_id is not None:
target_summary = f"{target_kind} logical {target_logical_id}"
if target_id is not None and target_id != target_logical_id:
target_summary += f" (topology {target_id})"
summary_parts = [operation_name, target_summary]
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'])}")
if parameters.get("target_length") is not None:
summary_parts.append(f"target_length={_format_value(parameters['target_length'])}")
if parameters.get("resize_strategy"):
summary_parts.append(f"strategy={parameters['resize_strategy']}")
actual_edge_length = _message_field(result_message, "nearest_length")
target_error = _message_field(result_message, "target_error")
if actual_edge_length is not None:
summary_parts.append(f"actual_length={actual_edge_length}")
if target_error is not None:
summary_parts.append(f"target_error={target_error}")
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 target_logical_id is not None:
lines.insert(4, f"target_logical_id: {target_logical_id}")
if target_id is not None and target_id != target_logical_id:
lines.insert(5, f"target_topological_id_at_operation: {target_id}")
if pick_position is not None:
insert_at = 6 if target_logical_id is not None and target_id is not None and target_id != target_logical_id else 5 if target_logical_id is not None else 4
lines.insert(insert_at, 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),
operation_name=operation_name,
target=target,
parameters=dict(parameters),
result_message=result_message,
target_kind=target_kind,
target_id=target_id,
target_logical_id=target_logical_id,
pick_position=pick_position,
before_snapshot=before_snapshot,
after_snapshot=after_snapshot,
)