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

3447 lines
174 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
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidgetItem,
QTreeWidgetItem,
)
from PySide6.QtGui import QColor
from .constants import SNAPSHOT_FACE_LOGICAL_IDS_KEY
from .model import StepModel
from .records import OperationRecord
from .ui_helpers import * # noqa: F403
from .workers import EditWorker, LoadWorker, ScanWorker
PROPERTY_VALUE_TOLERANCE = 1e-9
def _record_message_field(message: str | None, key: str) -> str | None:
if not message:
return None
marker = f"{key}="
start = str(message).find(marker)
if start < 0:
return None
start += len(marker)
end = str(message).find(",", start)
if end < 0:
end = len(str(message))
value = str(message)[start:end].strip().rstrip(".")
return value or None
def _triple_or_none(value: object) -> tuple[float, float, float] | None:
if isinstance(value, str):
text = value.strip().strip("()[]")
chunks = [chunk.strip() for chunk in text.replace(";", ",").split(",") if chunk.strip()]
elif isinstance(value, (list, tuple)):
chunks = list(value)
else:
return None
if len(chunks) != 3:
return None
try:
return (float(chunks[0]), float(chunks[1]), float(chunks[2]))
except (TypeError, ValueError):
return None
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
class WindowStateMixin:
def _reset_selection(self, clear_highlight: bool = True, clear_info: bool = True) -> None:
self.selected_kind = None
self.selected_part_id = None
self.selected_solid_id = None
self.selected_face_id = None
self.selected_edge_id = None
self.selected_pick_position = None
if clear_info:
self._clear_current_object_info()
if clear_highlight:
self._clear_highlight()
self._update_selected_object_title()
self._update_action_states()
def _clear_current_object_info(self) -> None:
self.current_info_values = {}
self.current_info_text = ""
if hasattr(self, "info_tree"):
self.info_tree.clear()
if hasattr(self, "info_text"):
self.info_text.clear()
self._clear_property_editor()
def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None:
if getattr(self, "pick_marker_actor", None) is not None and hasattr(self, "renderer"):
self.renderer.RemoveActor(self.pick_marker_actor)
self.pick_marker_actor = None
if getattr(self, "render_window", None) is not None:
self.render_window.Render()
def _with_pick_info(
self,
info: dict[str, object],
pick_position: tuple[float, float, float] | None,
) -> dict[str, object]:
if pick_position is None:
return info
enriched = dict(info)
enriched["pick_position"] = pick_position
return enriched
def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str:
if pick_position is None:
return message
return f"{message},拾取点 {_format_value(pick_position)}"
def _edit_busy(self, message: str = "编辑计算中,请等待当前操作完成。") -> bool:
if self.load_in_progress:
self.statusBar().showMessage("STEP background loading is still running.")
return True
if self.operation_in_progress:
self.statusBar().showMessage(message)
return True
if self.scan_in_progress:
self.statusBar().showMessage("扫描中,请等待扫描完成后再执行该操作。")
return True
return False
def _sync_id_picker(self, kind: str, target_id: int) -> None:
self.last_id_kind = kind
self.id_input.setText(str(target_id))
self._update_id_select_title(kind)
def _update_action_states(self) -> None:
self._update_selected_object_title()
self._update_id_select_title()
if not hasattr(self, "mode_combo"):
return
has_loaded_model = self.model is not None
busy = self.operation_in_progress or self.scan_in_progress or self.load_in_progress
has_model = has_loaded_model and not busy
selected_kind = self.selected_kind
has_selection = any(
item is not None
for item in (
self.selected_part_id,
self.selected_solid_id,
self.selected_face_id,
self.selected_edge_id,
)
)
has_part = self.selected_part_id is not None
has_solid = self.selected_solid_id is not None
has_face = self.selected_face_id is not None and selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and selected_kind == "edge"
id_text = self.id_input.text().strip() if hasattr(self, "id_input") else ""
history_row = self.history_list.currentRow() if hasattr(self, "history_list") else -1
has_history = has_model and bool(getattr(self, "operation_history", []))
has_history_selection = has_history and 0 <= history_row < len(self.operation_history)
has_diff_preview = has_model and bool(getattr(self, "diff_actors", []))
has_measure_points = bool(getattr(self, "measure_point_a", None) or getattr(self, "measure_point_b", None))
has_measure_result = bool(getattr(self, "measure_point_a", None) and getattr(self, "measure_point_b", None))
pick_value = getattr(self, "current_info_values", {}).get("pick_position")
has_pick_position = self.selected_pick_position is not None or (
isinstance(pick_value, (tuple, list)) and len(pick_value) == 3
)
selected_id_text = self._selected_id_text() if hasattr(self, "_selected_id_text") else ""
has_current_info = bool(getattr(self, "current_info_text", ""))
wait_or_load_tip = "请先加载 STEP 文件,或等待当前后台任务完成。"
if hasattr(self, "open_button"):
self._set_control_state(
self.open_button,
not busy,
"导入一个 .step 或 .stp 几何模型。",
"请等待当前后台任务完成后再导入其他几何模型。",
)
self._set_control_state(
self.reload_button,
has_loaded_model and not busy,
"按当前显示路径读取 STEP 模型。",
wait_or_load_tip,
)
if hasattr(self, "quick_export_all_button"):
self._set_control_state(
self.quick_export_all_button,
has_model,
"导出当前完整 STEP 模型。",
wait_or_load_tip,
)
if hasattr(self, "part_tree"):
self._set_control_state(
self.part_tree,
has_model,
"从结构树中选择零件、Solid 或装配节点。",
wait_or_load_tip,
)
if hasattr(self, "mode_combo"):
self._set_control_state(
self.mode_combo,
not busy,
"选择模式决定鼠标点击模型时按什么对象类型选择。",
"请等待当前后台任务完成后再切换选择模式。",
)
if hasattr(self, "id_input"):
self._set_control_state(
self.id_input,
True,
"输入要选中的对象 ID;加载模型后可点击选择按钮跳转。",
"输入要选中的对象 ID;加载模型后可点击选择按钮跳转。",
)
self._set_control_state(
self.select_id_button,
has_model and bool(id_text),
"按当前选择模式跳转到输入的 ID。",
"请先加载模型并输入整数 ID。",
)
if hasattr(self, "export_all_button"):
self._set_control_state(
self.export_all_button,
has_model,
"导出当前完整 STEP 模型。",
wait_or_load_tip,
)
self._set_control_state(
self.export_check_button,
has_model,
"检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。",
wait_or_load_tip,
)
self._set_control_state(
self.export_part_button,
has_model and has_part,
"导出当前选中对象所属零件。",
"请先选择零件,或选择属于某个零件的 Solid、Face 或 Edge。",
)
self._set_control_state(
self.export_solid_button,
has_model and has_solid,
"导出当前选中对象所属 Solid。",
"请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。",
)
self._set_control_state(
self.export_face_button,
has_model and has_face,
"导出当前选中的 Face 或同域面区域。",
"请先在 Face 或特征模式下选择一个 Face。",
)
self._set_control_state(
self.export_feature_button,
has_model and selected_kind == "feature" and self.selected_face_id is not None,
"导出特征模式识别到的区域。",
"请先切换到特征模式并选择孔、槽、圆角或凸台候选。",
)
self._set_control_state(
self.export_edge_button,
has_model and has_edge,
"导出当前选中的 Edge。",
"请先切换到 Edge 模式并选择一条 Edge。",
)
if hasattr(self, "repair_model_button"):
self._set_control_state(
self.repair_model_button,
has_model,
"对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。",
wait_or_load_tip,
)
self._set_control_state(
self.repair_selected_button,
has_model and (has_solid or has_part),
"优先修复当前选中对象所属Solid;没有Solid时修复所属零件,并写入撤销历史。",
"请先选择零件、Solid、Face 或 Edge,或等待当前后台任务完成。",
)
if hasattr(self, "isolate_button"):
self._set_control_state(
self.isolate_button,
has_model and has_selection,
"只显示当前选中的对象或区域。",
"请先加载模型并选择零件、Solid、Face、Edge 或特征。",
)
self._set_control_state(
self.fit_button,
has_model and has_selection,
"把相机对准当前选中对象。",
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.show_all_button,
has_model,
"恢复显示完整模型。",
wait_or_load_tip,
)
self._set_control_state(
self.show_internal_edges_checkbox,
has_model,
"显示或隐藏同域内部拓扑边。",
wait_or_load_tip,
)
if hasattr(self, "set_measure_a_button"):
measure_point_tip = "把当前选中对象的拾取点或中心设为测量点。"
self._set_control_state(
self.set_measure_a_button,
has_model and has_selection,
measure_point_tip,
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.set_measure_b_button,
has_model and has_selection,
measure_point_tip,
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.copy_measure_button,
has_measure_result,
"复制 A/B 两点距离和 X/Y/Z 差值。",
"请先设置测量点 A 和 B。",
)
self._set_control_state(
self.clear_measure_button,
has_measure_points,
"清除 A/B 测量点和 3D 测量线。",
"当前没有可清除的测量点。",
)
if hasattr(self, "history_list"):
self._set_control_state(
self.history_list,
has_history,
"点击历史记录可查看参数、定位目标并显示差异预览。",
"当前还没有编辑历史。",
)
self._set_control_state(
self.clear_diff_button,
has_diff_preview,
"清除当前显示的差异预览。",
"当前没有正在显示的差异预览。",
)
self._set_control_state(
self.export_diff_button,
has_history_selection,
"导出当前选中历史记录的差异报告。",
"请先在操作历史中选择一条记录。",
)
self._set_control_state(
self.export_history_button,
has_history,
"导出本次会话的完整编辑历史 JSON。",
"当前还没有可导出的编辑历史。",
)
if hasattr(self, "copy_id_button"):
self._set_control_state(
self.copy_id_button,
bool(selected_id_text),
"复制当前选中对象的 ID。",
"请先选择一个对象。",
)
self._set_control_state(
self.copy_pick_button,
has_pick_position,
"复制最近一次鼠标拾取到的三维坐标。",
"当前没有可复制的拾取坐标。",
)
self._set_control_state(
self.copy_info_button,
has_current_info,
"复制当前属性区里的完整文本。",
"当前属性区还没有可复制的信息。",
)
if hasattr(self, "property_table"):
self._set_control_state(
self.property_table,
has_selection and has_current_info,
"查看当前选中对象的属性;可修改的行可以输入目标值。",
"请先选择一个对象。",
)
self._update_edit_action_states(has_model)
def _update_edit_action_states(self, has_model: bool) -> None:
if not hasattr(self, "push_button"):
return
action_info = self._selected_action_info() if has_model else {}
surface = str(action_info.get("surface", ""))
curve = str(action_info.get("curve", ""))
feature_guess = str(action_info.get("feature_guess", ""))
angular_span = _float_or_none(action_info.get("angular_span"))
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
is_plane = has_face and surface == "plane"
is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
is_cone = has_face and surface == "cone" and "reference_radius" in action_info
is_sphere = has_face and surface == "sphere" and "radius" in action_info
is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info
is_generic_surface = has_face and surface in {
"bezier surface",
"b-spline surface",
"surface of revolution",
"surface of extrusion",
"offset surface",
"other surface",
}
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
is_slot_or_half_hole = (
is_hole_or_groove
and angular_span is not None
and angular_span < math.tau * 0.92
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
)
is_blind = action_info.get("cylinder_end_type") == "blind"
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
has_manual_bottom = bool(
hasattr(self, "hole_bottom_face_input")
and self.hole_bottom_face_input.text().strip()
)
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
can_push = has_model and is_plane
can_resize_shell = has_model and is_shell_candidate
can_resize_hole = has_model and is_hole_or_groove
can_resize_slot = has_model and is_slot_or_half_hole
can_resize_boss = has_model and is_boss and is_full_cylinder
can_suppress_hole = has_model and is_hole_or_groove and is_full_cylinder
can_resize_depth = has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom)
can_fillet_edge = has_model and is_line_edge
can_resize_existing_fillet = has_model and is_existing_fillet and has_fillet_support
can_chamfer_edge = has_model and is_line_edge
can_resize_edge_length = has_model and has_edge
can_transform_part = has_model and self.selected_part_id is not None
can_transform_solid = has_model and self.selected_solid_id is not None
self._set_control_state(
self.offset_input,
can_push,
"输入平面 Face 的推拉距离。",
"请先选择一个可推拉的平面 Face。",
)
self._set_control_state(
self.push_button,
can_push,
"对当前平面 Face 执行推拉。",
"请先选择一个平面 Face,或在特征模式下选择可推拉平面候选。",
)
if hasattr(self, "resize_shell_thickness_button"):
self._set_control_state(
self.shell_thickness_input,
can_resize_shell,
"输入薄壁/壳体局部区域的目标厚度。",
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
)
self._set_control_state(
self.resize_shell_thickness_button,
can_resize_shell,
"按目标厚度推拉当前薄壁/壳体平面区域。",
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
)
self._set_control_state(
self.hole_diameter_input,
can_resize_hole,
"输入圆柱孔/槽的目标直径。",
"请先选择被识别为孔/槽候选的圆柱 Face。",
)
self._set_control_state(
self.resize_button,
can_resize_hole,
"调整当前圆柱孔/槽候选的直径。",
"请先选择被识别为孔/槽候选的圆柱 Face。",
)
if hasattr(self, "resize_slot_button"):
self._set_control_state(
self.slot_width_input,
can_resize_slot,
"输入槽/半孔候选的目标宽度。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.resize_slot_button,
can_resize_slot,
"按目标槽宽调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
if hasattr(self, "resize_slot_depth_button"):
self._set_control_state(
self.slot_depth_input,
can_resize_slot,
"输入槽/半孔候选的目标凹入深度。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.resize_slot_depth_button,
can_resize_slot,
"按目标槽深调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.boss_diameter_input,
can_resize_boss,
"输入完整圆柱凸台候选的目标直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
)
self._set_control_state(
self.resize_boss_button,
can_resize_boss,
"调整当前完整圆柱凸台候选的直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
)
self._set_control_state(
self.suppress_button,
can_suppress_hole,
"封堵当前完整圆柱孔候选。",
"请先选择接近完整圆柱的孔候选;半孔/槽不会放行。",
)
self._set_control_state(
self.hole_depth_input,
can_resize_hole,
"输入盲孔/盲槽的目标深度。",
"请先选择孔/槽圆柱面。",
)
self._set_control_state(
self.hole_bottom_face_input,
can_resize_hole,
"必要时输入底面 Face ID,用来明确盲孔/盲槽深度。",
"请先选择孔/槽圆柱面。",
)
self._set_control_state(
self.resize_depth_button,
can_resize_depth,
"调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。",
"请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。",
)
self._set_control_state(
self.edge_fillet_radius_input,
can_fillet_edge or can_resize_existing_fillet,
"输入新圆角或已有圆角的目标半径。",
"请先选择直线Edge,或选择可修改的已有圆角 Face。",
)
self._set_control_state(
self.fillet_edge_button,
can_fillet_edge,
"给当前直线Edge添加新圆角。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.resize_existing_fillet_button,
can_resize_existing_fillet,
"尝试修改当前已有圆角/倒圆候选的半径。",
"请先选择一个已有圆角/倒圆候选 Face;当前版本需要识别到至少两个支撑 Face。",
)
self._set_control_state(
self.edge_chamfer_distance_input,
can_chamfer_edge,
"输入当前直线Edge的倒角距离。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.chamfer_edge_button,
can_chamfer_edge,
"给当前直线Edge添加倒角。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.edge_target_length_input,
can_resize_edge_length,
"输入当前Edge的目标长度。",
"请先选择一条Edge。",
)
self._set_control_state(
self.edge_length_anchor_combo,
can_resize_edge_length,
"选择修改Edge长度时尽量固定哪一端。",
"请先选择一条Edge。",
)
self._set_control_state(
self.resize_edge_length_button,
can_resize_edge_length,
"修改当前Edge的长度;直线Edge优先局部形变,圆/椭圆/平面曲线优先用局部或径向策略。",
"请先选择一条Edge。",
)
for widget in (self.translate_x_input, self.translate_y_input, self.translate_z_input):
self._set_control_state(
widget,
can_transform_part or can_transform_solid,
"输入选中零件或 Solid 的平移距离。",
"请先选择一个零件、Solid,或其所属 Face/Edge。",
)
self._set_control_state(
self.translate_part_button,
can_transform_part,
"平移当前选中对象所属零件。",
"请先选择一个零件,或选择属于某个零件的对象。",
)
self._set_control_state(
self.rotate_part_button,
can_transform_part,
"旋转当前选中对象所属零件。",
"请先选择一个零件,或选择属于某个零件的对象。",
)
self._set_control_state(
self.rotate_axis_combo,
can_transform_part or can_transform_solid,
"选择选中零件或 Solid 的旋转轴。",
"请先选择一个零件、Solid,或其所属 Face/Edge。",
)
self._set_control_state(
self.rotate_angle_input,
can_transform_part or can_transform_solid,
"输入选中零件或 Solid 的旋转角度。",
"请先选择一个零件、Solid,或其所属 Face/Edge。",
)
self._set_control_state(
self.translate_solid_button,
can_transform_solid,
"平移当前选中对象所属 Solid。",
"请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。",
)
self._set_control_state(
self.rotate_solid_button,
can_transform_solid,
"旋转当前选中对象所属 Solid。",
"请先选择一个 Solid,或选择属于某个 Solid 的 Face/Edge。",
)
if hasattr(self, "cylinders_button"):
self._set_control_state(
self.cylinders_button,
has_model,
"扫描当前模型中的圆柱面候选。",
"请先加载 STEP 文件。",
)
if hasattr(self, "editable_refresh_button"):
self._set_control_state(
self.editable_refresh_button,
has_model,
"扫描可编辑对象。",
"请先加载 STEP 文件,或等待当前后台任务完成。",
)
self._set_control_state(
self.editable_deep_scan_button,
has_model,
"深度扫描更多可编辑对象。",
"请先加载 STEP 文件,或等待当前后台任务完成。",
)
self._set_control_state(
self.editable_table,
has_model and self.editable_table.rowCount() > 0,
"点击一行可选中对应对象并填入相关编辑参数。",
"当前没有可编辑对象列表;请先点击扫描对象。",
)
if hasattr(self, "candidate_filter_combo"):
has_cylinder_candidates = has_model and bool(getattr(self, "cylinder_candidates_loaded", False))
self._set_control_state(
self.candidate_filter_combo,
has_cylinder_candidates,
"筛选已扫描的圆柱面候选。",
"请先点击扫描圆柱面。",
)
self._set_control_state(
self.cylinder_table,
has_model and self.cylinder_table.rowCount() > 0,
"点击一行可选中对应圆柱 Face。",
"当前没有圆柱面候选列表;请先点击扫描圆柱面。",
)
if hasattr(self, "undo_button"):
self._set_control_state(
self.undo_button,
has_model and bool(self.undo_stack),
"撤销上一步编辑。",
"当前没有可撤销的编辑。",
)
if hasattr(self, "redo_button"):
self._set_control_state(
self.redo_button,
has_model and bool(self.redo_stack),
"重做刚撤销的编辑。",
"当前没有可重做的编辑。",
)
self._update_property_apply_state(has_model)
def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None:
widget.setEnabled(enabled)
tip = enabled_tip if enabled else disabled_tip
if hasattr(self, "_set_help_tip"):
self._set_help_tip(widget, tip)
else:
widget.setToolTip(tip)
def _current_selection_mode(self) -> str:
if not hasattr(self, "mode_combo"):
return getattr(self, "last_id_kind", "Face")
data = self.mode_combo.currentData()
if data is not None:
return _selection_mode_value(data)
return _selection_mode_value(self.mode_combo.currentText())
def _set_selection_mode(self, mode: str) -> None:
if not hasattr(self, "mode_combo"):
self.last_id_kind = _selection_mode_value(mode)
return
value = _selection_mode_value(mode)
index = self.mode_combo.findData(value)
if index >= 0:
self.mode_combo.setCurrentIndex(index)
else:
self.mode_combo.setCurrentText(_selection_mode_label(value))
self.last_id_kind = value
self._update_id_select_title(value)
def _update_id_select_title(self, kind: str | None = None) -> None:
current_kind = kind
if current_kind is None and hasattr(self, "mode_combo"):
current_kind = self._current_selection_mode()
current_kind = current_kind or getattr(self, "last_id_kind", "Face")
if hasattr(self, "id_select_label"):
self.id_select_label.setText(f"按ID{_selection_mode_label(current_kind)}")
def _update_selected_object_title(self) -> None:
if not hasattr(self, "object_edit_box"):
return
self.object_edit_box.setTitle(f"当前选中对象:{self._selected_object_title_suffix()}")
def _selected_object_title_suffix(self) -> str:
if self.selected_kind == "part" and self.selected_part_id is not None:
return f"零件 {self.selected_part_id}"
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return f"Solid {self.selected_solid_id}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label()
if feature_label:
return f"{feature_label} {self.selected_face_id}(来源Face"
return f"特征 {self.selected_face_id}(来源Face"
if self.selected_kind == "face" and self.selected_face_id is not None:
logical_id = self.current_info_values.get("face_region_logical_id")
if logical_id is None:
logical_id = self.current_info_values.get("logical_face_id")
feature_label = self._selected_feature_label()
feature_suffix = f"(识别为{feature_label}" if feature_label else ""
if logical_id not in {None, "", self.selected_face_id}:
return f"Face {logical_id}(拓扑 {self.selected_face_id}{feature_suffix}"
return f"Face {self.selected_face_id}{feature_suffix}"
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return f"Edge {self.selected_edge_id}"
return "未选择"
def _selected_feature_label(self) -> str:
info = dict(getattr(self, "current_info_values", {}) or {})
explicit_type = str(info.get("feature_type", "") or "").strip()
if explicit_type:
return explicit_type
surface = str(info.get("surface", "") or "")
feature_guess = str(info.get("feature_guess", "") or "")
if surface == "cylinder" and feature_guess == "hole/groove candidate":
angular_span = _float_or_none(info.get("angular_span"))
if info.get("slot_kind") == "partial-cylindrical-groove" or (
angular_span is not None and angular_span < math.tau * 0.92
):
return "槽/半孔候选"
return "圆柱孔/槽候选"
if surface == "cylinder" and feature_guess == "boss/outer-round candidate":
return "凸台/外圆候选"
if surface == "cylinder" and feature_guess == "round/fillet candidate":
return "圆角/倒圆候选"
if surface == "plane" and info.get("push_pull_status"):
return "可推拉平面候选"
return ""
def _clear_property_editor(self) -> None:
if not hasattr(self, "property_table"):
return
self.property_editor_specs = []
self.property_table_expanded = False
was_blocked = self.property_table.blockSignals(True)
try:
self.property_table.setRowCount(0)
finally:
self.property_table.blockSignals(was_blocked)
self._resize_property_table_height()
self._update_property_apply_state(False)
def _refresh_property_editor(self) -> None:
if not hasattr(self, "property_table"):
return
has_selection = any(
item is not None
for item in (
self.selected_part_id,
self.selected_solid_id,
self.selected_face_id,
self.selected_edge_id,
)
)
if not has_selection:
self._clear_property_editor()
return
info = dict(getattr(self, "current_info_values", {}) or {})
action_info = self._selected_action_info()
for key, value in action_info.items():
info.setdefault(key, value)
specs = self._property_editor_specs(info, action_info)
self.property_editor_specs = specs
self.property_table_expanded = False
self.property_editor_updating = True
was_blocked = self.property_table.blockSignals(True)
try:
self.property_table.setRowCount(len(specs))
for row, spec in enumerate(specs):
editable = bool(spec.get("editable") and spec.get("enabled"))
value_type = str(spec.get("value_type", "number"))
input_editable = editable and value_type != "command"
label_item = self._property_table_item(str(spec.get("label", "")), editable=False)
current_item = self._property_table_item(str(spec.get("current_text", "")), editable=False)
target_item = self._property_table_item(
"" if input_editable else str(spec.get("target_text", "")),
editable=False,
)
status_text = "" if editable else str(spec.get("status_text", ""))
status_item = self._property_table_item(status_text, editable=False)
target_item.setToolTip(self._property_target_tooltip(spec, editable=editable))
row_items = (label_item, current_item, target_item, status_item)
self._style_property_row_items(row_items, editable=editable)
for column, item in enumerate(row_items):
item.setToolTip(item.toolTip() or item.text())
self.property_table.setItem(row, column, item)
self.property_table.setRowHeight(row, 28 if editable else 24)
if editable:
if input_editable:
self._set_property_target_editor(row, spec)
else:
self.property_table.removeCellWidget(row, 2)
self._set_property_row_button(row, spec)
else:
self.property_table.removeCellWidget(row, 2)
self.property_table.removeCellWidget(row, 3)
self._resize_property_table_height()
finally:
self.property_table.blockSignals(was_blocked)
self.property_editor_updating = False
self._update_property_apply_state()
def _style_property_row_items(
self,
items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem],
*,
editable: bool,
) -> None:
label_item, current_item, target_item, status_item = items
if editable:
row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed")
for item, color in zip(items, row_backgrounds):
item.setBackground(QColor(color))
label_item.setForeground(QColor("#7c2d12"))
current_item.setForeground(QColor("#431407"))
target_item.setForeground(QColor("#111827"))
status_item.setForeground(QColor("#9a3412"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
status_font = status_item.font()
status_font.setBold(True)
status_item.setFont(status_font)
return
target_item.setBackground(QColor("#eef2f6"))
target_item.setForeground(QColor("#8f99a8"))
status_item.setForeground(QColor("#64748b"))
def _set_property_target_editor(self, row: int, spec: dict[str, object]) -> None:
editor = QLineEdit(str(spec.get("target_text", "")))
editor.setObjectName("propertyTargetEditor")
editor.setToolTip(self._property_target_tooltip(spec, editable=True))
editor.setCursor(Qt.CursorShape.IBeamCursor)
editor.setFrame(True)
editor.textChanged.connect(lambda _text="", _row=row: self._update_property_apply_state())
editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row))
self.property_table.setCellWidget(row, 2, editor)
def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None:
button = QPushButton("未改动")
button.setObjectName("propertyRowEditButton")
tooltip = f"只应用“{spec.get('label', '当前属性')}”这一行的目标值。"
range_hint = self._property_range_hint(spec)
if range_hint:
tooltip = f"{tooltip}\n\n{range_hint}"
button.setToolTip(tooltip)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setProperty("changed", False)
button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row))
self.property_table.setCellWidget(row, 3, button)
def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str:
tip_key = "enabled_tip" if editable else "disabled_tip"
parts = [str(spec.get(tip_key, "")).strip()]
if editable:
parts.append(self._property_range_hint(spec))
return "\n\n".join(part for part in parts if part)
def _property_range_hint(self, spec: dict[str, object]) -> str:
parts: list[str] = []
hard_range = self._property_hard_range_text(spec)
if hard_range:
parts.append(f"有效范围:{hard_range}")
range_hint = str(spec.get("range_hint", "")).strip()
if range_hint:
parts.append(f"建议范围:{range_hint}")
return "\n".join(parts)
def _property_hard_range_text(self, spec: dict[str, object]) -> str:
chunks: list[str] = []
min_value = _float_or_none(spec.get("min_value"))
max_value = _float_or_none(spec.get("max_value"))
if min_value is not None:
operator = ">" if spec.get("min_exclusive") else ">="
chunks.append(f"{operator} {_format_float(min_value)}")
if max_value is not None:
operator = "<" if spec.get("max_exclusive") else "<="
chunks.append(f"{operator} {_format_float(max_value)}")
return " 且 ".join(chunks)
def _resize_property_table_height(self) -> None:
if not hasattr(self, "property_table"):
return
row_count = self.property_table.rowCount()
collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6)
expanded = bool(getattr(self, "property_table_expanded", False))
visible_rows = row_count if expanded else min(row_count, collapsed_rows)
visible_rows = max(1, visible_rows)
row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22)
header_height = int(self.property_table.horizontalHeader().height())
frame = int(self.property_table.frameWidth()) * 2
height = header_height + frame + visible_rows * row_height + 8
self.property_table.setMinimumHeight(height)
self.property_table.setMaximumHeight(height)
has_hidden_rows = row_count > collapsed_rows
self.property_table.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if expanded or not has_hidden_rows
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
if hasattr(self, "property_expand_button"):
self.property_expand_button.setVisible(has_hidden_rows)
if expanded:
self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项")
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几行。")
else:
self.property_expand_button.setText(f"展开全部属性 ({row_count} 项)")
self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。")
def toggle_property_table_expanded(self) -> None:
if not hasattr(self, "property_table"):
return
self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False))
self._resize_property_table_height()
def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem:
item = QTableWidgetItem(text)
flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
if editable:
flags |= Qt.ItemFlag.ItemIsEditable
item.setFlags(flags)
return item
def _property_editor_specs(
self,
info: dict[str, object],
action_info: dict[str, object],
) -> list[dict[str, object]]:
editable_specs, used_keys = self._editable_property_specs(action_info)
specs = list(editable_specs)
for key, value in self._ordered_property_info_items(info):
if key in used_keys:
continue
specs.append(
{
"key": key,
"label": INFO_LABELS.get(key, key),
"current_text": _format_value(value),
"current_raw": value,
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "只读",
"disabled_tip": "这个属性来自当前 STEP/B-Rep 几何或识别结果,当前版本只能查看,不能直接修改。",
}
)
if not specs:
specs.append(
{
"key": "empty",
"label": "当前对象",
"current_text": "尚未选择",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "只读",
"disabled_tip": "请先在模型中选择零件、Solid、Face、Edge 或特征。",
}
)
return specs
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info)
if not self._is_feature_like_info(info):
return items
item_by_key = {key: value for key, value in items}
priority_keys = [
"feature_type",
"feature_edit_actions",
"feature_mode",
"feature_guess",
"confidence",
"slot_status",
"slot_kind",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_arc_length_estimate",
"slot_angular_span",
"slot_open_angle",
"slot_note",
"diameter",
"radius",
"height_estimate",
"same_domain_height_estimate",
"hole_depth_estimate",
"cylinder_end_type",
"feature_bottom_face_ids",
"feature_bottom_confidence",
"feature_bottom_note",
"feature_source_face_id",
"feature_face_ids",
"feature_side_face_ids",
"feature_end_face_ids",
"feature_slot_face_ids",
"feature_slot_boundary_face_ids",
"surface",
"axis_point",
"axis",
]
ordered: list[tuple[str, object]] = []
emitted: set[str] = set()
for key in priority_keys:
if key in item_by_key and key not in emitted:
ordered.append((key, item_by_key[key]))
emitted.add(key)
ordered.extend((key, value) for key, value in items if key not in emitted)
return ordered
def _is_feature_like_info(self, info: dict[str, object]) -> bool:
if str(info.get("kind", "") or "") == "feature":
return True
if str(info.get("feature_type", "") or ""):
return True
feature_guess = str(info.get("feature_guess", "") or "")
return feature_guess in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
def _ordered_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
ordered: list[tuple[str, object]] = []
emitted: set[str] = set()
for _group_name, keys in INFO_GROUPS:
for key in keys:
if key in info and key not in emitted:
ordered.append((key, info[key]))
emitted.add(key)
for key in sorted(key for key in info if key not in emitted):
ordered.append((key, info[key]))
return ordered
def _editable_property_specs(self, action_info: dict[str, object]) -> tuple[list[dict[str, object]], set[str]]:
has_model = self.model is not None and not (self.operation_in_progress or self.scan_in_progress or self.load_in_progress)
surface = str(action_info.get("surface", ""))
curve = str(action_info.get("curve", ""))
feature_guess = str(action_info.get("feature_guess", ""))
angular_span = _float_or_none(action_info.get("angular_span"))
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
is_plane = has_face and surface == "plane"
is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
is_cone = has_face and surface == "cone" and "reference_radius" in action_info
is_sphere = has_face and surface == "sphere" and "radius" in action_info
is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info
is_generic_surface = has_face and surface in {
"bezier surface",
"b-spline surface",
"surface of revolution",
"surface of extrusion",
"offset surface",
"other surface",
}
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
is_slot_or_half_hole = (
is_hole_or_groove
and angular_span is not None
and angular_span < math.tau * 0.92
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
)
is_blind = action_info.get("cylinder_end_type") == "blind"
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
has_boss_height_cap = bool(
_int_values(action_info.get("feature_start_end_face_ids"))
or _int_values(action_info.get("feature_end_end_face_ids"))
)
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
specs: list[dict[str, object]] = []
used_keys: set[str] = set()
def numeric_text(value: object, fallback: str = "") -> str:
number = _float_or_none(value)
return _format_float(number) if number is not None else fallback
def vector_text(value: tuple[float, float, float] | None) -> str:
if value is None:
return ""
return ", ".join(_format_float(item) for item in value)
def relative_range_hint(current: object, caution_ratio: float, high_ratio: float) -> str:
number = _float_or_none(current)
if number is None or number <= 0:
return "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。"
lower = max(number * (1.0 - caution_ratio), 0.0)
upper = number * (1.0 + caution_ratio)
return (
f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改"
f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);"
f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。"
)
def positive_minimum() -> dict[str, object]:
return {"min_value": 0.0, "min_exclusive": True}
def push_pull_hint() -> str:
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if diagonal is not None and diagonal > 0:
return (
"可输入正数或负数,单位同模型;"
f"建议单次绝对值不超过 {_format_float(diagonal * 0.08)}"
f"超过 {_format_float(diagonal * 0.2)} 时风险较高。"
)
return "可输入正数或负数,单位同模型;建议先小幅试改并查看预览。"
def translation_hint() -> str:
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if diagonal is not None and diagonal > 0:
return (
"格式为 X, Y, Z"
f"建议单次平移距离不超过 {_format_float(diagonal * 0.5)}"
f"超过 {_format_float(diagonal * 2.0)} 时请重点确认单位和方向。"
)
return "格式为 X, Y, Z;建议先小幅试改并查看预览。"
def hole_diameter_upper_limit(current: object) -> float | None:
current_number = _float_or_none(current)
height = _float_or_none(action_info.get("height_estimate"))
limits: list[float] = []
if current_number is not None and current_number > 0:
limits.append(current_number * 2.0)
if height is not None and height > 0:
limits.append(height * 2.0)
return min(limits) if limits else None
def hole_diameter_hint(current: object) -> str:
hint = relative_range_hint(current, 0.35, 1.0)
upper = hole_diameter_upper_limit(current)
if upper is not None:
hint = (
f"{hint} 当前版本会阻止超过 {_format_float(upper)} 的目标直径,"
"避免布尔返回成功但孔壁没有真正变成目标直径。"
)
return hint
def hole_radius_hint(current_radius: object, current_diameter: object) -> str:
hint = relative_range_hint(current_radius, 0.35, 1.0)
upper = hole_diameter_upper_limit(current_diameter)
if upper is not None:
hint = (
f"{hint} 当前版本会阻止超过 {_format_float(upper * 0.5)} 的目标半径,"
"避免布尔返回成功但孔壁没有真正变成目标半径。"
)
return hint
def add_spec(
*,
key: str,
label: str,
current_raw: object,
target_text: str,
action: str,
target_attr: str | None = None,
target_attrs: tuple[str, ...] | None = None,
enabled: bool,
enabled_tip: str,
disabled_tip: str,
value_type: str = "number",
used: tuple[str, ...] = (),
range_hint: str = "",
min_value: float | None = None,
max_value: float | None = None,
min_exclusive: bool = False,
max_exclusive: bool = False,
target_transform: str | None = None,
transform_context: dict[str, object] | None = None,
current_text: str | None = None,
button_text: str | None = None,
choices: dict[str, object] | None = None,
positive_pair: bool = False,
) -> None:
specs.append(
{
"key": key,
"label": label,
"current_text": _format_value(current_raw) if current_text is None else current_text,
"current_raw": current_raw,
"target_text": target_text,
"action": action,
"target_attr": target_attr,
"target_attrs": target_attrs,
"editable": True,
"enabled": has_model and enabled,
"status_text": "可修改" if has_model and enabled else "不可修改",
"enabled_tip": enabled_tip,
"disabled_tip": disabled_tip,
"value_type": value_type,
"range_hint": range_hint,
"min_value": min_value,
"max_value": max_value,
"min_exclusive": min_exclusive,
"max_exclusive": max_exclusive,
"target_transform": target_transform,
"transform_context": transform_context or {},
"button_text": button_text or "",
"choices": choices or {},
"positive_pair": positive_pair,
}
)
used_keys.update(used or (key,))
if is_plane:
plane_origin = _triple_or_none(action_info.get("plane_origin"))
plane_direction = (
_triple_or_none(action_info.get("push_pull_outward_direction"))
or _triple_or_none(action_info.get("normal"))
)
current_plane_position = None
if plane_origin is not None and plane_direction is not None:
current_plane_position = (
plane_origin[0] * plane_direction[0]
+ plane_origin[1] * plane_direction[1]
+ plane_origin[2] * plane_direction[2]
)
add_spec(
key="face_target_normal_position",
label="Face目标法向位置",
current_raw=current_plane_position if current_plane_position is not None else "",
target_text=numeric_text(current_plane_position),
action="push_pull_face",
target_attr="offset_input",
enabled=current_plane_position is not None,
enabled_tip="输入当前平面沿推拉方向的目标位置;程序会换算成相对推拉距离执行。",
disabled_tip="当前平面缺少稳定法向或原点,不能换算目标位置。",
range_hint="这是沿当前推拉方向的一维位置坐标,单位同模型;目标值可大于或小于当前值。",
target_transform="plane_target_position_to_offset",
transform_context={"current_plane_position": current_plane_position},
used=("plane_origin", "push_pull_outward_direction", "normal"),
)
add_spec(
key="push_pull_distance",
label="Face本次偏移量",
current_raw=0.0,
target_text="0",
action="push_pull_face",
target_attr="offset_input",
enabled=True,
enabled_tip="输入本次沿当前平面推拉方向的相对偏移距离;执行后几何会更新,下一次本次偏移量会重新从 0 开始。",
disabled_tip="当前对象不是可推拉的平面 Face。",
range_hint=push_pull_hint(),
current_text="0(相对当前)",
)
if is_shell_candidate:
current = _float_or_none(action_info.get("shell_thickness_estimate"))
add_spec(
key="shell_thickness_estimate",
label="薄壁厚度",
current_raw=current if current is not None else "",
target_text=numeric_text(current),
action="resize_shell_thickness",
target_attr="shell_thickness_input",
enabled=current is not None,
enabled_tip="输入薄壁/壳体局部区域的目标厚度。",
disabled_tip="当前平面没有稳定识别到可修改的薄壁厚度。",
value_type="positive",
range_hint=relative_range_hint(current, 0.35, 0.8),
used=("shell_thickness_estimate", "shell_current_thickness"),
**positive_minimum(),
)
if is_hole_or_groove:
current_diameter = _float_or_none(action_info.get("diameter"))
current_radius = _float_or_none(action_info.get("radius"))
diameter_upper = hole_diameter_upper_limit(current_diameter)
add_spec(
key="diameter",
label="孔/圆柱直径",
current_raw=current_diameter if current_diameter is not None else "",
target_text=numeric_text(current_diameter),
action="resize_hole",
target_attr="hole_diameter_input",
enabled=current_diameter is not None,
enabled_tip="输入当前孔/槽圆柱面的目标直径。",
disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。",
value_type="positive",
range_hint=hole_diameter_hint(current_diameter),
max_value=diameter_upper,
**positive_minimum(),
)
add_spec(
key="hole_cylinder_radius",
label="孔/槽圆柱半径",
current_raw=current_radius if current_radius is not None else "",
target_text=numeric_text(current_radius),
action="resize_hole",
target_attr="hole_diameter_input",
enabled=current_radius is not None,
enabled_tip="输入当前孔/槽圆柱面的目标半径;程序会换算成目标直径后执行。",
disabled_tip="当前圆柱面没有稳定识别为孔/槽候选。",
value_type="positive",
range_hint=hole_radius_hint(current_radius, current_diameter),
target_transform="radius_to_diameter",
used=("radius",),
max_value=diameter_upper * 0.5 if diameter_upper is not None else None,
**positive_minimum(),
)
axis_point = _triple_or_none(action_info.get("axis_point"))
axis_direction = _triple_or_none(action_info.get("axis"))
axis_range_value = action_info.get("same_domain_v_range") or action_info.get("v_range")
current_axis_center = None
if (
axis_point is not None
and axis_direction is not None
and isinstance(axis_range_value, (list, tuple))
and len(axis_range_value) >= 2
):
v_min = _float_or_none(axis_range_value[0])
v_max = _float_or_none(axis_range_value[1])
if v_min is not None and v_max is not None:
v_mid = (v_min + v_max) * 0.5
current_axis_center = (
axis_point[0] + axis_direction[0] * v_mid,
axis_point[1] + axis_direction[1] * v_mid,
axis_point[2] + axis_direction[2] * v_mid,
)
add_spec(
key="hole_axis_center",
label="孔轴心坐标",
current_raw=current_axis_center if current_axis_center is not None else "",
target_text=vector_text(current_axis_center),
action="move_cylindrical_hole_axis",
target_attrs=("hole_center_x_input", "hole_center_y_input", "hole_center_z_input"),
enabled=bool(is_full_cylinder and current_axis_center is not None and current_diameter is not None),
enabled_tip="输入完整圆柱孔轴心的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。",
disabled_tip="当前只对接近完整圆柱的孔放行轴心坐标修改;半孔/开放槽需要保留扇形开口,暂不使用整圆柱工具修改。",
value_type="vector3",
range_hint=translation_hint(),
used=("axis_point", "axis", "same_domain_v_range", "v_range"),
)
add_spec(
key="suppress_cylindrical_hole",
label="封堵圆柱孔",
current_raw="未封堵",
target_text="无需输入",
action="suppress_hole",
enabled=bool(is_full_cylinder and current_diameter is not None),
enabled_tip="用补料体封堵当前完整圆柱孔。点击状态列按钮执行,不需要输入目标值。",
disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;半孔或槽请先使用槽/孔尺寸修改。",
value_type="command",
range_hint="该操作会直接改变几何并写入历史;复杂孔失败时会回滚到操作前状态。",
button_text="封堵",
)
if is_slot_or_half_hole:
current_slot = _float_or_none(action_info.get("slot_chord_width_estimate"))
add_spec(
key="slot_chord_width_estimate",
label="槽/半孔宽度",
current_raw=current_slot if current_slot is not None else "",
target_text=numeric_text(current_slot),
action="resize_slot_width",
target_attr="slot_width_input",
enabled=current_slot is not None,
enabled_tip="输入槽或半孔的目标宽度。",
disabled_tip="当前对象不是稳定的槽/半孔候选。",
value_type="positive",
range_hint=relative_range_hint(current_slot, 0.35, 1.0),
**positive_minimum(),
)
current_slot_depth = _float_or_none(action_info.get("slot_sagitta_depth_estimate"))
add_spec(
key="slot_sagitta_depth_estimate",
label="槽/半孔深度",
current_raw=current_slot_depth if current_slot_depth is not None else "",
target_text=numeric_text(current_slot_depth),
action="resize_slot_depth",
target_attr="slot_depth_input",
enabled=current_slot_depth is not None,
enabled_tip="输入槽或半孔的目标凹入深度。",
disabled_tip="当前对象没有稳定的槽/半孔深度估算。",
value_type="positive",
range_hint=relative_range_hint(current_slot_depth, 0.35, 1.0),
**positive_minimum(),
)
current_slot_arc = _float_or_none(action_info.get("slot_arc_length_estimate"))
add_spec(
key="slot_arc_length_estimate",
label="槽/半孔圆弧长度",
current_raw=current_slot_arc if current_slot_arc is not None else "",
target_text=numeric_text(current_slot_arc),
action="resize_slot_arc_length",
target_attr="slot_arc_length_input",
enabled=current_slot_arc is not None,
enabled_tip="输入槽或半孔的目标圆弧长度。",
disabled_tip="当前对象没有稳定的槽/半孔圆弧长度估算。",
value_type="positive",
range_hint=relative_range_hint(current_slot_arc, 0.35, 1.0),
**positive_minimum(),
)
current_slot_angle = (
_float_or_none(action_info.get("slot_angular_span"))
or _float_or_none(action_info.get("angular_span"))
)
current_slot_angle_degrees = (
math.degrees(current_slot_angle) if current_slot_angle is not None else None
)
add_spec(
key="slot_angular_span_degrees",
label="槽/半孔圆弧角度(度)",
current_raw=current_slot_angle_degrees if current_slot_angle_degrees is not None else "",
target_text=numeric_text(current_slot_angle_degrees),
action="resize_slot_angular_span",
target_attr="slot_angular_span_input",
enabled=current_slot_angle is not None,
enabled_tip="输入槽或半孔的目标圆弧角度;程序会保持当前圆柱半径并重建局部扇形槽。",
disabled_tip="当前对象没有稳定的槽/半孔圆弧角度估算。",
value_type="positive",
range_hint="建议先小幅修改;当前版本允许大于 0 且小于约 331 度的局部圆柱槽角度。",
max_value=math.degrees(math.tau * 0.92),
max_exclusive=True,
target_transform="degrees_to_radians",
used=("slot_angular_span", "angular_span"),
**positive_minimum(),
)
current_slot_open_angle_degrees = (
math.degrees(max(math.tau - current_slot_angle, 0.0))
if current_slot_angle is not None
else None
)
add_spec(
key="slot_open_angle_degrees",
label="槽/半孔开口角度(度)",
current_raw=current_slot_open_angle_degrees if current_slot_open_angle_degrees is not None else "",
target_text=numeric_text(current_slot_open_angle_degrees),
action="resize_slot_angular_span",
target_attr="slot_angular_span_input",
enabled=current_slot_angle is not None,
enabled_tip="输入槽或半孔的目标开口角度;程序会换算成圆弧角度后重建局部扇形槽。",
disabled_tip="当前对象没有稳定的槽/半孔开口角度估算。",
value_type="positive",
range_hint="开口角越小,槽越接近完整圆柱;当前版本要求开口角大于约 29 度且小于 360 度。",
min_value=math.degrees(math.tau * 0.08),
min_exclusive=True,
max_value=360.0,
max_exclusive=True,
target_transform="slot_open_angle_degrees_to_angular_span",
used=("slot_open_angle",),
)
manual_slot_pair_text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else ""
manual_slot_pair_id = None
if manual_slot_pair_text:
try:
manual_slot_pair_id = int(manual_slot_pair_text)
except ValueError:
manual_slot_pair_id = None
current_diameter_for_slot = _float_or_none(action_info.get("diameter"))
has_manual_slot_pair = bool(manual_slot_pair_text and manual_slot_pair_id is not None)
slot_pair_target_text = manual_slot_pair_text
add_spec(
key="slot_pair_face_id",
label="槽孔配对端Face ID",
current_raw=slot_pair_target_text,
target_text=slot_pair_target_text,
action="set_manual_slot_pair_face",
target_attr="slot_pair_face_input",
enabled=True,
enabled_tip="设置长圆槽/槽孔另一个半圆端的 Face ID;自动配对不准时,先填这里再修改槽孔参数。",
disabled_tip="当前对象不是槽/半孔候选,不能设置槽孔配对端。",
value_type="integer_or_empty",
range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在、且不是当前槽端自己的 Face ID。",
min_value=0.0,
button_text="设置",
)
add_spec(
key="slot_total_length_estimate",
label="槽孔总长度",
current_raw="",
current_text="执行时计算",
target_text="",
action="resize_slot_total_length",
target_attr="slot_total_length_input",
enabled=has_manual_slot_pair,
enabled_tip="输入长圆槽/槽孔的目标总长度;程序会在点击修改时按手动配对端计算当前长度并执行。",
disabled_tip="为了避免选择时卡顿,当前不会自动扫描配对端;请先在上一行手动填写另一个半圆槽端 Face ID。",
value_type="positive",
range_hint="需要先填写槽孔配对端 Face ID。点击修改时才会做配对端几何计划,避免选中对象时卡住界面。",
**positive_minimum(),
)
add_spec(
key="slot_center_distance_estimate",
label="槽孔中心距",
current_raw="",
current_text="执行时计算",
target_text="",
action="resize_slot_total_length",
target_attr="slot_total_length_input",
enabled=has_manual_slot_pair and current_diameter_for_slot is not None,
enabled_tip="输入长圆槽两端半圆中心距;程序会用当前槽宽换算成槽孔总长度后执行。",
disabled_tip="请先填写有效的槽孔配对端 Face ID;当前槽还需要有稳定直径才能按中心距换算。",
value_type="positive",
range_hint="目标槽孔总长度 = 目标中心距 + 当前槽宽/直径。点击修改时才会计算当前配对几何。",
target_transform="slot_center_distance_to_total_length",
transform_context={"slot_current_diameter": current_diameter_for_slot},
**positive_minimum(),
)
bottom_face_ids = _int_values(action_info.get("feature_bottom_face_ids"))
auto_bottom_face_id = bottom_face_ids[0] if bottom_face_ids else None
manual_bottom_text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else ""
manual_bottom_id = None
if manual_bottom_text:
try:
manual_bottom_id = int(manual_bottom_text)
except ValueError:
manual_bottom_id = None
bottom_target_text = manual_bottom_text or (str(auto_bottom_face_id) if auto_bottom_face_id is not None else "")
add_spec(
key="hole_bottom_face_id",
label="底面Face ID",
current_raw=bottom_target_text,
target_text=bottom_target_text,
action="set_manual_hole_bottom_face",
target_attr="hole_bottom_face_input",
enabled=True,
enabled_tip="设置盲孔/盲槽的底面 Face ID;自动识别不准时,先填这里再修改深度。",
disabled_tip="当前对象不是孔/槽候选,不能设置底面 Face ID。",
value_type="integer_or_empty",
range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在的 Face ID。",
min_value=0.0,
button_text="设置",
)
current_depth = _float_or_none(action_info.get("hole_depth_estimate"))
if self.model is not None and self.selected_face_id is not None and manual_bottom_id is not None:
probe_depth = current_depth if current_depth is not None and current_depth > 0 else 1.0
try:
depth_plan = self.model.cylindrical_depth_plan(
self.selected_face_id,
probe_depth,
bottom_face_id=manual_bottom_id,
)
manual_depth = _float_or_none(depth_plan.get("current_depth"))
if manual_depth is not None and manual_depth > 0:
current_depth = manual_depth
except Exception:
pass
can_depth = bool((is_blind or manual_bottom_id is not None) and (has_bottom or manual_bottom_id is not None) and current_depth is not None)
if is_blind or current_depth is not None:
add_spec(
key="hole_depth_estimate",
label="盲孔/盲槽深度",
current_raw=current_depth if current_depth is not None else "",
target_text=numeric_text(current_depth),
action="resize_hole_depth",
target_attr="hole_depth_input",
enabled=can_depth,
enabled_tip="输入盲孔或盲槽的目标深度。",
disabled_tip="需要识别到盲孔/盲槽底面后才能直接修改深度。",
value_type="positive",
range_hint=relative_range_hint(current_depth, 0.35, 1.0),
**positive_minimum(),
)
if is_boss:
current_boss = _float_or_none(action_info.get("diameter"))
current_boss_radius = _float_or_none(action_info.get("radius"))
add_spec(
key="boss_diameter",
label="凸台直径",
current_raw=current_boss if current_boss is not None else "",
target_text=numeric_text(current_boss),
action="resize_boss",
target_attr="boss_diameter_input",
enabled=bool(is_full_cylinder and current_boss is not None),
enabled_tip="输入完整圆柱凸台的目标直径。",
disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行直径修改。",
value_type="positive",
range_hint=relative_range_hint(current_boss, 0.3, 0.8),
used=("diameter",),
**positive_minimum(),
)
add_spec(
key="boss_radius",
label="凸台半径",
current_raw=current_boss_radius if current_boss_radius is not None else "",
target_text=numeric_text(current_boss_radius),
action="resize_boss",
target_attr="boss_diameter_input",
enabled=bool(is_full_cylinder and current_boss_radius is not None),
enabled_tip="输入完整圆柱凸台的目标半径;程序会换算成目标直径后执行。",
disabled_tip="当前凸台候选不是接近完整圆柱,暂不放行半径修改。",
value_type="positive",
range_hint=relative_range_hint(current_boss_radius, 0.3, 0.8),
target_transform="radius_to_diameter",
used=("radius",),
**positive_minimum(),
)
boss_axis_point = _triple_or_none(action_info.get("axis_point"))
boss_axis_direction = _triple_or_none(action_info.get("axis"))
boss_axis_range = action_info.get("same_domain_v_range") or action_info.get("v_range")
current_boss_axis_center = None
if (
boss_axis_point is not None
and boss_axis_direction is not None
and isinstance(boss_axis_range, (list, tuple))
and len(boss_axis_range) >= 2
):
v_min = _float_or_none(boss_axis_range[0])
v_max = _float_or_none(boss_axis_range[1])
if v_min is not None and v_max is not None:
v_mid = (v_min + v_max) * 0.5
current_boss_axis_center = (
boss_axis_point[0] + boss_axis_direction[0] * v_mid,
boss_axis_point[1] + boss_axis_direction[1] * v_mid,
boss_axis_point[2] + boss_axis_direction[2] * v_mid,
)
add_spec(
key="boss_axis_center",
label="凸台轴心坐标",
current_raw=current_boss_axis_center if current_boss_axis_center is not None else "",
target_text=vector_text(current_boss_axis_center),
action="move_cylindrical_boss_axis",
target_attrs=("boss_center_x_input", "boss_center_y_input", "boss_center_z_input"),
enabled=bool(is_full_cylinder and current_boss_axis_center is not None and current_boss is not None),
enabled_tip="输入完整圆柱凸台轴心的目标坐标 X, Y, Z;程序会先移除旧凸台包络,再按同直径在目标轴心补出凸台。",
disabled_tip="当前只对接近完整圆柱的凸台候选放行轴心坐标修改。",
value_type="vector3",
range_hint=translation_hint(),
used=("axis_point", "axis", "same_domain_v_range", "v_range"),
)
current_boss_height = _float_or_none(action_info.get("same_domain_height_estimate"))
if current_boss_height is None:
current_boss_height = _float_or_none(action_info.get("height_estimate"))
add_spec(
key="boss_height",
label="凸台高度",
current_raw=current_boss_height if current_boss_height is not None else "",
target_text=numeric_text(current_boss_height),
action="resize_boss_height",
target_attr="boss_height_input",
enabled=bool(is_full_cylinder and current_boss_height is not None and has_boss_height_cap),
enabled_tip="输入完整圆柱凸台的目标高度;程序会推拉识别到的凸台端盖 Face。",
disabled_tip="当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。",
value_type="positive",
range_hint=relative_range_hint(current_boss_height, 0.3, 0.8),
used=("same_domain_height_estimate", "height_estimate"),
**positive_minimum(),
)
if is_cylinder and not is_hole_or_groove and not is_boss and not is_existing_fillet:
current_generic_diameter = _float_or_none(action_info.get("diameter"))
current_generic_radius = _float_or_none(action_info.get("radius"))
add_spec(
key="generic_cylinder_diameter",
label="未明确圆柱直径",
current_raw=current_generic_diameter if current_generic_diameter is not None else "",
target_text=numeric_text(current_generic_diameter),
action="resize_hole",
target_attr="hole_diameter_input",
enabled=current_generic_diameter is not None,
enabled_tip="输入当前未明确圆柱面的目标直径;程序会按高风险圆柱切削/重切兜底路线尝试。",
disabled_tip="当前圆柱面缺少稳定直径,不能直接修改。",
value_type="positive",
range_hint="高风险兜底:如果这是凸台或圆角,请改用凸台/圆角专门入口;如果是未识别的孔或槽,可以小幅尝试。",
used=("diameter",),
**positive_minimum(),
)
add_spec(
key="generic_cylinder_radius",
label="未明确圆柱半径",
current_raw=current_generic_radius if current_generic_radius is not None else "",
target_text=numeric_text(current_generic_radius),
action="resize_hole",
target_attr="hole_diameter_input",
enabled=current_generic_radius is not None,
enabled_tip="输入当前未明确圆柱面的目标半径;程序会换算成直径后按高风险兜底路线尝试。",
disabled_tip="当前圆柱面缺少稳定半径,不能直接修改。",
value_type="positive",
range_hint="高风险兜底:半径会先换算成直径;建议只做小幅修改并检查结果。",
target_transform="radius_to_diameter",
used=("radius",),
**positive_minimum(),
)
current_cylinder_height = _float_or_none(action_info.get("same_domain_height_estimate"))
if current_cylinder_height is None:
current_cylinder_height = _float_or_none(action_info.get("height_estimate"))
add_spec(
key="cylinder_height",
label="圆柱高度",
current_raw=current_cylinder_height if current_cylinder_height is not None else "",
target_text=numeric_text(current_cylinder_height),
action="resize_cylinder_height",
target_attr="boss_height_input",
enabled=bool(is_full_cylinder and current_cylinder_height is not None and has_boss_height_cap),
enabled_tip="输入完整圆柱面的目标高度;程序会推拉识别到的圆柱端盖 Face。",
disabled_tip="当前圆柱面不是完整圆柱,或缺少可推拉端盖信息。",
value_type="positive",
range_hint=relative_range_hint(current_cylinder_height, 0.3, 0.8),
used=("same_domain_height_estimate", "height_estimate"),
**positive_minimum(),
)
if is_existing_fillet:
current_radius = _float_or_none(action_info.get("existing_fillet_radius_estimate"))
if current_radius is None:
current_radius = _float_or_none(action_info.get("radius"))
fillet_hint = relative_range_hint(current_radius, 0.35, 1.0)
arc_length = _float_or_none(action_info.get("existing_fillet_arc_length_estimate"))
if arc_length is not None and arc_length > 0:
fillet_hint = (
f"{fillet_hint} 目标半径接近 {_format_float(arc_length * 0.5)} "
"(圆角圆弧长度估算的一半)时比例会很异常。"
)
add_spec(
key="existing_fillet_radius_estimate",
label="已有圆角半径",
current_raw=current_radius if current_radius is not None else "",
target_text=numeric_text(current_radius),
action="resize_existing_fillet",
target_attr="edge_fillet_radius_input",
enabled=bool(current_radius is not None and has_fillet_support),
enabled_tip="输入已有圆角/倒圆面的目标半径。",
disabled_tip="当前圆角候选缺少稳定支撑面,暂不放行修改。",
value_type="positive",
range_hint=fillet_hint,
used=("existing_fillet_radius_estimate", "radius"),
**positive_minimum(),
)
current_fillet_arc = _float_or_none(action_info.get("existing_fillet_arc_length_estimate"))
current_fillet_span = _float_or_none(action_info.get("existing_fillet_angular_span"))
if current_fillet_span is None:
current_fillet_span = _float_or_none(action_info.get("angular_span"))
add_spec(
key="existing_fillet_arc_length_estimate",
label="已有圆角圆弧长度",
current_raw=current_fillet_arc if current_fillet_arc is not None else "",
target_text=numeric_text(current_fillet_arc),
action="resize_existing_fillet",
target_attr="edge_fillet_radius_input",
enabled=bool(
current_fillet_arc is not None
and current_fillet_span is not None
and current_fillet_span > 1e-6
and has_fillet_support
),
enabled_tip="输入已有圆角/倒圆面的目标圆弧长度;程序会按当前圆弧角度换算成目标半径。",
disabled_tip="当前圆角候选缺少稳定圆弧长度、角度或支撑面,暂不放行修改。",
value_type="positive",
range_hint=relative_range_hint(current_fillet_arc, 0.35, 1.0),
target_transform="arc_length_to_radius",
transform_context={"angular_span": current_fillet_span},
used=("existing_fillet_arc_length_estimate",),
**positive_minimum(),
)
if is_cone:
current_reference_radius = _float_or_none(action_info.get("reference_radius"))
current_reference_diameter = (
current_reference_radius * 2.0 if current_reference_radius is not None else None
)
add_spec(
key="cone_reference_radius",
label="圆锥参考半径",
current_raw=current_reference_radius if current_reference_radius is not None else "",
target_text=numeric_text(current_reference_radius),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None,
enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴做径向缩放。",
disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_reference_radius, 0.25, 0.6),
used=("reference_radius",),
**positive_minimum(),
)
add_spec(
key="cone_reference_diameter",
label="圆锥参考直径",
current_raw=current_reference_diameter if current_reference_diameter is not None else "",
target_text=numeric_text(current_reference_diameter),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_diameter is not None,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放。",
disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_reference_diameter, 0.25, 0.6),
target_transform="diameter_to_radius",
used=("feature_reference_diameter",),
**positive_minimum(),
)
current_semi_angle = _float_or_none(action_info.get("semi_angle"))
current_semi_angle_degrees = (
abs(math.degrees(current_semi_angle)) if current_semi_angle is not None else None
)
add_spec(
key="cone_semi_angle_degrees",
label="圆锥半角(度)",
current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "",
target_text=numeric_text(current_semi_angle_degrees),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None and current_semi_angle is not None,
enabled_tip="输入圆锥面的目标半角;程序会换算为目标参考半径后围绕圆锥轴径向缩放。",
disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。",
value_type="positive",
range_hint="建议先小幅修改;当前版本要求半角大于 0 且小于 89 度。",
max_value=89.0,
max_exclusive=True,
target_transform="cone_semi_angle_degrees_to_reference_radius",
transform_context={
"current_reference_radius": current_reference_radius,
"current_semi_angle": current_semi_angle,
},
used=("semi_angle",),
**positive_minimum(),
)
if is_sphere:
current_sphere_radius = _float_or_none(action_info.get("radius"))
current_sphere_diameter = _float_or_none(action_info.get("diameter"))
add_spec(
key="sphere_radius",
label="球面半径",
current_raw=current_sphere_radius if current_sphere_radius is not None else "",
target_text=numeric_text(current_sphere_radius),
action="resize_sphere_radius",
target_attr="sphere_radius_input",
enabled=current_sphere_radius is not None,
enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属零件/Solid。",
disabled_tip="当前球面缺少稳定半径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_sphere_radius, 0.25, 0.6),
used=("radius",),
**positive_minimum(),
)
add_spec(
key="sphere_diameter",
label="球面直径",
current_raw=current_sphere_diameter if current_sphere_diameter is not None else "",
target_text=numeric_text(current_sphere_diameter),
action="resize_sphere_radius",
target_attr="sphere_radius_input",
enabled=current_sphere_diameter is not None,
enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放。",
disabled_tip="当前球面缺少稳定直径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_sphere_diameter, 0.25, 0.6),
target_transform="diameter_to_radius",
used=("diameter", "feature_sphere_diameter"),
**positive_minimum(),
)
if is_torus:
current_major_radius = _float_or_none(action_info.get("major_radius"))
current_minor_radius = _float_or_none(action_info.get("minor_radius"))
current_major_diameter = (
current_major_radius * 2.0 if current_major_radius is not None else None
)
current_minor_diameter = (
current_minor_radius * 2.0 if current_minor_radius is not None else None
)
add_spec(
key="torus_major_radius",
label="环面主半径",
current_raw=current_major_radius if current_major_radius is not None else "",
target_text=numeric_text(current_major_radius),
action="resize_torus_major_radius",
target_attr="torus_radius_input",
enabled=current_major_radius is not None,
enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放,主半径和小半径会等比例变化。",
disabled_tip="当前环面缺少稳定主半径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_major_radius, 0.25, 0.6),
used=("major_radius", "feature_torus_major_radius"),
**positive_minimum(),
)
add_spec(
key="torus_major_diameter",
label="环面主直径",
current_raw=current_major_diameter if current_major_diameter is not None else "",
target_text=numeric_text(current_major_diameter),
action="resize_torus_major_radius",
target_attr="torus_radius_input",
enabled=current_major_diameter is not None,
enabled_tip="输入环面的目标主直径;程序会换算为主半径后执行环面修改。",
disabled_tip="当前环面缺少稳定主直径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_major_diameter, 0.25, 0.6),
target_transform="diameter_to_radius",
used=("feature_torus_major_radius",),
**positive_minimum(),
)
add_spec(
key="torus_minor_radius",
label="环面小半径",
current_raw=current_minor_radius if current_minor_radius is not None else "",
target_text=numeric_text(current_minor_radius),
action="resize_torus_minor_radius",
target_attr="torus_radius_input",
enabled=current_minor_radius is not None,
enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放,主半径和小半径会等比例变化。",
disabled_tip="当前环面缺少稳定小半径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_minor_radius, 0.25, 0.6),
used=("minor_radius", "feature_torus_minor_radius"),
**positive_minimum(),
)
add_spec(
key="torus_minor_diameter",
label="环面小直径",
current_raw=current_minor_diameter if current_minor_diameter is not None else "",
target_text=numeric_text(current_minor_diameter),
action="resize_torus_minor_radius",
target_attr="torus_radius_input",
enabled=current_minor_diameter is not None,
enabled_tip="输入环面的目标小直径;程序会换算为小半径后执行环面修改。",
disabled_tip="当前环面缺少稳定小直径,不能直接修改。",
value_type="positive",
range_hint=relative_range_hint(current_minor_diameter, 0.25, 0.6),
target_transform="diameter_to_radius",
used=("feature_torus_minor_radius",),
**positive_minimum(),
)
if has_edge:
current_length = _float_or_none(action_info.get("length"))
is_circle_edge = curve == "circle"
is_ellipse_edge = curve == "ellipse"
current_anchor_mode = "auto"
current_anchor_label = "自动"
if hasattr(self, "edge_length_anchor_combo"):
current_anchor_mode = str(self.edge_length_anchor_combo.currentData() or "auto")
current_anchor_label = self.edge_length_anchor_combo.currentText()
add_spec(
key="length",
label="Edge长度",
current_raw=current_length if current_length is not None else "",
target_text=numeric_text(current_length),
action="resize_any_edge_length",
target_attr="edge_target_length_input",
enabled=current_length is not None,
enabled_tip="输入当前Edge的目标长度。",
disabled_tip="当前Edge没有稳定长度信息。",
value_type="positive",
range_hint=relative_range_hint(current_length, 0.25, 0.5),
**positive_minimum(),
)
add_spec(
key="edge_length_anchor_mode",
label="Edge长度基准",
current_raw=current_anchor_mode,
target_text=current_anchor_label,
action="set_edge_length_anchor_mode",
target_attr="edge_length_anchor_combo",
enabled=True,
enabled_tip="设置修改Edge长度时固定哪里:自动、中心、固定起点或固定终点。",
disabled_tip="请先选择一条Edge。",
value_type="choice",
range_hint="可输入:自动、中心、固定起点、固定终点,也可以输入 auto、center、start、end。",
button_text="设置",
choices={
"自动": "auto",
"auto": "auto",
"中心": "center",
"固定中心": "center",
"center": "center",
"centre": "center",
"起点": "keep-start",
"固定起点": "keep-start",
"start": "keep-start",
"keep-start": "keep-start",
"终点": "keep-end",
"固定终点": "keep-end",
"end": "keep-end",
"keep-end": "keep-end",
},
)
edge_start_point = _triple_or_none(action_info.get("start_point"))
edge_end_point = _triple_or_none(action_info.get("end_point"))
edge_center_point = _triple_or_none(action_info.get("length_center"))
can_move_line_endpoint = bool(is_line_edge and edge_start_point is not None and edge_end_point is not None)
add_spec(
key="edge_start_point",
label="Edge起点坐标",
current_raw=edge_start_point if edge_start_point is not None else "",
target_text=vector_text(edge_start_point),
action="move_edge_start_point",
target_attrs=("edge_start_x_input", "edge_start_y_input", "edge_start_z_input"),
enabled=can_move_line_endpoint,
enabled_tip="输入目标 X, Y, Z 坐标,只移动当前直线 Edge 的起点,并重建周边平面。",
disabled_tip="只有带稳定起点/终点的直线 Edge 才能直接移动端点坐标。",
value_type="vector3",
range_hint="格式为 X, Y, Z。建议先小幅移动;当前版本仅对简单全平面多面体开放。",
used=("start_point",),
)
add_spec(
key="edge_center_point",
label="Edge中心坐标",
current_raw=edge_center_point if edge_center_point is not None else "",
target_text=vector_text(edge_center_point),
action="move_edge_center_point",
target_attrs=("edge_center_x_input", "edge_center_y_input", "edge_center_z_input"),
enabled=bool(can_move_line_endpoint and edge_center_point is not None),
enabled_tip="输入目标 X, Y, Z 坐标,保持当前直线Edge长度不变并移动整条Edge。",
disabled_tip="只有带稳定起点/终点/中心的直线 Edge 才能直接移动中心坐标。",
value_type="vector3",
range_hint="格式为 X, Y, Z。建议先小幅移动;当前版本仅对简单全平面多面体开放。",
used=("length_center",),
)
add_spec(
key="edge_end_point",
label="Edge终点坐标",
current_raw=edge_end_point if edge_end_point is not None else "",
target_text=vector_text(edge_end_point),
action="move_edge_end_point",
target_attrs=("edge_end_x_input", "edge_end_y_input", "edge_end_z_input"),
enabled=can_move_line_endpoint,
enabled_tip="输入目标 X, Y, Z 坐标,只移动当前直线 Edge 的终点,并重建周边平面。",
disabled_tip="只有带稳定起点/终点的直线 Edge 才能直接移动端点坐标。",
value_type="vector3",
range_hint="格式为 X, Y, Z。建议先小幅移动;当前版本仅对简单全平面多面体开放。",
used=("end_point",),
)
if is_circle_edge:
current_radius = _float_or_none(action_info.get("radius"))
current_diameter = _float_or_none(action_info.get("diameter"))
can_resize_circle = bool(current_length is not None and current_length > 0 and current_radius is not None and current_radius > 0)
circle_context = {
"edge_current_length": current_length,
"edge_current_radius": current_radius,
}
add_spec(
key="circle_edge_radius",
label="圆Edge半径",
current_raw=current_radius if current_radius is not None else "",
target_text=numeric_text(current_radius),
action="resize_any_edge_length",
target_attr="edge_target_length_input",
enabled=can_resize_circle,
enabled_tip="输入圆形/圆弧Edge的目标半径;程序会换算成等比例Edge目标长度后执行。",
disabled_tip="当前圆Edge缺少稳定半径或长度信息。",
value_type="positive",
range_hint=relative_range_hint(current_radius, 0.25, 0.5),
target_transform="circle_edge_radius_to_length",
transform_context=circle_context,
used=("radius",),
**positive_minimum(),
)
add_spec(
key="circle_edge_diameter",
label="圆Edge直径",
current_raw=current_diameter if current_diameter is not None else "",
target_text=numeric_text(current_diameter),
action="resize_any_edge_length",
target_attr="edge_target_length_input",
enabled=bool(can_resize_circle and current_diameter is not None and current_diameter > 0),
enabled_tip="输入圆形/圆弧Edge的目标直径;程序会换算成等比例Edge目标长度后执行。",
disabled_tip="当前圆Edge缺少稳定直径或长度信息。",
value_type="positive",
range_hint=relative_range_hint(current_diameter, 0.25, 0.5),
target_transform="circle_edge_diameter_to_length",
transform_context=circle_context,
used=("diameter",),
**positive_minimum(),
)
if is_ellipse_edge:
current_major = _float_or_none(action_info.get("major_radius"))
current_minor = _float_or_none(action_info.get("minor_radius"))
ellipse_context = {
"edge_current_length": current_length,
"edge_current_major_radius": current_major,
"edge_current_minor_radius": current_minor,
}
add_spec(
key="ellipse_edge_major_radius",
label="椭圆Edge主半径",
current_raw=current_major if current_major is not None else "",
target_text=numeric_text(current_major),
action="resize_any_edge_length",
target_attr="edge_target_length_input",
enabled=bool(current_length is not None and current_length > 0 and current_major is not None and current_major > 0),
enabled_tip="输入椭圆Edge的目标主半径;程序会换算成目标Edge长度并执行平面曲线缩放。",
disabled_tip="当前椭圆Edge缺少稳定主半径或长度信息。",
value_type="positive",
range_hint=relative_range_hint(current_major, 0.25, 0.5),
target_transform="ellipse_edge_major_radius_to_length",
transform_context=ellipse_context,
used=("major_radius",),
**positive_minimum(),
)
add_spec(
key="ellipse_edge_minor_radius",
label="椭圆Edge小半径",
current_raw=current_minor if current_minor is not None else "",
target_text=numeric_text(current_minor),
action="resize_any_edge_length",
target_attr="edge_target_length_input",
enabled=bool(current_length is not None and current_length > 0 and current_minor is not None and current_minor > 0),
enabled_tip="输入椭圆Edge的目标小半径;程序会换算成目标Edge长度并执行平面曲线缩放。",
disabled_tip="当前椭圆Edge缺少稳定小半径或长度信息。",
value_type="positive",
range_hint=relative_range_hint(current_minor, 0.25, 0.5),
target_transform="ellipse_edge_minor_radius_to_length",
transform_context=ellipse_context,
used=("minor_radius",),
**positive_minimum(),
)
if is_line_edge:
edge_limit = current_length * 0.45 if current_length is not None and current_length > 0 else None
edge_soft = current_length * 0.12 if current_length is not None and current_length > 0 else None
edge_warning = current_length * 0.25 if current_length is not None and current_length > 0 else None
fillet_edge_hint = "建议半径先用较小值试改;过大容易导致倒圆失败。"
chamfer_edge_hint = "建议距离先用较小值试改;过大容易导致倒角失败。"
if edge_soft is not None and edge_warning is not None:
fillet_edge_hint = (
f"建议半径先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});"
f"超过 25%(约 {_format_float(edge_warning)})时风险较高。"
)
chamfer_edge_hint = (
f"建议距离先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});"
f"超过 25%(约 {_format_float(edge_warning)})时风险较高。"
)
add_spec(
key="new_fillet_radius",
label="新增圆角半径",
current_raw="未添加",
target_text="",
action="fillet_edge",
target_attr="edge_fillet_radius_input",
enabled=True,
enabled_tip="输入半径后给当前直线Edge添加新圆角。",
disabled_tip="只有直线Edge才能直接添加圆角。",
value_type="positive",
range_hint=fillet_edge_hint,
max_value=edge_limit,
max_exclusive=edge_limit is not None,
**positive_minimum(),
)
add_spec(
key="new_chamfer_distance",
label="新增倒角距离",
current_raw="未添加",
target_text="",
action="chamfer_edge",
target_attr="edge_chamfer_distance_input",
enabled=True,
enabled_tip="输入距离后给当前直线Edge添加新倒角。",
disabled_tip="只有直线Edge才能直接添加倒角。",
value_type="positive",
range_hint=chamfer_edge_hint,
max_value=edge_limit,
max_exclusive=edge_limit is not None,
**positive_minimum(),
)
adjacent_face_ids = _int_values(action_info.get("adjacent_face_ids"))
default_chamfer_reference = adjacent_face_ids[0] if adjacent_face_ids else None
current_chamfer_reference = default_chamfer_reference
if hasattr(self, "edge_chamfer_reference_face_input"):
hidden_reference = self.edge_chamfer_reference_face_input.text().strip()
if hidden_reference:
try:
hidden_reference_id = int(hidden_reference)
if not adjacent_face_ids or hidden_reference_id in adjacent_face_ids:
current_chamfer_reference = hidden_reference_id
else:
self.edge_chamfer_reference_face_input.setText("")
except ValueError:
self.edge_chamfer_reference_face_input.setText("")
current_chamfer_reference = default_chamfer_reference
add_spec(
key="new_asymmetric_chamfer_distances",
label="新增不等距倒角D1/D2",
current_raw="未添加",
target_text="",
action="chamfer_edge_asymmetric",
target_attrs=("edge_chamfer_distance1_input", "edge_chamfer_distance2_input"),
enabled=bool(len(adjacent_face_ids) >= 2),
enabled_tip="输入两个距离,例如 1, 2,给当前直线Edge添加两侧距离不同的倒角。",
disabled_tip="不等距倒角需要当前直线Edge至少有两个相邻Face。",
value_type="number_pair",
range_hint=f"{chamfer_edge_hint} D1/D2 会以倒角参考Face决定方向。",
max_value=edge_limit,
max_exclusive=edge_limit is not None,
positive_pair=True,
)
add_spec(
key="new_distance_angle_chamfer",
label="新增距离+角度倒角D/角度",
current_raw="未添加",
target_text="",
action="chamfer_edge_distance_angle",
target_attrs=("edge_chamfer_angle_distance_input", "edge_chamfer_angle_degrees_input"),
enabled=bool(len(adjacent_face_ids) >= 2),
enabled_tip="输入距离和角度,例如 1, 45,给当前直线Edge添加距离+角度倒角。",
disabled_tip="距离+角度倒角需要当前直线Edge至少有两个相邻Face。",
value_type="number_pair",
range_hint=f"{chamfer_edge_hint} 角度单位是度,建议先使用 20 到 70 度。",
positive_pair=True,
)
add_spec(
key="chamfer_reference_face_id",
label="倒角参考Face ID",
current_raw=current_chamfer_reference if current_chamfer_reference is not None else "",
target_text=str(current_chamfer_reference) if current_chamfer_reference is not None else "",
action="set_chamfer_reference_face",
target_attr="edge_chamfer_reference_face_input",
enabled=bool(adjacent_face_ids),
enabled_tip=f"设置不等距倒角参考Face;可用相邻Face:{tuple(adjacent_face_ids)}。",
disabled_tip="当前Edge没有稳定的相邻Face,不能设置不等距倒角参考Face。",
value_type="integer_or_empty",
range_hint=f"可用相邻Face{tuple(adjacent_face_ids)}。留空则使用第一个相邻Face。",
button_text="设置",
)
if self.selected_kind in {"part", "solid"}:
target_is_part = self.selected_kind == "part"
translate_action = "translate_selected_part" if target_is_part else "translate_selected_solid"
rotate_action = "rotate_selected_part" if target_is_part else "rotate_selected_solid"
add_spec(
key="translation_vector",
label="平移 X/Y/Z",
current_raw=(0.0, 0.0, 0.0),
target_text="0, 0, 0",
action=translate_action,
target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"),
enabled=True,
enabled_tip="输入三个数,例如 10, 0, 0,平移当前对象。",
disabled_tip="请先选择零件或 Solid。",
value_type="vector3",
range_hint=translation_hint(),
)
current_center = _triple_or_none(action_info.get("center_of_mass"))
bbox_min = _triple_or_none(action_info.get("bbox_min"))
bbox_max = _triple_or_none(action_info.get("bbox_max"))
if current_center is None and bbox_min is not None and bbox_max is not None:
current_center = (
(bbox_min[0] + bbox_max[0]) * 0.5,
(bbox_min[1] + bbox_max[1]) * 0.5,
(bbox_min[2] + bbox_max[2]) * 0.5,
)
add_spec(
key="target_center_position",
label="目标中心坐标",
current_raw=current_center if current_center is not None else "",
target_text=vector_text(current_center),
action=translate_action,
target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"),
enabled=current_center is not None,
enabled_tip="输入目标中心坐标;程序会自动换算成平移 X/Y/Z 后移动当前对象。",
disabled_tip="当前对象缺少稳定中心坐标,不能按目标中心移动。",
value_type="vector3",
range_hint="格式为 X, Y, Z。这里输入的是目标绝对坐标,不是平移距离。",
target_transform="target_center_to_translation",
transform_context={"current_center": current_center},
used=("center_of_mass",),
)
current_diagonal = _float_or_none(action_info.get("bbox_diagonal"))
scale_action = "scale_selected_part" if target_is_part else "scale_selected_solid"
add_spec(
key="bbox_diagonal_scale",
label="目标整体尺寸",
current_raw=current_diagonal if current_diagonal is not None else "",
target_text=numeric_text(current_diagonal),
action=scale_action,
target_attr="scale_target_diagonal_input",
enabled=current_diagonal is not None and current_diagonal > 0,
enabled_tip="输入目标包围盒对角线,按当前对象中心等比缩放零件或Solid。",
disabled_tip="当前对象缺少有效包围盒尺寸,不能等比缩放。",
value_type="positive",
range_hint=relative_range_hint(current_diagonal, 0.35, 1.0),
used=("bbox_diagonal",),
**positive_minimum(),
)
current_volume = _float_or_none(action_info.get("volume"))
add_spec(
key="target_volume_scale",
label="目标体积",
current_raw=current_volume if current_volume is not None else "",
target_text=numeric_text(current_volume),
action=scale_action,
target_attr="scale_target_diagonal_input",
enabled=bool(current_volume is not None and current_volume > 0 and current_diagonal is not None and current_diagonal > 0),
enabled_tip="输入目标体积;程序会按体积比例换算为等比缩放后的整体尺寸。",
disabled_tip="当前对象缺少有效体积或包围盒尺寸,不能按体积缩放。",
value_type="positive",
range_hint=relative_range_hint(current_volume, 0.5, 2.0),
target_transform="volume_to_bbox_diagonal",
transform_context={
"current_volume": current_volume,
"current_bbox_diagonal": current_diagonal,
},
used=("volume",),
**positive_minimum(),
)
current_surface_area = _float_or_none(action_info.get("surface_area"))
add_spec(
key="target_surface_area_scale",
label="目标表面积",
current_raw=current_surface_area if current_surface_area is not None else "",
target_text=numeric_text(current_surface_area),
action=scale_action,
target_attr="scale_target_diagonal_input",
enabled=bool(
current_surface_area is not None
and current_surface_area > 0
and current_diagonal is not None
and current_diagonal > 0
),
enabled_tip="输入目标表面积;程序会按面积比例换算为等比缩放后的整体尺寸。",
disabled_tip="当前对象缺少有效表面积或包围盒尺寸,不能按表面积缩放。",
value_type="positive",
range_hint=relative_range_hint(current_surface_area, 0.5, 2.0),
target_transform="surface_area_to_bbox_diagonal",
transform_context={
"current_surface_area": current_surface_area,
"current_bbox_diagonal": current_diagonal,
},
used=("surface_area",),
**positive_minimum(),
)
bbox_size = _triple_or_none(action_info.get("bbox_size"))
axis_scale_action_prefix = "scale_selected_part" if target_is_part else "scale_selected_solid"
if bbox_size is not None:
for axis_name, axis_index, target_attr in (
("X", 0, "scale_x_size_input"),
("Y", 1, "scale_y_size_input"),
("Z", 2, "scale_z_size_input"),
):
current_axis_size = bbox_size[axis_index]
add_spec(
key=f"bbox_{axis_name.lower()}_size_scale",
label=f"目标{axis_name}向尺寸",
current_raw=current_axis_size,
target_text=numeric_text(current_axis_size),
action=f"{axis_scale_action_prefix}_{axis_name.lower()}_size",
target_attr=target_attr,
enabled=current_axis_size > 0,
enabled_tip=f"输入目标 {axis_name} 向包围盒尺寸,只沿 {axis_name} 轴缩放当前对象。",
disabled_tip=f"当前对象缺少有效 {axis_name} 向包围盒尺寸。",
value_type="positive",
range_hint=relative_range_hint(current_axis_size, 0.25, 1.0),
**positive_minimum(),
)
axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z"
add_spec(
key="rotation_axis",
label="旋转轴",
current_raw=axis,
target_text=axis,
action="set_rotate_axis",
target_attr="rotate_axis_combo",
enabled=True,
enabled_tip="设置零件或 Solid 旋转时使用的轴:X、Y 或 Z。",
disabled_tip="请先选择零件或 Solid。",
value_type="choice",
range_hint="可输入:X、Y、Z。",
button_text="设置",
choices={
"X": "X",
"x": "X",
"Y": "Y",
"y": "Y",
"Z": "Z",
"z": "Z",
},
)
add_spec(
key="rotation_angle_degrees",
label=f"旋转角度({axis}轴)",
current_raw=0.0,
target_text="0",
action=rotate_action,
target_attr="rotate_angle_input",
enabled=True,
enabled_tip=f"输入旋转角度,当前使用 {axis} 轴。",
disabled_tip="请先选择零件或 Solid。",
range_hint="角度可正可负;建议单次输入 -360 到 360 度,超过后请确认旋转方向和单位。",
)
return specs, used_keys
def _on_property_table_item_changed(self, _item: QTableWidgetItem) -> None:
if getattr(self, "property_editor_updating", False):
return
self._update_property_apply_state()
def _update_property_apply_state(self, has_model: bool | None = None) -> None:
if not hasattr(self, "property_table"):
return
if has_model is None:
has_model = self.model is not None and not (
self.operation_in_progress or self.scan_in_progress or self.load_in_progress
)
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
widget = self.property_table.cellWidget(row, 3)
if isinstance(widget, QPushButton):
if str(spec.get("value_type", "number")) == "command":
row_changed = True
widget.setText(str(spec.get("button_text") or "执行"))
enabled = bool(has_model and spec.get("enabled") and spec.get("action"))
tooltip = f"执行“{spec.get('label', '当前操作')}”。"
else:
text = self._property_target_text(row)
row_changed = self._property_target_changed(spec, text)
widget.setText("应用" if row_changed else "未改动")
enabled = bool(has_model and spec.get("enabled") and spec.get("action") and row_changed)
if row_changed:
tooltip = f"应用“{spec.get('label', '当前属性')}”这一行的目标值。"
else:
tooltip = f"“{spec.get('label', '当前属性')}”的目标值和当前值相同,无需执行。修改目标值后再应用。"
range_hint = self._property_range_hint(spec)
if range_hint:
tooltip = f"{tooltip}\n\n{range_hint}"
widget.setProperty("changed", bool(row_changed))
widget.setToolTip(tooltip)
widget.setCursor(Qt.CursorShape.PointingHandCursor if enabled else Qt.CursorShape.ArrowCursor)
widget.style().unpolish(widget)
widget.style().polish(widget)
widget.setEnabled(enabled)
if not hasattr(self, "apply_property_button"):
return
changed = self._changed_property_rows()
enabled = bool(has_model and changed)
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
if changed and len(changed) > 1:
disabled_tip = "请使用每一行状态列里的修改按钮,单独应用某个特征。"
self._set_control_state(
self.apply_property_button,
enabled and len(changed) == 1,
"应用属性表中被修改的一行目标值。",
disabled_tip,
)
def _changed_property_rows(self) -> list[tuple[int, dict[str, object], str]]:
if not hasattr(self, "property_table"):
return []
changed: list[tuple[int, dict[str, object], str]] = []
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
if not spec.get("action") or not spec.get("enabled"):
continue
if str(spec.get("value_type", "number")) == "command":
continue
if spec.get("action") in {
"set_manual_hole_bottom_face",
"set_manual_slot_pair_face",
"set_edge_length_anchor_mode",
"set_chamfer_reference_face",
"set_rotate_axis",
}:
continue
text = self._property_target_text(row)
if self._property_target_changed(spec, text):
changed.append((row, spec, text))
return changed
def _property_target_text(self, row: int) -> str:
if not hasattr(self, "property_table"):
return ""
widget = self.property_table.cellWidget(row, 2)
if isinstance(widget, QLineEdit):
return widget.text().strip()
item = self.property_table.item(row, 2)
return item.text().strip() if item is not None else ""
def _display_property_target_changed(
self,
spec: dict[str, object],
text: str,
value_type: str,
) -> bool | None:
current_text = str(spec.get("current_text", "")).strip()
if not current_text:
return None
if value_type == "vector3":
try:
values = self._parse_property_vector3(text)
current = self._parse_property_vector3(current_text)
except ValueError:
return None
return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3))
if value_type == "number_pair":
try:
values = self._parse_property_number_pair(text)
current = self._parse_property_number_pair(current_text)
except ValueError:
return None
return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(2))
if value_type in {"integer", "integer_or_empty"}:
try:
return int(text) != int(current_text)
except ValueError:
return None
if value_type in {"number", "positive"}:
value = _float_or_none(text)
current = _float_or_none(current_text)
if value is None or current is None:
return None
return abs(value - current) > PROPERTY_VALUE_TOLERANCE
return None
def _property_target_changed(self, spec: dict[str, object], text: str) -> bool:
value_type = str(spec.get("value_type", "number"))
if not text:
if value_type == "integer_or_empty":
return str(spec.get("current_raw", "")).strip() != ""
return False
display_changed = self._display_property_target_changed(spec, text, value_type)
if display_changed is not None:
return display_changed
if value_type == "vector3":
try:
values = self._parse_property_vector3(text)
except ValueError:
return True
if str(spec.get("target_transform") or "") == "target_center_to_translation":
current = _triple_or_none(spec.get("current_raw"))
if current is not None:
return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3))
current = _triple_or_none(spec.get("current_raw"))
if current is not None:
return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3))
return any(abs(value) > PROPERTY_VALUE_TOLERANCE for value in values)
if value_type == "choice":
try:
value = self._property_choice_value(spec, text)
except ValueError:
return True
return str(value) != str(spec.get("current_raw", ""))
if value_type == "number_pair":
try:
values = self._parse_property_number_pair(text)
except ValueError:
return True
if spec.get("positive_pair") and any(value <= 0 for value in values):
return True
if any(self._property_scalar_range_error(spec, value) for value in values):
return True
current = spec.get("current_raw")
if isinstance(current, (tuple, list)) and len(current) == 2:
try:
current_pair = (float(current[0]), float(current[1]))
except (TypeError, ValueError):
return True
return any(abs(values[index] - current_pair[index]) > PROPERTY_VALUE_TOLERANCE for index in range(2))
return True
if value_type in {"integer", "integer_or_empty"}:
try:
value = int(text)
except ValueError:
return True
if self._property_scalar_range_error(spec, float(value)):
return True
current_text = str(spec.get("current_raw", "")).strip()
try:
current = int(current_text)
except ValueError:
return True
return value != current
try:
value = float(text)
except ValueError:
return True
if value_type == "positive" and value <= 0:
return True
if self._property_scalar_range_error(spec, value):
return True
current = _float_or_none(spec.get("current_raw"))
if current is None:
return True
return abs(value - current) > PROPERTY_VALUE_TOLERANCE
def apply_current_property_edit(self) -> None:
changed = self._changed_property_rows()
if not changed:
self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。")
return
if len(changed) > 1:
QMessageBox.information(self, "一次应用一个修改", "请只修改一行目标值,然后再点击应用当前修改。")
return
row, _spec, _text = changed[0]
self.apply_property_row_edit(row)
def apply_property_row_edit(self, row: int) -> None:
specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs):
self.statusBar().showMessage("当前属性行已经失效,请重新选择对象。")
return
spec = specs[row]
if not spec.get("action") or not spec.get("enabled"):
self.statusBar().showMessage("这一行当前不能直接修改。")
return
text = self._property_target_text(row)
is_command = str(spec.get("value_type", "number")) == "command"
if not is_command and not self._property_target_changed(spec, text):
self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。")
return
try:
if not is_command:
self._sync_property_edit_target(spec, text)
except ValueError as exc:
QMessageBox.information(self, "目标值无效", str(exc))
return
action_name = str(spec.get("action", ""))
action = getattr(self, action_name, None)
if action is None:
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
return
action()
def set_manual_hole_bottom_face(self) -> None:
self._update_action_states()
self._refresh_property_editor()
text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else ""
if text:
self.statusBar().showMessage(f"已设置盲孔/盲槽底面 Face ID:{text},现在可以继续修改深度。")
else:
self.statusBar().showMessage("已清除手动底面 Face ID,将使用自动识别结果。")
def set_manual_slot_pair_face(self) -> None:
self._update_action_states()
self._refresh_property_editor()
text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else ""
if text:
self.statusBar().showMessage(f"已设置槽孔配对端 Face ID{text},后续槽孔修改会优先使用这个配对端。")
else:
self.statusBar().showMessage("已清除手动槽孔配对端 Face ID,将使用自动识别结果。")
def set_edge_length_anchor_mode(self) -> None:
self._update_action_states()
self._refresh_property_editor()
label = self.edge_length_anchor_combo.currentText() if hasattr(self, "edge_length_anchor_combo") else ""
self.statusBar().showMessage(f"已设置Edge长度基准:{label or '自动'}。")
def set_chamfer_reference_face(self) -> None:
self._update_action_states()
self._refresh_property_editor()
text = (
self.edge_chamfer_reference_face_input.text().strip()
if hasattr(self, "edge_chamfer_reference_face_input")
else ""
)
if text:
self.statusBar().showMessage(f"已设置不等距倒角参考 Face ID:{text}。")
else:
self.statusBar().showMessage("已清除不等距倒角参考 Face ID,将使用当前Edge的第一个相邻Face。")
def set_rotate_axis(self) -> None:
self._update_action_states()
self._refresh_property_editor()
axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z"
self.statusBar().showMessage(f"已设置旋转轴:{axis}。")
def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None:
value_type = str(spec.get("value_type", "number"))
if value_type == "command":
return
if value_type == "vector3":
values = self._parse_property_vector3(text)
values = self._transform_property_vector3_target(spec, values)
target_attrs = tuple(spec.get("target_attrs") or ())
if len(target_attrs) != 3:
raise ValueError("这个属性缺少 X/Y/Z 输入绑定。")
for attr, value in zip(target_attrs, values):
getattr(self, str(attr)).setText(_format_float(value))
return
if value_type == "choice":
value = self._property_choice_value(spec, text)
target_attr = spec.get("target_attr")
if not target_attr:
raise ValueError("这个属性缺少目标输入绑定。")
widget = getattr(self, str(target_attr))
index = widget.findData(value) if hasattr(widget, "findData") else -1
if index < 0 and hasattr(widget, "findText"):
index = widget.findText(str(text))
if index < 0:
raise ValueError(f"找不到可用选项:{text}")
widget.setCurrentIndex(index)
return
if value_type == "number_pair":
values = self._parse_property_number_pair(text)
if spec.get("positive_pair") and any(value <= 0 for value in values):
raise ValueError("请输入两个大于 0 的目标值。")
for value in values:
range_error = self._property_scalar_range_error(spec, value)
if range_error:
raise ValueError(range_error)
target_attrs = tuple(spec.get("target_attrs") or ())
if len(target_attrs) != 2:
raise ValueError("这个属性缺少两个目标输入绑定。")
for attr, value in zip(target_attrs, values):
getattr(self, str(attr)).setText(_format_float(value))
return
if value_type in {"integer", "integer_or_empty"}:
target_attr = spec.get("target_attr")
if not target_attr:
raise ValueError("这个属性缺少目标输入绑定。")
if not text and value_type == "integer_or_empty":
getattr(self, str(target_attr)).setText("")
return
try:
value = int(text)
except ValueError as exc:
raise ValueError("请输入整数形式的 Face ID。") from exc
range_error = self._property_scalar_range_error(spec, float(value))
if range_error:
raise ValueError(range_error)
key = str(spec.get("key", ""))
if self.model is not None and key in {"hole_bottom_face_id", "slot_pair_face_id", "chamfer_reference_face_id"}:
if value < 0 or value >= len(self.model.faces):
label = {
"hole_bottom_face_id": "底面",
"slot_pair_face_id": "槽孔配对端",
"chamfer_reference_face_id": "倒角参考",
}.get(key, "Face")
raise ValueError(f"{label} Face ID {value} 不存在。")
if key == "slot_pair_face_id" and self.selected_face_id is not None and value == self.selected_face_id:
raise ValueError("槽孔配对端不能和当前槽端使用同一个 Face ID。")
if key == "chamfer_reference_face_id" and self.selected_edge_id is not None:
try:
adjacent_face_ids = _int_values(self.model.edge_info(self.selected_edge_id).get("adjacent_face_ids"))
except Exception:
adjacent_face_ids = []
if adjacent_face_ids and value not in adjacent_face_ids:
raise ValueError(f"倒角参考 Face ID 必须是当前 Edge 的相邻 Face,可用值:{tuple(adjacent_face_ids)}。")
getattr(self, str(target_attr)).setText(str(value))
return
try:
value = float(text)
except ValueError as exc:
raise ValueError("请输入数字形式的目标值。") from exc
if value_type == "positive" and value <= 0:
raise ValueError("请输入大于 0 的目标值。")
range_error = self._property_scalar_range_error(spec, value)
if range_error:
raise ValueError(range_error)
value = self._transform_property_scalar_target(spec, value)
target_attr = spec.get("target_attr")
if not target_attr:
raise ValueError("这个属性缺少目标输入绑定。")
getattr(self, str(target_attr)).setText(_format_float(value))
def _transform_property_scalar_target(self, spec: dict[str, object], value: float) -> float:
transform = str(spec.get("target_transform") or "")
if not transform:
return value
context = spec.get("transform_context")
if not isinstance(context, dict):
context = {}
if transform == "radius_to_diameter":
if value <= 0:
raise ValueError("目标半径必须大于 0。")
return value * 2.0
if transform == "diameter_to_radius":
if value <= 0:
raise ValueError("目标直径必须大于 0。")
return value * 0.5
if transform == "plane_target_position_to_offset":
current_position = _float_or_none(context.get("current_plane_position"))
if current_position is None:
raise ValueError("当前平面缺少稳定法向位置,不能换算推拉距离。")
return value - current_position
if transform == "degrees_to_radians":
if value <= 0:
raise ValueError("目标角度必须大于 0。")
return math.radians(value)
if transform == "slot_open_angle_degrees_to_angular_span":
if value <= 0 or value >= 360.0:
raise ValueError("槽/半孔开口角度必须大于 0 且小于 360 度。")
target_span = math.tau - math.radians(value)
if target_span <= 1e-6 or target_span >= math.tau * 0.92:
raise ValueError("换算后的槽/半孔圆弧角度必须大于 0 且小于接近完整圆柱的范围。")
return target_span
if transform == "cone_semi_angle_degrees_to_reference_radius":
current_radius = _float_or_none(context.get("current_reference_radius"))
current_angle = _float_or_none(context.get("current_semi_angle"))
if current_radius is None or current_radius <= 0 or current_angle is None:
raise ValueError("当前圆锥面缺少稳定参考半径或半角,不能换算目标半角。")
if value <= 0 or value >= 89.0:
raise ValueError("圆锥目标半角必须大于 0 且小于 89 度。")
current_tangent = abs(math.tan(current_angle))
target_tangent = math.tan(math.radians(value))
if current_tangent <= 1e-9 or target_tangent <= 1e-9:
raise ValueError("圆锥半角过小,不能稳定换算参考半径。")
return current_radius * target_tangent / current_tangent
if transform == "arc_length_to_radius":
angular_span = _float_or_none(context.get("angular_span"))
if angular_span is None or angular_span <= 1e-6:
raise ValueError("当前对象缺少稳定圆弧角度,不能把圆弧长度换算为半径。")
return value / angular_span
if transform == "slot_center_distance_to_total_length":
diameter = _float_or_none(context.get("slot_current_diameter"))
if diameter is None or diameter <= 0:
raise ValueError("当前槽孔缺少稳定宽度/直径,不能把中心距换算为总长度。")
if value <= 0:
raise ValueError("槽孔中心距必须大于 0。")
return value + diameter
if transform == "volume_to_bbox_diagonal":
current_volume = _float_or_none(context.get("current_volume"))
current_diagonal = _float_or_none(context.get("current_bbox_diagonal"))
if current_volume is None or current_volume <= 0 or current_diagonal is None or current_diagonal <= 0:
raise ValueError("当前对象缺少稳定体积或整体尺寸,不能换算目标体积。")
if value <= 0:
raise ValueError("目标体积必须大于 0。")
return current_diagonal * ((value / current_volume) ** (1.0 / 3.0))
if transform == "surface_area_to_bbox_diagonal":
current_surface_area = _float_or_none(context.get("current_surface_area"))
current_diagonal = _float_or_none(context.get("current_bbox_diagonal"))
if current_surface_area is None or current_surface_area <= 0 or current_diagonal is None or current_diagonal <= 0:
raise ValueError("当前对象缺少稳定表面积或整体尺寸,不能换算目标表面积。")
if value <= 0:
raise ValueError("目标表面积必须大于 0。")
return current_diagonal * math.sqrt(value / current_surface_area)
if transform in {"circle_edge_radius_to_length", "circle_edge_diameter_to_length"}:
current_length = _float_or_none(context.get("edge_current_length"))
current_radius = _float_or_none(context.get("edge_current_radius"))
if current_length is None or current_length <= 0 or current_radius is None or current_radius <= 0:
raise ValueError("当前圆Edge缺少稳定的长度或半径,不能换算目标长度。")
target_radius = value * 0.5 if transform == "circle_edge_diameter_to_length" else value
if target_radius <= 0:
raise ValueError("圆Edge目标半径必须大于 0。")
return current_length * target_radius / current_radius
if transform in {"ellipse_edge_major_radius_to_length", "ellipse_edge_minor_radius_to_length"}:
current_length = _float_or_none(context.get("edge_current_length"))
radius_key = "edge_current_major_radius" if transform == "ellipse_edge_major_radius_to_length" else "edge_current_minor_radius"
current_radius = _float_or_none(context.get(radius_key))
if current_length is None or current_length <= 0 or current_radius is None or current_radius <= 0:
raise ValueError("当前椭圆Edge缺少稳定的长度或半径,不能换算目标长度。")
if value <= 0:
raise ValueError("椭圆Edge目标半径必须大于 0。")
return current_length * value / current_radius
raise ValueError(f"这个属性使用了未知的目标换算方式:{transform}")
def _transform_property_vector3_target(
self,
spec: dict[str, object],
values: tuple[float, float, float],
) -> tuple[float, float, float]:
transform = str(spec.get("target_transform") or "")
if not transform:
return values
context = spec.get("transform_context")
if not isinstance(context, dict):
context = {}
if transform == "target_center_to_translation":
current_center = _triple_or_none(context.get("current_center")) or _triple_or_none(spec.get("current_raw"))
if current_center is None:
raise ValueError("当前对象缺少稳定中心坐标,不能换算平移量。")
return (
values[0] - current_center[0],
values[1] - current_center[1],
values[2] - current_center[2],
)
raise ValueError(f"这个属性使用了未知的向量换算方式:{transform}")
def _property_choice_value(self, spec: dict[str, object], text: str) -> object:
choices = spec.get("choices")
if not isinstance(choices, dict) or not choices:
raise ValueError("这个属性缺少可用选项。")
normalized = str(text).strip()
if not normalized:
raise ValueError("请输入一个选项。")
if normalized in choices:
return choices[normalized]
lowered = normalized.lower()
for label, value in choices.items():
if str(label).strip().lower() == lowered:
return value
raise ValueError(f"不支持的选项:{text}")
def _property_scalar_range_error(self, spec: dict[str, object], value: float) -> str:
label = str(spec.get("label", "目标值"))
min_value = _float_or_none(spec.get("min_value"))
max_value = _float_or_none(spec.get("max_value"))
if min_value is not None:
min_exclusive = bool(spec.get("min_exclusive"))
if (min_exclusive and value <= min_value) or (not min_exclusive and value < min_value):
operator = "大于" if min_exclusive else "大于或等于"
return f"{label} 必须{operator} {_format_float(min_value)}。"
if max_value is not None:
max_exclusive = bool(spec.get("max_exclusive"))
if (max_exclusive and value >= max_value) or (not max_exclusive and value > max_value):
operator = "小于" if max_exclusive else "小于或等于"
return f"{label} 必须{operator} {_format_float(max_value)}。"
return ""
def _parse_property_number_pair(self, text: str) -> tuple[float, float]:
text = text.strip().strip("()[]")
chunks = text.replace("", ",").replace(";", ",").replace("", ",").replace(" ", ",").split(",")
values = [chunk for chunk in chunks if chunk.strip()]
if len(values) != 2:
raise ValueError("请输入两个数字,例如 1, 2。")
try:
return (float(values[0]), float(values[1]))
except ValueError as exc:
raise ValueError("这两个目标值都必须是数字。") from exc
def _parse_property_vector3(self, text: str) -> tuple[float, float, float]:
text = text.strip().strip("()[]")
chunks = text.replace("", ",").replace(";", ",").replace("", ",").replace(" ", ",").split(",")
values = [chunk for chunk in chunks if chunk.strip()]
if len(values) != 3:
raise ValueError("请输入三个数字,例如 10, 0, 0。")
try:
return (float(values[0]), float(values[1]), float(values[2]))
except ValueError as exc:
raise ValueError("平移 X/Y/Z 必须都是数字。") from exc
def _selected_action_info(self) -> dict[str, object]:
if self.model is None:
return {}
current_info = dict(getattr(self, "current_info_values", {}) or {})
current_face_id = _int_or_none(
current_info.get("feature_source_face_id", current_info.get("topological_face_id", current_info.get("face_id")))
)
if (
self.selected_face_id is not None
and current_face_id == self.selected_face_id
and str(current_info.get("surface", "") or "")
):
return current_info
try:
if self.selected_kind == "feature" and self.selected_face_id is not None:
return self.model.feature_info(self.selected_face_id)
if self.selected_kind == "face" and self.selected_face_id is not None:
info = self.model.face_info(self.selected_face_id)
if info.get("surface") in {"cylinder", "plane"}:
return self.model.feature_info(self.selected_face_id)
return info
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return self.model.edge_info(self.selected_edge_id)
except Exception:
return dict(self.current_info_values)
return dict(self.current_info_values)
def _locate_operation_record(self, record: OperationRecord) -> str:
if self.model is None:
return ""
self._reset_selection(clear_highlight=True)
located = False
locator_note = ""
if record.target_kind in {"face", "feature"} and record.target_id is not None:
resolved_face_id = self._resolve_record_face_id(record)
if resolved_face_id is not None:
info = self.model.feature_info(resolved_face_id) if record.target_kind == "feature" else self.model.face_info(resolved_face_id)
self.selected_kind = "feature" if record.target_kind == "feature" else "face"
self.selected_face_id = resolved_face_id
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
self.selected_pick_position = record.pick_position
self._set_selection_mode("Feature" if record.target_kind == "feature" else "Face")
current_logical_id = self.model.face_region_logical_id(resolved_face_id)
self._sync_id_picker("Feature" if record.target_kind == "feature" else "Face", current_logical_id)
face_ids = (
_int_values(info.get("feature_highlight_face_ids"))
if record.target_kind == "feature"
else self.model.face_region_ids(resolved_face_id)
)
if not face_ids:
face_ids = self.model.face_region_ids(resolved_face_id)
self._highlight_faces(face_ids=face_ids)
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
region_note = f";同域面区域 {len(face_ids)} 个Face" if len(face_ids) > 1 else ""
id_note = (
f"逻辑 Face ID {current_logical_id}"
if record.target_logical_id is not None
else f"Face {record.target_id}"
)
if resolved_face_id != record.target_id:
id_note += f"(当前拓扑Face {resolved_face_id}"
locator_note = (
f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。"
"布尔编辑后Face ID可能发生语义变化,请结合拾取点确认。"
)
else:
if record.target_logical_id is not None:
locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。"
else:
locator_note = f"定位: 原目标Face {record.target_id} 在当前模型索引中已经不存在。"
elif record.target_kind == "edge" and record.target_id is not None:
resolved_edge_id = self._resolve_record_edge_id(record)
if resolved_edge_id is not None:
info = self.model.edge_info(resolved_edge_id)
self.selected_kind = "edge"
self.selected_edge_id = resolved_edge_id
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info.get("solid_id", -1)) if int(info.get("solid_id", -1)) >= 0 else None
self.selected_pick_position = record.pick_position
self._set_selection_mode("Edge")
self._sync_id_picker("Edge", resolved_edge_id)
self._highlight_edge(resolved_edge_id)
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
locator_note = (
f"定位: 已尝试高亮当前模型中的Edge {resolved_edge_id}。"
"布尔/倒圆编辑后Edge ID可能发生语义变化,请结合拾取点确认。"
)
else:
locator_note = f"定位: 原目标Edge {record.target_id} 在当前模型索引中已经不存在。"
elif record.target_kind == "part" and record.target_id is not None:
part_id = int(record.target_id)
if self.model.part_by_id(part_id) is not None:
info = self.model.part_info(part_id)
self.selected_kind = "part"
self.selected_part_id = part_id
self.selected_pick_position = record.pick_position
self._set_selection_mode("Part")
self._sync_id_picker("Part", part_id)
self._highlight_faces(part_ids=[part_id])
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
locator_note = f"定位: 已尝试高亮当前模型中的零件 {part_id}。"
else:
locator_note = f"定位: 原目标零件 {part_id} 在当前模型索引中已经不存在。"
elif record.target_kind == "solid" and record.target_id is not None:
solid_id = int(record.target_id)
if 0 <= solid_id < len(self.model.solids):
info = self.model.solid_info(solid_id)
self.selected_kind = "solid"
self.selected_solid_id = solid_id
self.selected_part_id = int(info["part_id"])
self.selected_pick_position = record.pick_position
face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id]
self._set_selection_mode("Solid")
self._sync_id_picker("Solid", solid_id)
self._highlight_faces(face_ids=face_ids)
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
locator_note = f"定位: 已尝试高亮当前模型中的Solid {solid_id}。"
else:
locator_note = f"定位: 原目标Solid {solid_id} 在当前模型索引中已经不存在。"
if record.pick_position is not None:
self._show_pick_marker(record.pick_position)
if not locator_note:
locator_note = "定位: 已记录当时的拾取点。"
elif located:
locator_note += f"\n拾取点: {_format_value(record.pick_position)}"
else:
locator_note += f"\n已记录当时拾取点: {_format_value(record.pick_position)}"
if not locator_note:
locator_note = "定位: 这条历史记录没有可定位的目标或拾取点。"
self._update_action_states()
self.statusBar().showMessage(locator_note.splitlines()[0])
return locator_note
def _resolve_record_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
verified_face_id = self._record_verified_face_id(record)
if verified_face_id is not None:
return verified_face_id
moved_face_id = self._record_moved_face_id(record)
if moved_face_id is not None:
return moved_face_id
plane_position_face_id = self._record_plane_position_face_id(record)
if plane_position_face_id is not None:
return plane_position_face_id
if not self._record_face_count_changed(record):
stable_face_id = self._record_stable_face_id(record)
if stable_face_id is not None:
return stable_face_id
nearest_face_id = self._record_nearest_semantic_face_id(record)
if nearest_face_id is not None:
return nearest_face_id
stable_face_id = self._record_stable_face_id(record)
if stable_face_id is not None:
return stable_face_id
return None
def _record_verified_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
value = _record_message_field(record.result_message, "verified_face")
if value is None:
return None
try:
face_id = int(value)
except (TypeError, ValueError):
return None
if not (0 <= face_id < len(self.model.faces)):
return None
return face_id if self._record_face_semantic_matches(record, face_id) else None
def _record_moved_face_id(self, record: OperationRecord) -> int | None:
if self.model is None or record.pick_position is None:
return None
moved_point = self._record_moved_pick_position(record)
if moved_point is None:
return None
candidates = self._record_candidate_face_ids(record)
if not candidates:
return None
nearest = self.model.nearest_face_id_to_point(candidates, moved_point)
if nearest is None:
return None
return nearest if self._record_face_semantic_matches(record, nearest) else None
def _record_moved_pick_position(self, record: OperationRecord) -> tuple[float, float, float] | None:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
distance = (
self._record_float_parameter(record, "semantic_distance")
if parameters.get("semantic_distance") not in {"", None}
else self._record_float_parameter(record, "push_pull_distance")
)
direction = (
_triple_or_none(parameters.get("outward_direction"))
or _triple_or_none(parameters.get("push_pull_outward_direction"))
or _triple_or_none(parameters.get("shell_desired_movement_vector"))
)
if distance is None or direction is None:
return None
length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
if length <= 1e-12:
return None
unit = (direction[0] / length, direction[1] / length, direction[2] / length)
px, py, pz = record.pick_position
return (
float(px) + unit[0] * float(distance),
float(py) + unit[1] * float(distance),
float(pz) + unit[2] * float(distance),
)
def _record_nearest_semantic_face_id(self, record: OperationRecord) -> int | None:
if self.model is None or record.pick_position is None:
return None
candidates = self._record_candidate_face_ids(record)
if not candidates:
return None
nearest = self.model.nearest_face_id_to_point(candidates, record.pick_position)
if nearest is None:
return None
return nearest if self._record_face_semantic_matches(record, nearest) else None
def _record_stable_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
if record.target_logical_id is not None:
try:
logical_matches = self.model.face_ids_for_logical_id(int(record.target_logical_id))
except Exception:
logical_matches = []
logical_matches = [
face_id for face_id in logical_matches if self._record_face_semantic_matches(record, face_id)
]
if logical_matches:
return self.model.nearest_face_id_to_point(logical_matches, record.pick_position)
if record.target_id is None:
return None
try:
face_id = int(record.target_id)
except (TypeError, ValueError):
return None
if 0 <= face_id < len(self.model.faces) and self._record_face_semantic_matches(record, face_id):
return face_id
return None
def _record_face_count_changed(self, record: OperationRecord) -> bool:
before = record.before_snapshot if isinstance(record.before_snapshot, dict) else {}
after = record.after_snapshot if isinstance(record.after_snapshot, dict) else {}
before_ids = before.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
after_ids = after.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
if isinstance(before_ids, (list, tuple)) and isinstance(after_ids, (list, tuple)):
return len(before_ids) != len(after_ids)
return True
def _record_face_semantic_matches(self, record: OperationRecord, face_id: int) -> bool:
if self.model is None or not (0 <= int(face_id) < len(self.model.faces)):
return False
part_id = self._record_int_parameter(record, "part_id")
if part_id is not None and self.model.face_part_ids[int(face_id)] != part_id:
return False
surface_hint = self._record_surface_hint(record)
surface_kind = self.model.face_surface_kind(int(face_id))
if surface_hint and surface_kind != surface_hint:
return False
target_diameter = self._record_target_diameter(record)
if target_diameter is not None:
current_diameter = self.model.face_cylinder_diameter(int(face_id))
if current_diameter is None:
return False
diameter_tolerance = max(abs(target_diameter) * 0.02, 1e-5)
if abs(current_diameter - target_diameter) > diameter_tolerance:
return False
return True
def _record_surface_hint(self, record: OperationRecord) -> str:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
surface = str(parameters.get("surface", "") or "")
if surface in {
"plane",
"cylinder",
"cone",
"sphere",
"torus",
"bezier surface",
"b-spline surface",
"surface of revolution",
"surface of extrusion",
"offset surface",
"other surface",
}:
return surface
operation_name = str(record.operation_name or "")
if any(token in operation_name for token in ("孔", "圆柱", "槽", "凸台", "圆角", "倒圆")):
return "cylinder"
if any(token in operation_name for token in ("推拉", "薄壁", "壳体")):
return "plane"
if self._record_target_diameter(record) is not None:
return "cylinder"
return ""
def _record_plane_position_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
target_position = self._record_float_parameter(record, "target_plane_position")
direction = _triple_or_none((record.parameters or {}).get("outward_direction"))
if target_position is None or direction is None:
return None
direction_length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
if direction_length <= 1e-12:
return None
unit = (direction[0] / direction_length, direction[1] / direction_length, direction[2] / direction_length)
tolerance = max((self._record_float_parameter(record, "bbox_diagonal") or 1.0) * 1e-5, 1e-4)
best_face_id: int | None = None
best_error = math.inf
for face_id in self._record_candidate_face_ids(record):
if not (0 <= int(face_id) < len(self.model.faces)):
continue
try:
info = self.model.face_info(int(face_id))
except Exception:
continue
if info.get("surface") != "plane":
continue
normal = _triple_or_none(info.get("normal")) or _triple_or_none(info.get("oriented_normal"))
if normal is not None and abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85:
continue
origin = _triple_or_none(info.get("plane_origin"))
if origin is None:
continue
position = origin[0] * unit[0] + origin[1] * unit[1] + origin[2] * unit[2]
error = abs(position - target_position)
if error < best_error:
best_error = error
best_face_id = int(face_id)
if best_face_id is not None and best_error <= tolerance:
return best_face_id
return None
def _record_target_diameter(self, record: OperationRecord) -> float | None:
return (
self._record_float_parameter(record, "new_diameter")
or self._record_float_parameter(record, "derived_new_diameter")
or self._record_float_parameter(record, "target_diameter")
or self._record_float_parameter(record, "diameter")
)
def _resolve_record_edge_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
if record.pick_position is not None:
nearest = self.model.nearest_edge_id_to_point(self._record_candidate_edge_ids(record), record.pick_position)
if nearest is not None:
return nearest
if record.target_id is None:
return None
try:
edge_id = int(record.target_id)
except (TypeError, ValueError):
return None
return edge_id if 0 <= edge_id < len(self.model.edges) else None
def _record_candidate_face_ids(self, record: OperationRecord) -> list[int]:
if self.model is None:
return []
part_id = self._record_int_parameter(record, "part_id")
solid_id = self._record_int_parameter(record, "solid_id")
face_ids = list(range(len(self.model.faces)))
if part_id is not None:
face_ids = [face_id for face_id in face_ids if self.model.face_part_ids[face_id] == part_id]
if solid_id is not None and solid_id >= 0:
same_solid = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id]
if same_solid:
face_ids = same_solid
surface_hint = self._record_surface_hint(record)
if surface_hint:
surface_face_ids: list[int] = []
for face_id in face_ids:
if self.model.face_surface_kind(face_id) == surface_hint:
surface_face_ids.append(face_id)
if not surface_face_ids:
return []
face_ids = surface_face_ids
target_diameter = self._record_target_diameter(record)
if target_diameter is not None and target_diameter > 0:
diameter_tolerance = max(target_diameter * 0.02, 1e-5)
cylindrical_face_ids: list[int] = []
for face_id in face_ids:
current_diameter = self.model.face_cylinder_diameter(face_id)
if current_diameter is not None:
if abs(current_diameter - target_diameter) <= diameter_tolerance:
cylindrical_face_ids.append(face_id)
if not cylindrical_face_ids:
return []
face_ids = cylindrical_face_ids
return face_ids
def _record_candidate_edge_ids(self, record: OperationRecord) -> list[int]:
if self.model is None:
return []
part_id = self._record_int_parameter(record, "part_id")
solid_id = self._record_int_parameter(record, "solid_id")
edge_ids = list(range(len(self.model.edges)))
if part_id is not None:
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_part_ids[edge_id] == part_id]
if solid_id is not None and solid_id >= 0:
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_solid_ids[edge_id] == solid_id]
return edge_ids or list(range(len(self.model.edges)))
def _record_int_parameter(self, record: OperationRecord, key: str) -> int | None:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
value = parameters.get(key)
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _record_float_parameter(self, record: OperationRecord, key: str) -> float | None:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
return _float_or_none(parameters.get(key))
def undo_edit(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能撤销。"):
return
if not self.undo_stack:
self.statusBar().showMessage("没有可撤销的编辑")
return
current = self.model.snapshot()
snapshot = self.undo_stack[-1]
undone = self.operation_history[-1] if self.operation_history else OperationRecord("编辑", "编辑")
try:
self._restore_snapshot(snapshot)
except Exception as exc:
rollback_message = self._restore_after_failed_undo_redo(current)
QMessageBox.critical(self, "撤销失败", f"{exc}\n\n{rollback_message}")
self.statusBar().showMessage("撤销失败,模型已尽量恢复到撤销前状态")
return
self.undo_stack.pop()
if self.operation_history:
self.operation_history.pop()
self.redo_stack.append(current)
self.redo_history.append(undone)
self._refresh_history_list()
self._update_action_states()
self.statusBar().showMessage(f"已撤销:{undone.summary}")
def redo_edit(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能重做。"):
return
if not self.redo_stack:
self.statusBar().showMessage("没有可重做的编辑")
return
current = self.model.snapshot()
snapshot = self.redo_stack[-1]
redone = self.redo_history[-1] if self.redo_history else OperationRecord("编辑", "编辑")
try:
self._restore_snapshot(snapshot)
except Exception as exc:
rollback_message = self._restore_after_failed_undo_redo(current)
QMessageBox.critical(self, "重做失败", f"{exc}\n\n{rollback_message}")
self.statusBar().showMessage("重做失败,模型已尽量恢复到重做前状态")
return
self.redo_stack.pop()
if self.redo_history:
self.redo_history.pop()
self.undo_stack.append(current)
self.operation_history.append(redone)
self._refresh_history_list()
self._update_action_states()
self.statusBar().showMessage(f"已重做:{redone.summary}")
def _restore_snapshot(self, snapshot: dict[int, object]) -> None:
if self.model is None:
return
self.model.restore_snapshot(snapshot)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene(reset_camera=False)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
stats = self.model.stats()
self.set_info(
{
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
}
)
def _restore_after_failed_undo_redo(self, snapshot: dict[int, object]) -> str:
try:
self._restore_snapshot(snapshot)
except Exception as rollback_exc:
return f"恢复原状态也失败:{rollback_exc}。建议重新加载 STEP 文件。"
return "模型已恢复到操作前状态,历史记录未移动。"