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

5301 lines
280 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
from datetime import datetime
import math
from pathlib import Path
from PySide6.QtCore import Qt, QThread, QTimer, Slot
from PySide6.QtWidgets import (
QApplication,
QFileDialog,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidgetItem,
QTreeWidgetItem,
)
from PySide6.QtGui import QColor
from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY
from .model import StepModel
from .records import OperationRecord
from .ui_helpers import * # noqa: F403
from .widgets import NoWheelComboBox
from .workers import EditWorker, LoadWorker, ScanWorker
PROPERTY_VALUE_TOLERANCE = 1e-9
PROPERTY_SCOPE_COLUMN = 2
PROPERTY_TARGET_COLUMN = 3
PROPERTY_ACTION_COLUMN = 4
FEATURE_EDIT_SEMANTICS_KEYS = {
"face_first_level_topology",
"slot_edit_semantics",
"hole_edit_semantics",
"boss_edit_semantics",
"existing_fillet_edit_semantics",
"analytic_surface_edit_semantics",
"face_edit_semantics",
}
def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
"""Return the independent, user-facing dimensions for a feature candidate."""
surface = str(action_info.get("surface", "") or "")
feature_guess = str(action_info.get("feature_guess", "") or "")
angular_span = _float_or_none(action_info.get("angular_span"))
if surface == "plane":
if action_info.get("prismatic_profile_status") == "candidate":
keys = ["local_face_width", "local_face_height"]
if action_info.get("prismatic_extrusion_status") == "candidate":
keys.append("shell_thickness_estimate")
return tuple(keys)
if action_info.get("shell_region_status") == "candidate":
return ("shell_thickness_estimate",)
return ("face_target_normal_position",)
if surface == "cylinder":
is_partial = angular_span is not None and angular_span < math.tau * 0.92
if feature_guess == "hole/groove candidate":
if is_partial:
return (
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_total_length_estimate",
)
return ("diameter", "hole_depth_estimate")
if feature_guess == "boss/outer-round candidate":
return ("boss_diameter", "boss_height")
if feature_guess == "round/fillet candidate":
return ("existing_fillet_radius_estimate",)
return ("generic_cylinder_diameter", "cylinder_height")
if surface == "cone":
return ("cone_reference_radius", "cone_semi_angle_degrees")
if surface == "sphere":
return ("sphere_radius",)
if surface == "torus":
return ("torus_major_radius", "torus_minor_radius")
return ()
def _record_message_field(message: str | None, key: str) -> str | None:
if not message:
return None
marker = f"{key}="
start = str(message).find(marker)
if start < 0:
return None
start += len(marker)
end = str(message).find(",", start)
if end < 0:
end = len(str(message))
value = str(message)[start:end].strip().rstrip(".")
return value or None
def _triple_or_none(value: object) -> tuple[float, float, float] | None:
if isinstance(value, str):
text = value.strip().strip("()[]")
chunks = [chunk.strip() for chunk in text.replace(";", ",").split(",") if chunk.strip()]
elif isinstance(value, (list, tuple)):
chunks = list(value)
else:
return None
if len(chunks) != 3:
return None
try:
return (float(chunks[0]), float(chunks[1]), float(chunks[2]))
except (TypeError, ValueError):
return None
def _int_or_none(value: object) -> int | None:
try:
return int(value)
except (TypeError, ValueError):
return None
class WindowStateMixin:
def _reset_selection(self, clear_highlight: bool = True, clear_info: bool = True) -> None:
self.selected_kind = None
self.selected_part_id = None
self.selected_solid_id = None
self.selected_face_id = None
self.selected_edge_id = None
self.selected_pick_position = None
if clear_info:
self._clear_current_object_info()
if clear_highlight:
self._clear_highlight()
self._update_selected_object_title()
self._update_action_states()
def _clear_current_object_info(self) -> None:
self.current_info_values = {}
self.current_info_text = ""
if hasattr(self, "info_tree"):
self.info_tree.clear()
if hasattr(self, "info_text"):
self.info_text.clear()
self._clear_property_editor()
def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None:
if getattr(self, "pick_marker_actor", None) is not None and hasattr(self, "renderer"):
self.renderer.RemoveActor(self.pick_marker_actor)
self.pick_marker_actor = None
if getattr(self, "render_window", None) is not None:
self.render_window.Render()
def _with_pick_info(
self,
info: dict[str, object],
pick_position: tuple[float, float, float] | None,
) -> dict[str, object]:
if pick_position is None:
return info
enriched = dict(info)
enriched["pick_position"] = pick_position
return enriched
def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
try:
topology = self.model.face_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"same_domain_face_count": 1,
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_count": 0,
"first_level_topology_note": (
"当前 Face 的一级拓扑关系暂时无法确认;只改当前面的局部重建会在计划阶段再次检查。"
),
}
return {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"same_domain_face_ids": topology.get("same_domain_face_ids", (face_id,)),
"same_domain_face_count": topology.get("same_domain_face_count", 1),
"same_domain_region_kind": topology.get("same_domain_region_kind", "single-face"),
"first_level_boundary_edge_ids": topology.get("first_level_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("first_level_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("first_level_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("first_level_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("first_level_adjacent_face_count", 0),
"first_level_face_ids": topology.get("first_level_face_ids", (face_id,)),
"first_level_face_count": topology.get("first_level_face_count", 1),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
}
def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]:
if self.model is None:
return dict(fallback_info)
if "associated_feature_infos" in fallback_info:
return dict(fallback_info)
surface = str(fallback_info.get("surface", "") or "")
if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES:
return dict(fallback_info)
try:
cached_info = self.model.cached_feature_info(face_id)
except Exception:
cached_info = None
if cached_info is not None:
info = cached_info
else:
info = dict(fallback_info)
info.setdefault("feature_source_face_id", face_id)
info.setdefault("feature_highlight_face_ids", (face_id,))
info.setdefault(
"feature_mode",
"快速选择信息;不会在选中时扫描同域面、端盖或底面,点击具体修改时会再计算完整编辑计划。",
)
if surface == "plane":
info.setdefault("feature_type", "可推拉平面候选")
info.setdefault("feature_edit_actions", "推拉平面")
elif surface == "cylinder":
feature_guess = str(info.get("feature_guess", "") or "")
angular_span = _float_or_none(info.get("angular_span"))
if feature_guess == "hole/groove candidate":
if angular_span is not None and angular_span < math.tau * 0.92:
info.setdefault("feature_type", "槽/半孔候选")
info.setdefault("feature_edit_actions", "调整槽/半孔宽度、深度、圆弧参数;完整槽孔配对会在执行时计算")
else:
info.setdefault("feature_type", "圆柱孔/槽候选")
info.setdefault("feature_edit_actions", "调整圆柱孔径;底面/端盖会在执行深度或封堵时计算")
elif feature_guess == "boss/outer-round candidate":
info.setdefault("feature_type", "凸台/外圆候选")
info.setdefault("feature_edit_actions", "调整圆柱凸台直径;高度和端盖会在执行时计算")
elif feature_guess == "round/fillet candidate":
info.setdefault("feature_type", "圆角/倒圆候选")
info.setdefault("feature_edit_actions", "可尝试修改已有圆角半径;支撑面会在执行时计算")
else:
info.setdefault("feature_type", "未明确圆柱特征")
info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认")
elif surface == "cone":
info.setdefault("feature_type", "圆锥面候选")
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径/半角;程序会按几何选择重建或局部重切")
elif surface == "sphere":
info.setdefault("feature_type", "球面候选")
info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)")
elif surface == "torus":
info.setdefault("feature_type", "环面候选")
info.setdefault("feature_edit_actions", "修改环面主半径/小半径(整体缩放)")
for key in (
"same_domain_face_ids",
"same_domain_face_count",
"same_domain_note",
"pick_position",
):
if key in fallback_info:
info.setdefault(key, fallback_info[key])
if surface == "plane":
info.update(self._face_first_level_selection_fields(face_id))
return info
def _feature_context_info(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
detection_level = self._current_feature_detection_level()
if detection_level == "current-only":
root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
else:
root_info = self.model.feature_info(face_id)
if str(root_info.get("surface", "") or "") == "plane":
root_info.update(self._face_first_level_selection_fields(face_id))
associated: list[dict[str, object]] = []
if detection_level in {"associated-only", "secondary"}:
try:
associated = self.model.associated_feature_infos(face_id)
except Exception:
associated = []
if detection_level == "secondary":
associated = self._secondary_associated_feature_infos(face_id, associated)
highlight_ids = set(_int_values(root_info.get("feature_highlight_face_ids")) or [face_id])
for item in associated:
highlight_ids.update(_int_values(item.get("feature_highlight_face_ids")))
source_id = _int_or_none(item.get("association_source_face_id"))
if source_id is not None:
highlight_ids.add(source_id)
level_label = (
"二级特征"
if detection_level == "secondary"
else ("相邻特征" if detection_level == "associated-only" else "当前特征")
)
info = dict(root_info)
info.update(
{
"associated_feature_infos": associated,
"associated_feature_count": len(associated),
"feature_detection_level": level_label,
"associated_feature_face_ids": tuple(
sorted(
{
int(item.get("association_source_face_id", -1))
for item in associated
if _int_or_none(item.get("association_source_face_id")) is not None
}
)
),
"feature_highlight_face_ids": tuple(sorted(highlight_ids)),
"feature_context_note": (
f"已按“{level_label}”沿共享边拓扑探测当前特征及 {len(associated)} 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
if associated
else (
"当前为轻量识别:只读取被点击对象本身,不自动扫描周边拓扑;需要更多关联时可切换到“探测相邻特征”。"
if detection_level == "current-only"
else f"已按“{level_label}”沿共享边拓扑探测局部邻域,未发现额外的可参数化关联特征。"
)
),
}
)
return info
def _current_feature_detection_level(self) -> str:
combo = getattr(self, "feature_detection_combo", None)
if isinstance(combo, NoWheelComboBox):
value = combo.currentData()
if isinstance(value, str) and value:
return value
return str(getattr(self, "feature_detection_level", "current-only") or "current-only")
def _on_feature_detection_level_changed(self) -> None:
self.feature_detection_level = self._current_feature_detection_level()
if self.model is None or self.selected_kind != "feature" or self.selected_face_id is None:
return
try:
info = self._feature_context_info(self.selected_face_id)
except Exception:
return
self.current_info_values = dict(info)
self.current_info_text = "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
self._refresh_property_editor()
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id])
self._update_selected_object_title()
def _secondary_associated_feature_infos(
self,
source_face_id: int,
direct_infos: list[dict[str, object]],
) -> list[dict[str, object]]:
if self.model is None:
return list(direct_infos)
results: list[dict[str, object]] = []
seen: set[tuple[str, tuple[int, ...]]] = set()
def add(info: dict[str, object]) -> None:
source_id = _int_or_none(info.get("association_source_face_id"))
if source_id is None or source_id == source_face_id:
return
face_ids = tuple(sorted(_int_values(info.get("feature_highlight_face_ids")) or [source_id]))
identity = (str(info.get("feature_type", "") or info.get("feature_guess", "")), face_ids)
if identity in seen:
return
seen.add(identity)
results.append(dict(info))
for info in direct_infos:
add(info)
for info in list(results):
parent_id = _int_or_none(info.get("association_source_face_id"))
if parent_id is None:
continue
try:
for nested in self.model.associated_feature_infos(
parent_id,
max_depth=2,
max_scan_faces=48,
max_features=6,
):
add(nested)
except Exception:
continue
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:14]
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,
not busy and bool(getattr(self, "step_path", None)),
"按当前显示路径读取 STEP 模型。",
wait_or_load_tip,
)
if hasattr(self, "quick_export_all_button"):
self._set_control_state(
self.quick_export_all_button,
has_model,
"导出当前完整 STEP 模型。",
wait_or_load_tip,
)
if hasattr(self, "part_tree"):
self._set_control_state(
self.part_tree,
has_model,
"从结构树中选择零件、Solid 或装配节点。",
wait_or_load_tip,
)
if hasattr(self, "mode_combo"):
self._set_control_state(
self.mode_combo,
not busy,
"选择模式决定鼠标点击模型时按什么对象类型选择。",
"请等待当前后台任务完成后再切换选择模式。",
)
if hasattr(self, "id_input"):
self._set_control_state(
self.id_input,
True,
"输入要选中的对象 ID;加载模型后可点击选择按钮跳转。",
"输入要选中的对象 ID;加载模型后可点击选择按钮跳转。",
)
self._set_control_state(
self.select_id_button,
has_model and bool(id_text),
"按当前选择模式跳转到输入的 ID。",
"请先加载模型并输入整数 ID。",
)
if hasattr(self, "export_all_button"):
self._set_control_state(
self.export_all_button,
has_model,
"导出当前完整 STEP 模型。",
wait_or_load_tip,
)
self._set_control_state(
self.export_check_button,
has_model,
"检查当前导出对象的 B-Rep、数量、体积和包围盒等质量信息。",
wait_or_load_tip,
)
self._set_control_state(
self.export_part_button,
has_model and has_part,
"导出当前选中对象所属零件。",
"请先选择零件,或选择属于某个零件的 Solid、Face 或 Edge。",
)
self._set_control_state(
self.export_solid_button,
has_model and has_solid,
"导出当前选中对象所属 Solid。",
"请先选择 Solid,或选择属于某个 Solid 的 Face 或 Edge。",
)
self._set_control_state(
self.export_face_button,
has_model and has_face,
"导出当前选中的 Face 或同域面区域。",
"请先在 Face 或特征模式下选择一个 Face。",
)
self._set_control_state(
self.export_feature_button,
has_model and selected_kind == "feature" and self.selected_face_id is not None,
"导出特征模式识别到的区域。",
"请先切换到特征模式并选择孔、槽、圆角或凸台候选。",
)
self._set_control_state(
self.export_edge_button,
has_model and has_edge,
"导出当前选中的 Edge。",
"请先切换到 Edge 模式并选择一条 Edge。",
)
if hasattr(self, "repair_model_button"):
self._set_control_state(
self.repair_model_button,
has_model,
"对当前完整模型执行 ShapeFix 和同域面/边合并,并写入撤销历史。",
wait_or_load_tip,
)
self._set_control_state(
self.repair_selected_button,
has_model and (has_solid or has_part),
"优先修复当前选中对象所属Solid;没有Solid时修复所属零件,并写入撤销历史。",
"请先选择零件、Solid、Face 或 Edge,或等待当前后台任务完成。",
)
if hasattr(self, "isolate_button"):
self._set_control_state(
self.isolate_button,
has_model and has_selection,
"只显示当前选中的对象或区域。",
"请先加载模型并选择零件、Solid、Face、Edge 或特征。",
)
self._set_control_state(
self.fit_button,
has_model and has_selection,
"把相机对准当前选中对象。",
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.show_all_button,
has_model,
"恢复显示完整模型。",
wait_or_load_tip,
)
self._set_control_state(
self.show_internal_edges_checkbox,
has_model,
"显示或隐藏同域内部拓扑边。",
wait_or_load_tip,
)
if hasattr(self, "set_measure_a_button"):
measure_point_tip = "把当前选中对象的拾取点或中心设为测量点。"
self._set_control_state(
self.set_measure_a_button,
has_model and has_selection,
measure_point_tip,
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.set_measure_b_button,
has_model and has_selection,
measure_point_tip,
"请先加载模型并选择一个对象。",
)
self._set_control_state(
self.copy_measure_button,
has_measure_result,
"复制 A/B 两点距离和 X/Y/Z 差值。",
"请先设置测量点 A 和 B。",
)
self._set_control_state(
self.clear_measure_button,
has_measure_points,
"清除 A/B 测量点和 3D 测量线。",
"当前没有可清除的测量点。",
)
if hasattr(self, "history_list"):
self._set_control_state(
self.history_list,
has_history,
"点击历史记录可查看参数、定位目标并显示差异预览。",
"当前还没有编辑历史。",
)
self._set_control_state(
self.clear_diff_button,
has_diff_preview,
"清除当前显示的差异预览。",
"当前没有正在显示的差异预览。",
)
self._set_control_state(
self.export_diff_button,
has_history_selection,
"导出当前选中历史记录的差异报告。",
"请先在操作历史中选择一条记录。",
)
self._set_control_state(
self.export_history_button,
has_history,
"导出本次会话的完整编辑历史 JSON。",
"当前还没有可导出的编辑历史。",
)
if hasattr(self, "copy_id_button"):
self._set_control_state(
self.copy_id_button,
bool(selected_id_text),
"复制当前选中对象的 ID。",
"请先选择一个对象。",
)
self._set_control_state(
self.copy_pick_button,
has_pick_position,
"复制最近一次鼠标拾取到的三维坐标。",
"当前没有可复制的拾取坐标。",
)
self._set_control_state(
self.copy_info_button,
has_current_info,
"复制当前属性区里的完整文本。",
"当前属性区还没有可复制的信息。",
)
if hasattr(self, "property_table"):
self._set_control_state(
self.property_table,
has_selection and has_current_info,
"查看当前选中对象的属性;可修改的行可以输入目标值。",
"请先选择一个对象。",
)
self._update_edit_action_states(has_model)
def _update_edit_action_states(self, has_model: bool) -> None:
if not hasattr(self, "push_button"):
return
action_info = self._selected_action_info() if has_model else {}
surface = str(action_info.get("surface", ""))
curve = str(action_info.get("curve", ""))
feature_guess = str(action_info.get("feature_guess", ""))
angular_span = _float_or_none(action_info.get("angular_span"))
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
is_plane = has_face and surface == "plane"
is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
is_cone = has_face and surface == "cone" and "reference_radius" in action_info
is_sphere = has_face and surface == "sphere" and "radius" in action_info
is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info
is_generic_surface = has_face and surface in {
"bezier surface",
"b-spline surface",
"surface of revolution",
"surface of extrusion",
"offset surface",
"other surface",
}
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
is_slot_or_half_hole = (
is_hole_or_groove
and angular_span is not None
and angular_span < math.tau * 0.92
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
)
is_blind = action_info.get("cylinder_end_type") == "blind"
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
has_manual_bottom = bool(
hasattr(self, "hole_bottom_face_input")
and self.hole_bottom_face_input.text().strip()
)
has_fillet_support = len(_int_values(action_info.get("feature_existing_fillet_support_face_ids"))) >= 2
is_line_edge = has_edge and curve == "line"
can_push = has_model and is_plane
can_resize_shell = has_model and is_shell_candidate
can_resize_hole = has_model and is_hole_or_groove
can_resize_slot = has_model and is_slot_or_half_hole
can_resize_boss = has_model and is_boss and is_full_cylinder
can_suppress_hole = has_model and is_hole_or_groove and is_full_cylinder
can_resize_depth = has_model and is_hole_or_groove and ((is_blind and has_bottom) or has_manual_bottom)
can_fillet_edge = has_model and is_line_edge
can_resize_existing_fillet = has_model and is_existing_fillet and has_fillet_support
can_chamfer_edge = has_model and is_line_edge
can_resize_edge_length = has_model and has_edge
can_transform_part = has_model and self.selected_part_id is not None
can_transform_solid = has_model and self.selected_solid_id is not None
self._set_control_state(
self.offset_input,
can_push,
"输入平面 Face 的推拉距离。",
"请先选择一个可推拉的平面 Face。",
)
self._set_control_state(
self.push_button,
can_push,
"对当前平面 Face 执行推拉。",
"请先选择一个平面 Face,或在特征模式下选择可推拉平面候选。",
)
if hasattr(self, "resize_shell_thickness_button"):
self._set_control_state(
self.shell_thickness_input,
can_resize_shell,
"输入薄壁/壳体局部区域的目标厚度。",
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
)
self._set_control_state(
self.resize_shell_thickness_button,
can_resize_shell,
"按目标厚度推拉当前薄壁/壳体平面区域。",
"请先选择一个已识别到相对平面的薄壁/壳体平面候选。",
)
self._set_control_state(
self.hole_diameter_input,
can_resize_hole,
"输入圆柱孔/槽的目标直径。",
"请先选择被识别为孔/槽候选的圆柱 Face。",
)
self._set_control_state(
self.resize_button,
can_resize_hole,
"调整当前圆柱孔/槽候选的直径。",
"请先选择被识别为孔/槽候选的圆柱 Face。",
)
if hasattr(self, "resize_slot_button"):
self._set_control_state(
self.slot_width_input,
can_resize_slot,
"输入槽/半孔候选的目标宽度。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.resize_slot_button,
can_resize_slot,
"按目标槽宽调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
if hasattr(self, "resize_slot_depth_button"):
self._set_control_state(
self.slot_depth_input,
can_resize_slot,
"输入槽/半孔候选的目标凹入深度。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.resize_slot_depth_button,
can_resize_slot,
"按目标槽深调整当前槽/半孔候选;当前版本会换算为对应圆柱直径后执行。",
"请先选择被识别为槽/半孔的部分圆柱 Face。",
)
self._set_control_state(
self.boss_diameter_input,
can_resize_boss,
"输入完整圆柱凸台候选的目标直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
)
self._set_control_state(
self.resize_boss_button,
can_resize_boss,
"调整当前完整圆柱凸台候选的直径。",
"请先选择被识别为完整凸台/外圆候选的圆柱 Face。",
)
self._set_control_state(
self.suppress_button,
can_suppress_hole,
"封堵当前完整圆柱孔候选。",
"请先选择接近完整圆柱的孔候选;半孔/槽不会放行。",
)
self._set_control_state(
self.hole_depth_input,
can_resize_hole,
"输入盲孔/盲槽的目标深度。",
"请先选择孔/槽圆柱面。",
)
self._set_control_state(
self.hole_bottom_face_input,
can_resize_hole,
"必要时输入底面 Face ID,用来明确盲孔/盲槽深度。",
"请先选择孔/槽圆柱面。",
)
self._set_control_state(
self.resize_depth_button,
can_resize_depth,
"调整当前盲孔/盲槽深度;自动底面不稳定时可手动填写底面 Face ID。",
"请先选择孔/槽圆柱面;如果没有自动识别到底面,请填写底面 Face ID。",
)
self._set_control_state(
self.edge_fillet_radius_input,
can_fillet_edge or can_resize_existing_fillet,
"输入新圆角或已有圆角的目标半径。",
"请先选择直线Edge,或选择可修改的已有圆角 Face。",
)
self._set_control_state(
self.fillet_edge_button,
can_fillet_edge,
"给当前直线Edge添加新圆角。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.resize_existing_fillet_button,
can_resize_existing_fillet,
"尝试修改当前已有圆角/倒圆候选的半径。",
"请先选择一个已有圆角/倒圆候选 Face;当前版本需要识别到至少两个支撑 Face。",
)
self._set_control_state(
self.edge_chamfer_distance_input,
can_chamfer_edge,
"输入当前直线Edge的倒角距离。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.chamfer_edge_button,
can_chamfer_edge,
"给当前直线Edge添加倒角。",
"请先选择一条直线Edge。",
)
self._set_control_state(
self.edge_target_length_input,
can_resize_edge_length,
"输入当前Edge的目标长度。",
"请先选择一条Edge。",
)
self._set_control_state(
self.edge_length_anchor_combo,
can_resize_edge_length,
"选择修改Edge长度时尽量固定哪一端。",
"请先选择一条Edge。",
)
if hasattr(self, "edge_length_strategy_combo"):
self._set_control_state(
self.edge_length_strategy_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
section_label = "几何对象(高级)" if self.selected_kind in {"face", "edge", "solid", "part"} else "特征参数"
self.object_edit_box.setTitle(f"{section_label}{self._selected_object_title_suffix()}")
def _selected_object_title_suffix(self) -> str:
if self.selected_kind == "part" and self.selected_part_id is not None:
return f"零件 {self.selected_part_id}"
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return f"Solid {self.selected_solid_id}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label() or "特征"
confidence = str(self.current_info_values.get("confidence", "") or "")
confidence_label = {"high": "高", "medium": "中", "low": "低"}.get(confidence, "")
confidence_suffix = f"(置信度:{confidence_label}" if confidence_label else ""
related_count = int(self.current_info_values.get("associated_feature_count", 0) or 0)
related_suffix = f" · 关联 {related_count} 项" if related_count else ""
return f"{feature_label}{confidence_suffix} · 来源 Face {self.selected_face_id}{related_suffix}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label()
if feature_label:
return f"{feature_label} {self.selected_face_id}(来源Face"
return f"特征 {self.selected_face_id}(来源Face"
if self.selected_kind == "face" and self.selected_face_id is not None:
logical_id = self.current_info_values.get("face_region_logical_id")
if logical_id is None:
logical_id = self.current_info_values.get("logical_face_id")
feature_label = self._selected_feature_label()
feature_suffix = f"(识别为{feature_label}" if feature_label else ""
if logical_id not in {None, "", self.selected_face_id}:
return f"Face {logical_id}(拓扑 {self.selected_face_id}{feature_suffix}"
return f"Face {self.selected_face_id}{feature_suffix}"
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return f"Edge {self.selected_edge_id}"
return "未选择"
def _selected_feature_label(self) -> str:
info = dict(getattr(self, "current_info_values", {}) or {})
explicit_type = str(info.get("feature_type", "") or "").strip()
if explicit_type:
return explicit_type
surface = str(info.get("surface", "") or "")
feature_guess = str(info.get("feature_guess", "") or "")
if surface == "cylinder" and feature_guess == "hole/groove candidate":
angular_span = _float_or_none(info.get("angular_span"))
if info.get("slot_kind") == "partial-cylindrical-groove" or (
angular_span is not None and angular_span < math.tau * 0.92
):
return "槽/半孔候选"
return "圆柱孔/槽候选"
if surface == "cylinder" and feature_guess == "boss/outer-round candidate":
return "凸台/外圆候选"
if surface == "cylinder" and feature_guess == "round/fillet candidate":
return "圆角/倒圆候选"
if surface == "plane" and info.get("push_pull_status"):
return "可推拉平面候选"
return ""
def _clear_property_editor(self) -> None:
if not hasattr(self, "property_table"):
return
self.property_editor_specs = []
self.property_table_expanded = False
was_blocked = self.property_table.blockSignals(True)
try:
self.property_table.setRowCount(0)
finally:
self.property_table.blockSignals(was_blocked)
self._resize_property_table_height()
self._update_property_apply_state(False)
def _refresh_property_editor(self) -> None:
if not hasattr(self, "property_table"):
return
has_selection = any(
item is not None
for item in (
self.selected_part_id,
self.selected_solid_id,
self.selected_face_id,
self.selected_edge_id,
)
)
if not has_selection:
self._clear_property_editor()
return
info = dict(getattr(self, "current_info_values", {}) or {})
action_info = self._selected_action_info()
for key, value in action_info.items():
info.setdefault(key, value)
specs = self._sort_property_specs_for_display(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):
effective_spec = self._effective_property_spec(spec)
editable = bool(effective_spec.get("editable") and effective_spec.get("enabled"))
value_type = str(effective_spec.get("value_type", "number"))
input_editable = editable and value_type != "command"
label_item = self._property_table_item(str(effective_spec.get("label", "")), editable=False)
current_item = self._property_table_item(str(effective_spec.get("current_text", "")), editable=False)
scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False)
target_item = self._property_table_item(
"" if input_editable else str(effective_spec.get("target_text", "")),
editable=False,
)
action_text = "" if editable else str(effective_spec.get("status_text", ""))
action_item = self._property_table_item(action_text, editable=False)
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
row_items = (label_item, current_item, scope_item, target_item, action_item)
self._style_property_row_items(row_items, editable=editable)
for column, item in enumerate(row_items):
item.setToolTip(item.toolTip() or item.text())
self.property_table.setItem(row, column, item)
self.property_table.setRowHeight(row, 28 if editable else 24)
if spec.get("scope_modes"):
self._set_property_scope_editor(row, spec)
else:
self.property_table.removeCellWidget(row, PROPERTY_SCOPE_COLUMN)
if editable:
if input_editable:
self._set_property_target_editor(row, effective_spec)
else:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
self._set_property_row_button(row, effective_spec)
else:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
self.property_table.removeCellWidget(row, PROPERTY_ACTION_COLUMN)
self._resize_property_table_height()
finally:
self.property_table.blockSignals(was_blocked)
self.property_editor_updating = False
self._update_property_apply_state()
def _sort_property_specs_for_display(self, specs: list[dict[str, object]]) -> list[dict[str, object]]:
def rank(spec: dict[str, object]) -> int:
editable = bool(spec.get("editable"))
enabled = bool(spec.get("enabled"))
has_action = bool(spec.get("action"))
value_type = str(spec.get("value_type", "number"))
if editable and enabled and has_action and value_type != "command":
return 0
if editable and enabled and has_action:
return 1
if editable:
return 2
if str(spec.get("status_text", "")) == "说明":
return 3
return 4
return [spec for _index, spec in sorted(enumerate(specs), key=lambda item: (rank(item[1]), item[0]))]
def _style_property_row_items(
self,
items: tuple[QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem, QTableWidgetItem],
*,
editable: bool,
) -> None:
label_item, current_item, scope_item, target_item, status_item = items
if editable:
row_backgrounds = ("#fff7ed", "#fffbeb", "#fff7ed", "#fff7ed", "#fff7ed")
for item, color in zip(items, row_backgrounds):
item.setBackground(QColor(color))
label_item.setForeground(QColor("#7c2d12"))
current_item.setForeground(QColor("#431407"))
scope_item.setForeground(QColor("#9a3412"))
target_item.setForeground(QColor("#111827"))
status_item.setForeground(QColor("#9a3412"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
scope_font = scope_item.font()
scope_font.setBold(True)
scope_item.setFont(scope_font)
status_font = status_item.font()
status_font.setBold(True)
status_item.setFont(status_font)
return
scope_item.setForeground(QColor("#64748b"))
target_item.setBackground(QColor("#eef2f6"))
target_item.setForeground(QColor("#8f99a8"))
status_item.setForeground(QColor("#64748b"))
def _property_scope_default(self, spec: dict[str, object]) -> str:
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or not modes:
return ""
preferred = str(spec.get("scope_default") or "")
if preferred in modes and isinstance(modes[preferred], dict) and bool(modes[preferred].get("enabled", True)):
return preferred
for scope_key, mode in modes.items():
if isinstance(mode, dict) and bool(mode.get("enabled", True)):
return str(scope_key)
return str(next(iter(modes)))
def _property_scope_value(self, row: int, spec: dict[str, object]) -> str:
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or not modes:
return ""
widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN) if hasattr(self, "property_table") else None
if isinstance(widget, NoWheelComboBox):
value = widget.currentData()
if value in modes:
return str(value)
return self._property_scope_default(spec)
def _effective_property_spec(self, spec: dict[str, object], row: int | None = None) -> dict[str, object]:
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or not modes:
return dict(spec)
scope_key = self._property_scope_value(row, spec) if row is not None else self._property_scope_default(spec)
mode = modes.get(scope_key)
if not isinstance(mode, dict):
scope_key = self._property_scope_default(spec)
mode = modes.get(scope_key, {})
effective = dict(spec)
base_label = str(spec.get("label", ""))
inherited_enabled = bool(spec.get("enabled"))
effective.update(mode)
effective["label"] = base_label
effective["scope_key"] = scope_key
effective["scope_label"] = str(mode.get("label") or scope_key)
effective["scope_text"] = str(mode.get("label") or scope_key)
effective["enabled"] = inherited_enabled and bool(mode.get("enabled", True))
effective["status_text"] = "可修改" if effective["enabled"] else "不可修改"
return effective
def _property_scope_tooltip(self, spec: dict[str, object], scope_key: str) -> str:
modes = spec.get("scope_modes")
if not isinstance(modes, dict):
return ""
mode = modes.get(scope_key)
if not isinstance(mode, dict):
return ""
label = str(mode.get("label") or scope_key)
tip_key = "enabled_tip" if bool(mode.get("enabled", True)) else "disabled_tip"
tip = str(mode.get(tip_key) or mode.get("enabled_tip") or mode.get("disabled_tip") or "").strip()
if tip:
return f"影响范围:{label}\n\n{tip}"
return f"影响范围:{label}"
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.setPlaceholderText("目标值;清空不会删除模型")
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, PROPERTY_TARGET_COLUMN, editor)
def _set_property_scope_editor(self, row: int, spec: dict[str, object]) -> None:
modes = spec.get("scope_modes")
if not isinstance(modes, dict) or not modes:
self.property_table.removeCellWidget(row, PROPERTY_SCOPE_COLUMN)
return
combo = NoWheelComboBox()
combo.setObjectName("propertyScopeCombo")
default_scope = self._property_scope_default(spec)
selected_index = 0
for index, (scope_key, mode) in enumerate(modes.items()):
if not isinstance(mode, dict):
continue
label = str(mode.get("label") or scope_key)
if not bool(mode.get("enabled", True)):
label = f"{label}(不可用)"
combo.addItem(label, scope_key)
tip = str(mode.get("enabled_tip") or mode.get("disabled_tip") or "").strip()
if tip:
combo.setItemData(index, tip, Qt.ItemDataRole.ToolTipRole)
if scope_key == default_scope:
selected_index = index
combo.setCurrentIndex(selected_index)
combo.setToolTip(self._property_scope_tooltip(spec, default_scope))
combo.currentIndexChanged.connect(lambda _index=0, target_row=row: self._on_property_scope_changed(target_row))
self.property_table.setCellWidget(row, PROPERTY_SCOPE_COLUMN, combo)
def _set_property_row_button(self, row: int, spec: dict[str, object]) -> None:
button = QPushButton("未改动")
button.setObjectName("propertyRowEditButton")
tooltip = f"只应用“{spec.get('label', '当前属性')}”这一行的目标值。"
range_hint = self._property_range_hint(spec)
if range_hint:
tooltip = f"{tooltip}\n\n{range_hint}"
button.setToolTip(tooltip)
button.setCursor(Qt.CursorShape.PointingHandCursor)
button.setProperty("changed", False)
button.clicked.connect(lambda _checked=False, target_row=row: self.apply_property_row_edit(target_row))
self.property_table.setCellWidget(row, PROPERTY_ACTION_COLUMN, button)
def _on_property_scope_changed(self, row: int) -> None:
specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs):
return
effective_spec = self._effective_property_spec(specs[row], row=row)
editable = bool(effective_spec.get("editable") and effective_spec.get("enabled"))
input_editable = editable and str(effective_spec.get("value_type", "number")) != "command"
scope_widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN)
if isinstance(scope_widget, NoWheelComboBox):
scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row])))
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
if isinstance(target_widget, QLineEdit):
target_widget.setEnabled(input_editable)
target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
target_item = self.property_table.item(row, PROPERTY_TARGET_COLUMN)
if target_item is not None:
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
action_item = self.property_table.item(row, PROPERTY_ACTION_COLUMN)
if action_item is not None and not editable:
action_item.setText(str(effective_spec.get("status_text", "")))
self._update_property_apply_state()
def _property_target_tooltip(self, spec: dict[str, object], *, editable: bool) -> str:
tip_key = "enabled_tip" if editable else "disabled_tip"
parts = [str(spec.get(tip_key, "")).strip()]
if editable:
parts.append(self._property_range_hint(spec))
return "\n\n".join(part for part in parts if part)
def _property_range_hint(self, spec: dict[str, object]) -> str:
parts: list[str] = []
hard_range = self._property_hard_range_text(spec)
if hard_range:
parts.append(f"有效范围:{hard_range}")
range_hint = str(spec.get("range_hint", "")).strip()
if range_hint:
parts.append(f"建议范围:{range_hint}")
return "\n".join(parts)
def _property_hard_range_text(self, spec: dict[str, object]) -> str:
chunks: list[str] = []
min_value = _float_or_none(spec.get("min_value"))
max_value = _float_or_none(spec.get("max_value"))
if min_value is not None:
operator = ">" if spec.get("min_exclusive") else ">="
chunks.append(f"{operator} {_format_float(min_value)}")
if max_value is not None:
operator = "<" if spec.get("max_exclusive") else "<="
chunks.append(f"{operator} {_format_float(max_value)}")
return " 且 ".join(chunks)
def _resize_property_table_height(self) -> None:
if not hasattr(self, "property_table"):
return
row_count = self.property_table.rowCount()
collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 6) or 6)
expanded = bool(getattr(self, "property_table_expanded", False))
visible_rows = row_count if expanded else min(row_count, collapsed_rows)
visible_rows = max(1, visible_rows)
row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22)
header_height = int(self.property_table.horizontalHeader().height())
frame = int(self.property_table.frameWidth()) * 2
height = header_height + frame + visible_rows * row_height + 8
self.property_table.setMinimumHeight(height)
self.property_table.setMaximumHeight(height)
has_hidden_rows = row_count > collapsed_rows
self.property_table.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
if expanded or not has_hidden_rows
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
)
if hasattr(self, "property_expand_button"):
self.property_expand_button.setVisible(has_hidden_rows)
if expanded:
self.property_expand_button.setText(f"收起到前 {collapsed_rows} 项")
self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几项。")
else:
self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)")
self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。")
def toggle_property_table_expanded(self) -> None:
if not hasattr(self, "property_table"):
return
self.property_table_expanded = not bool(getattr(self, "property_table_expanded", False))
self._resize_property_table_height()
def _property_table_item(self, text: str, *, editable: bool) -> QTableWidgetItem:
item = QTableWidgetItem(text)
flags = Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled
if editable:
flags |= Qt.ItemFlag.ItemIsEditable
item.setFlags(flags)
return item
def _property_editor_specs(
self,
info: dict[str, object],
action_info: dict[str, object],
) -> list[dict[str, object]]:
editable_specs, used_keys = self._editable_property_specs(action_info)
if self.selected_kind == "feature":
return self._feature_context_property_specs(editable_specs, action_info)
specs = list(editable_specs)
for key, value in self._ordered_property_info_items(info):
if key in used_keys:
continue
specs.append(
{
"key": key,
"label": INFO_LABELS.get(key, key),
"current_text": _format_info_value(key, value),
"current_raw": value,
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "只读",
"disabled_tip": "这个属性来自当前 STEP/B-Rep 几何或识别结果,当前版本只能查看,不能直接修改。",
}
)
if not specs:
specs.append(
{
"key": "empty",
"label": "当前对象",
"current_text": "尚未选择",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "只读",
"disabled_tip": "请先在模型中选择零件、Solid、Face、Edge 或特征。",
}
)
return specs
def _feature_property_specs(
self,
specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
allowed_keys = _feature_dimension_keys(action_info)
spec_by_key = {str(spec.get("key", "")): spec for spec in specs}
dimensions: list[dict[str, object]] = []
for key in allowed_keys:
spec = spec_by_key.get(key)
if spec is None or not bool(spec.get("editable")) or not bool(spec.get("enabled")):
continue
dimension = dict(spec)
dimension["parameter_role"] = "dimension"
if action_info.get("prismatic_profile_status") == "candidate":
label_overrides = {
"local_face_width": "长度",
"local_face_height": "宽度",
"shell_thickness_estimate": "高度/深度",
}
if key in label_overrides:
dimension["label"] = label_overrides[key]
dimensions.append(dimension)
explanations = [
dict(spec)
for spec in specs
if str(spec.get("key", "")) in FEATURE_EDIT_SEMANTICS_KEYS
]
if not dimensions:
dimensions.append(
{
"key": "no_editable_feature_dimensions",
"label": "可变尺寸",
"current_text": "未识别到可靠的独立尺寸",
"current_raw": "",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "说明",
"disabled_tip": (
"当前几何仍可在诊断信息中查看,但不会把面积、中心、包围盒或底层曲面参数"
"伪装成特征设计尺寸。"
),
}
)
return dimensions + explanations
def _feature_context_property_specs(
self,
root_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
root_rows = self._feature_property_specs(root_specs, action_info)
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_rows
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"]
related_rows: list[dict[str, object]] = []
for index, related_info in enumerate(associated, start=1):
if not isinstance(related_info, dict):
continue
related_specs, _used = self._editable_property_specs(related_info)
feature_label = str(related_info.get("feature_type") or related_info.get("feature_guess") or "关联特征")
source_face_id = _int_or_none(related_info.get("association_source_face_id"))
for spec in self._feature_property_specs(related_specs, related_info):
if spec.get("parameter_role") != "dimension":
continue
related = dict(spec)
related["label"] = f"{feature_label} · {spec.get('label', '')}"
related["scope_text"] = f"关联 Face {source_face_id}" if source_face_id is not None else f"关联特征 {index}"
related["source_face_id"] = source_face_id
related["source_feature_info"] = dict(related_info)
related["association_index"] = index
related_rows.append(related)
return root_dimensions + related_rows + root_explanations
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info)
if not self._is_feature_like_info(info):
return items
item_by_key = {key: value for key, value in items}
priority_keys = [
"feature_type",
"feature_edit_actions",
"feature_mode",
"feature_guess",
"confidence",
"slot_status",
"slot_kind",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_arc_length_estimate",
"slot_angular_span",
"slot_open_angle",
"slot_note",
"diameter",
"radius",
"height_estimate",
"same_domain_height_estimate",
"hole_depth_estimate",
"cylinder_end_type",
"feature_bottom_face_ids",
"feature_bottom_confidence",
"feature_bottom_note",
"feature_source_face_id",
"feature_face_ids",
"feature_side_face_ids",
"feature_end_face_ids",
"feature_slot_face_ids",
"feature_slot_boundary_face_ids",
"surface",
"axis_point",
"axis",
]
ordered: list[tuple[str, object]] = []
emitted: set[str] = set()
for key in priority_keys:
if key in item_by_key and key not in emitted:
ordered.append((key, item_by_key[key]))
emitted.add(key)
ordered.extend((key, value) for key, value in items if key not in emitted)
return ordered
def _is_feature_like_info(self, info: dict[str, object]) -> bool:
if str(info.get("kind", "") or "") == "feature":
return True
if str(info.get("feature_type", "") or ""):
return True
feature_guess = str(info.get("feature_guess", "") or "")
return feature_guess in {
"hole/groove candidate",
"boss/outer-round candidate",
"round/fillet candidate",
}
def _ordered_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
ordered: list[tuple[str, object]] = []
emitted: set[str] = set()
for _group_name, keys in INFO_GROUPS:
for key in keys:
if key in info and key not in emitted:
ordered.append((key, info[key]))
emitted.add(key)
for key in sorted(key for key in info if key not in emitted):
ordered.append((key, info[key]))
return ordered
def _editable_property_specs(self, action_info: dict[str, object]) -> tuple[list[dict[str, object]], set[str]]:
has_model = self.model is not None and not (self.operation_in_progress or self.scan_in_progress or self.load_in_progress)
surface = str(action_info.get("surface", ""))
curve = str(action_info.get("curve", ""))
feature_guess = str(action_info.get("feature_guess", ""))
angular_span = _float_or_none(action_info.get("angular_span"))
has_face = self.selected_face_id is not None and self.selected_kind in {"face", "feature"}
has_edge = self.selected_edge_id is not None and self.selected_kind == "edge"
is_plane = has_face and surface == "plane"
is_shell_candidate = is_plane and action_info.get("shell_region_status") == "candidate"
is_cylinder = has_face and surface == "cylinder" and "diameter" in action_info
is_cone = has_face and surface == "cone" and "reference_radius" in action_info
is_sphere = has_face and surface == "sphere" and "radius" in action_info
is_torus = has_face and surface == "torus" and "major_radius" in action_info and "minor_radius" in action_info
is_generic_surface = has_face and surface in {
"bezier surface",
"b-spline surface",
"surface of revolution",
"surface of extrusion",
"offset surface",
"other surface",
}
is_hole_or_groove = is_cylinder and feature_guess == "hole/groove candidate"
is_boss = is_cylinder and feature_guess == "boss/outer-round candidate"
is_existing_fillet = is_cylinder and feature_guess == "round/fillet candidate"
is_full_cylinder = angular_span is not None and angular_span >= math.tau * 0.92
is_slot_or_half_hole = (
is_hole_or_groove
and angular_span is not None
and angular_span < math.tau * 0.92
and _float_or_none(action_info.get("slot_chord_width_estimate")) is not None
)
is_blind = action_info.get("cylinder_end_type") == "blind"
has_bottom = bool(_int_values(action_info.get("feature_bottom_face_ids")))
has_boss_height_cap = bool(
_int_values(action_info.get("feature_start_end_face_ids"))
or _int_values(action_info.get("feature_end_end_face_ids"))
)
show_generic_face_edit_specs = bool(has_face and (is_plane or is_shell_candidate))
local_face_deform_ready = bool(action_info.get("local_face_deform_ready", True))
local_face_deform_blocker = str(action_info.get("local_face_deform_blocker") or "").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"
specs: list[dict[str, object]] = []
used_keys: set[str] = set()
def numeric_text(value: object, fallback: str = "") -> str:
number = _float_or_none(value)
return _format_float(number) if number is not None else fallback
def vector_text(value: tuple[float, float, float] | None) -> str:
if value is None:
return ""
return ", ".join(_format_float(item) for item in value)
def relative_range_hint(
current: object,
caution_ratio: float,
high_ratio: float,
hard_limits: str = "",
) -> str:
number = _float_or_none(current)
if number is None or number <= 0:
base = "建议先小幅试改并查看预览,过大变化可能导致布尔失败或周边变形。"
return f"{base} {hard_limits}".strip()
lower = max(number * (1.0 - caution_ratio), 0.0)
upper = number * (1.0 + caution_ratio)
base = (
f"建议先在 {_format_float(lower)} - {_format_float(upper)} 内试改"
f"(相对当前值约 +/-{caution_ratio * 100.0:.0f}%);"
f"变化超过 {high_ratio * 100.0:.0f}% 时风险较高。"
)
return f"{base} {hard_limits}".strip()
def positive_minimum() -> dict[str, object]:
return {"min_value": 0.0, "min_exclusive": True}
def face_scale_limits(current: object, *, squared: bool = False) -> dict[str, object]:
number = _float_or_none(current)
if number is None or number <= 0:
return positive_minimum()
power = 2 if squared else 1
return {
"min_value": number * (0.05 ** power),
"max_value": number * (5.0 ** power),
}
def face_offset_target_limits(current_position: object) -> dict[str, object]:
position = _float_or_none(current_position)
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if position is None or diagonal is None or diagonal <= 0:
return {}
span = diagonal * 5.0
return {"min_value": position - span, "max_value": position + span}
def face_vector_move_limit(reference: object) -> dict[str, object]:
center = _triple_or_none(reference)
diagonal = _float_or_none(action_info.get("bbox_diagonal"))
if center is None or diagonal is None or diagonal <= 0:
return {}
return {
"vector_distance_reference": center,
"max_vector_distance": diagonal * 5.0,
"vector_distance_label": "中心移动距离",
}
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)} 时风险较高;"
f"超过 {_format_float(diagonal * 5.0)} 会被阻止。"
)
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;建议先小幅试改并查看预览。"
face_linear_hard_limit = "目标值低于当前值的 5% 或高于当前值的 5 倍会被阻止。"
face_area_hard_limit = "如果目标面积会让面宽/面高缩到当前 5% 以下或放大到 5 倍以上,会被阻止。"
face_offset_hard_limit = "如果目标位置与当前位置差距远超当前模型尺寸,会被阻止。"
face_center_hard_limit = "如果目标中心与当前中心距离超过所属对象尺寸的 5 倍,会被阻止。"
def hole_diameter_upper_limit(current: object) -> float | None:
current_number = _float_or_none(current)
height = _float_or_none(action_info.get("height_estimate"))
limits: list[float] = []
if current_number is not None and current_number > 0:
limits.append(current_number * 2.0)
if height is not None and height > 0:
limits.append(height * 2.0)
return min(limits) if limits else None
def hole_diameter_hint(current: object) -> str:
hint = relative_range_hint(current, 0.35, 1.0)
upper = hole_diameter_upper_limit(current)
if upper is not None:
hint = (
f"{hint} 当前版本会阻止超过 {_format_float(upper)} 的目标直径,"
"避免布尔返回成功但孔壁没有真正变成目标直径。"
)
return hint
def hole_radius_hint(current_radius: object, current_diameter: object) -> str:
hint = relative_range_hint(current_radius, 0.35, 1.0)
upper = hole_diameter_upper_limit(current_diameter)
if upper is not None:
hint = (
f"{hint} 当前版本会阻止超过 {_format_float(upper * 0.5)} 的目标半径,"
"避免布尔返回成功但孔壁没有真正变成目标半径。"
)
return hint
def cylinder_owning_scale_hint(current_diameter: object) -> str:
hint = relative_range_hint(current_diameter, 0.2, 0.5)
return (
f"{hint} 这是另一种修改语义:按目标直径比例均匀缩放所属特征或 Solid,"
"高度、厚度和其它尺寸会同比例变化;如果只想改孔壁/槽壁,请使用普通直径行。"
)
def cylinder_owning_scale_radius_hint(current_radius: object) -> str:
hint = relative_range_hint(current_radius, 0.2, 0.5)
return (
f"{hint} 这是另一种修改语义:按目标半径换算成直径比例后均匀缩放所属特征或 Solid,"
"高度、厚度和其它尺寸会同比例变化;如果只想改孔壁/槽壁,请使用普通半径行。"
)
def face_local_disabled_tip(base: str) -> str:
if local_face_deform_ready or not local_face_deform_blocker:
return base
return f"{base} 当前不能只改当前面的原因:{local_face_deform_blocker}"
def cone_semi_angle_capability(current_angle_degrees: float | None) -> tuple[bool, str]:
if current_angle_degrees is None:
return True, ""
if not (
has_model
and is_cone
and self.selected_face_id is not None
and hasattr(self.model, "conical_semi_angle_plan")
):
return True, ""
delta = max(1.0, min(5.0, abs(current_angle_degrees) * 0.25))
probe_target = current_angle_degrees + delta
if probe_target >= 89.0:
probe_target = max(0.1, current_angle_degrees - delta)
if abs(probe_target - current_angle_degrees) <= 1e-7:
return False, "当前圆锥半角太接近允许范围边界,不能稳定探测可编辑性。"
try:
plan = self.model.conical_semi_angle_plan(int(self.selected_face_id), probe_target)
except Exception as exc:
return False, f"无法确认当前圆锥半角是否可稳定修改:{exc}"
strategy = str(plan.get("resize_strategy") or "")
if strategy.startswith("analytic-cone-rebuild") or strategy.startswith("bounded-cone-recut"):
return True, ""
if strategy.startswith("blocked-complex-cone-semi-angle") or strategy == "radial-affine-scale-cone-semi-angle":
message = str(plan.get("message") or "").strip()
if message:
return False, message
return (
False,
"当前 Face 是圆锥面/拔模面,但不是简单圆锥,也不是可识别的锥孔/沉孔;"
"当前版本不把它作为稳定的圆锥半角参数开放。",
)
if str(plan.get("status") or "") == "blocked":
return False, str(plan.get("message") or "当前圆锥半角不能稳定修改。")
return True, ""
def cone_reference_radius_capability(current_radius: float | None) -> tuple[bool, str]:
if current_radius is None or current_radius <= 0:
return True, ""
if not (
has_model
and is_cone
and self.selected_face_id is not None
and hasattr(self.model, "conical_reference_radius_plan")
):
return True, ""
probe_target = current_radius * 1.05
try:
plan = self.model.conical_reference_radius_plan(int(self.selected_face_id), probe_target)
except Exception as exc:
return False, f"无法确认当前圆锥参考半径是否可稳定修改:{exc}"
strategy = str(plan.get("resize_strategy") or "")
if strategy.startswith("analytic-cone-rebuild") or strategy.startswith("bounded-cone-recut"):
return True, ""
if strategy.startswith("blocked-complex-cone-reference-radius") or strategy.startswith(
"blocked-cone-reference-radius"
) or strategy == "radial-affine-scale-cone-reference-radius":
message = str(plan.get("message") or "").strip()
if message:
return False, message
return (
False,
"当前 Face 是圆锥面/拔模面,但不是简单圆锥,也不是可识别的锥孔/沉孔;"
"当前版本不把它作为稳定的参考半径/直径参数开放。",
)
if str(plan.get("status") or "") == "blocked":
return False, str(plan.get("message") or "当前圆锥参考半径不能稳定修改。")
return True, ""
def add_spec(
*,
key: str,
label: str,
current_raw: object,
target_text: str,
action: str,
target_attr: str | None = None,
target_attrs: tuple[str, ...] | None = None,
enabled: bool,
enabled_tip: str,
disabled_tip: str,
value_type: str = "number",
used: tuple[str, ...] = (),
range_hint: str = "",
min_value: float | None = None,
max_value: float | None = None,
min_exclusive: bool = False,
max_exclusive: bool = False,
target_transform: str | None = None,
transform_context: dict[str, object] | None = None,
current_text: str | None = None,
button_text: str | None = None,
choices: dict[str, object] | None = None,
positive_pair: bool = False,
) -> None:
specs.append(
{
"key": key,
"label": label,
"current_text": _format_value(current_raw) if current_text is None else current_text,
"current_raw": current_raw,
"target_text": target_text,
"action": action,
"target_attr": target_attr,
"target_attrs": target_attrs,
"editable": True,
"enabled": has_model and enabled,
"status_text": "可修改" if has_model and enabled else "不可修改",
"enabled_tip": enabled_tip,
"disabled_tip": disabled_tip,
"value_type": value_type,
"range_hint": range_hint,
"min_value": min_value,
"max_value": max_value,
"min_exclusive": min_exclusive,
"max_exclusive": max_exclusive,
"target_transform": target_transform,
"transform_context": transform_context or {},
"button_text": button_text or "",
"choices": choices or {},
"positive_pair": positive_pair,
}
)
used_keys.update(used or (key,))
def add_scoped_spec(
*,
key: str,
label: str,
current_raw: object,
target_text: str,
scope_modes: dict[str, dict[str, object]],
scope_default: str,
value_type: str = "number",
used: tuple[str, ...] = (),
min_value: float | None = None,
max_value: float | None = None,
min_exclusive: bool = False,
max_exclusive: bool = False,
current_text: str | None = None,
) -> None:
if not scope_modes:
return
preferred = scope_default if scope_default in scope_modes else next(iter(scope_modes))
if not bool(scope_modes.get(preferred, {}).get("enabled", True)):
for mode_key, mode in scope_modes.items():
if bool(mode.get("enabled", True)):
preferred = mode_key
break
selected_mode = scope_modes.get(preferred, {})
any_enabled = any(bool(mode.get("enabled", True)) for mode in scope_modes.values())
add_spec(
key=key,
label=label,
current_raw=current_raw,
target_text=target_text,
action=str(selected_mode.get("action", "")),
target_attr=selected_mode.get("target_attr"),
target_attrs=selected_mode.get("target_attrs"),
enabled=any_enabled,
enabled_tip=str(selected_mode.get("enabled_tip", "")),
disabled_tip=str(selected_mode.get("disabled_tip", "")),
value_type=value_type,
used=used,
range_hint=str(selected_mode.get("range_hint", "")),
min_value=min_value,
max_value=max_value,
min_exclusive=min_exclusive,
max_exclusive=max_exclusive,
target_transform=selected_mode.get("target_transform"),
transform_context=selected_mode.get("transform_context"),
current_text=current_text,
)
specs[-1]["scope_modes"] = scope_modes
specs[-1]["scope_default"] = preferred
specs[-1]["scope_text"] = str(selected_mode.get("label", ""))
def add_readonly_spec(
*,
key: str,
label: str,
text: str,
tip: str,
) -> None:
specs.append(
{
"key": key,
"label": label,
"current_text": text,
"current_raw": text,
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "说明",
"disabled_tip": tip or text,
}
)
if has_face:
if is_hole_or_groove:
if is_slot_or_half_hole:
add_readonly_spec(
key="slot_edit_semantics",
label="编辑方式",
text="槽/半孔:局部扇形槽重建;槽孔总长度会尝试配对另一端。",
tip=(
"槽/半孔宽度、深度和圆弧参数会换算成圆柱半径或圆弧角度,"
"然后填旧槽、切新槽;槽孔总长度会优先按长圆槽两端整体重建。"
"轴心坐标有两种语义:局部重建槽/半孔,或平移所属对象。"
),
)
else:
add_readonly_spec(
key="hole_edit_semantics",
label="编辑方式",
text="孔:同轴重切孔壁;移动轴心会先填旧孔再切新孔。",
tip=(
"孔径/半径不是缩放整个模型,而是在当前孔轴线上做受限布尔重切;"
"轴心选择“移动孔”时会填补旧孔,再按同直径切出新孔;选择“移动整个特征”时"
"会移动整个所属对象;盲孔/盲槽深度会按底面方向切削或补料。"
),
)
elif is_boss:
add_readonly_spec(
key="boss_edit_semantics",
label="编辑方式",
text="凸台:直径重建包络;高度推拉端盖;轴心先移除再补新凸台。",
tip=(
"凸台直径会通过局部布尔补料或移除旧包络后重建目标圆柱;"
"凸台高度会推拉识别到的端盖 Face;凸台轴心会移除旧凸台包络后在目标轴心补出同直径凸台;"
"带“平移所属对象”的轴心坐标会移动整个所属对象。"
),
)
elif is_existing_fillet:
add_readonly_spec(
key="existing_fillet_edit_semantics",
label="编辑方式",
text="圆角:移除已有圆角面,再尝试按目标半径重新倒圆。",
tip=(
"已有圆角修改不是恢复 CAD 历史参数;当前会先 defeature 当前圆角面,"
"再寻找可重新倒圆的锐边。复杂 blend 或支撑面不稳定时会阻止或回滚。"
),
)
elif is_cone or is_sphere or is_torus:
add_readonly_spec(
key="analytic_surface_edit_semantics",
label="编辑方式",
text="解析曲面:围绕中心或轴线缩放所属对象,会影响同对象其它尺寸。",
tip=(
"圆锥、球面、环面这类曲面当前走几何缩放语义,"
"不是只替换单个曲面的历史参数;执行前需要确认影响范围。"
),
)
elif is_plane or is_shell_candidate:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
if topology_depth == 1:
same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0
boundary_edge_count = _int_or_none(action_info.get("first_level_boundary_edge_count")) or 0
boundary_vertex_count = _int_or_none(action_info.get("first_level_boundary_vertex_count")) or 0
adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
topology_tip = "\n".join(item for item in (topology_note, ignored_note) if item) or (
"当前阶段只处理当前 Face、同域碎片、边界 Edge/Vertex 和共享边相邻 Face;二级、三级关系暂不自动传播。"
)
add_readonly_spec(
key="face_first_level_topology",
label="一级关系",
text=(
f"Face 区域 {same_domain_count} 个;边界 Edge {boundary_edge_count} 条;"
f"边界 Vertex {boundary_vertex_count} 个;共享边相邻 Face {adjacent_face_count} 个。"
),
tip=topology_tip,
)
face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。"
face_semantics_tip = (
"面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;"
"面偏移是沿当前面垂直方向测到的位置。影响范围决定这次修改只作用在当前 Face,"
"还是推拉加料/切削,或带动所属特征或 Solid。"
)
if is_plane and not local_face_deform_ready:
face_semantics_text = "Face:当前面暂不能只改当前面,可用推拉或整体策略。"
face_semantics_tip = (
"此 Face 不适合做“只改当前面”的局部顶点重建;"
f"原因:{local_face_deform_blocker or '当前拓扑不满足局部重建条件。'}"
"可优先使用“推拉当前面”、孔/槽专门入口,或选择移动/调整整个特征。"
)
add_readonly_spec(
key="face_edit_semantics",
label="编辑方式",
text=face_semantics_text,
tip=face_semantics_tip,
)
if has_edge:
add_readonly_spec(
key="edge_edit_semantics",
label="编辑方式",
text="Edge:长度修改可选择局部形变、移动端面或缩放所属对象。",
tip=(
"只变当前Edge会重建局部相邻面;移动端面更像整体尺寸变化;"
"缩放所属会影响同一特征或 Solid 上的其它尺寸。"
),
)
if show_generic_face_edit_specs:
current_face_area = _float_or_none(action_info.get("area"))
current_face_center = (
_triple_or_none(action_info.get("area_center"))
or _triple_or_none(action_info.get("bbox_center"))
)
add_scoped_spec(
key="area",
label="面积",
current_raw=current_face_area if current_face_area is not None else "",
target_text=numeric_text(current_face_area),
scope_default="local",
scope_modes={
"local": {
"label": "只改当前面",
"action": "resize_face_area_local",
"target_attr": "face_area_input",
"enabled": bool(
is_plane
and local_face_deform_ready
and current_face_area is not None
and current_face_area > 0
and current_face_center is not None
),
"enabled_tip": (
"输入目标面积;程序只在当前 Face 平面内缩放这个面的顶点,"
"相邻面会按新边界重建。"
),
"disabled_tip": face_local_disabled_tip(
"只有带稳定面积和中心坐标的平面 Face 才能尝试只修改当前面面积。"
),
"range_hint": (
"只改当前面时,相邻面可能自然变斜。当前只对简单全平面实体开放,"
f"建议先小幅修改。 {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}"
),
},
"owning": {
"label": "调整整个特征",
"action": "resize_face_area",
"target_attr": "face_area_input",
"enabled": current_face_area is not None and current_face_area > 0,
"enabled_tip": (
"输入目标面积;程序会用这个面作为参考,均匀缩放它所属的特征或 Solid,"
"让该面的面积接近目标值。"
),
"disabled_tip": "当前 Face 缺少稳定面积,不能按目标面积缩放所属对象。",
"range_hint": (
"调整整个特征会影响同一对象上的其它尺寸。"
f" {relative_range_hint(current_face_area, 0.15, 0.8, face_area_hard_limit)}"
),
},
},
value_type="positive",
used=("area", "area_center", "bbox_center"),
**face_scale_limits(current_face_area, squared=True),
)
if is_plane:
current_face_width = _float_or_none(action_info.get("local_face_width"))
current_face_height = _float_or_none(action_info.get("local_face_height"))
face_size_tip = (
"这里的面宽/面高是选中 Face 在自身平面内两个稳定方向上的投影长度,"
"不是面积,也不是模型整体高度。只修改当前 Face 顶点,相邻面会按新顶点重建。"
)
face_size_owner_tip = (
"这里的面宽/面高是选中 Face 在自身平面内两个稳定方向上的投影长度,"
"不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
)
add_scoped_spec(
key="local_face_width",
label="面宽",
current_raw=current_face_width if current_face_width is not None else "",
target_text=numeric_text(current_face_width),
scope_default="local",
scope_modes={
"local": {
"label": "只改当前面",
"action": "resize_face_width_local",
"target_attr": "face_width_input",
"enabled": bool(
local_face_deform_ready
and current_face_width is not None
and current_face_width > 0
and current_face_center is not None
),
"enabled_tip": f"输入目标面宽;{face_size_tip}",
"disabled_tip": face_local_disabled_tip(
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面宽。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
},
"owning": {
"label": "调整整个特征",
"action": "resize_face_width_owning_scale",
"target_attr": "face_width_input",
"enabled": bool(
current_face_width is not None
and current_face_width > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": f"输入目标面宽;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面宽、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_width, 0.2, 0.5, face_linear_hard_limit)}",
},
},
value_type="positive",
used=("local_face_width", "local_face_width_direction", "local_face_size_center"),
**face_scale_limits(current_face_width),
)
add_scoped_spec(
key="local_face_height",
label="面高",
current_raw=current_face_height if current_face_height is not None else "",
target_text=numeric_text(current_face_height),
scope_default="local",
scope_modes={
"local": {
"label": "只改当前面",
"action": "resize_face_height_local",
"target_attr": "face_height_input",
"enabled": bool(
local_face_deform_ready
and current_face_height is not None
and current_face_height > 0
and current_face_center is not None
),
"enabled_tip": f"输入目标面高;{face_size_tip}",
"disabled_tip": face_local_disabled_tip(
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改当前面高。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
},
"owning": {
"label": "调整整个特征",
"action": "resize_face_height_owning_scale",
"target_attr": "face_height_input",
"enabled": bool(
current_face_height is not None
and current_face_height > 0
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": f"输入目标面高;{face_size_owner_tip}",
"disabled_tip": "当前 Face 缺少稳定面高、中心坐标或所属对象,不能按这个方向缩放所属对象。",
"range_hint": f"{face_size_owner_tip} {relative_range_hint(current_face_height, 0.2, 0.5, face_linear_hard_limit)}",
},
},
value_type="positive",
used=("local_face_height", "local_face_height_direction", "local_face_size_center"),
**face_scale_limits(current_face_height),
)
add_scoped_spec(
key="face_center_position",
label="中心",
current_raw=current_face_center if current_face_center is not None else "",
target_text=vector_text(current_face_center),
scope_default="local",
scope_modes={
"local": {
"label": "只改当前面",
"action": "move_selected_face_center_local",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(is_plane and local_face_deform_ready and current_face_center is not None),
"enabled_tip": (
"输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;"
"程序只移动当前 Face 的顶点,并让相邻平面按新顶点重建。"
),
"disabled_tip": face_local_disabled_tip(
"只有带稳定中心坐标的平面 Face 才能尝试只移动当前 Face。"
),
"range_hint": (
"只改当前面时,相邻面可能自然变斜,非共面面可能被拆成三角面。"
f"{translation_hint()} {face_center_hard_limit}"
),
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_face_center},
**face_vector_move_limit(current_face_center),
},
"owning": {
"label": "移动整个特征",
"action": "move_selected_face_center",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None),
"enabled_tip": (
"输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;"
"程序会换算成平移量并移动它所属的特征或 Solid。"
),
"disabled_tip": "当前 Face 缺少稳定中心坐标或所属对象,不能按中心坐标平移。",
"range_hint": (
"移动整个特征不会改变当前对象形状,但同一对象会整体搬动。"
f"{translation_hint()} {face_center_hard_limit}"
),
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_face_center},
**face_vector_move_limit(current_face_center),
},
},
value_type="vector3",
used=("area_center", "bbox_center"),
)
if is_plane:
plane_origin = _triple_or_none(action_info.get("plane_origin"))
plane_direction = (
_triple_or_none(action_info.get("push_pull_outward_direction"))
or _triple_or_none(action_info.get("normal"))
)
current_plane_position = None
if plane_origin is not None and plane_direction is not None:
direction_length = math.sqrt(
plane_direction[0] * plane_direction[0]
+ plane_direction[1] * plane_direction[1]
+ plane_direction[2] * plane_direction[2]
)
if direction_length > 1e-12:
plane_direction = (
plane_direction[0] / direction_length,
plane_direction[1] / direction_length,
plane_direction[2] / direction_length,
)
current_plane_position = (
plane_origin[0] * plane_direction[0]
+ plane_origin[1] * plane_direction[1]
+ plane_origin[2] * plane_direction[2]
)
add_scoped_spec(
key="face_target_normal_position",
label="面偏移",
current_raw=current_plane_position if current_plane_position is not None else "",
target_text=numeric_text(current_plane_position),
scope_default="push_pull",
scope_modes={
"push_pull": {
"label": "推拉当前面",
"action": "push_pull_face",
"target_attr": "offset_input",
"enabled": current_plane_position is not None,
"enabled_tip": "输入目标面偏移;程序会沿当前面的垂直方向移动面,自动加料或切削。",
"disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置推拉。",
"range_hint": (
"面偏移是沿当前面垂直方向测量的目标值,不是面积、不是移动距离,也不是 X/Y/Z 坐标;单位同模型。"
"程序会把目标面偏移自动换算成本次需要移动的距离。"
f"{face_offset_hard_limit}"
),
"target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position},
},
"local": {
"label": "只改当前面",
"action": "move_selected_face_plane_position_local",
"target_attr": "offset_input",
"enabled": bool(
current_plane_position is not None
and local_face_deform_ready
and plane_direction is not None
and current_face_center is not None
),
"enabled_tip": (
"输入目标面偏移;程序只把当前 Face 沿当前面的垂直方向移动到该目标值,"
"并让相邻平面按新顶点重建。"
),
"disabled_tip": face_local_disabled_tip(
"当前平面缺少稳定方向、基准点或中心,不能按面偏移只移动当前 Face。"
),
"range_hint": (
"只改当前面不会自动加料/切削,相邻面可能自然变斜,"
"非共面面可能被拆成三角面。"
f"{translation_hint()} {face_offset_hard_limit}"
),
"target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position},
},
"owning": {
"label": "移动整个特征",
"action": "move_selected_face_plane_position_by_translation",
"target_attr": "offset_input",
"enabled": bool(
current_plane_position is not None
and plane_direction is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": (
"输入目标面偏移;程序会沿当前面的垂直方向平移所属特征或 Solid,"
"当前面形状和所属对象内部尺寸不变。"
),
"disabled_tip": "当前平面缺少稳定方向、基准点或所属对象,不能按面偏移整体平移。",
"range_hint": (
"这是整体移动所属对象,不是推拉当前面;如果想改变形状或厚度,请选择“推拉当前面”。"
f"{translation_hint()} {face_offset_hard_limit}"
),
"target_transform": "plane_target_position_to_offset",
"transform_context": {"current_plane_position": current_plane_position},
},
},
used=("plane_origin", "push_pull_outward_direction", "normal"),
**face_offset_target_limits(current_plane_position),
)
if is_shell_candidate:
current = _float_or_none(action_info.get("shell_thickness_estimate"))
signed_thickness = _float_or_none(action_info.get("shell_signed_thickness"))
shell_normal = _triple_or_none(action_info.get("normal"))
shell_origin = _triple_or_none(action_info.get("plane_origin"))
add_scoped_spec(
key="shell_thickness_estimate",
label="薄壁厚度",
current_raw=current if current is not None else "",
target_text=numeric_text(current),
scope_default="local",
scope_modes={
"local": {
"label": "推拉当前面",
"action": "resize_shell_thickness",
"target_attr": "shell_thickness_input",
"enabled": current is not None,
"enabled_tip": "输入薄壁/壳体局部区域的目标厚度;程序会推拉当前平面来改变与相对面的距离。",
"disabled_tip": "当前平面没有稳定识别到可修改的薄壁厚度。",
"range_hint": relative_range_hint(current, 0.35, 0.8, face_linear_hard_limit),
},
"owning": {
"label": "调整整个特征",
"action": "resize_shell_thickness_owning_scale",
"target_attr": "shell_thickness_input",
"enabled": bool(
current is not None
and current > 0
and signed_thickness is not None
and abs(signed_thickness) > 1e-9
and shell_normal is not None
and shell_origin is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": (
"输入目标薄壁厚度;程序会沿厚度方向缩放所属特征或 Solid,"
"同一对象上的其它厚度方向尺寸会跟随变化。"
),
"disabled_tip": "当前薄壁候选缺少稳定厚度、厚度方向、基准平面或所属对象,不能按厚度整体缩放。",
"range_hint": (
"这是整体缩放所属对象,不是推拉当前平面;"
f"如果只想移动当前薄壁平面区域,请把影响范围设为“推拉当前面”。 {relative_range_hint(current, 0.2, 0.5, face_linear_hard_limit)}"
),
},
},
value_type="positive",
used=("shell_thickness_estimate", "shell_current_thickness", "shell_signed_thickness", "normal", "plane_origin"),
**face_scale_limits(current),
)
if is_hole_or_groove:
current_diameter = _float_or_none(action_info.get("diameter"))
current_radius = _float_or_none(action_info.get("radius"))
diameter_upper = hole_diameter_upper_limit(current_diameter)
cylinder_scale_axis_point = _triple_or_none(action_info.get("axis_point"))
cylinder_scale_axis_direction = _triple_or_none(action_info.get("axis"))
add_scoped_spec(
key="diameter",
label="直径",
current_raw=current_diameter if current_diameter is not None else "",
target_text=numeric_text(current_diameter),
scope_default="local",
scope_modes={
"local": {
"label": "只改孔/槽壁",
"action": "resize_hole",
"target_attr": "hole_diameter_input",
"enabled": current_diameter is not None,
"enabled_tip": "输入当前孔/槽圆柱面的目标直径;程序会局部重切或重建孔/槽壁。",
"disabled_tip": "当前圆柱面没有稳定识别为孔/槽候选。",
"range_hint": hole_diameter_hint(current_diameter),
"max_value": diameter_upper,
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_diameter is not None
and cylinder_scale_axis_point is not None
and cylinder_scale_axis_direction is not None
),
"enabled_tip": "输入目标直径后,按比例均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。",
"disabled_tip": "当前圆柱面缺少稳定直径或缩放中心,不能缩放所属对象。",
"range_hint": cylinder_owning_scale_hint(current_diameter),
},
},
value_type="positive",
**positive_minimum(),
)
add_scoped_spec(
key="hole_cylinder_radius",
label="半径",
current_raw=current_radius if current_radius is not None else "",
target_text=numeric_text(current_radius),
scope_default="local",
scope_modes={
"local": {
"label": "只改孔/槽壁",
"action": "resize_hole",
"target_attr": "hole_diameter_input",
"enabled": current_radius is not None,
"enabled_tip": "输入当前孔/槽圆柱面的目标半径;程序会换算成目标直径后局部重切或重建孔/槽壁。",
"disabled_tip": "当前圆柱面没有稳定识别为孔/槽候选。",
"range_hint": hole_radius_hint(current_radius, current_diameter),
"target_transform": "radius_to_diameter",
"max_value": diameter_upper * 0.5 if diameter_upper is not None else None,
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_radius is not None
and cylinder_scale_axis_point is not None
and cylinder_scale_axis_direction is not None
),
"enabled_tip": "输入目标半径后,换算成目标直径比例并均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。",
"disabled_tip": "当前圆柱面缺少稳定半径或缩放中心,不能缩放所属对象。",
"range_hint": cylinder_owning_scale_radius_hint(current_radius),
"target_transform": "radius_to_diameter",
},
},
value_type="positive",
used=("radius",),
**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,
)
if not is_slot_or_half_hole:
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "移动孔",
"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": "当前只对接近完整圆柱的孔放行轴心坐标修改;槽/半孔会使用扇形槽轴心坐标修改。",
"range_hint": translation_hint(),
},
"owning": {
"label": "移动整个特征",
"action": "move_selected_axis_center_by_translation",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(
current_axis_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": (
"输入目标孔轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid,"
"不会填旧孔再切新孔。"
),
"disabled_tip": "当前孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。",
"range_hint": (
"这不是移动孔本身,而是整体搬动所属对象;"
f"{translation_hint()}"
),
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_axis_center},
},
},
value_type="vector3",
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:
add_scoped_spec(
key="slot_axis_center",
label="轴心",
current_raw=current_axis_center if current_axis_center is not None else "",
target_text=vector_text(current_axis_center),
scope_default="local",
scope_modes={
"local": {
"label": "移动槽/半孔",
"action": "move_cylindrical_slot_axis",
"target_attrs": ("slot_center_x_input", "slot_center_y_input", "slot_center_z_input"),
"enabled": bool(current_axis_center is not None and current_diameter is not None),
"enabled_tip": "输入槽/半孔轴心的目标坐标 X, Y, Z;程序会先填旧扇形槽,再按同宽度、同角度切出新槽。",
"disabled_tip": "当前槽/半孔缺少稳定轴心或宽度,不能移动轴心坐标。",
"range_hint": translation_hint(),
},
"owning": {
"label": "移动整个特征",
"action": "move_selected_axis_center_by_translation",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(
current_axis_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": (
"输入目标槽/半孔轴心 X, Y, Z 坐标;程序会平移所属特征或 Solid,"
"不会填旧槽再切新槽。"
),
"disabled_tip": "当前槽/半孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。",
"range_hint": (
"这不是移动槽/半孔本身,而是整体搬动所属对象;"
f"{translation_hint()}"
),
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_axis_center},
},
},
value_type="vector3",
used=("axis_point", "axis", "same_domain_v_range", "v_range"),
)
current_slot = _float_or_none(action_info.get("slot_chord_width_estimate"))
slot_metric_angle = (
_float_or_none(action_info.get("slot_angular_span"))
or _float_or_none(action_info.get("angular_span"))
)
slot_owning_scale_ready = bool(
current_diameter is not None
and current_diameter > 0
and slot_metric_angle is not None
and slot_metric_angle > 1e-6
and slot_metric_angle < math.tau * 0.92
and cylinder_scale_axis_point is not None
and cylinder_scale_axis_direction is not None
)
slot_owning_scale_hint = (
"这是整体缩放所属特征或 Solid,不是局部重切槽;槽宽、槽深、弧长以及同一对象上的其它尺寸都会按比例跟随变化。"
)
add_scoped_spec(
key="slot_chord_width_estimate",
label="槽宽",
current_raw=current_slot if current_slot is not None else "",
target_text=numeric_text(current_slot),
scope_default="local",
scope_modes={
"local": {
"label": "只改槽壁",
"action": "resize_slot_width",
"target_attr": "slot_width_input",
"enabled": current_slot is not None,
"enabled_tip": "输入槽或半孔的目标宽度;程序会局部重建槽壁。",
"disabled_tip": "当前对象不是稳定的槽/半孔候选。",
"range_hint": relative_range_hint(current_slot, 0.35, 1.0),
},
"owning": {
"label": "调整整个特征",
"action": "resize_slot_width_owning_scale",
"target_attr": "slot_width_input",
"enabled": bool(current_slot is not None and slot_owning_scale_ready),
"enabled_tip": f"输入目标槽宽;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}",
"disabled_tip": "当前槽/半孔缺少稳定槽宽、圆弧角度、圆柱直径或缩放中心,不能按槽宽整体缩放所属对象。",
"range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot, 0.2, 0.5)}",
},
},
value_type="positive",
used=("slot_chord_width_estimate", "slot_angular_span", "angular_span"),
**positive_minimum(),
)
current_slot_depth = _float_or_none(action_info.get("slot_sagitta_depth_estimate"))
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "只改槽壁",
"action": "resize_slot_depth",
"target_attr": "slot_depth_input",
"enabled": current_slot_depth is not None,
"enabled_tip": "输入槽或半孔的目标凹入深度;程序会局部重建槽壁。",
"disabled_tip": "当前对象没有稳定的槽/半孔深度估算。",
"range_hint": relative_range_hint(current_slot_depth, 0.35, 1.0),
},
"owning": {
"label": "调整整个特征",
"action": "resize_slot_depth_owning_scale",
"target_attr": "slot_depth_input",
"enabled": bool(current_slot_depth is not None and slot_owning_scale_ready),
"enabled_tip": f"输入目标槽深;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}",
"disabled_tip": "当前槽/半孔缺少稳定槽深、圆弧角度、圆柱直径或缩放中心,不能按槽深整体缩放所属对象。",
"range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot_depth, 0.2, 0.5)}",
},
},
value_type="positive",
used=("slot_sagitta_depth_estimate", "slot_angular_span", "angular_span"),
**positive_minimum(),
)
current_slot_arc = _float_or_none(action_info.get("slot_arc_length_estimate"))
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "只改槽壁",
"action": "resize_slot_arc_length",
"target_attr": "slot_arc_length_input",
"enabled": current_slot_arc is not None,
"enabled_tip": "输入槽或半孔的目标圆弧长度;程序会局部重建槽壁。",
"disabled_tip": "当前对象没有稳定的槽/半孔圆弧长度估算。",
"range_hint": relative_range_hint(current_slot_arc, 0.35, 1.0),
},
"owning": {
"label": "调整整个特征",
"action": "resize_slot_arc_length_owning_scale",
"target_attr": "slot_arc_length_input",
"enabled": bool(current_slot_arc is not None and slot_owning_scale_ready),
"enabled_tip": f"输入目标弧长;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}",
"disabled_tip": "当前槽/半孔缺少稳定弧长、圆弧角度、圆柱直径或缩放中心,不能按弧长整体缩放所属对象。",
"range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot_arc, 0.2, 0.5)}",
},
},
value_type="positive",
used=("slot_arc_length_estimate", "slot_angular_span", "angular_span"),
**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 ""
current_diameter_for_slot = _float_or_none(action_info.get("diameter"))
slot_pair_target_text = manual_slot_pair_text
add_spec(
key="slot_pair_face_id",
label="配对端Face",
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=current_diameter_for_slot is not None,
enabled_tip="输入长圆槽/槽孔的目标总长度;程序会在点击修改时自动识别另一个半圆槽端,上一行 Face ID 只用于自动识别不准时手动指定。",
disabled_tip="当前槽/半孔缺少稳定宽度/直径,不能按槽孔总长度修改。",
value_type="positive",
range_hint="目标总长度必须大于当前槽宽/直径;点击修改时才会识别配对端,避免选中对象时卡住界面。",
**positive_minimum(),
)
add_spec(
key="slot_center_distance_estimate",
label="中心距",
current_raw="",
current_text="执行时自动识别",
target_text="",
action="resize_slot_center_distance",
target_attr="slot_center_distance_input",
enabled=current_diameter_for_slot is not None,
enabled_tip="输入长圆槽两端半圆中心距;程序会保持槽宽不变,并在执行时自动识别配对端。",
disabled_tip="当前槽/半孔缺少稳定宽度/直径,不能按中心距修改。",
value_type="positive",
range_hint="目标中心距必须大于 0;目标总长度会等于目标中心距加当前槽宽/直径。自动配对失败时,可先在上一行手动填写配对端 Face ID。",
**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",
current_raw=bottom_target_text,
target_text=bottom_target_text,
action="set_manual_hole_bottom_face",
target_attr="hole_bottom_face_input",
enabled=True,
enabled_tip="设置盲孔/盲槽的底面 Face ID;自动识别不准时,先填这里再修改深度。",
disabled_tip="当前对象不是孔/槽候选,不能设置底面 Face ID。",
value_type="integer_or_empty",
range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在的 Face ID。",
min_value=0.0,
button_text="设置",
)
current_depth = _float_or_none(action_info.get("hole_depth_estimate"))
if self.model is not None and self.selected_face_id is not None and manual_bottom_id is not None:
probe_depth = current_depth if current_depth is not None and current_depth > 0 else 1.0
try:
depth_plan = self.model.cylindrical_depth_plan(
self.selected_face_id,
probe_depth,
bottom_face_id=manual_bottom_id,
)
manual_depth = _float_or_none(depth_plan.get("current_depth"))
if manual_depth is not None and manual_depth > 0:
current_depth = manual_depth
except Exception:
pass
can_depth = bool((is_blind or manual_bottom_id is not None) and (has_bottom or manual_bottom_id is not None) and current_depth is not None)
if is_blind or current_depth is not None:
add_scoped_spec(
key="hole_depth_estimate",
label="盲孔/盲槽深度",
current_raw=current_depth if current_depth is not None else "",
target_text=numeric_text(current_depth),
scope_default="local",
scope_modes={
"local": {
"label": "改底面深度",
"action": "resize_hole_depth",
"target_attr": "hole_depth_input",
"enabled": can_depth,
"enabled_tip": "输入盲孔或盲槽的目标深度;加深会切削,变浅会补料到新的底面位置。",
"disabled_tip": "需要识别到盲孔/盲槽底面后才能直接修改深度。",
"range_hint": relative_range_hint(current_depth, 0.35, 1.0),
},
"owning": {
"label": "调整整个特征",
"action": "resize_hole_depth_owning_scale",
"target_attr": "hole_depth_input",
"enabled": bool(
can_depth
and current_depth is not None
and current_depth > 0
and _triple_or_none(action_info.get("axis_point")) is not None
and _triple_or_none(action_info.get("axis")) is not None
),
"enabled_tip": "输入目标盲孔/盲槽深度;程序会沿孔/槽轴向整体缩放所属特征或 Solid,不是局部切削或补料。",
"disabled_tip": "需要稳定深度、底面方向、轴线和缩放中心后,才能按深度整体缩放所属对象。",
"range_hint": (
"这是整体缩放所属对象,同一对象上的壁厚、孔距和其它轴向尺寸会跟随变化;"
f"{relative_range_hint(current_depth, 0.2, 0.5)}"
),
},
},
value_type="positive",
used=("hole_depth_estimate", "axis_point", "axis", "feature_bottom_face_ids"),
**positive_minimum(),
)
if is_boss:
current_boss = _float_or_none(action_info.get("diameter"))
current_boss_radius = _float_or_none(action_info.get("radius"))
boss_scale_axis_point = _triple_or_none(action_info.get("axis_point"))
boss_scale_axis_direction = _triple_or_none(action_info.get("axis"))
add_scoped_spec(
key="boss_diameter",
label="直径",
current_raw=current_boss if current_boss is not None else "",
target_text=numeric_text(current_boss),
scope_default="local",
scope_modes={
"local": {
"label": "只改凸台",
"action": "resize_boss",
"target_attr": "boss_diameter_input",
"enabled": bool(is_full_cylinder and current_boss is not None),
"enabled_tip": "输入完整圆柱凸台的目标直径;程序会重建凸台局部包络,尽量只改变当前凸台。",
"disabled_tip": "当前凸台候选不是接近完整圆柱,暂不放行直径修改。",
"range_hint": relative_range_hint(current_boss, 0.3, 0.8),
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_boss is not None
and boss_scale_axis_point is not None
and boss_scale_axis_direction is not None
),
"enabled_tip": "输入目标直径;程序会按比例均匀缩放所属特征或 Solid,而不是只重建凸台包络。",
"disabled_tip": "当前凸台缺少稳定直径或缩放中心,不能缩放所属对象。",
"range_hint": cylinder_owning_scale_hint(current_boss),
},
},
value_type="positive",
used=("diameter", "axis_point", "axis"),
**positive_minimum(),
)
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "只改凸台",
"action": "resize_boss",
"target_attr": "boss_diameter_input",
"enabled": bool(is_full_cylinder and current_boss_radius is not None),
"enabled_tip": "输入完整圆柱凸台的目标半径;程序会换算成目标直径后重建凸台局部包络。",
"disabled_tip": "当前凸台候选不是接近完整圆柱,暂不放行半径修改。",
"range_hint": relative_range_hint(current_boss_radius, 0.3, 0.8),
"target_transform": "radius_to_diameter",
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_boss_radius is not None
and boss_scale_axis_point is not None
and boss_scale_axis_direction is not None
),
"enabled_tip": "输入目标半径;程序会换算成目标直径比例并均匀缩放所属特征或 Solid。",
"disabled_tip": "当前凸台缺少稳定半径或缩放中心,不能缩放所属对象。",
"range_hint": cylinder_owning_scale_radius_hint(current_boss_radius),
"target_transform": "radius_to_diameter",
},
},
value_type="positive",
used=("radius", "axis_point", "axis"),
**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_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "移动凸台",
"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": "当前只对接近完整圆柱的凸台候选放行轴心坐标修改。",
"range_hint": translation_hint(),
},
"owning": {
"label": "移动整个特征",
"action": "move_selected_axis_center_by_translation",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(
current_boss_axis_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
),
"enabled_tip": (
"输入目标凸台轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid,"
"不会移除旧凸台再补新凸台。"
),
"disabled_tip": "当前凸台缺少稳定轴心或所属对象,不能按轴心平移所属对象。",
"range_hint": (
"这不是移动凸台本身,而是整体搬动所属对象;"
f"{translation_hint()}"
),
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_boss_axis_center},
},
},
value_type="vector3",
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_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "推拉端盖",
"action": "resize_boss_height",
"target_attr": "boss_height_input",
"enabled": bool(is_full_cylinder and current_boss_height is not None),
"enabled_tip": "输入完整圆柱凸台的目标高度;点击修改时程序会再计算并确认可推拉的凸台端盖 Face。",
"disabled_tip": "当前凸台候选缺少稳定高度或端盖信息,暂不放行高度修改。",
"range_hint": relative_range_hint(current_boss_height, 0.3, 0.8),
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_height_owning_scale",
"target_attr": "boss_height_input",
"enabled": bool(
current_boss_height is not None
and current_boss_height > 0
and _triple_or_none(action_info.get("axis_point")) is not None
and _triple_or_none(action_info.get("axis")) is not None
),
"enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉凸台端盖。",
"disabled_tip": "当前凸台缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。",
"range_hint": (
"这是整体缩放所属对象,同一对象上的其它轴向尺寸会跟随变化。"
f"{relative_range_hint(current_boss_height, 0.2, 0.5)}"
),
},
},
value_type="positive",
used=("same_domain_height_estimate", "height_estimate", "axis_point", "axis"),
**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"))
generic_scale_axis_point = _triple_or_none(action_info.get("axis_point"))
generic_scale_axis_direction = _triple_or_none(action_info.get("axis"))
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "兜底重切",
"action": "resize_hole",
"target_attr": "hole_diameter_input",
"enabled": current_generic_diameter is not None,
"enabled_tip": "输入当前未明确圆柱面的目标直径;程序会按高风险圆柱切削/重切兜底路线尝试。",
"disabled_tip": "当前圆柱面缺少稳定直径,不能直接修改。",
"range_hint": "高风险兜底:如果这是凸台或圆角,请改用凸台/圆角专门入口;如果是未识别的孔或槽,可以小幅尝试。",
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_generic_diameter is not None
and generic_scale_axis_point is not None
and generic_scale_axis_direction is not None
),
"enabled_tip": "输入目标直径后,按比例均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。",
"disabled_tip": "当前圆柱面缺少稳定直径或缩放中心,不能缩放所属对象。",
"range_hint": cylinder_owning_scale_hint(current_generic_diameter),
},
},
value_type="positive",
used=("diameter", "axis_point", "axis"),
**positive_minimum(),
)
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "兜底重切",
"action": "resize_hole",
"target_attr": "hole_diameter_input",
"enabled": current_generic_radius is not None,
"enabled_tip": "输入当前未明确圆柱面的目标半径;程序会换算成直径后按高风险兜底路线尝试。",
"disabled_tip": "当前圆柱面缺少稳定半径,不能直接修改。",
"range_hint": "高风险兜底:半径会先换算成直径;建议只做小幅修改并检查结果。",
"target_transform": "radius_to_diameter",
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_generic_radius is not None
and generic_scale_axis_point is not None
and generic_scale_axis_direction is not None
),
"enabled_tip": "输入目标半径后,换算成目标直径比例并均匀缩放所属特征或 Solid;高度、厚度和其它尺寸会同比例变化。",
"disabled_tip": "当前圆柱面缺少稳定半径或缩放中心,不能缩放所属对象。",
"range_hint": cylinder_owning_scale_radius_hint(current_generic_radius),
"target_transform": "radius_to_diameter",
},
},
value_type="positive",
used=("radius", "axis_point", "axis"),
**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_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "推拉端盖",
"action": "resize_cylinder_height",
"target_attr": "boss_height_input",
"enabled": bool(is_full_cylinder and current_cylinder_height is not None),
"enabled_tip": "输入完整圆柱面的目标高度;点击修改时程序会再计算并确认可推拉的圆柱端盖 Face。",
"disabled_tip": "当前圆柱面不是完整圆柱,或缺少稳定高度信息。",
"range_hint": relative_range_hint(current_cylinder_height, 0.3, 0.8),
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_height_owning_scale",
"target_attr": "boss_height_input",
"enabled": bool(
current_cylinder_height is not None
and current_cylinder_height > 0
and generic_scale_axis_point is not None
and generic_scale_axis_direction is not None
),
"enabled_tip": "输入目标高度;程序会沿圆柱轴向整体缩放所属特征或 Solid,不是推拉单个端盖。",
"disabled_tip": "当前圆柱缺少稳定高度、轴线或缩放中心,不能按高度整体缩放所属对象。",
"range_hint": (
"这是整体缩放所属对象,同一对象上的其它轴向尺寸会跟随变化。"
f"{relative_range_hint(current_cylinder_height, 0.2, 0.5)}"
),
},
},
value_type="positive",
used=("same_domain_height_estimate", "height_estimate", "axis_point", "axis"),
**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)} "
"(圆角圆弧长度估算的一半)时比例会很异常。"
)
fillet_scale_axis_point = _triple_or_none(action_info.get("axis_point"))
fillet_scale_axis_direction = _triple_or_none(action_info.get("axis"))
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_scoped_spec(
key="existing_fillet_radius_estimate",
label="圆角半径",
current_raw=current_radius if current_radius is not None else "",
target_text=numeric_text(current_radius),
scope_default="local",
scope_modes={
"local": {
"label": "重建圆角",
"action": "resize_existing_fillet",
"target_attr": "edge_fillet_radius_input",
"enabled": bool(current_radius is not None),
"enabled_tip": "输入已有圆角/倒圆面的目标半径;点击修改时程序会再确认支撑面,并尝试移除旧圆角后重建。",
"disabled_tip": "当前圆角候选缺少稳定半径,暂不放行修改。",
"range_hint": fillet_hint,
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_radius is not None
and current_radius > 0
and fillet_scale_axis_point is not None
and fillet_scale_axis_direction is not None
),
"enabled_tip": "输入目标圆角半径;程序会换算成目标圆柱直径后整体缩放所属特征或 Solid,不会移除并重建圆角。",
"disabled_tip": "当前圆角缺少稳定半径、轴线或缩放中心,不能按圆角半径整体缩放所属对象。",
"range_hint": (
"这是整体缩放所属对象,同一对象上的其它尺寸都会跟随变化;"
f"{cylinder_owning_scale_radius_hint(current_radius)}"
),
"target_transform": "radius_to_diameter",
},
},
value_type="positive",
used=("existing_fillet_radius_estimate", "radius", "axis_point", "axis"),
**positive_minimum(),
)
add_scoped_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),
scope_default="local",
scope_modes={
"local": {
"label": "重建圆角",
"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
),
"enabled_tip": "输入已有圆角/倒圆面的目标圆弧长度;程序会按当前圆弧角度换算成目标半径,再尝试重建圆角。",
"disabled_tip": "当前圆角候选缺少稳定圆弧长度或角度,暂不放行修改。",
"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},
},
"owning": {
"label": "调整整个特征",
"action": "resize_cylindrical_owning_scale",
"target_attr": "hole_diameter_input",
"enabled": bool(
current_fillet_arc is not None
and current_fillet_arc > 0
and current_fillet_span is not None
and current_fillet_span > 1e-6
and fillet_scale_axis_point is not None
and fillet_scale_axis_direction is not None
),
"enabled_tip": "输入目标圆角弧长;程序会按当前圆弧角度换算成目标圆柱直径,再整体缩放所属特征或 Solid。",
"disabled_tip": "当前圆角缺少稳定弧长、圆弧角度、轴线或缩放中心,不能按圆角弧长整体缩放所属对象。",
"range_hint": (
"这是整体缩放所属对象,不会移除旧圆角再重建;同一对象上的其它尺寸也会跟随变化。"
f"{relative_range_hint(current_fillet_arc, 0.2, 0.5)}"
),
"target_transform": "arc_length_to_diameter",
"transform_context": {"angular_span": current_fillet_span},
},
},
value_type="positive",
used=("existing_fillet_arc_length_estimate", "existing_fillet_angular_span", "angular_span"),
**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
)
reference_radius_supported, reference_radius_disabled_reason = cone_reference_radius_capability(
current_reference_radius
)
reference_radius_enabled = current_reference_radius is not None and reference_radius_supported
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=reference_radius_enabled,
enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。",
disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考半径,不能直接修改。"
),
value_type="positive",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {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 and reference_radius_supported,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切。",
disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考直径,不能直接修改。"
),
value_type="positive",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {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
)
semi_angle_supported, semi_angle_disabled_reason = cone_semi_angle_capability(current_semi_angle_degrees)
semi_angle_enabled = (
current_reference_radius is not None
and current_semi_angle is not None
and semi_angle_supported
)
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_semi_angle",
target_attr="cone_reference_radius_input",
enabled=semi_angle_enabled,
enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切。",
disabled_tip=(
semi_angle_disabled_reason
or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。"
),
value_type="positive",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先保持小端半径和深度,只改变锥孔开口。"
"复杂圆锥/拔模面暂不使用整体缩放兜底。当前版本要求半角大于 0 且小于 89 度。"
),
max_value=89.0,
max_exclusive=True,
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="输入球面的目标半径;程序会围绕球心均匀缩放所属对象。",
disabled_tip="当前球面缺少稳定半径,不能直接修改。",
value_type="positive",
range_hint=f"这是整体缩放所属对象,不是只替换单个球面的历史半径参数。 {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=f"这是整体缩放所属对象,不是只替换单个球面的历史直径参数。 {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=f"这是整体缩放所属对象,不是只改环面主半径;小半径和其它尺寸也会跟随变化。 {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=f"这是整体缩放所属对象,不是只改环面主直径;小半径和其它尺寸也会跟随变化。 {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=f"这是整体缩放所属对象,不是只改环面小半径;主半径和其它尺寸也会跟随变化。 {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=f"这是整体缩放所属对象,不是只改环面小直径;主半径和其它尺寸也会跟随变化。 {relative_range_hint(current_minor_diameter, 0.25, 0.6)}",
target_transform="diameter_to_radius",
used=("feature_torus_minor_radius",),
**positive_minimum(),
)
if has_edge:
current_length = _float_or_none(action_info.get("length"))
is_circle_edge = curve == "circle"
is_ellipse_edge = curve == "ellipse"
def edge_strategy_effect_text(strategy_mode: str, anchor_mode: str) -> str:
anchor_text = {
"auto": "自动基准:默认尽量固定一端,移动另一端。",
"center": "固定中心:两端或所属对象围绕中心变化。",
"keep-start": "固定起点:起点尽量不动。",
"keep-end": "固定终点:终点尽量不动。",
}.get(anchor_mode, "自动基准:程序选择更稳定的一端。")
strategy_text = {
"auto": "自动选择:按局部边形变、端面移动、相邻圆柱编辑、缩放所属对象的顺序寻找可用路径。",
"local-edge-only-deform": "只改当前Edge:移动被选Edge端点,相邻面自然变斜,整体端面不一起平移。",
"move-edge-end-plane-by-push-pull": "移动端面/保持垂直:端面和端面上的相关边一起移动,相邻平面尽量保持垂直,正方体会更像变成长方体。",
"resize-adjacent-cylinder-from-circular-edge-length": "相邻圆柱直径:把圆Edge长度换算成圆柱直径,优先改孔/槽/凸台局部特征。",
"scale-owning-shape-from-edge": "缩放所属对象:所属特征或 Solid 整体跟随变化,其它尺寸也会变。",
}.get(strategy_mode, "自动选择:程序会在确认窗口显示实际采用的策略。")
return f"{strategy_text} {anchor_text}"
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()
current_strategy_mode = "auto"
if hasattr(self, "edge_length_strategy_combo"):
current_strategy_mode = str(self.edge_length_strategy_combo.currentData() or "auto")
def edge_length_strategy_preselect(strategy_mode: str) -> list[dict[str, object]]:
return [{"target_attr": "edge_length_strategy_combo", "value": strategy_mode}]
add_scoped_spec(
key="length",
label="长度",
current_raw=current_length if current_length is not None else "",
target_text=numeric_text(current_length),
scope_default=current_strategy_mode,
scope_modes={
"auto": {
"label": "自动",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": current_length is not None,
"enabled_tip": "输入当前Edge的目标长度;程序会自动选择局部形变、端面移动、相邻圆柱编辑或缩放所属对象。",
"disabled_tip": "当前Edge没有稳定长度信息。",
"range_hint": (
f"{edge_strategy_effect_text('auto', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.25, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("auto"),
},
"local-edge-only-deform": {
"label": "只改当前Edge",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": bool(is_line_edge and current_length is not None),
"enabled_tip": "输入当前Edge的目标长度;只移动被选直线Edge端点并重建周边平面,相邻面可能变斜。",
"disabled_tip": "只有直线Edge才能使用只改当前Edge的局部形变策略。",
"range_hint": (
f"{edge_strategy_effect_text('local-edge-only-deform', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.25, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("local-edge-only-deform"),
},
"move-edge-end-plane-by-push-pull": {
"label": "移动端面",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": bool(is_line_edge and current_length is not None),
"enabled_tip": "输入当前Edge的目标长度;程序会尝试移动相关端面,让相邻平面尽量保持垂直。",
"disabled_tip": "只有直线Edge才能尝试移动端面/保持垂直策略。",
"range_hint": (
f"{edge_strategy_effect_text('move-edge-end-plane-by-push-pull', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.25, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("move-edge-end-plane-by-push-pull"),
},
"resize-adjacent-cylinder-from-circular-edge-length": {
"label": "相邻圆柱",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": bool(is_circle_edge and current_length is not None),
"enabled_tip": "输入圆形/圆弧Edge的目标长度;程序会优先换算为相邻圆柱孔、槽或凸台的直径修改。",
"disabled_tip": "只有圆形/圆弧Edge才能使用相邻圆柱直径策略。",
"range_hint": (
f"{edge_strategy_effect_text('resize-adjacent-cylinder-from-circular-edge-length', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.25, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("resize-adjacent-cylinder-from-circular-edge-length"),
},
"scale-owning-shape-from-edge": {
"label": "缩放所属",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": current_length is not None,
"enabled_tip": "输入当前Edge的目标长度;程序会缩放所属特征或 Solid,其它尺寸也会跟随变化。",
"disabled_tip": "当前Edge没有稳定长度信息,不能缩放所属对象。",
"range_hint": (
f"{edge_strategy_effect_text('scale-owning-shape-from-edge', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.2, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("scale-owning-shape-from-edge"),
},
},
value_type="positive",
**positive_minimum(),
)
add_spec(
key="edge_length_anchor_mode",
label="长度基准",
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="起点",
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="中心",
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="终点",
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"))
circle_edge_center = _triple_or_none(action_info.get("center"))
can_resize_circle = bool(current_length is not None and current_length > 0 and current_radius is not None and current_radius > 0)
add_spec(
key="circle_edge_axis_center",
label="圆心/轴心",
current_raw=circle_edge_center if circle_edge_center is not None else "",
target_text=vector_text(circle_edge_center),
action="move_circular_edge_axis_center",
target_attrs=("edge_center_x_input", "edge_center_y_input", "edge_center_z_input"),
enabled=bool(circle_edge_center is not None and current_radius is not None and current_radius > 0),
enabled_tip=(
"输入目标 X, Y, Z 坐标;程序会用圆Edge圆心的移动量,"
"移动相邻孔、槽或凸台的圆柱轴心。"
),
disabled_tip="当前圆Edge缺少稳定圆心/半径,或没有可识别的相邻圆柱特征。",
value_type="vector3",
range_hint=(
"格式为 X, Y, Z。这个修改只在点击“修改”时才识别相邻圆柱;"
"优先移动局部孔/槽/凸台,不会整体平移零件。建议先小幅移动。"
),
used=("center", "adjacent_face_ids"),
)
circle_context = {
"edge_current_length": current_length,
"edge_current_radius": current_radius,
}
circle_scope_default = (
current_strategy_mode
if current_strategy_mode
in {
"auto",
"resize-adjacent-cylinder-from-circular-edge-length",
"scale-owning-shape-from-edge",
}
else "auto"
)
def add_circle_edge_size_spec(
*,
key: str,
label: str,
current_raw: object,
target_text: str,
enabled: bool,
disabled_tip: str,
transform: str,
used: tuple[str, ...],
) -> None:
add_scoped_spec(
key=key,
label=label,
current_raw=current_raw,
target_text=target_text,
scope_default=circle_scope_default,
scope_modes={
"auto": {
"label": "自动",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": enabled,
"enabled_tip": f"输入圆形/圆弧Edge的目标{label[-2:]};程序会优先尝试相邻圆柱,找不到时再按圆边平面或所属对象缩放。",
"disabled_tip": disabled_tip,
"range_hint": (
"自动:优先重切相邻孔/槽或重建凸台;没有稳定相邻圆柱时,再进入缩放所属对象路径。 "
f"{relative_range_hint(current_raw, 0.25, 0.5)}"
),
"target_transform": transform,
"transform_context": circle_context,
"preselect_combos": edge_length_strategy_preselect("auto"),
},
"resize-adjacent-cylinder-from-circular-edge-length": {
"label": "相邻圆柱",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": enabled,
"enabled_tip": f"输入圆形/圆弧Edge的目标{label[-2:]};程序会强制换算成相邻孔/槽或凸台直径修改。",
"disabled_tip": disabled_tip,
"range_hint": (
"相邻圆柱:只在圆Edge贴着孔/槽/凸台圆柱面时可用;找不到相邻圆柱会阻止,不会偷偷缩放整体。 "
f"{relative_range_hint(current_raw, 0.25, 0.5)}"
),
"target_transform": transform,
"transform_context": circle_context,
"preselect_combos": edge_length_strategy_preselect(
"resize-adjacent-cylinder-from-circular-edge-length"
),
},
"scale-owning-shape-from-edge": {
"label": "缩放所属",
"action": "resize_any_edge_length",
"target_attr": "edge_target_length_input",
"enabled": enabled,
"enabled_tip": f"输入圆形/圆弧Edge的目标{label[-2:]};程序会按圆边平面或所属对象缩放,周边尺寸也可能跟随变化。",
"disabled_tip": disabled_tip,
"range_hint": (
"缩放所属:这是高风险兜底语义,会影响同一特征或 Solid 的其它尺寸。 "
f"{relative_range_hint(current_raw, 0.2, 0.5)}"
),
"target_transform": transform,
"transform_context": circle_context,
"preselect_combos": edge_length_strategy_preselect("scale-owning-shape-from-edge"),
},
},
value_type="positive",
used=used,
**positive_minimum(),
)
add_circle_edge_size_spec(
key="circle_edge_radius",
label="圆边半径",
current_raw=current_radius if current_radius is not None else "",
target_text=numeric_text(current_radius),
enabled=can_resize_circle,
disabled_tip="当前圆Edge缺少稳定半径或长度信息。",
transform="circle_edge_radius_to_length",
used=("radius",),
)
add_circle_edge_size_spec(
key="circle_edge_diameter",
label="圆边直径",
current_raw=current_diameter if current_diameter is not None else "",
target_text=numeric_text(current_diameter),
enabled=bool(can_resize_circle and current_diameter is not None and current_diameter > 0),
disabled_tip="当前圆Edge缺少稳定直径或长度信息。",
transform="circle_edge_diameter_to_length",
used=("diameter",),
)
if is_ellipse_edge:
current_major = _float_or_none(action_info.get("major_radius"))
current_minor = _float_or_none(action_info.get("minor_radius"))
add_spec(
key="ellipse_edge_major_radius",
label="椭圆主半径",
current_raw=current_major if current_major is not None else "",
target_text=numeric_text(current_major),
action="resize_ellipse_edge_major_radius",
target_attr="ellipse_edge_major_radius_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的目标主半径;程序会沿椭圆主轴方向单轴缩放所属对象,小半径方向尽量不动。",
disabled_tip="当前椭圆Edge缺少稳定主半径或长度信息。",
value_type="positive",
range_hint=f"单轴缩放所属对象,不是恢复 CAD 草图约束;同方向上的其它几何会跟随变化。 {relative_range_hint(current_major, 0.25, 0.5)}",
used=("major_radius",),
**positive_minimum(),
)
add_spec(
key="ellipse_edge_minor_radius",
label="椭圆小半径",
current_raw=current_minor if current_minor is not None else "",
target_text=numeric_text(current_minor),
action="resize_ellipse_edge_minor_radius",
target_attr="ellipse_edge_minor_radius_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的目标小半径;程序会沿椭圆小轴方向单轴缩放所属对象,主半径方向尽量不动。",
disabled_tip="当前椭圆Edge缺少稳定小半径或长度信息。",
value_type="positive",
range_hint=f"单轴缩放所属对象,不是恢复 CAD 草图约束;同方向上的其它几何会跟随变化。 {relative_range_hint(current_minor, 0.25, 0.5)}",
used=("minor_radius",),
**positive_minimum(),
)
if is_line_edge:
edge_limit = current_length * 0.45 if current_length is not None and current_length > 0 else None
edge_soft = current_length * 0.12 if current_length is not None and current_length > 0 else None
edge_warning = current_length * 0.25 if current_length is not None and current_length > 0 else None
fillet_edge_hint = "建议半径先用较小值试改;过大容易导致倒圆失败。"
chamfer_edge_hint = "建议距离先用较小值试改;过大容易导致倒角失败。"
if edge_soft is not None and edge_warning is not None:
fillet_edge_hint = (
f"建议半径先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});"
f"超过 25%(约 {_format_float(edge_warning)})时风险较高。"
)
chamfer_edge_hint = (
f"建议距离先不超过 Edge 长度的 12%(约 {_format_float(edge_soft)});"
f"超过 25%(约 {_format_float(edge_warning)})时风险较高。"
)
add_spec(
key="new_fillet_radius",
label="圆角半径",
current_raw="未添加",
target_text="",
action="fillet_edge",
target_attr="edge_fillet_radius_input",
enabled=True,
enabled_tip="输入半径后给当前直线Edge添加新圆角。",
disabled_tip="只有直线Edge才能直接添加圆角。",
value_type="positive",
range_hint=fillet_edge_hint,
max_value=edge_limit,
max_exclusive=edge_limit is not None,
**positive_minimum(),
)
add_spec(
key="new_chamfer_distance",
label="倒角距离",
current_raw="未添加",
target_text="",
action="chamfer_edge",
target_attr="edge_chamfer_distance_input",
enabled=True,
enabled_tip="输入距离后给当前直线Edge添加新倒角。",
disabled_tip="只有直线Edge才能直接添加倒角。",
value_type="positive",
range_hint=chamfer_edge_hint,
max_value=edge_limit,
max_exclusive=edge_limit is not None,
**positive_minimum(),
)
adjacent_face_ids = _int_values(action_info.get("adjacent_face_ids"))
default_chamfer_reference = adjacent_face_ids[0] if adjacent_face_ids else None
current_chamfer_reference = default_chamfer_reference
if hasattr(self, "edge_chamfer_reference_face_input"):
hidden_reference = self.edge_chamfer_reference_face_input.text().strip()
if hidden_reference:
try:
hidden_reference_id = int(hidden_reference)
if not adjacent_face_ids or hidden_reference_id in adjacent_face_ids:
current_chamfer_reference = hidden_reference_id
else:
self.edge_chamfer_reference_face_input.setText("")
except ValueError:
self.edge_chamfer_reference_face_input.setText("")
current_chamfer_reference = default_chamfer_reference
add_spec(
key="new_asymmetric_chamfer_distances",
label="不等距倒角",
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="距离+角度倒角",
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",
current_raw=current_chamfer_reference if current_chamfer_reference is not None else "",
target_text=str(current_chamfer_reference) if current_chamfer_reference is not None else "",
action="set_chamfer_reference_face",
target_attr="edge_chamfer_reference_face_input",
enabled=bool(adjacent_face_ids),
enabled_tip=f"设置不等距倒角参考Face;可用相邻Face:{tuple(adjacent_face_ids)}。",
disabled_tip="当前Edge没有稳定的相邻Face,不能设置不等距倒角参考Face。",
value_type="integer_or_empty",
range_hint=f"可用相邻Face{tuple(adjacent_face_ids)}。留空则使用第一个相邻Face。",
button_text="设置",
)
if self.selected_kind in {"part", "solid"}:
target_is_part = self.selected_kind == "part"
translate_action = "translate_selected_part" if target_is_part else "translate_selected_solid"
rotate_action = "rotate_selected_part" if target_is_part else "rotate_selected_solid"
add_spec(
key="translation_vector",
label="平移 X/Y/Z",
current_raw=(0.0, 0.0, 0.0),
target_text="0, 0, 0",
action=translate_action,
target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"),
enabled=True,
enabled_tip="输入三个数,例如 10, 0, 0,平移当前对象。",
disabled_tip="请先选择零件或 Solid。",
value_type="vector3",
range_hint=translation_hint(),
)
current_center = _triple_or_none(action_info.get("center_of_mass"))
bbox_min = _triple_or_none(action_info.get("bbox_min"))
bbox_max = _triple_or_none(action_info.get("bbox_max"))
if current_center is None and bbox_min is not None and bbox_max is not None:
current_center = (
(bbox_min[0] + bbox_max[0]) * 0.5,
(bbox_min[1] + bbox_max[1]) * 0.5,
(bbox_min[2] + bbox_max[2]) * 0.5,
)
add_spec(
key="target_center_position",
label="中心坐标",
current_raw=current_center if current_center is not None else "",
target_text=vector_text(current_center),
action=translate_action,
target_attrs=("translate_x_input", "translate_y_input", "translate_z_input"),
enabled=current_center is not None,
enabled_tip="输入目标中心坐标;程序会自动换算成平移 X/Y/Z 后移动当前对象。",
disabled_tip="当前对象缺少稳定中心坐标,不能按目标中心移动。",
value_type="vector3",
range_hint="格式为 X, Y, Z。这里输入的是目标绝对坐标,不是平移距离。",
target_transform="target_center_to_translation",
transform_context={"current_center": current_center},
used=("center_of_mass",),
)
current_diagonal = _float_or_none(action_info.get("bbox_diagonal"))
scale_action = "scale_selected_part" if target_is_part else "scale_selected_solid"
add_spec(
key="bbox_diagonal_scale",
label="整体尺寸",
current_raw=current_diagonal if current_diagonal is not None else "",
target_text=numeric_text(current_diagonal),
action=scale_action,
target_attr="scale_target_diagonal_input",
enabled=current_diagonal is not None and current_diagonal > 0,
enabled_tip="输入目标包围盒对角线,按当前对象中心等比缩放零件或Solid。",
disabled_tip="当前对象缺少有效包围盒尺寸,不能等比缩放。",
value_type="positive",
range_hint=relative_range_hint(current_diagonal, 0.35, 1.0),
used=("bbox_diagonal",),
**positive_minimum(),
)
current_volume = _float_or_none(action_info.get("volume"))
add_spec(
key="target_volume_scale",
label="体积",
current_raw=current_volume if current_volume is not None else "",
target_text=numeric_text(current_volume),
action=scale_action,
target_attr="scale_target_diagonal_input",
enabled=bool(current_volume is not None and current_volume > 0 and current_diagonal is not None and current_diagonal > 0),
enabled_tip="输入目标体积;程序会按体积比例换算为等比缩放后的整体尺寸。",
disabled_tip="当前对象缺少有效体积或包围盒尺寸,不能按体积缩放。",
value_type="positive",
range_hint=relative_range_hint(current_volume, 0.5, 2.0),
target_transform="volume_to_bbox_diagonal",
transform_context={
"current_volume": current_volume,
"current_bbox_diagonal": current_diagonal,
},
used=("volume",),
**positive_minimum(),
)
current_surface_area = _float_or_none(action_info.get("surface_area"))
add_spec(
key="target_surface_area_scale",
label="表面积",
current_raw=current_surface_area if current_surface_area is not None else "",
target_text=numeric_text(current_surface_area),
action=scale_action,
target_attr="scale_target_diagonal_input",
enabled=bool(
current_surface_area is not None
and current_surface_area > 0
and current_diagonal is not None
and current_diagonal > 0
),
enabled_tip="输入目标表面积;程序会按面积比例换算为等比缩放后的整体尺寸。",
disabled_tip="当前对象缺少有效表面积或包围盒尺寸,不能按表面积缩放。",
value_type="positive",
range_hint=relative_range_hint(current_surface_area, 0.5, 2.0),
target_transform="surface_area_to_bbox_diagonal",
transform_context={
"current_surface_area": current_surface_area,
"current_bbox_diagonal": current_diagonal,
},
used=("surface_area",),
**positive_minimum(),
)
bbox_size = _triple_or_none(action_info.get("bbox_size"))
axis_scale_action_prefix = "scale_selected_part" if target_is_part else "scale_selected_solid"
if bbox_size is not None:
for axis_name, axis_index, target_attr in (
("X", 0, "scale_x_size_input"),
("Y", 1, "scale_y_size_input"),
("Z", 2, "scale_z_size_input"),
):
current_axis_size = bbox_size[axis_index]
add_spec(
key=f"bbox_{axis_name.lower()}_size_scale",
label=f"{axis_name}向尺寸",
current_raw=current_axis_size,
target_text=numeric_text(current_axis_size),
action=f"{axis_scale_action_prefix}_{axis_name.lower()}_size",
target_attr=target_attr,
enabled=current_axis_size > 0,
enabled_tip=f"输入目标 {axis_name} 向包围盒尺寸,只沿 {axis_name} 轴缩放当前对象。",
disabled_tip=f"当前对象缺少有效 {axis_name} 向包围盒尺寸。",
value_type="positive",
range_hint=relative_range_hint(current_axis_size, 0.25, 1.0),
**positive_minimum(),
)
axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z"
add_spec(
key="rotation_axis",
label="旋转轴",
current_raw=axis,
target_text=axis,
action="set_rotate_axis",
target_attr="rotate_axis_combo",
enabled=True,
enabled_tip="设置零件或 Solid 旋转时使用的轴:X、Y 或 Z。",
disabled_tip="请先选择零件或 Solid。",
value_type="choice",
range_hint="可输入:X、Y、Z。",
button_text="设置",
choices={
"X": "X",
"x": "X",
"Y": "Y",
"y": "Y",
"Z": "Z",
"z": "Z",
},
)
add_spec(
key="rotation_angle_degrees",
label=f"角度({axis}轴)",
current_raw=0.0,
target_text="0",
action=rotate_action,
target_attr="rotate_angle_input",
enabled=True,
enabled_tip=f"输入旋转角度,当前使用 {axis} 轴。",
disabled_tip="请先选择零件或 Solid。",
range_hint="角度可正可负;建议单次输入 -360 到 360 度,超过后请确认旋转方向和单位。",
)
return specs, used_keys
def _on_property_table_item_changed(self, _item: QTableWidgetItem) -> None:
if getattr(self, "property_editor_updating", False):
return
self._update_property_apply_state()
def _update_property_apply_state(self, has_model: bool | None = None) -> None:
if not hasattr(self, "property_table"):
return
if has_model is None:
has_model = self.model is not None and not (
self.operation_in_progress or self.scan_in_progress or self.load_in_progress
)
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
effective_spec = self._effective_property_spec(spec, row=row)
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
if isinstance(target_widget, QLineEdit):
editable = bool(effective_spec.get("editable") and effective_spec.get("enabled"))
input_editable = editable and str(effective_spec.get("value_type", "number")) != "command"
target_widget.setEnabled(input_editable)
target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
widget = self.property_table.cellWidget(row, PROPERTY_ACTION_COLUMN)
if isinstance(widget, QPushButton):
validation_error = ""
if str(effective_spec.get("value_type", "number")) == "command":
row_changed = True
widget.setText(str(effective_spec.get("button_text") or "执行"))
enabled = bool(has_model and effective_spec.get("enabled") and effective_spec.get("action"))
tooltip = f"执行“{effective_spec.get('label', '当前操作')}”。"
else:
text = self._property_target_text(row)
row_changed = self._property_target_changed(effective_spec, text)
empty_target = not bool(text.strip())
validation_error = "" if empty_target else self._property_target_validation_error(effective_spec, text)
if empty_target:
widget.setText("未输入")
elif validation_error:
widget.setText("无效")
else:
widget.setText("应用" if row_changed else "未改动")
enabled = bool(
has_model
and effective_spec.get("enabled")
and effective_spec.get("action")
and row_changed
and not validation_error
)
if empty_target:
tooltip = (
f"“{effective_spec.get('label', '当前属性')}”的目标值为空。"
"这不会删除模型里的点、边或面,只是暂时没有要应用的目标值;"
"重新选择对象会按当前模型值重新填入。"
)
elif validation_error:
tooltip = f"{validation_error} 请调整目标值后再应用。"
elif row_changed:
scope_label = str(effective_spec.get("scope_label") or "").strip()
suffix = f"{scope_label}" if scope_label else ""
tooltip = f"应用“{effective_spec.get('label', '当前属性')}{suffix}”这一行的目标值。"
else:
tooltip = f"“{effective_spec.get('label', '当前属性')}”的目标值和当前值相同,无需执行。修改目标值后再应用。"
range_hint = self._property_range_hint(effective_spec)
if range_hint:
tooltip = f"{tooltip}\n\n{range_hint}"
widget.setProperty("changed", bool(row_changed and not validation_error))
widget.setProperty("invalid", bool(validation_error))
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", [])):
effective_spec = self._effective_property_spec(spec, row=row)
if not effective_spec.get("action") or not effective_spec.get("enabled"):
continue
if str(effective_spec.get("value_type", "number")) == "command":
continue
if effective_spec.get("action") in {
"set_manual_hole_bottom_face",
"set_manual_slot_pair_face",
"set_edge_length_anchor_mode",
"set_chamfer_reference_face",
"set_rotate_axis",
}:
continue
text = self._property_target_text(row)
if self._property_target_changed(effective_spec, text) and not self._property_target_validation_error(effective_spec, text):
changed.append((row, effective_spec, text))
return changed
def _property_target_text(self, row: int) -> str:
if not hasattr(self, "property_table"):
return ""
widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
if isinstance(widget, QLineEdit):
return widget.text().strip()
item = self.property_table.item(row, PROPERTY_TARGET_COLUMN)
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_validation_error(self, spec: dict[str, object], text: str) -> str:
value_type = str(spec.get("value_type", "number"))
if value_type == "command":
return ""
if not text.strip():
return ""
try:
if value_type == "vector3":
values = self._parse_property_vector3(text)
return self._property_vector_distance_error(spec, values)
if value_type == "choice":
self._property_choice_value(spec, text)
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):
return "请输入两个大于 0 的目标值。"
for value in values:
range_error = self._property_scalar_range_error(spec, value)
if range_error:
return range_error
return ""
if value_type in {"integer", "integer_or_empty"}:
if not text and value_type == "integer_or_empty":
return ""
try:
value = int(text)
except ValueError:
return "请输入整数形式的目标值。"
return self._property_scalar_range_error(spec, float(value))
try:
value = float(text)
except ValueError:
return "请输入数字形式的目标值。"
if value_type == "positive" and value <= 0:
return "请输入大于 0 的目标值。"
return self._property_scalar_range_error(spec, value)
except ValueError as exc:
return str(exc)
def _property_vector_distance_error(
self,
spec: dict[str, object],
values: tuple[float, float, float],
) -> str:
max_distance = _float_or_none(spec.get("max_vector_distance"))
reference = _triple_or_none(spec.get("vector_distance_reference"))
if max_distance is None or max_distance <= 0 or reference is None:
return ""
distance = math.sqrt(
(values[0] - reference[0]) * (values[0] - reference[0])
+ (values[1] - reference[1]) * (values[1] - reference[1])
+ (values[2] - reference[2]) * (values[2] - reference[2])
)
if distance > max_distance + PROPERTY_VALUE_TOLERANCE:
label = str(spec.get("vector_distance_label") or spec.get("label") or "目标距离")
return f"{label} 必须小于或等于 {_format_float(max_distance)}。"
return ""
def _property_target_changed(self, spec: dict[str, object], text: str) -> bool:
value_type = str(spec.get("value_type", "number"))
if not text:
if value_type == "integer_or_empty":
return str(spec.get("current_raw", "")).strip() != ""
return False
display_changed = self._display_property_target_changed(spec, text, value_type)
if display_changed is not None:
return display_changed
if value_type == "vector3":
try:
values = self._parse_property_vector3(text)
except ValueError:
return True
if str(spec.get("target_transform") or "") == "target_center_to_translation":
current = _triple_or_none(spec.get("current_raw"))
if current is not None:
return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3))
current = _triple_or_none(spec.get("current_raw"))
if current is not None:
return any(abs(values[index] - current[index]) > PROPERTY_VALUE_TOLERANCE for index in range(3))
return any(abs(value) > PROPERTY_VALUE_TOLERANCE for value in values)
if value_type == "choice":
try:
value = self._property_choice_value(spec, text)
except ValueError:
return True
return str(value) != str(spec.get("current_raw", ""))
if value_type == "number_pair":
try:
values = self._parse_property_number_pair(text)
except ValueError:
return True
if spec.get("positive_pair") and any(value <= 0 for value in values):
return True
if any(self._property_scalar_range_error(spec, value) for value in values):
return True
current = spec.get("current_raw")
if isinstance(current, (tuple, list)) and len(current) == 2:
try:
current_pair = (float(current[0]), float(current[1]))
except (TypeError, ValueError):
return True
return any(abs(values[index] - current_pair[index]) > PROPERTY_VALUE_TOLERANCE for index in range(2))
return True
if value_type in {"integer", "integer_or_empty"}:
try:
value = int(text)
except ValueError:
return True
if self._property_scalar_range_error(spec, float(value)):
return True
current_text = str(spec.get("current_raw", "")).strip()
try:
current = int(current_text)
except ValueError:
return True
return value != current
try:
value = float(text)
except ValueError:
return True
if value_type == "positive" and value <= 0:
return True
if self._property_scalar_range_error(spec, value):
return True
current = _float_or_none(spec.get("current_raw"))
if current is None:
return True
return abs(value - current) > PROPERTY_VALUE_TOLERANCE
def apply_current_property_edit(self) -> None:
changed = self._changed_property_rows()
if not changed:
self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。")
return
if len(changed) > 1:
QMessageBox.information(self, "一次应用一个修改", "请只修改一行目标值,然后再点击应用当前修改。")
return
row, _spec, _text = changed[0]
self.apply_property_row_edit(row)
def _activate_property_source_feature(self, spec: dict[str, object]) -> None:
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None or source_face_id == self.selected_face_id:
return
if self.model is None or source_face_id < 0 or source_face_id >= len(self.model.faces):
raise ValueError("关联特征已经失效,请重新选择模型对象。")
info = spec.get("source_feature_info")
if not isinstance(info, dict):
info = self._feature_context_info(source_face_id)
else:
info = dict(info)
self.selected_kind = "feature"
self.selected_face_id = source_face_id
self.selected_edge_id = None
self.selected_part_id = int(info.get("part_id", self.model.face_part_ids[source_face_id]))
self.selected_solid_id = int(info.get("solid_id", self.model.face_solid_ids[source_face_id]))
self.current_info_values = dict(info)
self.current_info_text = "\n".join(
f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items()
)
self._sync_id_picker("Feature", source_face_id)
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [source_face_id])
self._update_selected_object_title()
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 = self._effective_property_spec(specs[row], row=row)
if not spec.get("action") or not spec.get("enabled"):
self.statusBar().showMessage("这一行当前不能直接修改。")
return
text = self._property_target_text(row)
is_command = str(spec.get("value_type", "number")) == "command"
if not is_command and not self._property_target_changed(spec, text):
self.statusBar().showMessage("请先在这一行的目标值列输入一个不同的新值。")
return
validation_error = "" if is_command else self._property_target_validation_error(spec, text)
if validation_error:
QMessageBox.information(self, "目标值无效", validation_error)
return
try:
self._activate_property_source_feature(spec)
if not is_command:
self._sync_property_edit_target(spec, text)
self._sync_property_preselects(spec)
except ValueError as exc:
QMessageBox.information(self, "目标值无效", str(exc))
return
action_name = str(spec.get("action", ""))
action = getattr(self, action_name, None)
if action is None:
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
return
action()
def set_manual_hole_bottom_face(self) -> None:
self._update_action_states()
self._refresh_property_editor()
text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else ""
if text:
self.statusBar().showMessage(f"已设置盲孔/盲槽底面 Face ID:{text},现在可以继续修改深度。")
else:
self.statusBar().showMessage("已清除手动底面 Face ID,将使用自动识别结果。")
def set_manual_slot_pair_face(self) -> None:
self._update_action_states()
self._refresh_property_editor()
text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else ""
if text:
self.statusBar().showMessage(f"已设置槽孔配对端 Face ID{text},后续槽孔修改会优先使用这个配对端。")
else:
self.statusBar().showMessage("已清除手动槽孔配对端 Face ID,将使用自动识别结果。")
def set_edge_length_anchor_mode(self) -> None:
self._update_action_states()
self._refresh_property_editor()
label = self.edge_length_anchor_combo.currentText() if hasattr(self, "edge_length_anchor_combo") else ""
self.statusBar().showMessage(f"已设置长度基准:{label or '自动'}。")
def set_edge_length_strategy_mode(self) -> None:
self._update_action_states()
self._refresh_property_editor()
label = self.edge_length_strategy_combo.currentText() if hasattr(self, "edge_length_strategy_combo") else ""
self.statusBar().showMessage(f"已设置修改策略:{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_preselects(self, spec: dict[str, object]) -> None:
preselects = spec.get("preselect_combos")
if not isinstance(preselects, (list, tuple)):
return
for item in preselects:
if not isinstance(item, dict):
continue
target_attr = str(item.get("target_attr") or "")
value = item.get("value")
if not target_attr:
continue
widget = getattr(self, target_attr, None)
if widget is None or not hasattr(widget, "setCurrentIndex"):
raise ValueError(f"这个属性缺少可同步的选项控件:{target_attr}")
index = widget.findData(value) if hasattr(widget, "findData") else -1
if index < 0 and hasattr(widget, "findText"):
index = widget.findText(str(value))
if index < 0:
raise ValueError(f"找不到可用选项:{value}")
widget.setCurrentIndex(index)
def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None:
value_type = str(spec.get("value_type", "number"))
if value_type == "command":
return
if value_type == "vector3":
values = self._parse_property_vector3(text)
values = self._transform_property_vector3_target(spec, values)
target_attrs = tuple(spec.get("target_attrs") or ())
if len(target_attrs) != 3:
raise ValueError("这个属性缺少 X/Y/Z 输入绑定。")
for attr, value in zip(target_attrs, values):
getattr(self, str(attr)).setText(_format_float(value))
return
if value_type == "choice":
value = self._property_choice_value(spec, text)
target_attr = spec.get("target_attr")
if not target_attr:
raise ValueError("这个属性缺少目标输入绑定。")
widget = getattr(self, str(target_attr))
index = widget.findData(value) if hasattr(widget, "findData") else -1
if index < 0 and hasattr(widget, "findText"):
index = widget.findText(str(text))
if index < 0:
raise ValueError(f"找不到可用选项:{text}")
widget.setCurrentIndex(index)
return
if value_type == "number_pair":
values = self._parse_property_number_pair(text)
if spec.get("positive_pair") and any(value <= 0 for value in values):
raise ValueError("请输入两个大于 0 的目标值。")
for value in values:
range_error = self._property_scalar_range_error(spec, value)
if range_error:
raise ValueError(range_error)
target_attrs = tuple(spec.get("target_attrs") or ())
if len(target_attrs) != 2:
raise ValueError("这个属性缺少两个目标输入绑定。")
for attr, value in zip(target_attrs, values):
getattr(self, str(attr)).setText(_format_float(value))
return
if value_type in {"integer", "integer_or_empty"}:
target_attr = spec.get("target_attr")
if not target_attr:
raise ValueError("这个属性缺少目标输入绑定。")
if not text and value_type == "integer_or_empty":
getattr(self, str(target_attr)).setText("")
return
try:
value = int(text)
except ValueError as exc:
raise ValueError("请输入整数形式的 Face ID。") from exc
range_error = self._property_scalar_range_error(spec, float(value))
if range_error:
raise ValueError(range_error)
key = str(spec.get("key", ""))
if self.model is not None and key in {"hole_bottom_face_id", "slot_pair_face_id", "chamfer_reference_face_id"}:
if value < 0 or value >= len(self.model.faces):
label = {
"hole_bottom_face_id": "底面",
"slot_pair_face_id": "槽孔配对端",
"chamfer_reference_face_id": "倒角参考",
}.get(key, "Face")
raise ValueError(f"{label} Face ID {value} 不存在。")
if key == "slot_pair_face_id" and self.selected_face_id is not None and value == self.selected_face_id:
raise ValueError("槽孔配对端不能和当前槽端使用同一个 Face ID。")
if key == "chamfer_reference_face_id" and self.selected_edge_id is not None:
try:
adjacent_face_ids = _int_values(self.model.edge_info(self.selected_edge_id).get("adjacent_face_ids"))
except Exception:
adjacent_face_ids = []
if adjacent_face_ids and value not in adjacent_face_ids:
raise ValueError(f"倒角参考 Face ID 必须是当前 Edge 的相邻 Face,可用值:{tuple(adjacent_face_ids)}。")
getattr(self, str(target_attr)).setText(str(value))
return
try:
value = float(text)
except ValueError as exc:
raise ValueError("请输入数字形式的目标值。") from exc
if value_type == "positive" and value <= 0:
raise ValueError("请输入大于 0 的目标值。")
range_error = self._property_scalar_range_error(spec, value)
if range_error:
raise ValueError(range_error)
value = self._transform_property_scalar_target(spec, value)
target_attr = spec.get("target_attr")
if not target_attr:
raise ValueError("这个属性缺少目标输入绑定。")
getattr(self, str(target_attr)).setText(_format_float(value))
def _transform_property_scalar_target(self, spec: dict[str, object], value: float) -> float:
transform = str(spec.get("target_transform") or "")
if not transform:
return value
context = spec.get("transform_context")
if not isinstance(context, dict):
context = {}
if transform == "radius_to_diameter":
if value <= 0:
raise ValueError("目标半径必须大于 0。")
return value * 2.0
if transform == "diameter_to_radius":
if value <= 0:
raise ValueError("目标直径必须大于 0。")
return value * 0.5
if transform == "plane_target_position_to_offset":
current_position = _float_or_none(context.get("current_plane_position"))
if current_position is None:
raise ValueError("当前平面缺少稳定法向位置,不能换算推拉距离。")
return value - current_position
if transform == "degrees_to_radians":
if value <= 0:
raise ValueError("目标角度必须大于 0。")
return math.radians(value)
if transform == "slot_open_angle_degrees_to_angular_span":
if value <= 0 or value >= 360.0:
raise ValueError("槽/半孔开口角度必须大于 0 且小于 360 度。")
target_span = math.tau - math.radians(value)
if target_span <= 1e-6 or target_span >= math.tau * 0.92:
raise ValueError("换算后的槽/半孔圆弧角度必须大于 0 且小于接近完整圆柱的范围。")
return target_span
if transform == "cone_semi_angle_degrees_to_reference_radius":
current_radius = _float_or_none(context.get("current_reference_radius"))
current_angle = _float_or_none(context.get("current_semi_angle"))
if current_radius is None or current_radius <= 0 or current_angle is None:
raise ValueError("当前圆锥面缺少稳定参考半径或半角,不能换算目标半角。")
if value <= 0 or value >= 89.0:
raise ValueError("圆锥目标半角必须大于 0 且小于 89 度。")
current_tangent = abs(math.tan(current_angle))
target_tangent = math.tan(math.radians(value))
if current_tangent <= 1e-9 or target_tangent <= 1e-9:
raise ValueError("圆锥半角过小,不能稳定换算参考半径。")
return current_radius * target_tangent / current_tangent
if transform in {"arc_length_to_radius", "arc_length_to_diameter"}:
angular_span = _float_or_none(context.get("angular_span"))
if angular_span is None or angular_span <= 1e-6:
raise ValueError("当前对象缺少稳定圆弧角度,不能把圆弧长度换算为半径。")
radius = value / angular_span
return radius * 2.0 if transform == "arc_length_to_diameter" else radius
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
raise ValueError(f"这个属性使用了未知的目标换算方式:{transform}")
def _transform_property_vector3_target(
self,
spec: dict[str, object],
values: tuple[float, float, float],
) -> tuple[float, float, float]:
transform = str(spec.get("target_transform") or "")
if not transform:
return values
context = spec.get("transform_context")
if not isinstance(context, dict):
context = {}
if transform == "target_center_to_translation":
current_center = _triple_or_none(context.get("current_center")) or _triple_or_none(spec.get("current_raw"))
if current_center is None:
raise ValueError("当前对象缺少稳定中心坐标,不能换算平移量。")
return (
values[0] - current_center[0],
values[1] - current_center[1],
values[2] - current_center[2],
)
raise ValueError(f"这个属性使用了未知的向量换算方式:{transform}")
def _property_choice_value(self, spec: dict[str, object], text: str) -> object:
choices = spec.get("choices")
if not isinstance(choices, dict) or not choices:
raise ValueError("这个属性缺少可用选项。")
normalized = str(text).strip()
if not normalized:
raise ValueError("请输入一个选项。")
if normalized in choices:
return choices[normalized]
lowered = normalized.lower()
for label, value in choices.items():
if str(label).strip().lower() == lowered:
return value
raise ValueError(f"不支持的选项:{text}")
def _property_scalar_range_error(self, spec: dict[str, object], value: float) -> str:
label = str(spec.get("label", "目标值"))
min_value = _float_or_none(spec.get("min_value"))
max_value = _float_or_none(spec.get("max_value"))
if min_value is not None:
min_exclusive = bool(spec.get("min_exclusive"))
if (min_exclusive and value <= min_value) or (not min_exclusive and value < min_value):
operator = "大于" if min_exclusive else "大于或等于"
return f"{label} 必须{operator} {_format_float(min_value)}。"
if max_value is not None:
max_exclusive = bool(spec.get("max_exclusive"))
if (max_exclusive and value >= max_value) or (not max_exclusive and value > max_value):
operator = "小于" if max_exclusive else "小于或等于"
return f"{label} 必须{operator} {_format_float(max_value)}。"
return ""
def _parse_property_number_pair(self, text: str) -> tuple[float, float]:
text = text.strip().strip("()[]")
chunks = text.replace("", ",").replace(";", ",").replace("", ",").replace(" ", ",").split(",")
values = [chunk for chunk in chunks if chunk.strip()]
if len(values) != 2:
raise ValueError("请输入两个数字,例如 1, 2。")
try:
return (float(values[0]), float(values[1]))
except ValueError as exc:
raise ValueError("这两个目标值都必须是数字。") from exc
def _parse_property_vector3(self, text: str) -> tuple[float, float, float]:
text = text.strip().strip("()[]")
chunks = text.replace("", ",").replace(";", ",").replace("", ",").replace(" ", ",").split(",")
values = [chunk for chunk in chunks if chunk.strip()]
if len(values) != 3:
raise ValueError("请输入三个数字,例如 10, 0, 0。")
try:
return (float(values[0]), float(values[1]), float(values[2]))
except ValueError as exc:
raise ValueError("平移 X/Y/Z 必须都是数字。") from exc
def _selected_action_info(self) -> dict[str, object]:
if self.model is None:
return {}
current_info = dict(getattr(self, "current_info_values", {}) or {})
current_face_id = _int_or_none(
current_info.get("feature_source_face_id", current_info.get("topological_face_id", current_info.get("face_id")))
)
if (
self.selected_face_id is not None
and current_face_id == self.selected_face_id
and str(current_info.get("surface", "") or "")
):
return self._feature_info_for_selected_face(self.selected_face_id, current_info)
try:
if self.selected_kind == "feature" and self.selected_face_id is not None:
return self._feature_info_for_selected_face(
self.selected_face_id,
self.model.quick_face_info(self.selected_face_id),
)
if self.selected_kind == "face" and self.selected_face_id is not None:
info = self.model.quick_face_info(self.selected_face_id)
return self._feature_info_for_selected_face(self.selected_face_id, 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._feature_info_for_selected_face(resolved_face_id, self.model.face_info(resolved_face_id))
)
self.selected_kind = "feature" if record.target_kind == "feature" else "face"
self.selected_face_id = resolved_face_id
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info["solid_id"]) if int(info["solid_id"]) >= 0 else None
self.selected_pick_position = record.pick_position
self._set_selection_mode("Feature" if record.target_kind == "feature" else "Face")
current_logical_id = self.model.face_region_logical_id(resolved_face_id)
self._sync_id_picker("Feature" if record.target_kind == "feature" else "Face", current_logical_id)
face_ids = (
_int_values(info.get("feature_highlight_face_ids"))
if record.target_kind == "feature"
else self.model.face_region_ids(resolved_face_id)
)
if not face_ids:
face_ids = self.model.face_region_ids(resolved_face_id)
self._highlight_faces(face_ids=face_ids)
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
region_note = f";同域面区域 {len(face_ids)} 个Face" if len(face_ids) > 1 else ""
id_note = (
f"逻辑 Face ID {current_logical_id}"
if record.target_logical_id is not None
else f"Face {record.target_id}"
)
if resolved_face_id != record.target_id:
id_note += f"(当前拓扑Face {resolved_face_id}"
locator_note = (
f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。"
"布尔编辑后Face ID可能发生语义变化,请结合拾取点确认。"
)
else:
if record.target_logical_id is not None:
locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。"
else:
locator_note = f"定位: 原目标Face {record.target_id} 在当前模型索引中已经不存在。"
elif record.target_kind == "edge" and record.target_id is not None:
resolved_edge_id = self._resolve_record_edge_id(record)
if resolved_edge_id is not None:
info = self.model.edge_info(resolved_edge_id)
self.selected_kind = "edge"
self.selected_edge_id = resolved_edge_id
self.selected_part_id = int(info["part_id"])
self.selected_solid_id = int(info.get("solid_id", -1)) if int(info.get("solid_id", -1)) >= 0 else None
self.selected_pick_position = record.pick_position
self._set_selection_mode("Edge")
self._sync_id_picker("Edge", resolved_edge_id)
self._highlight_edge(resolved_edge_id)
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
locator_note = (
f"定位: 已尝试高亮当前模型中的Edge {resolved_edge_id}。"
"布尔/倒圆编辑后Edge ID可能发生语义变化,请结合拾取点确认。"
)
else:
locator_note = f"定位: 原目标Edge {record.target_id} 在当前模型索引中已经不存在。"
elif record.target_kind == "part" and record.target_id is not None:
part_id = int(record.target_id)
if self.model.part_by_id(part_id) is not None:
info = self.model.part_info(part_id)
self.selected_kind = "part"
self.selected_part_id = part_id
self.selected_pick_position = record.pick_position
self._set_selection_mode("Part")
self._sync_id_picker("Part", part_id)
self._highlight_faces(part_ids=[part_id])
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
locator_note = f"定位: 已尝试高亮当前模型中的零件 {part_id}。"
else:
locator_note = f"定位: 原目标零件 {part_id} 在当前模型索引中已经不存在。"
elif record.target_kind == "solid" and record.target_id is not None:
solid_id = int(record.target_id)
if 0 <= solid_id < len(self.model.solids):
info = self.model.solid_info(solid_id)
self.selected_kind = "solid"
self.selected_solid_id = solid_id
self.selected_part_id = int(info["part_id"])
self.selected_pick_position = record.pick_position
face_ids = [i for i, sid in enumerate(self.model.face_solid_ids) if sid == solid_id]
self._set_selection_mode("Solid")
self._sync_id_picker("Solid", solid_id)
self._highlight_faces(face_ids=face_ids)
self.set_info(self._with_pick_info(info, record.pick_position))
located = True
locator_note = f"定位: 已尝试高亮当前模型中的Solid {solid_id}。"
else:
locator_note = f"定位: 原目标Solid {solid_id} 在当前模型索引中已经不存在。"
if record.pick_position is not None:
self._show_pick_marker(record.pick_position)
if not locator_note:
locator_note = "定位: 已记录当时的拾取点。"
elif located:
locator_note += f"\n拾取点: {_format_value(record.pick_position)}"
else:
locator_note += f"\n已记录当时拾取点: {_format_value(record.pick_position)}"
if not locator_note:
locator_note = "定位: 这条历史记录没有可定位的目标或拾取点。"
self._update_action_states()
self.statusBar().showMessage(locator_note.splitlines()[0])
return locator_note
def _resolve_record_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
verified_face_id = self._record_verified_face_id(record)
if verified_face_id is not None:
return verified_face_id
moved_face_id = self._record_moved_face_id(record)
if moved_face_id is not None:
return moved_face_id
plane_position_face_id = self._record_plane_position_face_id(record)
if plane_position_face_id is not None:
return plane_position_face_id
if not self._record_face_count_changed(record):
stable_face_id = self._record_stable_face_id(record)
if stable_face_id is not None:
return stable_face_id
nearest_face_id = self._record_nearest_semantic_face_id(record)
if nearest_face_id is not None:
return nearest_face_id
stable_face_id = self._record_stable_face_id(record)
if stable_face_id is not None:
return stable_face_id
return None
def _record_verified_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
value = _record_message_field(record.result_message, "verified_face")
if value is None:
return None
try:
face_id = int(value)
except (TypeError, ValueError):
return None
if not (0 <= face_id < len(self.model.faces)):
return None
return face_id if self._record_face_semantic_matches(record, face_id) else None
def _record_moved_face_id(self, record: OperationRecord) -> int | None:
if self.model is None or record.pick_position is None:
return None
moved_point = self._record_moved_pick_position(record)
if moved_point is None:
return None
candidates = self._record_candidate_face_ids(record)
if not candidates:
return None
nearest = self.model.nearest_face_id_to_point(candidates, moved_point)
if nearest is None:
return None
return nearest if self._record_face_semantic_matches(record, nearest) else None
def _record_moved_pick_position(self, record: OperationRecord) -> tuple[float, float, float] | None:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
distance = (
self._record_float_parameter(record, "semantic_distance")
if parameters.get("semantic_distance") not in {"", None}
else self._record_float_parameter(record, "push_pull_distance")
)
direction = (
_triple_or_none(parameters.get("outward_direction"))
or _triple_or_none(parameters.get("push_pull_outward_direction"))
or _triple_or_none(parameters.get("shell_desired_movement_vector"))
)
if distance is None or direction is None:
return None
length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
if length <= 1e-12:
return None
unit = (direction[0] / length, direction[1] / length, direction[2] / length)
px, py, pz = record.pick_position
return (
float(px) + unit[0] * float(distance),
float(py) + unit[1] * float(distance),
float(pz) + unit[2] * float(distance),
)
def _record_nearest_semantic_face_id(self, record: OperationRecord) -> int | None:
if self.model is None or record.pick_position is None:
return None
candidates = self._record_candidate_face_ids(record)
if not candidates:
return None
nearest = self.model.nearest_face_id_to_point(candidates, record.pick_position)
if nearest is None:
return None
return nearest if self._record_face_semantic_matches(record, nearest) else None
def _record_stable_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
if record.target_logical_id is not None:
try:
logical_matches = self.model.face_ids_for_logical_id(int(record.target_logical_id))
except Exception:
logical_matches = []
logical_matches = [
face_id for face_id in logical_matches if self._record_face_semantic_matches(record, face_id)
]
if logical_matches:
return self.model.nearest_face_id_to_point(logical_matches, record.pick_position)
if record.target_id is None:
return None
try:
face_id = int(record.target_id)
except (TypeError, ValueError):
return None
if 0 <= face_id < len(self.model.faces) and self._record_face_semantic_matches(record, face_id):
return face_id
return None
def _record_face_count_changed(self, record: OperationRecord) -> bool:
before = record.before_snapshot if isinstance(record.before_snapshot, dict) else {}
after = record.after_snapshot if isinstance(record.after_snapshot, dict) else {}
before_ids = before.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
after_ids = after.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
if isinstance(before_ids, (list, tuple)) and isinstance(after_ids, (list, tuple)):
return len(before_ids) != len(after_ids)
return True
def _record_face_semantic_matches(self, record: OperationRecord, face_id: int) -> bool:
if self.model is None or not (0 <= int(face_id) < len(self.model.faces)):
return False
part_id = self._record_int_parameter(record, "part_id")
if part_id is not None and self.model.face_part_ids[int(face_id)] != part_id:
return False
surface_hint = self._record_surface_hint(record)
surface_kind = self.model.face_surface_kind(int(face_id))
if surface_hint and surface_kind != surface_hint:
return False
target_diameter = self._record_target_diameter(record)
if target_diameter is not None:
current_diameter = self.model.face_cylinder_diameter(int(face_id))
if current_diameter is None:
return False
diameter_tolerance = max(abs(target_diameter) * 0.02, 1e-5)
if abs(current_diameter - target_diameter) > diameter_tolerance:
return False
return True
def _record_surface_hint(self, record: OperationRecord) -> str:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
surface = str(parameters.get("surface", "") or "")
if surface in {
"plane",
"cylinder",
"cone",
"sphere",
"torus",
"bezier surface",
"b-spline surface",
"surface of revolution",
"surface of extrusion",
"offset surface",
"other surface",
}:
return surface
operation_name = str(record.operation_name or "")
if any(token in operation_name for token in ("孔", "圆柱", "槽", "凸台", "圆角", "倒圆")):
return "cylinder"
if any(token in operation_name for token in ("推拉", "薄壁", "壳体")):
return "plane"
if self._record_target_diameter(record) is not None:
return "cylinder"
return ""
def _record_plane_position_face_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
target_position = self._record_float_parameter(record, "target_plane_position")
direction = _triple_or_none((record.parameters or {}).get("outward_direction"))
if target_position is None or direction is None:
return None
direction_length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
if direction_length <= 1e-12:
return None
unit = (direction[0] / direction_length, direction[1] / direction_length, direction[2] / direction_length)
tolerance = max((self._record_float_parameter(record, "bbox_diagonal") or 1.0) * 1e-5, 1e-4)
best_face_id: int | None = None
best_error = math.inf
for face_id in self._record_candidate_face_ids(record):
if not (0 <= int(face_id) < len(self.model.faces)):
continue
try:
info = self.model.face_info(int(face_id))
except Exception:
continue
if info.get("surface") != "plane":
continue
normal = _triple_or_none(info.get("normal")) or _triple_or_none(info.get("oriented_normal"))
if normal is not None and abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85:
continue
origin = _triple_or_none(info.get("plane_origin"))
if origin is None:
continue
position = origin[0] * unit[0] + origin[1] * unit[1] + origin[2] * unit[2]
error = abs(position - target_position)
if error < best_error:
best_error = error
best_face_id = int(face_id)
if best_face_id is not None and best_error <= tolerance:
return best_face_id
return None
def _record_target_diameter(self, record: OperationRecord) -> float | None:
return (
self._record_float_parameter(record, "new_diameter")
or self._record_float_parameter(record, "derived_new_diameter")
or self._record_float_parameter(record, "target_diameter")
or self._record_float_parameter(record, "diameter")
)
def _resolve_record_edge_id(self, record: OperationRecord) -> int | None:
if self.model is None:
return None
if record.pick_position is not None:
nearest = self.model.nearest_edge_id_to_point(self._record_candidate_edge_ids(record), record.pick_position)
if nearest is not None:
return nearest
if record.target_id is None:
return None
try:
edge_id = int(record.target_id)
except (TypeError, ValueError):
return None
return edge_id if 0 <= edge_id < len(self.model.edges) else None
def _record_candidate_face_ids(self, record: OperationRecord) -> list[int]:
if self.model is None:
return []
part_id = self._record_int_parameter(record, "part_id")
solid_id = self._record_int_parameter(record, "solid_id")
face_ids = list(range(len(self.model.faces)))
if part_id is not None:
face_ids = [face_id for face_id in face_ids if self.model.face_part_ids[face_id] == part_id]
if solid_id is not None and solid_id >= 0:
same_solid = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id]
if same_solid:
face_ids = same_solid
surface_hint = self._record_surface_hint(record)
if surface_hint:
surface_face_ids: list[int] = []
for face_id in face_ids:
if self.model.face_surface_kind(face_id) == surface_hint:
surface_face_ids.append(face_id)
if not surface_face_ids:
return []
face_ids = surface_face_ids
target_diameter = self._record_target_diameter(record)
if target_diameter is not None and target_diameter > 0:
diameter_tolerance = max(target_diameter * 0.02, 1e-5)
cylindrical_face_ids: list[int] = []
for face_id in face_ids:
current_diameter = self.model.face_cylinder_diameter(face_id)
if current_diameter is not None:
if abs(current_diameter - target_diameter) <= diameter_tolerance:
cylindrical_face_ids.append(face_id)
if not cylindrical_face_ids:
return []
face_ids = cylindrical_face_ids
return face_ids
def _record_candidate_edge_ids(self, record: OperationRecord) -> list[int]:
if self.model is None:
return []
part_id = self._record_int_parameter(record, "part_id")
solid_id = self._record_int_parameter(record, "solid_id")
edge_ids = list(range(len(self.model.edges)))
if part_id is not None:
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_part_ids[edge_id] == part_id]
if solid_id is not None and solid_id >= 0:
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_solid_ids[edge_id] == solid_id]
return edge_ids or list(range(len(self.model.edges)))
def _record_int_parameter(self, record: OperationRecord, key: str) -> int | None:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
value = parameters.get(key)
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _record_float_parameter(self, record: OperationRecord, key: str) -> float | None:
parameters = record.parameters if isinstance(record.parameters, dict) else {}
return _float_or_none(parameters.get(key))
def undo_edit(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能撤销。"):
return
if not self.undo_stack:
self.statusBar().showMessage("没有可撤销的编辑")
return
current = self.model.snapshot()
snapshot = self.undo_stack[-1]
undone = self.operation_history[-1] if self.operation_history else OperationRecord("编辑", "编辑")
try:
self._restore_snapshot(snapshot)
except Exception as exc:
rollback_message = self._restore_after_failed_undo_redo(current)
QMessageBox.critical(self, "撤销失败", f"{exc}\n\n{rollback_message}")
self.statusBar().showMessage("撤销失败,模型已尽量恢复到撤销前状态")
return
self.undo_stack.pop()
if self.operation_history:
self.operation_history.pop()
self.redo_stack.append(current)
self.redo_history.append(undone)
self._refresh_history_list()
self._update_action_states()
self.statusBar().showMessage(f"已撤销:{undone.summary}")
def redo_edit(self) -> None:
if self.model is None:
return
if self._edit_busy("编辑计算中,暂时不能重做。"):
return
if not self.redo_stack:
self.statusBar().showMessage("没有可重做的编辑")
return
current = self.model.snapshot()
snapshot = self.redo_stack[-1]
redone = self.redo_history[-1] if self.redo_history else OperationRecord("编辑", "编辑")
try:
self._restore_snapshot(snapshot)
except Exception as exc:
rollback_message = self._restore_after_failed_undo_redo(current)
QMessageBox.critical(self, "重做失败", f"{exc}\n\n{rollback_message}")
self.statusBar().showMessage("重做失败,模型已尽量恢复到重做前状态")
return
self.redo_stack.pop()
if self.redo_history:
self.redo_history.pop()
self.undo_stack.append(current)
self.operation_history.append(redone)
self._refresh_history_list()
self._update_action_states()
self.statusBar().showMessage(f"已重做:{redone.summary}")
def _restore_snapshot(self, snapshot: dict[int, object]) -> None:
if self.model is None:
return
self.model.restore_snapshot(snapshot)
self._reset_selection()
self._populate_part_tree()
self._rebuild_scene(reset_camera=False)
self._clear_editable_candidates()
self._clear_cylinder_candidates()
stats = self.model.stats()
self.set_info(
{
"parts": stats.parts,
"solids": stats.solids,
"faces": stats.faces,
"edges": stats.edges,
"vertices": stats.vertices,
}
)
def _restore_after_failed_undo_redo(self, snapshot: dict[int, object]) -> str:
try:
self._restore_snapshot(snapshot)
except Exception as rollback_exc:
return f"恢复原状态也失败:{rollback_exc}。建议重新加载 STEP 文件。"
return "模型已恢复到操作前状态,历史记录未移动。"