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

2716 lines
128 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)