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

3447 lines
174 KiB
Python
Raw Normal View History

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,
2026-07-29 15:43:28 +08:00
QLineEdit,
QMessageBox,
QPushButton,
QTableWidgetItem,
QTreeWidgetItem,
)
from PySide6.QtGui import QColor
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
if clear_info:
self._clear_current_object_info()
if clear_highlight:
self._clear_highlight()
self._update_selected_object_title()
self._update_action_states()
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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,
)
2026-07-29 15:43:28 +08:00
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,
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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。",
)
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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")
2026-07-29 15:43:28 +08:00
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}:
2026-07-29 15:43:28 +08:00
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 "未选择"
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
has_selection = any(
item is not None
for item in (
self.selected_part_id,
self.selected_solid_id,
self.selected_face_id,
self.selected_edge_id,
)
)
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"))
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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))
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
self.property_table.setRowHeight(row, 28 if editable else 24)
if editable:
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项")
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几行。")
else:
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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")))
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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,
2026-07-29 15:43:28 +08:00
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,
2026-07-29 15:43:28 +08:00
"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,
2026-07-29 15:43:28 +08:00
"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:
2026-07-29 15:43:28 +08:00
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",
2026-07-29 15:43:28 +08:00
label="Face本次偏移量",
current_raw=0.0,
target_text="0",
action="push_pull_face",
target_attr="offset_input",
enabled=True,
2026-07-29 15:43:28 +08:00
enabled_tip="输入本次沿当前平面推拉方向的相对偏移距离;执行后几何会更新,下一次本次偏移量会重新从 0 开始。",
disabled_tip="当前对象不是可推拉的平面 Face。",
range_hint=push_pull_hint(),
2026-07-29 15:43:28 +08:00
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"))
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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"))
2026-07-29 15:43:28 +08:00
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"))
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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"))
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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(),
)
2026-07-29 15:43:28 +08:00
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"
2026-07-29 15:43:28 +08:00
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):
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
text = self._property_target_text(row)
if self._property_target_changed(spec, text):
changed.append((row, spec, text))
return changed
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
value_type = str(spec.get("value_type", "number"))
if not text:
2026-07-29 15:43:28 +08:00
if value_type == "integer_or_empty":
return str(spec.get("current_raw", "")).strip() != ""
return False
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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()
2026-07-29 15:43:28 +08:00
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"))
2026-07-29 15:43:28 +08:00
if value_type == "command":
return
if value_type == "vector3":
values = self._parse_property_vector3(text)
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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))
2026-07-29 15:43:28 +08:00
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 ""
2026-07-29 15:43:28 +08:00
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]:
2026-07-29 15:43:28 +08:00
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 {}
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
locator_note = "定位: 已记录当时的拾取点。"
elif located:
locator_note += f"\n拾取点: {_format_value(record.pick_position)}"
else:
2026-07-29 15:43:28 +08:00
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
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
face_id = int(value)
except (TypeError, ValueError):
return None
if not (0 <= face_id < len(self.model.faces)):
return None
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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:
2026-07-29 15:43:28 +08:00
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)
2026-07-29 15:43:28 +08:00
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 "模型已恢复到操作前状态,历史记录未移动。"