Files
pythonocc-step-editor/step_editor/window_actions.py
T
nikelaluo 26216064e0 feat: 拆分 STEP 编辑器并完善最小系统
将原来的 main.py/step_model.py 拆分为 step_editor 包,补充测量、同域高亮、模型修复、历史导出、槽宽/凸台/边长等 MVP 编辑能力,并更新 README 和忽略规则。
2026-07-27 18:28:26 +08:00

2274 lines
107 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
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 或 Feature 模式并选择一个 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, "未选择特征", "请先切换到 Feature 模式并选择一个局部特征。")
return
if not self._confirm_export_quality("feature", self.selected_face_id):
return
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中特征区域",
str(self.step_path.parent / f"feature_face_{self.selected_face_id}_export.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_feature(self.selected_face_id, target),
f"已导出选中特征区域到 {Path(target).name}",
)
def export_selected_edge(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再导出。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择 edge", "请先切换到 Edge 模式并选择一个 edge。")
return
if not self._confirm_export_quality("edge", self.selected_edge_id):
return
target, _ = QFileDialog.getSaveFileName(
self,
"导出选中 edge",
str(self.step_path.parent / f"edge_{self.selected_edge_id}_export.step"),
"STEP 文件 (*.step *.stp);;所有文件 (*.*)",
)
if not target:
return
self._run_action(
lambda: self.model.export_edge(self.selected_edge_id, target),
f"已导出选中 edge 到 {Path(target).name}",
)
def check_export_quality(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能检查导出质量。"):
return
scope, target_id = self._current_export_quality_target()
try:
info = self.model.export_quality_info(scope, target_id)
except Exception as exc:
QMessageBox.critical(self, "质量检查失败", str(exc))
self.statusBar().showMessage("导出质量检查失败")
return
report = _export_quality_text(info)
self.set_plain_info(report)
if info.get("quality_status") == "ok":
self.statusBar().showMessage("导出质量检查通过")
else:
self.statusBar().showMessage("导出质量检查发现警告")
def _current_export_quality_target(self) -> tuple[str, int | None]:
if self.selected_kind == "feature" and self.selected_face_id is not None:
return "feature", self.selected_face_id
if self.selected_kind == "face" and self.selected_face_id is not None:
return "face", self.selected_face_id
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return "edge", self.selected_edge_id
if self.selected_kind == "edge":
if self.selected_solid_id is not None:
return "solid", self.selected_solid_id
if self.selected_part_id is not None:
return "part", self.selected_part_id
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return "solid", self.selected_solid_id
if self.selected_kind == "part" and self.selected_part_id is not None:
return "part", self.selected_part_id
return "all", None
def _confirm_export_quality(self, scope: str, target_id: int | None = None) -> bool:
if self.model is None:
return False
try:
info = self.model.export_quality_info(scope, target_id)
except Exception as exc:
QMessageBox.critical(self, "导出质量检查失败", str(exc))
self.statusBar().showMessage("导出质量检查失败")
return False
report = _export_quality_text(info)
self.set_plain_info(report)
if info.get("quality_status") == "ok":
return True
result = QMessageBox.question(
self,
"导出质量警告",
f"{report}\n\n仍然继续导出吗?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return result == QMessageBox.StandardButton.Yes
def 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 {part_id}",
parameters={
"scope": "part",
"part_id": part_id,
"repair_strategy": "ShapeFix + UnifySameDomain",
},
target_kind="part",
target_id=part_id,
)
return
QMessageBox.information(self, "未选择对象", "请先选择 part、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。")
return
try:
distance = float(self.offset_input.text())
except ValueError:
QMessageBox.critical(self, "距离无效", "请输入数字形式的面偏移距离。")
return
face_id = self.selected_face_id
plan = self.model.push_pull_plan(face_id, distance)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能推拉平面", str(plan["message"]))
self.statusBar().showMessage("推拉平面已阻止")
return
self._show_push_pull_preview(face_id, distance)
def action():
return self.model.push_pull_face(face_id, distance)
self._run_edit_action(
action,
operation_name="推拉平面",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"semantic_distance": distance,
"distance_rule": "positive=outward fuse, negative=inward cut",
"surface": plan.get("surface"),
"outward_direction": plan.get("outward_direction"),
"direction_confidence": plan.get("direction_confidence"),
"direction_note": plan.get("direction_note"),
"push_pull_risk": plan.get("risk"),
"push_pull_status": plan.get("status"),
"push_pull_message": plan.get("message"),
"push_pull_scope_face_ids": plan.get("push_pull_scope_face_ids"),
"push_pull_scope_face_count": plan.get("push_pull_scope_face_count"),
"push_pull_scope_note": plan.get("push_pull_scope_note"),
"bbox_diagonal": plan.get("bbox_diagonal"),
},
target_kind="face",
target_id=face_id,
)
def resize_hole(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再调整孔径。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱面。")
return
try:
diameter = float(self.hole_diameter_input.text())
except ValueError:
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标直径。")
return
info = self.model.face_info(self.selected_face_id)
if "diameter" not in info:
QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱孔径。")
return
plan = self.model.cylindrical_resize_plan(self.selected_face_id, diameter)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整孔径", str(plan["message"]))
self.statusBar().showMessage("圆柱孔径调整已阻止")
return
guess = str(info.get("feature_guess", "cylindrical face"))
if plan["risk"] != "low":
target_to_height_ratio = plan.get("target_to_height_ratio", "")
target_to_height_line = (
""
if target_to_height_ratio is None or target_to_height_ratio == ""
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
)
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认圆柱孔径调整",
(
f"face: {plan['face_id']}\n"
f"当前直径: {_format_value(plan['current_diameter'])}\n"
f"目标直径: {_format_value(plan['target_diameter'])}\n"
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
f"{target_to_height_line}"
f"调整模式: {plan['resize_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"深度估算: {_format_value(plan['hole_depth_estimate'])}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"Cutter: {plan['cutter_strategy']}\n"
f"Cutter 高度: {_format_value(plan['cutter_height'])}\n"
f"Cutter 起点/终点余量: {_format_value(plan['cutter_start_margin'])} / "
f"{_format_value(plan['cutter_end_margin'])}\n\n"
f"底面保护: {_format_value(plan.get('cutter_bottom_protection', ''))}\n"
f"{_format_value(plan.get('cutter_bottom_note', ''))}\n\n"
f"补料策略: {plan.get('fill_strategy', '')}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆柱孔径调整")
return
face_id = self.selected_face_id
self._show_cylinder_resize_preview(face_id, diameter)
def action():
return self.model.resize_cylindrical_hole(face_id, diameter)
self._run_edit_action(
action,
operation_name="调整圆柱孔径",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"new_diameter": diameter,
"old_diameter": info.get("diameter"),
"delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"target_to_height_ratio": plan.get("target_to_height_ratio"),
"resize_mode": plan.get("resize_mode"),
"feature_type": plan.get("feature_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_note": plan.get("feature_bottom_note"),
"feature_guess": guess,
"confidence": plan.get("confidence"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
"resize_warnings": plan.get("warnings"),
"resize_blockers": plan.get("blockers"),
"material_vote_summary": plan.get("material_vote_summary"),
"cylinder_end_type": plan.get("cylinder_end_type"),
"hole_depth_estimate": plan.get("hole_depth_estimate"),
"start_end_state": plan.get("start_end_state"),
"end_end_state": plan.get("end_end_state"),
"cutter_strategy": plan.get("cutter_strategy"),
"cutter_height": plan.get("cutter_height"),
"cutter_margin": plan.get("cutter_margin"),
"cutter_start_margin": plan.get("cutter_start_margin"),
"cutter_end_margin": plan.get("cutter_end_margin"),
"cutter_bottom_protection": plan.get("cutter_bottom_protection"),
"cutter_protected_bottom_face_ids": plan.get("cutter_protected_bottom_face_ids"),
"cutter_opening_face_ids": plan.get("cutter_opening_face_ids"),
"cutter_bottom_note": plan.get("cutter_bottom_note"),
"cutter_note": plan.get("cutter_note"),
"fill_strategy": plan.get("fill_strategy"),
"fill_height": plan.get("fill_height"),
"fill_radius": plan.get("fill_radius"),
"fill_radius_overlap": plan.get("fill_radius_overlap"),
"fill_note": plan.get("fill_note"),
},
target_kind="face",
target_id=face_id,
)
def resize_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.model.feature_info(face_id)
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.model.cylindrical_resize_plan(face_id, target_diameter)
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: {face_id}\n"
f"当前槽宽估算: {_format_value(current_width)}\n"
f"目标槽宽: {_format_value(target_width)}\n"
f"槽圆弧角度: {_format_value(angular_span)}\n"
f"换算目标圆柱直径: {_format_value(target_diameter)}\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_cylinder_resize_preview(face_id, target_diameter)
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": info.get("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"),
"cutter_strategy": plan.get("cutter_strategy"),
"cutter_height": plan.get("cutter_height"),
"fill_strategy": plan.get("fill_strategy"),
},
target_kind="face",
target_id=face_id,
)
def resize_boss(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再调整凸台直径。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱凸台", "请先选择一个圆柱凸台候选 face。")
return
try:
diameter = float(self.boss_diameter_input.text())
except ValueError:
QMessageBox.critical(self, "直径无效", "请输入数字形式的目标凸台直径。")
return
info = self.model.face_info(self.selected_face_id)
if "diameter" not in info:
QMessageBox.information(self, "不是圆柱面", "当前选中的 face 不是圆柱面,不能调整圆柱凸台直径。")
return
plan = self.model.cylindrical_boss_resize_plan(self.selected_face_id, diameter)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能调整凸台直径", str(plan["message"]))
self.statusBar().showMessage("圆柱凸台直径调整已阻止")
return
if plan["risk"] != "low":
target_to_height_ratio = plan.get("target_to_height_ratio", "")
target_to_height_line = (
""
if target_to_height_ratio is None or target_to_height_ratio == ""
else f"目标直径/估算高度: {_format_value(target_to_height_ratio)}\n"
)
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认圆柱凸台直径调整",
(
f"face: {plan['face_id']}\n"
f"当前凸台直径: {_format_value(plan['current_diameter'])}\n"
f"目标凸台直径: {_format_value(plan['target_diameter'])}\n"
f"直径变化量: {_format_value(plan['delta_diameter'])}\n"
f"直径变化比例: {_format_percent(plan['diameter_delta_ratio'])}\n"
f"{target_to_height_line}"
f"调整模式: {plan['resize_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"工具策略: {plan['boss_tool_strategy']}\n"
f"工具高度: {_format_value(plan['boss_tool_height'])}\n"
f"轴向重叠余量: {_format_value(plan['boss_tool_axial_margin'])}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆柱凸台直径调整")
return
face_id = self.selected_face_id
self._show_cylinder_boss_resize_preview(face_id, diameter)
def action():
return self.model.resize_cylindrical_boss(face_id, diameter)
self._run_edit_action(
action,
operation_name="调整圆柱凸台直径",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"new_diameter": diameter,
"old_diameter": info.get("diameter"),
"delta_diameter": plan.get("delta_diameter"),
"diameter_delta_ratio": plan.get("diameter_delta_ratio"),
"target_to_height_ratio": plan.get("target_to_height_ratio"),
"resize_mode": plan.get("resize_mode"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"boss_resize_status": plan.get("status"),
"boss_resize_risk": plan.get("risk"),
"boss_resize_message": plan.get("message"),
"boss_resize_warnings": plan.get("warnings"),
"boss_resize_blockers": plan.get("blockers"),
"material_vote_summary": plan.get("material_vote_summary"),
"boss_tool_strategy": plan.get("boss_tool_strategy"),
"boss_tool_height": plan.get("boss_tool_height"),
"boss_tool_axial_margin": plan.get("boss_tool_axial_margin"),
"boss_tool_radius": plan.get("boss_tool_radius"),
"boss_tool_outer_radius": plan.get("boss_tool_outer_radius"),
"boss_tool_inner_radius": plan.get("boss_tool_inner_radius"),
"boss_tool_radial_overlap": plan.get("boss_tool_radial_overlap"),
"boss_tool_note": plan.get("boss_tool_note"),
"feature_adjacent_face_ids": plan.get("feature_adjacent_face_ids"),
"feature_boundary_edge_ids": plan.get("feature_boundary_edge_ids"),
},
target_kind="face",
target_id=face_id,
)
def suppress_hole(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再封堵圆柱孔。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆柱面", "请先选择一个圆柱孔 face。")
return
info = self.model.face_info(self.selected_face_id)
if info.get("surface") != "cylinder" or "diameter" not in info:
QMessageBox.information(self, "不是圆柱孔", "当前选中的 face 不是可封堵的圆柱孔。")
return
plan = self.model.cylindrical_suppress_plan(self.selected_face_id)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能封堵圆柱孔", str(plan["message"]))
self.statusBar().showMessage("封堵圆柱孔已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认封堵圆柱孔",
(
f"face: {plan['face_id']}\n"
f"直径: {_format_value(plan['diameter'])}\n"
f"估算高度: {_format_value(plan['height_estimate'])}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"补料策略: {plan['fill_strategy']}\n"
f"补料半径: {_format_value(plan['fill_radius'])}\n"
f"补料高度: {_format_value(plan['fill_height'])}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会用补料体封堵当前孔,并对当前零件执行实验性 Fuse。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消封堵圆柱孔")
return
face_id = self.selected_face_id
self._show_cylinder_suppress_preview(face_id)
def action():
return self.model.suppress_cylindrical_hole(face_id)
self._run_edit_action(
action,
operation_name="封堵圆柱孔",
target=f"face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"diameter": plan.get("diameter"),
"height_estimate": plan.get("height_estimate"),
"feature_type": plan.get("feature_type"),
"feature_guess": plan.get("feature_guess"),
"confidence": plan.get("confidence"),
"suppress_status": plan.get("status"),
"suppress_risk": plan.get("risk"),
"suppress_message": plan.get("message"),
"suppress_warnings": plan.get("warnings"),
"suppress_blockers": plan.get("blockers"),
"cylinder_end_type": plan.get("cylinder_end_type"),
"feature_bottom_face_ids": plan.get("feature_bottom_face_ids"),
"feature_opening_face_ids": plan.get("feature_opening_face_ids"),
"feature_bottom_note": plan.get("feature_bottom_note"),
"fill_strategy": plan.get("fill_strategy"),
"fill_height": plan.get("fill_height"),
"fill_radius": plan.get("fill_radius"),
"fill_radius_overlap": plan.get("fill_radius_overlap"),
"fill_note": plan.get("fill_note"),
},
target_kind="face",
target_id=face_id,
)
def _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 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
info = self.model.face_info(self.selected_face_id)
if info.get("surface") != "cylinder":
QMessageBox.information(self, "不是圆柱孔/槽", "当前选中的 face 不是可调整孔深的圆柱孔/槽。")
return
plan = self.model.cylindrical_depth_plan(
self.selected_face_id,
target_depth,
bottom_face_id=bottom_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['current_depth'])}\n"
f"目标深度: {_format_value(plan['target_depth'])}\n"
f"深度变化量: {_format_value(plan['delta_depth'])}\n"
f"深度变化比例: {_format_percent(plan['depth_delta_ratio'])}\n"
f"调整模式: {plan['depth_mode']}\n"
f"候选判断: {plan['feature_guess']}\n"
f"置信度: {plan['confidence']}\n"
f"材料投票: {plan['material_vote_summary']}\n"
f"端部类型: {plan['cylinder_end_type']}\n"
f"疑似底面 Face: {_format_value(plan.get('feature_bottom_face_ids', ''))}\n"
f"手动底面 Face: {_format_value(plan.get('manual_bottom_face_id', ''))}\n"
f"底面识别来源: {_format_value(plan.get('feature_bottom_detection', ''))}\n"
f"当前深度来源: {_format_value(plan.get('depth_current_depth_source', ''))}\n"
f"开口方向来源: {_format_value(plan.get('depth_open_direction_source', ''))}\n"
f"开口端相邻 Face: {_format_value(plan.get('feature_opening_face_ids', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"工具策略: {plan['depth_tool_strategy']}\n"
f"工具类型: {plan['depth_tool_role']}\n"
f"工具高度: {_format_value(plan['depth_tool_height'])}\n"
f"工具半径: {_format_value(plan['depth_tool_radius'])}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性布尔修改。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消盲孔深度调整")
return
face_id = self.selected_face_id
self._show_cylinder_depth_preview(face_id, target_depth, bottom_face_id=bottom_face_id)
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"),
"depth_tool_strategy": plan.get("depth_tool_strategy"),
"depth_tool_role": plan.get("depth_tool_role"),
"depth_tool_height": plan.get("depth_tool_height"),
"depth_tool_radius": plan.get("depth_tool_radius"),
"depth_tool_radius_overlap": plan.get("depth_tool_radius_overlap"),
"depth_current_depth": plan.get("depth_current_depth"),
"depth_current_depth_source": plan.get("depth_current_depth_source"),
"depth_bottom_parameter_source": plan.get("depth_bottom_parameter_source"),
"depth_open_direction_source": plan.get("depth_open_direction_source"),
"depth_open_point": plan.get("depth_open_point"),
"depth_current_bottom_point": plan.get("depth_current_bottom_point"),
"depth_target_bottom_point": plan.get("depth_target_bottom_point"),
},
target_kind="face",
target_id=face_id,
)
def resize_existing_fillet(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改已有圆角。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择已有圆角", "请先选择一个已有圆角/倒圆候选 face。")
return
try:
target_radius = float(self.edge_fillet_radius_input.text())
except ValueError:
QMessageBox.critical(self, "半径无效", "请输入数字形式的目标圆角半径。")
return
face_id = self.selected_face_id
plan = self.model.existing_fillet_resize_plan(face_id, target_radius)
if 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。")
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,
"确认给边添加圆角",
(
f"edge: {plan['edge_id']}\n"
f"边长: {_format_value(plan['edge_length'])}\n"
f"目标圆角半径: {_format_value(plan['target_radius'])}\n"
f"半径/边长比例: {_format_percent(plan['radius_to_length_ratio'])}\n"
f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n"
f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性 OCCT 倒圆。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消添加圆角")
return
self._show_edge_fillet_preview(edge_id, radius)
def action():
return self.model.fillet_edge(edge_id, radius)
self._run_edit_action(
action,
operation_name="给边添加圆角",
target=f"edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"edge_length": plan.get("edge_length"),
"fillet_radius": radius,
"radius_to_length_ratio": plan.get("radius_to_length_ratio"),
"curve": plan.get("curve"),
"adjacent_face_ids": plan.get("adjacent_face_ids"),
"adjacent_face_count": plan.get("adjacent_face_count"),
"fillet_status": plan.get("status"),
"fillet_risk": plan.get("risk"),
"fillet_message": plan.get("message"),
"fillet_warnings": plan.get("warnings"),
"fillet_blockers": plan.get("blockers"),
},
target_kind="edge",
target_id=edge_id,
)
def chamfer_edge(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再添加倒角。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择边", "请先选择一个直线 edge。")
return
try:
distance = float(self.edge_chamfer_distance_input.text())
except ValueError:
QMessageBox.critical(self, "距离无效", "请输入数字形式的倒角距离。")
return
edge_id = self.selected_edge_id
plan = self.model.edge_chamfer_plan(edge_id, distance)
if 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,
"确认给边添加倒角",
(
f"edge: {plan['edge_id']}\n"
f"边长: {_format_value(plan['edge_length'])}\n"
f"目标倒角距离: {_format_value(plan['target_distance'])}\n"
f"倒角距离/边长比例: {_format_percent(plan['distance_to_length_ratio'])}\n"
f"相邻 Face: {_format_value(plan.get('adjacent_face_ids', ''))}\n"
f"相邻 Face 数: {_format_value(plan.get('adjacent_face_count', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会对当前零件执行实验性 OCCT 倒角。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消添加倒角")
return
self._show_edge_chamfer_preview(edge_id, distance)
def action():
return self.model.chamfer_edge(edge_id, distance)
self._run_edit_action(
action,
operation_name="给边添加倒角",
target=f"edge {edge_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"edge_length": plan.get("edge_length"),
"chamfer_distance": distance,
"distance_to_length_ratio": plan.get("distance_to_length_ratio"),
"curve": plan.get("curve"),
"adjacent_face_ids": plan.get("adjacent_face_ids"),
"adjacent_face_count": plan.get("adjacent_face_count"),
"chamfer_status": plan.get("status"),
"chamfer_risk": plan.get("risk"),
"chamfer_message": plan.get("message"),
"chamfer_warnings": plan.get("warnings"),
"chamfer_blockers": plan.get("blockers"),
},
target_kind="edge",
target_id=edge_id,
)
def resize_edge_length(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再直接修改边长。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择边", "请先选择一条 edge。")
return
try:
target_length = float(self.edge_target_length_input.text())
except ValueError:
QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。")
return
edge_id = self.selected_edge_id
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, "不能直接修改边长", str(plan["message"]))
self.statusBar().showMessage("边长直接修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
"确认直接修改边长",
(
f"edge: {plan['edge_id']}\n"
f"当前边长: {_format_value(plan['current_length'])}\n"
f"目标边长: {_format_value(plan['target_length'])}\n"
f"变化量: {_format_value(plan['delta_length'])}\n"
f"边长基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n"
f"策略: {_format_value(plan.get('resize_strategy', ''))}\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"圆边模式: {_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_target_kind', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"当前会优先通过端面推拉或相邻圆柱直径编辑改变边长;没有明确局部路径时会尝试几何缩放 fallback。"
"这不是通用参数化边长编辑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消边长直接修改")
return
self._show_edge_length_preview(edge_id, target_length, 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="直接修改边长",
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"),
"end_face_id": plan.get("end_face_id"),
"end_face_label": plan.get("end_face_label"),
"push_pull_distance": plan.get("push_pull_distance"),
"resize_strategy": plan.get("resize_strategy"),
"affine_scale": plan.get("affine_scale"),
"affine_transform_kind": plan.get("affine_transform_kind"),
"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"),
"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("请等待当前编辑完成后再直接修改边长。"):
return
if self.selected_edge_id is None:
QMessageBox.information(self, "未选择边", "请先选择一条 edge。")
return
try:
target_length = float(self.edge_target_length_input.text())
except ValueError:
QMessageBox.critical(self, "目标长度无效", "请输入数字形式的目标边长。")
return
edge_id = self.selected_edge_id
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, "不能修改边长", str(plan["message"]))
self.statusBar().showMessage("边长直接修改已阻止")
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,
"确认直接修改边长",
(
f"edge: {plan['edge_id']}\n"
f"曲线类型: {_format_value(plan.get('curve', ''))}\n"
f"当前边长: {_format_value(plan['current_length'])}\n"
f"目标边长: {_format_value(plan['target_length'])}\n"
f"变化量: {_format_value(plan['delta_length'])}\n"
f"边长基准: {_format_value(plan.get('edge_length_anchor_label', ''))}\n"
f"策略: {_format_value(strategy)}\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"圆边模式: {_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_kind', ''))}\n"
f"缩放目标: {_format_value(plan.get('affine_target_kind', ''))}\n"
f"缩放比例: {_format_value(plan.get('affine_scale', ''))}\n"
f"轴线来源: {_format_value(plan.get('affine_axis_source', ''))}\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("已取消边长直接修改")
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="直接修改边长",
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"),
"end_face_id": plan.get("end_face_id"),
"end_face_label": plan.get("end_face_label"),
"push_pull_distance": plan.get("push_pull_distance"),
"resize_strategy": plan.get("resize_strategy"),
"affine_scale": plan.get("affine_scale"),
"affine_transform_kind": plan.get("affine_transform_kind"),
"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"),
"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, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。")
return
try:
vector = self._translation_vector_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "平移向量无效", str(exc))
return
part_id = self.selected_part_id
plan = self.model.translate_part_plan(part_id, vector)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能平移零件", str(plan["message"]))
self.statusBar().showMessage("平移零件已阻止")
return
if not self._confirm_translation_plan("确认平移零件", plan):
self.statusBar().showMessage("已取消平移零件")
return
def action():
return self.model.translate_part(part_id, vector)
self._run_edit_action(
action,
operation_name="平移零件",
target=f"part {part_id}",
parameters={
"part_id": part_id,
"translation_vector": vector,
"translation_distance": plan.get("translation_distance"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"translate_status": plan.get("status"),
"translate_risk": plan.get("risk"),
"translate_message": plan.get("message"),
"translate_warnings": plan.get("warnings"),
"translate_blockers": plan.get("blockers"),
},
target_kind="part",
target_id=part_id,
)
def translate_selected_solid(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再平移 solid。"):
return
if self.selected_solid_id is None:
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。")
return
try:
vector = self._translation_vector_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "平移向量无效", str(exc))
return
solid_id = self.selected_solid_id
plan = self.model.translate_solid_plan(solid_id, vector)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能平移 solid", str(plan["message"]))
self.statusBar().showMessage("平移 solid 已阻止")
return
if not self._confirm_translation_plan("确认平移 solid", plan):
self.statusBar().showMessage("已取消平移 solid")
return
def action():
return self.model.translate_solid(solid_id, vector)
self._run_edit_action(
action,
operation_name="平移 solid",
target=f"solid {solid_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": solid_id,
"part_solid_count": plan.get("part_solid_count"),
"translation_vector": vector,
"translation_distance": plan.get("translation_distance"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"translate_status": plan.get("status"),
"translate_risk": plan.get("risk"),
"translate_message": plan.get("message"),
"translate_warnings": plan.get("warnings"),
"translate_blockers": plan.get("blockers"),
},
target_kind="solid",
target_id=solid_id,
)
def _translation_vector_from_inputs(self) -> tuple[float, float, float]:
try:
return (
float(self.translate_x_input.text()),
float(self.translate_y_input.text()),
float(self.translate_z_input.text()),
)
except ValueError as exc:
raise ValueError("请输入数字形式的 X/Y/Z 平移量。") from exc
def _confirm_translation_plan(self, title: str, plan: dict[str, object]) -> bool:
if plan["risk"] == "low":
return True
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
title,
(
f"对象: {plan.get('target_kind', '')}\n"
f"part: {_format_value(plan.get('part_id', ''))}\n"
f"solid: {_format_value(plan.get('solid_id', ''))}\n"
f"平移向量: {_format_value(plan['translation_vector'])}\n"
f"平移距离: {_format_value(plan['translation_distance'])}\n"
f"对象 bbox_diagonal: {_format_value(plan.get('bbox_diagonal', ''))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会移动当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return result == QMessageBox.StandardButton.Yes
def rotate_selected_part(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再旋转零件。"):
return
if self.selected_part_id is None:
QMessageBox.information(self, "未选择零件", "请先选择一个 part,或选择属于某个 part 的对象。")
return
try:
axis, angle = self._rotation_values_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "旋转参数无效", str(exc))
return
part_id = self.selected_part_id
plan = self.model.rotate_part_plan(part_id, axis, angle)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能旋转零件", str(plan["message"]))
self.statusBar().showMessage("旋转零件已阻止")
return
if not self._confirm_rotation_plan("确认旋转零件", plan):
self.statusBar().showMessage("已取消旋转零件")
return
def action():
return self.model.rotate_part(part_id, axis, angle)
self._run_edit_action(
action,
operation_name="旋转零件",
target=f"part {part_id}",
parameters={
"part_id": part_id,
"rotation_axis": plan.get("rotation_axis"),
"rotation_angle_degrees": angle,
"rotation_center": plan.get("rotation_center"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"rotate_status": plan.get("status"),
"rotate_risk": plan.get("risk"),
"rotate_message": plan.get("message"),
"rotate_warnings": plan.get("warnings"),
"rotate_blockers": plan.get("blockers"),
},
target_kind="part",
target_id=part_id,
)
def rotate_selected_solid(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再旋转 solid。"):
return
if self.selected_solid_id is None:
QMessageBox.information(self, "未选择 solid", "请先选择一个 solid,或选择属于 solid 的 face/edge。")
return
try:
axis, angle = self._rotation_values_from_inputs()
except ValueError as exc:
QMessageBox.critical(self, "旋转参数无效", str(exc))
return
solid_id = self.selected_solid_id
plan = self.model.rotate_solid_plan(solid_id, axis, angle)
if plan["status"] == "blocked":
QMessageBox.information(self, "不能旋转 solid", str(plan["message"]))
self.statusBar().showMessage("旋转 solid 已阻止")
return
if not self._confirm_rotation_plan("确认旋转 solid", plan):
self.statusBar().showMessage("已取消旋转 solid")
return
def action():
return self.model.rotate_solid(solid_id, axis, angle)
self._run_edit_action(
action,
operation_name="旋转 solid",
target=f"solid {solid_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": solid_id,
"part_solid_count": plan.get("part_solid_count"),
"rotation_axis": plan.get("rotation_axis"),
"rotation_angle_degrees": angle,
"rotation_center": plan.get("rotation_center"),
"bbox_diagonal": plan.get("bbox_diagonal"),
"rotate_status": plan.get("status"),
"rotate_risk": plan.get("risk"),
"rotate_message": plan.get("message"),
"rotate_warnings": plan.get("warnings"),
"rotate_blockers": plan.get("blockers"),
},
target_kind="solid",
target_id=solid_id,
)
def _rotation_values_from_inputs(self) -> tuple[str, float]:
try:
angle = float(self.rotate_angle_input.text())
except ValueError as exc:
raise ValueError("请输入数字形式的旋转角度。") from exc
return self.rotate_axis_combo.currentText(), angle
def _confirm_rotation_plan(self, title: str, plan: dict[str, object]) -> bool:
if plan["risk"] == "low":
return True
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
result = QMessageBox.question(
self,
title,
(
f"对象: {plan.get('target_kind', '')}\n"
f"part: {_format_value(plan.get('part_id', ''))}\n"
f"solid: {_format_value(plan.get('solid_id', ''))}\n"
f"旋转轴: {_format_value(plan['rotation_axis'])}\n"
f"旋转角度: {_format_value(plan['rotation_angle_degrees'])}\n"
f"旋转中心: {_format_value(plan['rotation_center'])}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
"继续操作会旋转当前对象的 B-Rep 形状,并刷新模型拓扑。\n\n"
"确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
return result == QMessageBox.StandardButton.Yes
def _run_scan_task(self, scan_kind: str, action, finished_callback, failed_callback) -> 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._clear_hover(render=True)
self._update_action_states()
thread = QThread(self)
worker = ScanWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(finished_callback)
worker.failed.connect(failed_callback)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_scan_thread)
self.scan_thread = thread
self.scan_worker = worker
thread.start()
def _end_scan_task(self) -> None:
self.scan_in_progress = False
self.pending_scan_kind = None
self._update_action_states()
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,
lambda result: self._finish_editable_scan(result, show_info, scan_label, limit),
self._fail_editable_scan,
)
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:
self.editable_table.setRowCount(len(candidates))
lines = [f"第一版可编辑对象:显示 {len(candidates)} 个({scan_label}上限 {limit}"]
for row, item in enumerate(candidates):
target_kind = str(item.get("target_kind", "face"))
target_id = int(item.get("target_id", item.get("face_id", item.get("edge_id", -1))))
operation_item = QTableWidgetItem(str(item["operation"]))
operation_item.setData(EDITABLE_TARGET_ID_ROLE, target_id)
operation_item.setData(EDITABLE_TARGET_KIND_ROLE, target_kind)
operation_item.setData(EDITABLE_ACTION_ROLE, str(item["operation_key"]))
id_item = QTableWidgetItem(f"{target_kind} {target_id}")
object_item = QTableWidgetItem(str(item["feature_guess"]))
current_item = QTableWidgetItem(
f"{item['current_value_label']}={_format_value(item['current_value'])}"
)
status_item = QTableWidgetItem(str(item["status"]))
risk_item = QTableWidgetItem(str(item["risk"]))
confidence_item = QTableWidgetItem(str(item["confidence"]))
note_item = QTableWidgetItem(str(item["note"]))
row_items = [
operation_item,
id_item,
object_item,
current_item,
status_item,
risk_item,
confidence_item,
note_item,
]
for column, table_item in enumerate(row_items):
table_item.setToolTip(table_item.text())
self.editable_table.setItem(row, column, table_item)
lines.append(
f"{target_kind} {target_id}: {item['operation']}, "
f"part={item['part_id']}, solid={item['solid_id']}, "
f"object={item['feature_guess']}, "
f"{item['current_value_label']}={_format_value(item['current_value'])}, "
f"status={item['status']}, risk={item['risk']}, "
f"confidence={item['confidence']}, note={item['note']}"
)
self.editable_table.resizeColumnsToContents()
self.statusBar().showMessage(f"已{scan_label}第一版可编辑对象:{len(candidates)} 个")
if show_info:
self.set_plain_info("\n".join(lines))
def _clear_cylinder_candidates(self) -> None:
self.cylinder_candidate_cache = []
self.cylinder_candidates_loaded = False
if hasattr(self, "cylinder_table"):
self.cylinder_table.setRowCount(0)
def refresh_cylinder_candidates(self, show_info: bool = True) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能扫描圆柱候选。"):
return
self.statusBar().showMessage("正在后台扫描圆柱候选...")
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,
lambda result: self._finish_cylinder_scan(result, show_info),
self._fail_cylinder_scan,
)
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 self.cylinder_candidates_loaded:
self.cylinder_table.setRowCount(0)
if show_info:
self.set_plain_info("圆柱候选尚未扫描。点击 `列出圆柱候选` 后再切换筛选类型。")
return
candidates = [
item
for item in self.cylinder_candidate_cache
if self._candidate_matches_filter(str(item["feature_guess"]))
]
self._populate_cylinder_table(candidates, show_info=show_info)
def _populate_cylinder_table(self, candidates: list[dict[str, object]], show_info: bool = True) -> None:
self.cylinder_table.setRowCount(len(candidates))
lines = [f"圆柱候选:显示 {len(candidates)} 个"]
for row, item in enumerate(candidates):
face_item = QTableWidgetItem(str(item["face_id"]))
face_item.setData(Qt.UserRole, int(item["face_id"]))
guess_item = QTableWidgetItem(str(item["feature_guess"]))
diameter_item = QTableWidgetItem(_format_float(float(item["diameter"])))
span_item = QTableWidgetItem(_format_float(float(item["angular_span"])))
height_item = QTableWidgetItem(_format_float(float(item["height_estimate"])))
confidence_item = QTableWidgetItem(str(item["confidence"]))
risk_item = QTableWidgetItem(str(item["resize_risk"]))
part_item = QTableWidgetItem(str(item["part_id"]))
self.cylinder_table.setItem(row, 0, face_item)
self.cylinder_table.setItem(row, 1, guess_item)
self.cylinder_table.setItem(row, 2, diameter_item)
self.cylinder_table.setItem(row, 3, span_item)
self.cylinder_table.setItem(row, 4, height_item)
self.cylinder_table.setItem(row, 5, confidence_item)
self.cylinder_table.setItem(row, 6, risk_item)
self.cylinder_table.setItem(row, 7, part_item)
lines.append(
f"face {item['face_id']}: part {item['part_id']}, "
f"guess={item['feature_guess']}, "
f"diameter={_format_float(float(item['diameter']))}, "
f"height={_format_float(float(item['height_estimate']))}, "
f"confidence={item['confidence']}, "
f"risk={item['resize_risk']}"
)
self.cylinder_table.resizeColumnsToContents()
self.statusBar().showMessage(f"已显示圆柱候选:{len(candidates)} 个")
if show_info:
self.set_plain_info("\n".join(lines))
def _candidate_matches_filter(self, feature_guess: str) -> bool:
current = self.candidate_filter_combo.currentText()
if current == "All":
return True
if current == "Hole/Groove":
return feature_guess == "hole/groove candidate"
if current == "Round/Fillet":
return feature_guess == "round/fillet candidate"
if current == "Boss/Outer":
return feature_guess == "boss/outer-round candidate"
if current == "Unclear":
return feature_guess == "cylindrical face"
return True
def _run_action(self, action, success_message: str | None) -> None:
try:
result = action()
except Exception as exc:
QMessageBox.critical(self, "操作失败", str(exc))
self.statusBar().showMessage("操作失败")
return
message = success_message or str(result)
self.statusBar().showMessage(message)
self.set_plain_info(message)
def _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)
worker.failed.connect(self._fail_edit_action)
worker.finished.connect(thread.quit)
worker.failed.connect(thread.quit)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_edit_thread)
self.edit_thread = thread
self.edit_worker = worker
thread.start()
def _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
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,
}
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:
if "已有" in operation_name:
progress_text = f"{operation_name} 正在后台计算,蓝色预览表示将移除并重建的已有圆角面。"
else:
progress_text = f"{operation_name} 正在后台计算,蓝色预览表示目标边和半径范围。"
elif "倒角" in operation_name:
progress_text = f"{operation_name} 正在后台计算,橙色预览表示目标边和倒角距离范围。"
elif "边长" in operation_name:
progress_text = f"{operation_name} 正在后台计算,预览表示端面推拉或几何缩放后的目标范围。"
elif "平移" in operation_name:
progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型位置和拓扑索引。"
elif "旋转" in operation_name:
progress_text = f"{operation_name} 正在后台计算,完成后会刷新模型姿态和拓扑索引。"
else:
progress_text = f"{operation_name} 正在后台计算。"
self.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:
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._reset_selection()
self._populate_part_tree()
self._rebuild_scene_from_polydata(model_polydata, edge_polydata, reset_camera=False)
except Exception as exc:
rollback_message = self._restore_failed_edit_snapshot(result.get("snapshot") if isinstance(result, dict) else None)
self._end_edit_task(clear_preview=True)
QMessageBox.critical(self, "操作失败", f"编辑结果刷新失败:{exc}\n\n{rollback_message}")
self.statusBar().showMessage("编辑已完成,但刷新结果时失败")
return
self.undo_stack.append(result["snapshot"])
self.redo_stack.clear()
self.operation_history.append(record)
self.redo_history.clear()
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self._refresh_history_list()
self._end_edit_task(clear_preview=False)
if result.get("quality_warnings"):
self.statusBar().showMessage("编辑完成,但有质量警告,请查看操作历史详情")
else:
self.statusBar().showMessage(message)
self.set_plain_info(record.detail)
@Slot(str)
def _fail_edit_action(self, message: str) -> None:
self._end_edit_task(clear_preview=True)
QMessageBox.critical(self, "操作失败", message)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
self.statusBar().showMessage("操作失败,模型已保持在编辑前状态")
def _restore_failed_edit_snapshot(self, snapshot: object) -> str:
if self.model is None or not isinstance(snapshot, dict):
return "未找到可用的编辑前快照,请重新加载 STEP 文件确认状态。"
try:
self.model.restore_snapshot(snapshot)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene(reset_camera=False)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
except Exception as rollback_exc:
return f"尝试回滚到编辑前状态失败:{rollback_exc}。建议重新加载 STEP 文件。"
return "模型已回滚到编辑前状态。"
def _end_edit_task(self, clear_preview: bool = True) -> None:
self.operation_in_progress = False
self.pending_edit_context = None
if clear_preview:
self.clear_edit_preview(render=False)
self._update_action_states()
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'])}")
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),
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,
)