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

10621 lines
550 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 json
import math
from pathlib import Path
import re
import time
from typing import Iterable
from PySide6.QtCore import Qt, QThread, QTimer, Slot, QStringListModel
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QCompleter,
QDialog,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QTableWidgetItem,
QTextEdit,
QTreeWidgetItem,
QToolTip,
QVBoxLayout,
QWidget,
)
from PySide6.QtGui import QColor
from .constants import FACE_SELECTION_FEATURE_INFO_SURFACES, FREEFORM_FACE_SURFACES, SNAPSHOT_FACE_LOGICAL_IDS_KEY
from .model import StepModel
from .parametric_component import component_edit_config_from_spec
from .relation_formulas import (
ObjectParameterRef,
RELATION_REF_PATTERN,
RelationFormulaError,
Vector3,
evaluate_relation_formula,
parse_relation_formula,
relation_value_to_text,
rewrite_relation_formula_ids,
validate_relation_formula_graph,
)
from .scdm_backend import resolve_scdm_backend
from .scdm_edit_runner import run_scdm_edit_job
from .records import OperationRecord
from .scdm_property_specs import property_specs_from_scdm_cache
from .scdm_result_validator import build_scdm_id_mapping, rewrite_scdm_relation_formula_ids, validate_scdm_edit_result
from .scdm_status import cached_scdm_backend_payload, summarize_scdm_capability_progress, summarize_scdm_runtime
from .ui_helpers import * # noqa: F403
from .widgets import NoWheelComboBox
from .workers import EditWorker, LoadWorker, ScanWorker
PROPERTY_VALUE_TOLERANCE = 1e-9
PROPERTY_LABEL_COLUMN = 0
PROPERTY_CURRENT_COLUMN = 1
PROPERTY_TARGET_COLUMN = 2
PROPERTY_SCOPE_COLUMN = 3
PROPERTY_INPUT_COLUMN = 4
PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "目标值", "建模意图", "输入参数")
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 62, 66, 50)
RELATION_FORMULA_OBJECT_COMPLETION_LIMIT = 300
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 82, 88, 66)
PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS = {
"face_center_position",
"edge_center_point",
}
PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_LABELS = {"中心"}
PROPERTY_COMMAND_ORDER = ("offset", "move", "scale", "rotate", "feature", "diagnostics")
PROPERTY_COMMAND_LABELS = {
"offset": "偏移",
"move": "移动",
"scale": "缩放",
"rotate": "旋转",
"feature": "特征",
"diagnostics": "诊断",
}
PROPERTY_COMMAND_SUBTITLES = {
"offset": "沿法线调面",
"move": "移动中心/坐标",
"scale": "改尺寸/半径",
"rotate": "绕轴旋转",
"feature": "孔槽/圆角",
"diagnostics": "识别与关系",
}
PROPERTY_COMMAND_HELP = {
"offset": "沿 Face 法线调整位置,适合平面推拉、偏移或切除深度类修改。",
"move": "移动当前 Face、Edge、特征或 Solid 的中心/位置,不直接修改尺寸。",
"scale": "修改长度、宽度、高度、半径、直径等尺寸参数。",
"rotate": "设置旋转轴或旋转角度,适合 Part/Solid 的整体姿态调整。",
"feature": "执行孔、槽、圆角、倒角、封堵等离散特征命令。",
"diagnostics": "查看识别依据、一级拓扑关系和当前不支持修改的原因。",
}
RELATION_VECTOR_COMPONENTS = {
"位置X": ("位置", 0),
"位置Y": ("位置", 1),
"位置Z": ("位置", 2),
"中心X": ("中心", 0),
"中心Y": ("中心", 1),
"中心Z": ("中心", 2),
}
RELATION_SPEC_ALIASES = {
"diameter": ("直径",),
"boss_diameter": ("直径",),
"generic_cylinder_diameter": ("直径",),
"multi_hole_diameter": ("直径",),
"circle_edge_diameter": ("直径",),
"sphere_diameter": ("直径",),
"cone_reference_diameter": ("直径", "参考直径"),
"radius": ("半径",),
"multi_hole_radius": ("半径",),
"circle_edge_radius": ("半径",),
"sphere_radius": ("半径",),
"existing_fillet_radius_estimate": ("圆角半径", "半径"),
"boss_radius": ("半径",),
"hole_axis_center": ("位置",),
"slot_axis_center": ("位置",),
"boss_axis_center": ("位置",),
"multi_hole_position_delta": ("位置偏移",),
"circle_edge_axis_center": ("位置", "圆心", "轴心"),
"face_target_normal_position": ("偏移",),
"local_face_width": ("面内长度",),
"local_face_height": ("面内宽度",),
"edge_length": ("长度",),
"target_length": ("长度",),
"cylinder_height": ("高度",),
"boss_height": ("高度",),
"hole_depth_estimate": ("深度",),
"slot_sagitta_depth_estimate": ("槽深", "深度"),
"slot_chord_width_estimate": ("槽宽", "宽度"),
}
FEATURE_EDIT_SEMANTICS_KEYS = {
"cad_modeling_form",
"cad_recommended_operation",
"edge_first_level_topology",
"face_first_level_topology",
"cylindrical_feature_first_level_topology",
"slot_edit_semantics",
"hole_edit_semantics",
"boss_edit_semantics",
"existing_fillet_edit_semantics",
"analytic_surface_edit_semantics",
"freeform_surface_edit_semantics",
"face_edit_semantics",
}
PROPERTY_EXPLANATION_TOOLTIPS = {
"cad_modeling_form": (
"建模形式:\n"
"程序对你选中对象的 CAD 语义判断。比如你点了一个平面 Face,它可能被理解成“可拉伸/切除的平面”"
"“偏移面”“壳体区域”等。它是在说“这东西像什么建模对象”。"
),
"cad_recommended_operation": (
"推荐操作:\n"
"程序根据当前识别结果,给出的相对安全的改法建议。比如平面 Face 通常建议优先用 偏移 + 拉伸/切除,"
"不要一上来做复杂局部重建。它是在说“建议你怎么改”。"
),
"feature_context_note": (
"关联探测:\n"
"程序有没有去找当前特征周围的相关特征。比如你点了一个面,它会尝试看共享边附近有没有孔、槽、凸台、"
"相邻 Face 等。它是在说“我额外看了周围没有,找到什么没有”。"
),
}
def _property_table_column_widths(available_width: int) -> tuple[int, ...]:
"""Prefer current value visibility while keeping modeling intent and target usable."""
column_count = len(PROPERTY_TABLE_MIN_COLUMN_WIDTHS)
available = max(int(available_width or 0), column_count * 44)
minimum = PROPERTY_TABLE_MIN_COLUMN_WIDTHS
preferred = PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS
min_total = sum(minimum)
preferred_total = sum(preferred)
if available >= preferred_total:
widths = [int(value) for value in preferred]
extra = available - preferred_total
weights = (0.30, 0.24, 0.18, 0.16, 0.12)
for index, weight in enumerate(weights):
addition = int(extra * weight)
widths[index] += addition
widths[1] += available - sum(widths)
return tuple(widths) # type: ignore[return-value]
if available >= min_total:
scale = (available - min_total) / max(preferred_total - min_total, 1)
widths = [
int(round(min_width + (pref_width - min_width) * scale))
for min_width, pref_width in zip(minimum, preferred)
]
widths[1] += available - sum(widths)
return tuple(widths) # type: ignore[return-value]
floors = (44, 44, 44, 44, 44)
widths = [int(value) for value in minimum]
deficit = min_total - available
for index in (0, 2, 3, 1, 4):
if deficit <= 0:
break
reducible = max(0, widths[index] - floors[index])
reduction = min(reducible, deficit)
widths[index] -= reduction
deficit -= reduction
if deficit > 0:
widths[1] = max(44, widths[1] - deficit)
widths[1] += available - sum(widths)
return tuple(max(44, width) for width in widths) # type: ignore[return-value]
def _compact_property_card_text(text: str, limit: int = 220) -> str:
compact = " ".join(str(text or "").replace("\r", "\n").split())
if len(compact) <= limit:
return compact
return f"{compact[: max(0, limit - 1)].rstrip()}…"
def _property_parameter_is_visible(spec: dict[str, object]) -> bool:
key = str(spec.get("key", "") or "")
label = str(spec.get("label", "") or "").strip()
if key in PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_KEYS:
return False
if label in PROPERTY_TEMPORARILY_HIDDEN_PARAMETER_LABELS:
return False
return True
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"))
face_size_keys = (
("local_face_width", "local_face_height")
if bool(action_info.get("local_face_size_edit_ready"))
else ()
)
if surface == "plane":
if action_info.get("multistep_prismatic_status") == "blocked":
return ()
if action_info.get("existing_chamfer_status") == "candidate":
return ("existing_chamfer_distance_estimate",)
if action_info.get("prismatic_profile_status") == "candidate":
keys = list(face_size_keys)
keys.append("face_target_normal_position")
if action_info.get("prismatic_extrusion_status") == "candidate":
keys.append("shell_thickness_estimate")
if str(action_info.get("prismatic_feature_semantics") or "") in {"additive-boss", "subtractive-pocket"}:
keys.append("face_center_position")
return tuple(keys)
if action_info.get("shell_region_status") == "candidate":
return (
*face_size_keys,
"face_center_position",
"face_target_normal_position",
"shell_thickness_estimate",
)
return (
*face_size_keys,
"face_center_position",
"face_target_normal_position",
)
if surface == "cylinder":
is_partial = (
not _is_effectively_full_cylinder(action_info)
and 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_axis_center", "hole_depth_estimate")
if feature_guess == "boss/outer-round candidate":
return ("boss_diameter", "boss_height")
if feature_guess == "round/fillet candidate":
if str(action_info.get("existing_fillet_status") or "") == "blocked":
return ()
return ("existing_fillet_radius_estimate", "existing_fillet_arc_length_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 _is_effectively_full_cylinder(action_info: dict[str, object]) -> bool:
if bool(action_info.get("is_full_cylinder")):
return True
for key in ("same_domain_angular_span", "angular_span"):
angular_span = _float_or_none(action_info.get(key))
if angular_span is not None and angular_span >= math.tau * 0.92:
return True
return False
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._clear_multi_feature_selection_state()
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
self._invalidate_selected_action_info_cache()
if clear_info:
self._clear_current_object_info()
if clear_highlight:
self._clear_highlight()
if clear_info or clear_highlight:
self._update_selected_object_title()
self._update_action_states()
def _invalidate_selected_action_info_cache(self) -> None:
self._selected_action_info_cache_key = None
self._selected_action_info_cache_value = None
def _clear_multi_feature_selection_state(self) -> None:
self.multi_selected_feature_face_ids = []
self.multi_selected_hole_entries = []
self.multi_selection_active = False
def _axis_center_from_feature_info(self, info: dict[str, object]) -> tuple[float, float, float] | None:
center = _triple_or_none(info.get("axis_center"))
if center is not None:
return center
axis_point = _triple_or_none(info.get("axis_point"))
axis_direction = _triple_or_none(info.get("axis"))
axis_range = info.get("same_domain_v_range") or info.get("v_range")
if axis_point is None or axis_direction is None or not isinstance(axis_range, (list, tuple)) or len(axis_range) < 2:
return None
v_min = _float_or_none(axis_range[0])
v_max = _float_or_none(axis_range[1])
if v_min is None or v_max is None:
return None
v_mid = (v_min + v_max) * 0.5
return (
axis_point[0] + axis_direction[0] * v_mid,
axis_point[1] + axis_direction[1] * v_mid,
axis_point[2] + axis_direction[2] * v_mid,
)
def _hole_multi_select_entry(self, face_id: int) -> dict[str, object] | None:
if self.model is None or face_id < 0 or face_id >= len(self.model.faces):
return None
try:
info = self.model.feature_info(face_id)
except Exception:
try:
info = self.model.quick_face_info(face_id)
except Exception:
return None
if info.get("surface") != "cylinder" or str(info.get("feature_guess") or "") != "hole/groove candidate":
return None
if not _is_effectively_full_cylinder(info):
return None
diameter = _float_or_none(info.get("diameter"))
center = self._axis_center_from_feature_info(info)
if diameter is None or diameter <= 0 or center is None:
return None
try:
logical_id = int(self.model.face_region_logical_id(face_id))
except Exception:
logical_id = int(face_id)
try:
region_face_ids = self.model.face_region_ids(face_id)
except Exception:
region_face_ids = _int_values(info.get("same_domain_face_ids")) or [face_id]
highlight_face_ids = tuple(
sorted({int(item) for item in region_face_ids if 0 <= int(item) < len(self.model.faces)})
) or (int(face_id),)
return {
"face_id": int(face_id),
"logical_id": logical_id,
"diameter": float(diameter),
"axis_center": center,
"feature_highlight_face_ids": highlight_face_ids,
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
}
def _multi_hole_selection_info(self) -> dict[str, object]:
entries = [dict(item) for item in getattr(self, "multi_selected_hole_entries", []) or []]
face_ids = [int(item["face_id"]) for item in entries if item.get("face_id") is not None]
logical_ids = [int(item["logical_id"]) for item in entries if item.get("logical_id") is not None]
diameters = [float(item["diameter"]) for item in entries if _float_or_none(item.get("diameter")) is not None]
centers = [_triple_or_none(item.get("axis_center")) for item in entries]
centers = [item for item in centers if item is not None]
highlight_ids: set[int] = set()
for entry in entries:
highlight_ids.update(_int_values(entry.get("feature_highlight_face_ids")))
diameter_text = ""
if diameters:
first = diameters[0]
if all(abs(value - first) <= PROPERTY_VALUE_TOLERANCE for value in diameters):
diameter_text = _format_float(first)
else:
diameter_text = f"{min(diameters):g} ~ {max(diameters):g}"
return {
"kind": "multi_feature",
"selection_title": f"多选孔 {len(entries)} 个",
"selection_display_id": ", ".join(str(item) for item in logical_ids),
"surface": "cylinder",
"feature_guess": "hole/groove candidate",
"feature_type": "多选圆柱孔",
"feature_edit_actions": "批量改孔径/半径;按 ΔX/ΔY/ΔZ 整体移动孔位置;批量封堵",
"feature_highlight_face_ids": tuple(sorted(highlight_ids or set(face_ids))),
"multi_selection_kind": "holes",
"multi_selected_count": len(entries),
"multi_selected_face_ids": tuple(face_ids),
"multi_selected_logical_ids": tuple(logical_ids),
"multi_selected_hole_entries": tuple(entries),
"multi_hole_diameter_text": diameter_text,
"multi_hole_all_same_diameter": bool(diameters and diameter_text and "~" not in diameter_text),
"multi_hole_relation_status": "当前支持批量孔径/半径、位置偏移和封堵;等直径、保持中心距、Z 差值固定会作为关系式阶段继续接入。",
}
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_safely()
def _with_pick_info(
self,
info: dict[str, object],
pick_position: tuple[float, float, float] | None,
) -> dict[str, object]:
if pick_position is None:
return info
enriched = dict(info)
enriched["pick_position"] = pick_position
return enriched
def _selection_identity_fields(
self,
face_id: int,
title_prefix: str = "Face",
region_face_ids: Iterable[int] | None = None,
) -> dict[str, object]:
logical_id = int(face_id)
region_ids = sorted(
{
int(item)
for item in _int_values(region_face_ids)
if self.model is not None and 0 <= int(item) < len(self.model.faces)
}
)
if self.model is not None:
if region_ids:
logical_candidates: list[int] = []
for item in region_ids:
try:
logical_candidates.append(int(self.model.face_logical_id(item)))
except Exception:
logical_candidates.append(int(item))
logical_id = min(logical_candidates) if logical_candidates else int(face_id)
elif bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
try:
logical_id = int(self.model.face_logical_id(face_id))
except Exception:
logical_id = int(face_id)
else:
try:
logical_id = int(self.model.face_region_logical_id(face_id))
except Exception:
try:
logical_id = int(self.model.face_logical_id(face_id))
except Exception:
logical_id = int(face_id)
title = f"{title_prefix} {logical_id}"
result = {
"selection_title": title,
"selection_display_id": logical_id,
"selection_topological_face_id": int(face_id),
}
if self.model is not None:
if not region_ids and not bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
try:
region_ids = self.model.face_region_ids(face_id)
except Exception:
region_ids = []
region_ids = sorted({int(item) for item in region_ids if 0 <= int(item) < len(self.model.faces)})
if len(region_ids) > 1:
result.update(
{
"face_region_logical_id": logical_id,
"face_region_ids": tuple(region_ids),
"face_region_count": len(region_ids),
"face_region_note": "STEP/B-Rep 将该局部面拆成多个同域拓扑 Face;界面按一个逻辑面区域显示,高亮包含全部碎片。",
}
)
return result
def _first_level_fact_selection_fields(self, face_id: int, scope: str = "auto") -> dict[str, object]:
if self.model is None:
return {}
try:
return self.model.face_first_level_facts(face_id, scope=scope)
except Exception as exc:
return {
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_status": "unavailable",
"first_level_fact_relation_depth": 1,
"first_level_fact_scope": scope,
"first_level_fact_subject_face_ids": (face_id,),
"first_level_fact_subject_face_count": 1,
"first_level_fact_boundary_edge_count": 0,
"first_level_fact_boundary_vertex_count": 0,
"first_level_fact_adjacent_face_count": 0,
"first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"first_level_fact_summary": f"当前 Face 的一级事实图暂时无法生成:{exc}",
}
def _should_defer_selection_first_level_topology(self, face_id: int) -> bool:
if self.model is None:
return False
try:
if face_id in getattr(self.model, "_face_first_level_topology_cache", {}):
return False
except Exception:
pass
try:
face_count = len(getattr(self.model, "faces", []) or [])
edge_count = len(getattr(self.model, "edges", []) or [])
except Exception:
return False
if face_count <= 1000 and edge_count <= 2500:
return False
return True
def _selection_feature_detection_level(self) -> str:
level = self._current_feature_detection_level()
if level != "current-only" and bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return "current-only"
return level
def _quick_face_first_level_selection_fields(
self,
face_id: int,
fallback_info: dict[str, object] | None = None,
) -> dict[str, object]:
if self.model is None:
return {}
fallback = dict(fallback_info or {})
try:
boundary_edge_ids = tuple(self.model.face_boundary_edge_ids(face_id))
except Exception:
boundary_edge_ids = ()
adjacent_face_ids: set[int] = set()
for edge_id in boundary_edge_ids:
try:
for adjacent_id in self.model._edge_adjacent_face_ids(edge_id):
adjacent_id = int(adjacent_id)
if adjacent_id != face_id:
adjacent_face_ids.add(adjacent_id)
except Exception:
continue
boundary_vertex_count = 0
try:
vertex_points: set[tuple[float, float, float]] = set()
for edge_id in boundary_edge_ids:
for point in self.model.edge_vertex_points(int(edge_id)):
vertex_points.add(tuple(float(item) for item in point))
boundary_vertex_count = len(vertex_points)
except Exception:
boundary_vertex_count = 0
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_scope": "quick selected Face boundary",
"topology_relation_boundary": "shared-edge",
"topology_relation_status": "deferred",
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": "Large model selection keeps first-level relation expansion deferred until edit planning.",
"same_domain_face_ids": tuple(_int_values(fallback.get("same_domain_face_ids")) or [face_id]),
"same_domain_face_count": int(fallback.get("same_domain_face_count") or 1),
"same_domain_region_kind": str(fallback.get("same_domain_region_kind") or "single-face"),
"first_level_boundary_edge_ids": boundary_edge_ids,
"first_level_boundary_edge_count": len(boundary_edge_ids),
"first_level_boundary_vertex_count": boundary_vertex_count,
"first_level_adjacent_face_ids": tuple(sorted(adjacent_face_ids)),
"first_level_adjacent_face_count": len(adjacent_face_ids),
"first_level_face_ids": tuple(sorted({face_id, *adjacent_face_ids})),
"first_level_face_count": 1 + len(adjacent_face_ids),
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_status": "deferred",
"first_level_fact_relation_depth": 1,
"first_level_fact_scope": "face",
"first_level_fact_subject_face_ids": (face_id,),
"first_level_fact_subject_face_count": 1,
"first_level_fact_boundary_edge_count": len(boundary_edge_ids),
"first_level_fact_boundary_vertex_count": boundary_vertex_count,
"first_level_fact_adjacent_face_count": len(adjacent_face_ids),
"first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"first_level_fact_summary": "Large model: full first-level fact graph is deferred until edit planning.",
"first_level_topology_note": (
"Large model selection uses a quick first-level boundary summary; "
"the complete relation graph is recalculated in the background edit plan."
),
}
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", ""),
**self._first_level_fact_selection_fields(face_id, scope="face"),
}
def _cylindrical_first_level_selection_fields(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
try:
topology = self.model.cylindrical_feature_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep cylindrical-feature shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": "当前阶段只传播一级关系;二级、三级拓扑暂不自动递归编辑。",
"cylindrical_feature_side_face_count": 1,
"cylindrical_feature_boundary_edge_count": 0,
"cylindrical_feature_boundary_vertex_count": 0,
"cylindrical_feature_adjacent_face_count": 0,
"cylindrical_feature_end_face_count": 0,
"cylindrical_feature_bottom_face_count": 0,
"cylindrical_feature_opening_face_count": 0,
"cylindrical_feature_slot_boundary_face_count": 0,
"first_level_topology_note": f"当前圆柱特征的一级关系暂时无法确认:{exc}",
}
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", ""),
"first_level_boundary_edge_ids": topology.get("cylindrical_feature_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("cylindrical_feature_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("cylindrical_feature_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("cylindrical_feature_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("cylindrical_feature_adjacent_face_count", 0),
"first_level_face_ids": topology.get("cylindrical_feature_first_level_face_ids", (face_id,)),
"first_level_face_count": topology.get("cylindrical_feature_first_level_face_count", 1),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
"cylindrical_feature_side_face_ids": topology.get("cylindrical_feature_side_face_ids", (face_id,)),
"cylindrical_feature_side_face_count": topology.get("cylindrical_feature_side_face_count", 1),
"cylindrical_feature_boundary_edge_ids": topology.get("cylindrical_feature_boundary_edge_ids", ()),
"cylindrical_feature_boundary_edge_count": topology.get("cylindrical_feature_boundary_edge_count", 0),
"cylindrical_feature_boundary_vertex_count": topology.get("cylindrical_feature_boundary_vertex_count", 0),
"cylindrical_feature_adjacent_face_ids": topology.get("cylindrical_feature_adjacent_face_ids", ()),
"cylindrical_feature_adjacent_face_count": topology.get("cylindrical_feature_adjacent_face_count", 0),
"cylindrical_feature_end_face_ids": topology.get("cylindrical_feature_end_face_ids", ()),
"cylindrical_feature_end_face_count": topology.get("cylindrical_feature_end_face_count", 0),
"cylindrical_feature_bottom_face_ids": topology.get("cylindrical_feature_bottom_face_ids", ()),
"cylindrical_feature_bottom_face_count": topology.get("cylindrical_feature_bottom_face_count", 0),
"cylindrical_feature_opening_face_ids": topology.get("cylindrical_feature_opening_face_ids", ()),
"cylindrical_feature_opening_face_count": topology.get("cylindrical_feature_opening_face_count", 0),
"cylindrical_feature_slot_boundary_face_ids": topology.get(
"cylindrical_feature_slot_boundary_face_ids",
(),
),
"cylindrical_feature_slot_boundary_face_count": topology.get(
"cylindrical_feature_slot_boundary_face_count",
0,
),
**self._first_level_fact_selection_fields(face_id, scope="cylindrical-feature"),
}
def _edge_first_level_selection_fields(self, edge_id: int) -> dict[str, object]:
if self.model is None:
return {}
try:
topology = self.model.edge_first_level_topology(edge_id)
facts = self.model.edge_first_level_facts(edge_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"topology_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"topology_ignored_relation_note": "Current Edge first-level topology is unavailable.",
"first_level_vertex_count": 0,
"first_level_adjacent_edge_count": 0,
"first_level_adjacent_face_count": 0,
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_status": "unavailable",
"first_level_fact_source_model": "edge",
"first_level_fact_relation_depth": 1,
"first_level_fact_scope": "edge",
"first_level_fact_subject_edge_ids": (edge_id,),
"first_level_fact_subject_edge_count": 1,
"first_level_fact_boundary_edge_count": 1,
"first_level_fact_boundary_vertex_count": 0,
"first_level_fact_adjacent_edge_count": 0,
"first_level_fact_adjacent_face_count": 0,
"first_level_fact_ignored_relation_depths": ("second-level", "third-level", "deeper"),
"first_level_fact_summary": f"Current Edge first-level fact graph is unavailable: {exc}",
}
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", ""),
"selected_edge_ids": topology.get("selected_edge_ids", (edge_id,)),
"selected_edge_count": topology.get("selected_edge_count", 1),
"first_level_vertex_points": topology.get("first_level_vertex_points", ()),
"first_level_vertex_count": topology.get("first_level_vertex_count", 0),
"first_level_adjacent_edge_ids": topology.get("first_level_adjacent_edge_ids", ()),
"first_level_adjacent_edge_count": topology.get("first_level_adjacent_edge_count", 0),
"first_level_edge_ids": topology.get("first_level_edge_ids", (edge_id,)),
"first_level_edge_count": topology.get("first_level_edge_count", 1),
"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_topology_note": topology.get("first_level_topology_note", ""),
**facts,
}
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)
allow_cached_info = self._selection_feature_detection_level() != "current-only"
if allow_cached_info:
try:
cached_info = self.model.cached_feature_info(face_id)
except Exception:
cached_info = None
else:
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":
same_domain_ids = _int_values(info.get("same_domain_face_ids")) or _int_values(
info.get("feature_highlight_face_ids")
)
if not same_domain_ids:
try:
same_domain_ids = self.model._connected_cocylindrical_face_ids(face_id) or [face_id]
except Exception:
same_domain_ids = [face_id]
same_domain_ids = sorted({int(item) for item in same_domain_ids if 0 <= int(item) < len(self.model.faces)})
if same_domain_ids:
info["same_domain_face_ids"] = tuple(same_domain_ids)
info["same_domain_face_count"] = len(same_domain_ids)
info["feature_highlight_face_ids"] = tuple(same_domain_ids)
if len(same_domain_ids) > 1:
info.setdefault(
"same_domain_note",
"当前圆柱面在 STEP/B-Rep 中被拆成多个同域拓扑 Face,界面按一个局部孔/槽区域高亮。",
)
feature_guess = str(info.get("feature_guess", "") or "")
angular_span = _float_or_none(info.get("angular_span"))
if feature_guess == "hole/groove candidate":
if (
not _is_effectively_full_cylinder(info)
and 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":
if self._should_defer_selection_first_level_topology(face_id):
info.update(self._quick_face_first_level_selection_fields(face_id, fallback_info))
else:
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 {}
requested_detection_level = self._current_feature_detection_level()
detection_level = self._selection_feature_detection_level()
root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
if detection_level != requested_detection_level:
root_info.setdefault(
"feature_detection_large_model_note",
"当前模型较大,点击选择只做快速当前特征识别;相邻/二级识别请使用显式扫描或后台识别。",
)
if str(root_info.get("surface", "") or "") == "plane" and "topology_relation_depth" not in root_info:
if self._should_defer_selection_first_level_topology(face_id):
root_info.update(self._quick_face_first_level_selection_fields(face_id, root_info))
else:
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,
max_depth=3,
max_scan_faces=72,
max_features=10,
time_budget_seconds=0.55 if detection_level == "associated-only" else 0.75,
lightweight=True,
)
except Exception:
associated = []
if detection_level == "secondary":
associated = self._secondary_associated_feature_infos(
face_id,
associated,
time_budget_seconds=0.45,
)
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}”沿共享边拓扑探测局部邻域,未发现额外的可参数化关联特征。"
)
),
}
)
info.update(
self._selection_identity_fields(
face_id,
"特征来源 Face",
region_face_ids=_int_values(info.get("feature_highlight_face_ids")) or [face_id],
)
)
try:
info.update(self.model._recognition_summary_fields(info))
except Exception:
pass
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
if hasattr(self, "set_info"):
self.set_info(self._with_pick_info(info, self.selected_pick_position))
else:
self._invalidate_selected_action_info_cache()
self.current_info_values = dict(info)
self.current_info_text = _info_to_text(info)
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]],
*,
time_budget_seconds: float | None = None,
) -> 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()
deadline = None
if time_budget_seconds is not None and time_budget_seconds > 0:
deadline = time.monotonic() + float(time_budget_seconds)
def budget_expired() -> bool:
return deadline is not None and time.monotonic() >= deadline
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):
if budget_expired():
break
parent_id = _int_or_none(info.get("association_source_face_id"))
if parent_id is None:
continue
try:
nested_budget = None
if deadline is not None:
nested_budget = max(0.02, min(0.12, deadline - time.monotonic()))
for nested in self.model.associated_feature_infos(
parent_id,
max_depth=1,
max_scan_faces=16,
max_features=4,
time_budget_seconds=nested_budget,
lightweight=True,
):
add(nested)
if budget_expired():
break
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 and not bool(getattr(self, "_property_batch_running_action", False)):
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,
"导出当前模型。",
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,
"导出当前模型。",
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,
"查看当前选中对象的属性;可修改的行可以输入目标值。",
"请先选择一个对象。",
)
if hasattr(self, "property_card_scroll"):
self._set_control_state(
self.property_card_scroll,
False,
"旧展示层已停用;当前使用表格展示特征参数。",
"当前使用表格展示特征参数。",
)
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 FREEFORM_FACE_SURFACES
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"
and str(action_info.get("existing_fillet_status") or "") != "blocked"
)
is_existing_chamfer = (
is_plane
and feature_guess == "chamfer candidate"
and str(action_info.get("existing_chamfer_status") or "") == "candidate"
)
is_full_cylinder = _is_effectively_full_cylinder(action_info)
is_slot_or_half_hole = (
is_hole_or_groove
and not is_full_cylinder
and angular_span is not None
and angular_span < math.tau * 0.92
and str(action_info.get("slot_status") or "") == "candidate"
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_depth_estimate = _float_or_none(action_info.get("hole_depth_estimate")) is not None
depth_status = str(action_info.get("depth_status") or "")
can_try_auto_blind_depth = bool(is_blind and has_depth_estimate and depth_status != "blocked")
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 and not is_existing_chamfer
can_resize_shell = has_model and is_shell_candidate and not is_existing_chamfer
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 (
can_try_auto_blind_depth or (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)
self._update_relation_formula_buttons()
def _set_control_state(self, widget, enabled: bool, enabled_tip: str, disabled_tip: str) -> None:
tip = enabled_tip if enabled else disabled_tip
cache = getattr(self, "_control_state_cache", None)
cache_key = id(widget)
state = (bool(enabled), str(tip))
if isinstance(cache, dict) and cache.get(cache_key) == state:
return
widget.setEnabled(enabled)
if hasattr(self, "_set_help_tip"):
self._set_help_tip(widget, tip)
else:
widget.setToolTip(tip)
if isinstance(cache, dict):
cache[cache_key] = state
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 == "multi_feature":
count = len(getattr(self, "multi_selected_hole_entries", []) or [])
ids = [
str(item.get("logical_id"))
for item in (getattr(self, "multi_selected_hole_entries", []) or [])[:4]
if item.get("logical_id") is not None
]
suffix = f"{', '.join(ids)}" if ids else ""
return f"多选孔 {count}{suffix}"
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 ""
logical_id = self.current_info_values.get("selection_display_id")
if logical_id in {None, ""}:
logical_id = self.current_info_values.get("face_region_logical_id")
if logical_id in {None, ""}:
logical_id = self.current_info_values.get("logical_face_id")
source_suffix = f"来源 Face {logical_id if logical_id not in {None, ''} else self.selected_face_id}"
return f"{feature_label}{confidence_suffix} · {source_suffix}{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("selection_display_id")
if logical_id in {None, ""}:
logical_id = self.current_info_values.get("face_region_logical_id")
if logical_id in {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, ""}:
return f"Face {logical_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 self._feature_display_label(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 not _is_effectively_full_cylinder(info) and (
info.get("slot_kind") == "partial-cylindrical-groove"
or (angular_span is not None and angular_span < math.tau * 0.92)
):
return self._feature_display_label("槽/半孔候选")
return self._feature_display_label("圆柱孔/槽候选")
if surface == "cylinder" and feature_guess == "boss/outer-round candidate":
return self._feature_display_label("凸台/外圆候选")
if surface == "cylinder" and feature_guess == "round/fillet candidate":
return self._feature_display_label("圆角/倒圆候选")
if surface == "plane" and info.get("push_pull_status"):
return self._feature_display_label("可拉伸/切除平面候选")
return ""
def _feature_display_label(self, label: object) -> str:
text = str(label or "").strip()
if text == "矩形口袋候选":
return "矩形槽/口袋"
if len(text) > 2 and text.endswith("候选"):
return text[:-2].rstrip(" /·,,")
return text
def _clear_property_editor(self) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._clear_property_editor):
return
if not hasattr(self, "property_table"):
return
self.property_editor_specs = []
self.property_table_expanded = False
self.property_editor_selected_row = None
self.property_command_active_key = ""
was_blocked = self.property_table.blockSignals(True)
try:
self.property_table.clearSpans()
self.property_table.setRowCount(0)
finally:
self.property_table.blockSignals(was_blocked)
self._clear_property_cards()
self._clear_property_command_bar()
self._update_current_capability_panel()
self._update_relation_formula_completions_if_active()
self._refresh_relation_formula_list()
self._resize_property_table_height()
self._update_property_apply_state(False)
def _clear_property_cards(self) -> None:
self.property_card_rows = {}
layout = getattr(self, "property_card_layout", None)
if layout is None:
return
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.hide()
widget.setParent(None)
widget.deleteLater()
def _clear_property_command_bar(self) -> None:
self.property_command_buttons = {}
if hasattr(self, "property_command_summary_label"):
self.property_command_summary_label.setText("未选择可编辑对象")
self.property_command_summary_label.setToolTip("")
if hasattr(self, "property_command_help_label"):
self.property_command_help_label.setText("")
self.property_command_help_label.setToolTip("")
self.property_command_help_label.setVisible(False)
layout = getattr(self, "property_command_layout", None)
if layout is not None:
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.hide()
widget.setParent(None)
widget.deleteLater()
if hasattr(self, "property_command_bar"):
self.property_command_bar.setVisible(False)
if hasattr(self, "property_command_summary_label"):
self.property_command_summary_label.setVisible(False)
def _is_actionable_property_row(self, row: int, spec: dict[str, object]) -> bool:
effective_spec = self._effective_property_spec(spec, row=row)
return bool(effective_spec.get("editable") and effective_spec.get("enabled") and effective_spec.get("action"))
def _actionable_property_rows(self) -> list[tuple[int, dict[str, object]]]:
specs = list(getattr(self, "property_editor_specs", []) or [])
return [(row, spec) for row, spec in enumerate(specs) if self._is_actionable_property_row(row, spec)]
def _property_command_summary_text(self) -> tuple[str, str]:
specs = list(getattr(self, "property_editor_specs", []) or [])
topology_text = ""
for spec in specs:
key = str(spec.get("key", "") or "")
current_text = str(spec.get("current_text", "") or "").strip()
if key in {
"face_first_level_topology",
"edge_first_level_topology",
"cylindrical_feature_first_level_topology",
} and current_text:
topology_text = _compact_property_card_text(current_text, 86)
actionable = self._actionable_property_rows()
labels = [str(spec.get("label", "") or "").strip() for _row, spec in actionable]
labels = [label for label in labels if label]
if labels:
summary = f"可修改项 {len(labels)} 个:{'、'.join(labels[:4])}"
if topology_text:
summary = f"{summary} | {topology_text}"
else:
summary = topology_text or "当前对象没有稳定可修改项;请查看诊断信息。"
tooltip_parts = [str(spec.get("current_text", "") or "") for spec in specs if bool(spec.get("pin_top"))]
return summary, "\n\n".join(part for part in tooltip_parts if part)
def _current_capability_text(self) -> tuple[str, str, str]:
headline = "当前支持:常见 Face、孔、槽、凸台、圆角/倒角、壳体、Edge"
detail = ""
tooltip = (
"能改:Face 偏移,孔径/位置,证据完整的简单槽/长圆槽,凸台位置/尺寸,简单圆角/倒角,Edge 长度等。\n"
"能识别:基础拓扑、曲面类型、一级关系、孔组、槽、凸台、圆角/倒角候选。\n"
"暂不能:原 CAD 历史树、复杂装配约束、二级/三级拓扑自动传播。"
)
return headline, detail, tooltip
def configure_scdm_backend(self, *, automatic: bool = False, reason: str = "", message: str = "") -> bool:
start_dir = Path("D:/softwaresInstallDir/ANSYS Inc") if Path("D:/softwaresInstallDir/ANSYS Inc").exists() else Path.home()
title = "找不到 SCDM,请选择 SpaceClaim.exe" if automatic else "选择 SpaceClaim.exe"
path, _ = QFileDialog.getOpenFileName(
self,
title,
str(start_dir),
"SpaceClaim.exe (SpaceClaim.exe);;可执行文件 (*.exe);;所有文件 (*.*)",
)
if not path:
if automatic:
detail = self._compact_status_message(message or reason or "未选择 SpaceClaim.exe")
self.statusBar().showMessage(f"未配置 SCDM,当前使用 OCCT/Analysis Situs 兜底:{detail}")
self._update_current_capability_panel()
return False
button = getattr(self, "configure_scdm_button", None)
if isinstance(button, QPushButton):
button.setEnabled(False)
QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor)
QApplication.processEvents()
try:
result = resolve_scdm_backend(
project_root_override=Path(__file__).resolve().parent.parent,
manual_path=path,
prefer_cache=False,
save_cache=True,
validate=True,
)
finally:
QApplication.restoreOverrideCursor()
if isinstance(button, QPushButton):
button.setEnabled(True)
if result.get("ok") is not True:
reason = str(result.get("reason") or "missing-spaceclaim")
message = str(result.get("message") or "无法使用这个 SCDM/SpaceClaim 路径。")
if not automatic:
QMessageBox.information(self, "SCDM 配置失败", f"不能启用 SCDM。\n类型:{reason}\n原因:{message}")
self.statusBar().showMessage(f"SCDM 配置失败,当前使用 OCCT/Analysis Situs 兜底:{reason}")
self._update_current_capability_panel()
return False
backend = result.get("backend")
backend_path = str(getattr(backend, "path", "") or path)
if hasattr(backend, "to_cache"):
try:
self.scdm_backend_status = backend.to_cache()
except Exception:
self.scdm_backend_status = {"path": backend_path, "source": "manual"}
else:
self.scdm_backend_status = {"path": backend_path, "source": "manual"}
self.statusBar().showMessage(f"SCDM 已配置:{backend_path}")
self._update_current_capability_panel()
if not automatic:
QMessageBox.information(self, "SCDM 配置完成", f"已缓存 SpaceClaim.exe\n{backend_path}")
if self.model is not None and self.step_path is not None:
self._invalidate_scdm_feature_cache("SCDM 后端已重新配置,cache 已失效。")
self._start_scdm_probe_preload()
return True
def maybe_prompt_missing_scdm_backend(self, *, reason: str = "", message: str = "") -> None:
reason_key = str(reason or "").strip()
if reason_key not in {"missing-spaceclaim", "missing-scdm"}:
return
if bool(getattr(self, "scdm_auto_config_prompt_seen", False)):
return
if bool(getattr(self, "scdm_auto_config_prompt_active", False)):
return
self.scdm_auto_config_prompt_seen = True
self.scdm_auto_config_prompt_active = True
def prompt() -> None:
try:
self.configure_scdm_backend(automatic=True, reason=reason_key, message=message)
finally:
self.scdm_auto_config_prompt_active = False
QTimer.singleShot(0, prompt)
def _update_current_capability_panel(self) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._update_current_capability_panel):
return
headline, _detail, tooltip = self._current_capability_text()
progress_tooltip = "\n".join(
part
for part in (
"点击查看软件能力清单。",
headline,
tooltip,
)
if part
)
button = getattr(self, "current_capability_button", None)
if isinstance(button, QPushButton):
button.setText("软件进度")
button.setToolTip(progress_tooltip)
headline_widget = getattr(self, "current_capability_headline", None)
if isinstance(headline_widget, QLabel):
headline_widget.setText(headline)
headline_widget.setToolTip(tooltip or headline)
if hasattr(self, "scdm_backend_status_label"):
self.scdm_backend_status_label.setText("")
self.scdm_backend_status_label.setToolTip("")
if hasattr(self, "scdm_backend_detail_label"):
self.scdm_backend_detail_label.setText("")
self.scdm_backend_detail_label.setToolTip("")
def show_software_progress_dialog(self) -> None:
dialog = QDialog(self)
dialog.setWindowTitle("软件进度")
dialog.resize(680, 560)
layout = QVBoxLayout(dialog)
layout.setContentsMargins(14, 12, 14, 12)
layout.setSpacing(8)
title = QLabel("软件进度")
title.setStyleSheet("font-size: 16px; font-weight: 800; color: #14532d;")
layout.addWidget(title)
body = QTextEdit(dialog)
body.setReadOnly(True)
detail_text = self._software_progress_detail_text()
if hasattr(body, "setMarkdown"):
body.setMarkdown(detail_text)
else:
body.setPlainText(detail_text)
body.setStyleSheet(
"QTextEdit { background: #f8fafc; border: 1px solid #cbd5e1; "
"border-radius: 6px; color: #172033; padding: 8px; }"
)
layout.addWidget(body, stretch=1)
button_row = QHBoxLayout()
button_row.addStretch(1)
close_button = QPushButton("关闭")
close_button.setMinimumWidth(88)
close_button.clicked.connect(dialog.accept)
button_row.addWidget(close_button)
layout.addLayout(button_row)
dialog.exec()
def _software_progress_detail_text(self) -> str:
return "\n".join(
[
"# 软件进度",
"",
"## 已能修改",
"- **Face**:偏移/推拉,部分稳定矩形平面的长度、宽度,局部壳体厚度。",
"- **孔**:直径、半径、位置、盲孔深度、封堵;拆成多片的圆柱孔会按同一孔组处理。",
"- **槽**:证据完整的简单槽/长圆槽可改位置、槽宽、槽深、弧长、总长、中心距;中置信度“圆柱孔/槽候选”只表示已识别到槽可能性,不等于已经可改。",
"- **凸台**:圆柱凸台位置、直径、高度;部分矩形凸台/口袋的长度、宽度、高度或深度。",
"- **圆角/倒角**:简单已有圆角半径、已有等距倒角,直线 Edge 新增圆角/倒角。",
"- **Edge**:直线 Edge 长度/端点,圆或椭圆 Edge 的半径类参数。",
"- **关系式/参数导出**`FaceID.参数` 公式、输入参数 JSON、组件 `main.py` 导出。",
"",
"## 已能识别",
"- **基础拓扑**Part、Solid、Face、Edge、Vertex。",
"- **曲面类型**:平面、圆柱、圆锥、球面、环面、自由曲面。",
"- **一级关系**:当前对象边界、顶点、共享边相邻面、共面/同域碎片、同轴圆柱。",
"- **常见特征**:孔组、槽/长圆槽、圆柱/矩形凸台、圆角/倒角候选、壳体候选。",
"- **辅助识别**:可读取外部后端发现的高频可改对象,并缓存到本软件能力表。",
"",
"## 暂不能修改",
"- 任意复杂自由曲面的直接变形。",
"- 二级/三级拓扑自动传播。",
"- 阵列间距联动、跨特征强约束、复杂公式组求解。",
"- 只有槽候选、但缺少槽宽/槽深/槽底面/方向/成对端面等证据的复杂槽或多槽组。",
"- 复杂圆角链/倒角链批量重建。",
"- 无法通过 B-Rep 校验或目标回测的修改会被阻止并回滚。",
"",
"## 暂不能稳定识别",
"- 原 CAD 历史树、草图尺寸、真实建模顺序。",
"- CAD 原始关系式、装配约束、阵列约束。",
"- 语义不明确的破面、碎小面、导入质量较差的 STEP。",
"- 任意厂商私有参数或私有命名特征。",
]
)
def _current_scdm_backend_payload(self) -> dict[str, object] | None:
cached = getattr(self, "scdm_backend_status", None)
if isinstance(cached, dict) and str(cached.get("path") or "").strip():
return dict(cached)
try:
return cached_scdm_backend_payload(Path(__file__).resolve().parent.parent)
except Exception:
return None
def _sync_scdm_selection_diagnostics(self) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._sync_scdm_selection_diagnostics):
return
if not isinstance(getattr(self, "current_info_values", None), dict):
return
info = dict(getattr(self, "current_info_values", {}) or {})
runtime = summarize_scdm_runtime(
backend=self._current_scdm_backend_payload(),
cache_state=str(getattr(self, "scdm_feature_cache_state", "") or ""),
cache_message=str(getattr(self, "scdm_feature_cache_message", "") or ""),
feature_cache=self._current_scdm_feature_cache() if hasattr(self, "_current_scdm_feature_cache") else None,
)
info["scdm_backend_status"] = str(runtime.get("headline") or "")
info["scdm_runtime_status"] = str(runtime.get("detail") or "")
selection_status = str(getattr(self, "scdm_selection_status_message", "") or "").strip()
if selection_status:
info["scdm_selection_status"] = selection_status
scdm_specs = self._scdm_property_specs_for_selection() if hasattr(self, "_scdm_property_specs_for_selection") else []
filter_message = ""
if scdm_specs and hasattr(self, "_filter_scdm_specs_for_local_recognition"):
try:
action_info = self._selected_action_info() if hasattr(self, "_selected_action_info") else info
except Exception:
action_info = info
try:
scdm_specs, filter_message = self._filter_scdm_specs_for_local_recognition(
list(scdm_specs),
dict(action_info or {}),
)
except Exception:
filter_message = ""
if filter_message and not selection_status:
info["scdm_selection_status"] = filter_message
enabled_labels = [
str(spec.get("label") or spec.get("scdm_capability_key") or "").strip()
for spec in scdm_specs
if bool(spec.get("enabled"))
]
blocked_items: list[str] = []
for spec in scdm_specs:
if bool(spec.get("enabled")):
continue
label = str(spec.get("label") or spec.get("scdm_capability_key") or "").strip()
reason = str(spec.get("disabled_tip") or spec.get("status_text") or "").strip()
if label:
blocked_items.append(f"{label}{self._compact_status_message(reason, 64)}" if reason else label)
if enabled_labels:
info["scdm_selection_enabled_capabilities"] = "、".join(enabled_labels[:8])
else:
info.pop("scdm_selection_enabled_capabilities", None)
if blocked_items:
info["scdm_selection_blocked_capabilities"] = "".join(blocked_items[:6])
else:
info.pop("scdm_selection_blocked_capabilities", None)
info["scdm_selection_capability_count"] = len(scdm_specs)
info["scdm_selection_blocked_count"] = len(blocked_items)
self.current_info_values = info
try:
self.current_info_text = _info_to_text(info)
if hasattr(self, "info_text"):
self.info_text.setPlainText(self.current_info_text)
except Exception:
pass
try:
diagnostic_content = getattr(self, "diagnostic_info_content", None)
diagnostics_visible = bool(
diagnostic_content is None
or not hasattr(diagnostic_content, "isVisible")
or diagnostic_content.isVisible()
)
if diagnostics_visible and hasattr(self, "_populate_info_tree"):
self._populate_info_tree(info)
except Exception:
pass
def _rebuild_property_command_bar(self) -> None:
layout = getattr(self, "property_command_layout", None)
if layout is None:
return
self.property_command_buttons = {}
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
if widget is not None:
widget.deleteLater()
self.property_command_active_key = ""
summary, tooltip = self._property_command_summary_text()
if hasattr(self, "property_command_summary_label"):
self.property_command_summary_label.setText(summary)
self.property_command_summary_label.setToolTip(tooltip or summary)
self.property_command_summary_label.setVisible(True)
if hasattr(self, "property_command_help_label"):
self.property_command_help_label.setText("")
self.property_command_help_label.setToolTip("")
self.property_command_help_label.setVisible(False)
if hasattr(self, "property_command_bar"):
self.property_command_bar.setVisible(False)
def _select_property_command(self, command_key: str) -> None:
if not command_key:
return
def _visible_property_card_rows(self) -> list[tuple[int, dict[str, object]]]:
specs = list(getattr(self, "property_editor_specs", []) or [])
if not specs:
return []
if bool(getattr(self, "property_table_expanded", False)):
return [(row, spec) for row, spec in enumerate(specs)]
visible = self._actionable_property_rows()
if not visible:
visible = [
(row, spec)
for row, spec in enumerate(specs)
if bool(spec.get("editable") or spec.get("action") or spec.get("pin_top"))
]
if not visible:
visible = [(row, spec) for row, spec in enumerate(specs)]
selected_row = getattr(self, "property_editor_selected_row", None)
if selected_row is not None:
selected_items = [(row, spec) for row, spec in enumerate(specs) if row == selected_row]
if selected_items:
visible = selected_items + [(row, spec) for row, spec in visible if row != selected_row]
collapsed_rows = max(int(getattr(self, "property_table_collapsed_rows", 5) or 5), 5)
return visible[:collapsed_rows]
def _rebuild_property_cards(self) -> None:
layout = getattr(self, "property_card_layout", None)
if layout is None:
return
self._clear_property_cards()
visible_rows = self._visible_property_card_rows()
visible_row_ids = {row for row, _spec in visible_rows}
selected_row = getattr(self, "property_editor_selected_row", None)
if selected_row not in visible_row_ids:
self.property_editor_selected_row = None
for row, spec in visible_rows:
card = self._build_property_card(row, spec)
layout.addWidget(card)
def _select_property_card_row(self, row: int) -> None:
specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs):
return
if getattr(self, "property_editor_selected_row", None) == row:
return
self.property_editor_selected_row = row
self._rebuild_property_cards()
self._resize_property_table_height()
self._update_property_apply_state()
self._scroll_property_card_row_to_top(row)
def _toggle_property_card_row(self, row: int) -> None:
QToolTip.hideText()
specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs):
return
if getattr(self, "property_editor_selected_row", None) == row:
self.property_editor_selected_row = None
self._rebuild_property_cards()
self._resize_property_table_height()
self._update_property_apply_state()
self._scroll_property_cards_to_top()
return
self._select_property_card_row(row)
def _scroll_property_cards_to_top(self) -> None:
scroll = getattr(self, "property_card_scroll", None)
if scroll is None:
return
bar = scroll.verticalScrollBar()
if bar is None:
return
bar.setValue(0)
QTimer.singleShot(0, lambda target_bar=bar: target_bar.setValue(0))
def _scroll_property_card_row_to_top(self, row: int) -> None:
scroll = getattr(self, "property_card_scroll", None)
card = self._property_card_widgets(row).get("card")
if scroll is None or not isinstance(card, QWidget):
return
bar = scroll.verticalScrollBar()
if bar is None:
return
def apply_scroll() -> None:
bar.setValue(max(0, int(card.y()) - 4))
apply_scroll()
QTimer.singleShot(0, apply_scroll)
def _property_card_widgets(self, row: int) -> dict[str, object]:
rows = getattr(self, "property_card_rows", {})
if isinstance(rows, dict):
widgets = rows.get(row, {})
if isinstance(widgets, dict):
return widgets
return {}
def _build_property_card(self, row: int, spec: dict[str, object]) -> QFrame:
effective_spec = self._effective_property_spec(spec, row=row)
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"
span_value_columns = bool(effective_spec.get("span_value_columns")) and not editable
selected = row == getattr(self, "property_editor_selected_row", None)
card = QFrame()
card.setObjectName("propertyCard")
card.setProperty("editable", bool(editable))
card.setProperty("pinTop", bool(effective_spec.get("pin_top")))
card.setProperty("selected", bool(selected))
card.setProperty("compact", bool(not selected))
card.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
card.setCursor(Qt.CursorShape.PointingHandCursor)
card.mousePressEvent = lambda _event, target_row=row: self._toggle_property_card_row(target_row)
card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(7 if not selected else 8, 2 if not selected else 7, 7 if not selected else 8, 2 if not selected else 7)
card_layout.setSpacing(0 if not selected else 5)
diagnostics_expanded = bool(getattr(self, "property_table_expanded", False))
header = QHBoxLayout()
header.setContentsMargins(0, 0, 0, 0)
header.setSpacing(8 if not selected else 4)
title = QLabel(str(effective_spec.get("label", "")))
title.setObjectName("propertyCardTitle")
title.setMinimumWidth(0)
title.setSizePolicy(QSizePolicy.Policy.Ignored, QSizePolicy.Policy.Fixed)
if selected or span_value_columns:
title.setToolTip(str(effective_spec.get("disabled_tip") or effective_spec.get("enabled_tip") or ""))
header.addWidget(title, 1)
compact_detail_below_header = False
if not selected and not span_value_columns:
current_text = str(effective_spec.get("current_text", ""))
detail_parts = [current_text] if current_text else []
scope_text = str(effective_spec.get("scope_text") or effective_spec.get("scope_label") or "").strip()
target_text = self._property_target_text(row) or str(effective_spec.get("target_text", ""))
if editable and value_type != "command":
validation_error = (
"" if not target_text.strip() else self._property_target_validation_error(effective_spec, target_text)
)
row_changed = self._property_target_changed(effective_spec, target_text)
if validation_error:
scope_text = "目标无效"
elif row_changed:
scope_text = f"目标 {target_text}"
elif editable and value_type == "command":
scope_text = str(effective_spec.get("button_text") or scope_text or "可执行").strip()
if scope_text:
detail_parts.append(scope_text)
compact_detail_text = _compact_property_card_text(" · ".join(detail_parts), 150)
compact_multiline = len(str(effective_spec.get("label", ""))) >= 14 or len(compact_detail_text) >= 30
title.setWordWrap(compact_multiline)
if compact_multiline:
title.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
card_layout.setSpacing(1)
compact_detail = QLabel(_compact_property_card_text(" · ".join(detail_parts), 150 if compact_multiline else 96))
compact_detail.setObjectName("propertyCardCompactValue")
compact_detail.setAlignment(
(Qt.AlignmentFlag.AlignLeft if compact_multiline else Qt.AlignmentFlag.AlignRight)
| Qt.AlignmentFlag.AlignVCenter
)
compact_detail.setMinimumWidth(0)
compact_detail.setSizePolicy(
QSizePolicy.Policy.Expanding if compact_multiline else QSizePolicy.Policy.Fixed,
QSizePolicy.Policy.Fixed,
)
if compact_multiline:
compact_detail_below_header = True
else:
header.addWidget(compact_detail, 0)
row_widgets_placeholder = compact_detail
status_text = "可修改" if editable else str(effective_spec.get("status_text", ""))
else:
status_text = str(effective_spec.get("status_text", ""))
status_label = QLabel(str(effective_spec.get("status_text", "")), card)
status_label.setObjectName("propertyCardMetaLabel")
status_label.setText(status_text)
status_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
header.addWidget(status_label, 0)
card_layout.addLayout(header)
status_label.setVisible(bool(status_text))
if compact_detail_below_header:
card_layout.addWidget(row_widgets_placeholder)
row_widgets: dict[str, object] = {
"card": card,
"status_label": status_label,
}
if not selected and not span_value_columns:
row_widgets["current_value"] = row_widgets_placeholder
self.property_card_rows[row] = row_widgets
card.style().unpolish(card)
card.style().polish(card)
return card
if span_value_columns:
current_text = str(effective_spec.get("current_text", ""))
show_full_span_text = bool(selected or diagnostics_expanded)
span_limit = 520 if show_full_span_text else 96
value = QLabel(_compact_property_card_text(current_text, span_limit))
value.setObjectName("propertyCardValue")
value.setWordWrap(show_full_span_text)
value.setToolTip(str(effective_spec.get("disabled_tip") or current_text))
card_layout.addWidget(value)
hint_text = _compact_property_card_text(str(effective_spec.get("disabled_tip") or ""), 150)
if selected and hint_text and hint_text != value.text():
hint = QLabel(hint_text)
hint.setObjectName("propertyCardHint")
hint.setWordWrap(True)
card_layout.addWidget(hint)
row_widgets["hint_label"] = hint
self.property_card_rows[row] = row_widgets
card.style().unpolish(card)
card.style().polish(card)
return card
current_row = QHBoxLayout()
current_row.setContentsMargins(0, 0, 0, 0)
current_label = QLabel("当前值")
current_label.setObjectName("propertyCardMetaLabel")
current_row.addWidget(current_label, 0)
current_text = str(effective_spec.get("current_text", ""))
current_value = QLabel(_compact_property_card_text(current_text, 220 if selected else 118))
current_value.setObjectName("propertyCardValue")
current_value.setWordWrap(bool(selected))
current_value.setToolTip(current_text)
current_row.addWidget(current_value, 1)
card_layout.addLayout(current_row)
if not selected:
compact_parts: list[str] = []
scope_text = str(effective_spec.get("scope_text") or effective_spec.get("scope_label") or "").strip()
if scope_text:
compact_parts.append(scope_text)
target_text = self._property_target_text(row) or str(effective_spec.get("target_text", ""))
if editable and value_type != "command":
validation_error = (
"" if not target_text.strip() else self._property_target_validation_error(effective_spec, target_text)
)
row_changed = self._property_target_changed(effective_spec, target_text)
if validation_error:
compact_parts.append("目标无效")
elif row_changed:
compact_parts.append(f"目标 {target_text}")
elif editable and value_type == "command":
button_text = str(effective_spec.get("button_text") or "可执行").strip()
if button_text:
compact_parts.append(button_text)
status_text = str(effective_spec.get("status_text") or "").strip()
if status_text and not compact_parts:
compact_parts.append(status_text)
if compact_parts:
compact_meta = QLabel(_compact_property_card_text(" · ".join(compact_parts), 118))
compact_meta.setObjectName("propertyCardCompactMeta")
compact_meta.setWordWrap(True)
compact_meta.setToolTip(" · ".join(compact_parts))
card_layout.addWidget(compact_meta)
row_widgets["compact_meta"] = compact_meta
self.property_card_rows[row] = row_widgets
card.style().unpolish(card)
card.style().polish(card)
return card
if input_editable:
target_row = QHBoxLayout()
target_row.setContentsMargins(0, 0, 0, 0)
target_label = QLabel("目标值")
target_label.setObjectName("propertyCardMetaLabel")
target_row.addWidget(target_label, 0)
editor = QLineEdit(self._property_target_text(row) or str(effective_spec.get("target_text", "")))
editor.setObjectName("propertyCardTargetEditor")
editor.setToolTip(self._property_target_tooltip(effective_spec, editable=True))
editor.setPlaceholderText("输入目标值")
editor.setCursor(Qt.CursorShape.IBeamCursor)
editor.textChanged.connect(lambda _text="", _row=row: self._on_property_card_target_changed(_row))
editor.returnPressed.connect(lambda target_row=row: self.apply_property_row_edit(target_row))
target_row.addWidget(editor, 1)
card_layout.addLayout(target_row)
row_widgets["target_editor"] = editor
modes = spec.get("scope_modes")
if isinstance(modes, dict) and modes:
scope_row = QHBoxLayout()
scope_row.setContentsMargins(0, 0, 0, 0)
scope_label = QLabel("建模意图")
scope_label.setObjectName("propertyCardMetaLabel")
scope_row.addWidget(scope_label, 0)
combo = self._make_property_scope_combo(row, spec)
scope_row.addWidget(combo, 1)
card_layout.addLayout(scope_row)
row_widgets["scope_combo"] = combo
hint_text = self._property_card_hint_text(effective_spec, editable=editable)
hint = QLabel(hint_text, card)
hint.setObjectName("propertyCardHint")
hint.setWordWrap(True)
hint.setToolTip(str(effective_spec.get("enabled_tip") or effective_spec.get("disabled_tip") or hint_text))
card_layout.addWidget(hint)
hint.setVisible(bool(hint_text))
row_widgets["hint_label"] = hint
self.property_card_rows[row] = row_widgets
card.style().unpolish(card)
card.style().polish(card)
return card
def _property_card_hint_text(self, spec: dict[str, object], *, editable: bool) -> str:
parts: list[str] = []
if editable:
scope_label = str(spec.get("scope_label") or "").strip()
if scope_label:
parts.append(f"建模意图:{scope_label}")
tip = str(spec.get("enabled_tip") or "").strip()
if tip:
parts.append(tip)
range_hint = self._property_range_hint(spec)
if range_hint:
parts.append(range_hint)
else:
tip = str(spec.get("disabled_tip") or spec.get("enabled_tip") or "").strip()
status = str(spec.get("status_text") or "").strip()
if tip:
parts.append(tip)
elif status:
parts.append(status)
return _compact_property_card_text(" ".join(part for part in parts if part), 150)
def _make_property_scope_combo(self, row: int, spec: dict[str, object]) -> NoWheelComboBox:
combo = NoWheelComboBox()
combo.setObjectName("propertyScopeCombo")
modes = spec.get("scope_modes")
default_scope = self._property_scope_value(row, spec) or self._property_scope_default(spec)
selected_index = 0
if isinstance(modes, dict):
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))
return combo
def _refresh_property_editor(self) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._refresh_property_editor):
return
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._sync_scdm_selection_diagnostics()
self.property_table_expanded = False
self.property_editor_selected_row = None
self.property_command_active_key = ""
self.property_editor_updating = True
was_blocked = self.property_table.blockSignals(True)
try:
self.property_table.clearSpans()
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)
target_item = self._property_table_item(
"" if input_editable else str(effective_spec.get("target_text", "")),
editable=False,
)
scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False)
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
input_item = self._property_table_item("", editable=False)
input_item.setToolTip("勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py。")
row_items = (label_item, current_item, target_item, scope_item, input_item)
self._style_property_row_items(row_items, editable=editable, spec=effective_spec)
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)
else:
self.property_table.removeCellWidget(row, PROPERTY_TARGET_COLUMN)
self._set_property_input_checkbox(
row,
exportable=bool(input_editable and effective_spec.get("label")),
)
self._clear_property_command_bar()
self._clear_property_cards()
self._update_current_capability_panel()
self._update_relation_formula_completions_if_active()
self._refresh_relation_formula_list()
self._resize_property_table_height()
finally:
self.property_table.blockSignals(was_blocked)
self.property_editor_updating = False
self._resize_property_table_columns()
self._update_property_apply_state()
scdm_message = str(getattr(self, "scdm_selection_status_message", "") or "").strip()
if scdm_message:
self.statusBar().showMessage(scdm_message)
def _on_relation_formula_input_changed(self, _text: str = "") -> None:
self._update_relation_formula_completions()
self._update_relation_formula_buttons()
def _update_relation_formula_completions_if_active(self) -> None:
editor = getattr(self, "relation_formula_input", None)
if not isinstance(editor, QLineEdit) or not editor.hasFocus():
self._hide_relation_formula_completion_popup()
return
self._update_relation_formula_completions()
def _update_relation_formula_completions(self) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(self._update_relation_formula_completions):
return
completer = getattr(self, "relation_formula_completer", None)
if not isinstance(completer, QCompleter):
return
editor = getattr(self, "relation_formula_input", None)
completion_context_key = (
(editor.text(), editor.cursorPosition()) if isinstance(editor, QLineEdit) else None
)
completions, prefix = self._relation_formula_context_completions()
model = getattr(self, "relation_formula_completer_model", None)
if isinstance(model, QStringListModel):
model.setStringList(completions)
else:
completer.setModel(QStringListModel(completions, completer))
completer.setCompletionPrefix(prefix)
if isinstance(editor, QLineEdit):
completer.setWidget(editor)
self._relation_formula_completion_context_key = completion_context_key
if completions:
try:
completer.setCurrentRow(0)
except Exception:
pass
else:
self._hide_relation_formula_completion_popup()
if completions and isinstance(editor, QLineEdit) and editor.hasFocus():
QTimer.singleShot(0, self._show_relation_formula_completion_popup)
def _show_relation_formula_completion_popup(self) -> None:
completer = getattr(self, "relation_formula_completer", None)
editor = getattr(self, "relation_formula_input", None)
if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit):
return
if not editor.hasFocus():
return
popup = completer.popup()
if popup is not None:
popup.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if completer.completionCount() <= 0:
return
popup_width = max(editor.width(), 320)
if popup is not None:
popup.setMinimumWidth(popup_width)
popup_rect = editor.cursorRect()
popup_rect.moveLeft(0)
popup_rect.setWidth(popup_width)
completer.complete(popup_rect)
editor.setFocus(Qt.FocusReason.OtherFocusReason)
QTimer.singleShot(0, self._restore_relation_formula_input_focus)
def _restore_relation_formula_input_focus(self) -> None:
editor = getattr(self, "relation_formula_input", None)
if isinstance(editor, QLineEdit):
editor.setFocus(Qt.FocusReason.OtherFocusReason)
def _refresh_relation_formula_completion_popup(self) -> None:
editor = getattr(self, "relation_formula_input", None)
if isinstance(editor, QLineEdit):
editor.setFocus(Qt.FocusReason.OtherFocusReason)
self._update_relation_formula_completions()
self._show_relation_formula_completion_popup()
def _hide_relation_formula_completion_popup(self) -> None:
completer = getattr(self, "relation_formula_completer", None)
if not isinstance(completer, QCompleter):
return
self._relation_formula_completion_context_key = None
popup = completer.popup()
if popup is not None and hasattr(popup, "hide"):
popup.hide()
def _relation_formula_popup_current_completion(self) -> str:
completer = getattr(self, "relation_formula_completer", None)
editor = getattr(self, "relation_formula_input", None)
if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit):
return ""
if getattr(self, "_relation_formula_completion_context_key", None) != (editor.text(), editor.cursorPosition()):
return ""
popup = completer.popup()
if popup is None:
return ""
index = popup.currentIndex()
if not index.isValid():
return ""
return str(index.data() or "")
def _on_relation_formula_completion_activated(self, completion: str) -> None:
self._insert_relation_formula_completion(str(completion or ""))
def _relation_formula_context_completions(self) -> tuple[list[str], str]:
editor = getattr(self, "relation_formula_input", None)
text = editor.text() if isinstance(editor, QLineEdit) else ""
cursor = editor.cursorPosition() if isinstance(editor, QLineEdit) else len(text)
before = text[:cursor]
fragment_start = self._relation_formula_fragment_start(before)
fragment = before[fragment_start:]
stripped_fragment = fragment.strip()
self._relation_formula_completion_prefix_start = fragment_start
if "=" not in before:
if stripped_fragment and RELATION_REF_PATTERN.fullmatch(stripped_fragment):
return [" = "], ""
if self._relation_formula_fragment_is_object_prefix(stripped_fragment):
object_context = self._relation_formula_object_context_completions(stripped_fragment)
if object_context is not None:
return object_context
if self._relation_formula_fragment_is_existing_object(stripped_fragment):
return self._relation_formula_exact_object_ref_completion_tokens(stripped_fragment), stripped_fragment
return (
self._sort_relation_formula_completions(self._relation_formula_ref_completion_tokens(fragment)),
fragment,
)
if not stripped_fragment:
return (
self._sort_relation_formula_completions(self._relation_formula_ref_completion_tokens(fragment)),
"",
)
if self._relation_formula_fragment_is_object_prefix(stripped_fragment):
object_context = self._relation_formula_object_context_completions(stripped_fragment)
if object_context is not None:
return object_context
if self._relation_formula_fragment_is_existing_object(stripped_fragment):
return self._relation_formula_exact_object_ref_completion_tokens(stripped_fragment), stripped_fragment
if RELATION_REF_PATTERN.fullmatch(stripped_fragment) or self._relation_formula_fragment_is_number(stripped_fragment):
return [" + ", " - ", " * ", " / "], ""
return (
self._sort_relation_formula_completions(self._relation_formula_ref_completion_tokens(fragment)),
fragment,
)
def _relation_formula_object_context_completions(self, fragment: str) -> tuple[list[str], str] | None:
object_tokens = self._sort_relation_formula_completions(self._relation_formula_object_completion_tokens(fragment))
if not object_tokens:
return None
lowered = str(fragment or "").strip().lower()
exact_object = self._relation_formula_fragment_is_existing_object(fragment)
has_longer_match = any(token.lower() != lowered and token.lower().startswith(lowered) for token in object_tokens)
if exact_object and not has_longer_match:
ref_tokens = self._relation_formula_exact_object_ref_completion_tokens(fragment)
if ref_tokens:
return ref_tokens, fragment
if exact_object and has_longer_match:
object_tokens = [token for token in object_tokens if token.lower() != lowered]
return object_tokens, fragment
def _relation_formula_exact_object_ref_completion_tokens(self, fragment: str) -> list[str]:
match = re.fullmatch(r"(?P<kind>Face|Edge)(?P<object_id>\d+)", str(fragment or ""), flags=re.IGNORECASE)
if match is None:
return ["."]
kind = "Face" if match.group("kind").lower() == "face" else "Edge"
object_id = int(match.group("object_id"))
names = self._relation_known_parameter_names(kind, object_id)
tokens = [f"{kind}{object_id}.{name}" for name in names if self._relation_parameter_name_is_safe(str(name))]
return self._sort_relation_formula_completions(tokens) or ["."]
@staticmethod
def _sort_relation_formula_completions(tokens: list[str]) -> list[str]:
unique = list(dict.fromkeys(str(item) for item in tokens if str(item)))
def sort_key(token: str) -> tuple[int, int, int, str]:
stripped = token.strip()
if stripped == "Face":
return (0, -1, -1, "")
if stripped == "Edge":
return (1, -1, -1, "")
match = re.match(r"^(Face|Edge)(\d+)(?:\.|$)", stripped, flags=re.IGNORECASE)
if match is not None:
kind = 0 if match.group(1).lower() == "face" else 1
return (kind, int(match.group(2)), 0 if "." not in stripped else 1, stripped.lower())
return (9, 0, 0, stripped.lower())
return sorted(unique, key=sort_key)
@staticmethod
def _relation_formula_fragment_start(text: str) -> int:
separators = " \t\r\n=+-*/(),"
return max((text.rfind(char) for char in separators), default=-1) + 1
@staticmethod
def _relation_formula_fragment_is_number(fragment: str) -> bool:
try:
float(fragment)
return True
except ValueError:
return False
@staticmethod
def _relation_formula_fragment_is_object_prefix(fragment: str) -> bool:
if not fragment:
return False
lowered = fragment.lower()
return "face".startswith(lowered) or "edge".startswith(lowered) or bool(
re.fullmatch(r"(Face|Edge)\d*", fragment, flags=re.IGNORECASE)
)
def _relation_formula_object_completion_tokens(self, fragment: str = "") -> list[str]:
normalized = str(fragment or "").strip()
lowered = normalized.lower()
kind_only = lowered in {"face", "edge"}
prefix_kinds: list[str] = []
if lowered and "face".startswith(lowered):
prefix_kinds.append("Face")
if lowered and "edge".startswith(lowered):
prefix_kinds.append("Edge")
tokens = [] if kind_only else list(prefix_kinds)
labels = {object_label for object_label, _row, _spec in self._relation_visible_parameter_specs() if object_label}
if self.selected_kind == "multi_feature":
for entry in getattr(self, "multi_selected_hole_entries", []) or []:
if not isinstance(entry, dict):
continue
logical_id = _int_or_none(entry.get("logical_id"))
if logical_id is not None:
labels.add(f"Face{logical_id}")
if self.selected_kind in {"face", "feature"} and self.selected_face_id is not None and self.model is not None:
try:
labels.add(f"Face{int(self.model.face_logical_id(int(self.selected_face_id)))}")
except Exception:
labels.add(f"Face{int(self.selected_face_id)}")
if self.selected_kind == "edge" and self.selected_edge_id is not None:
labels.add(f"Edge{int(self.selected_edge_id)}")
for label in labels:
if not lowered or label.lower().startswith(lowered):
tokens.append(label)
match = re.fullmatch(r"(?P<kind>Face|Edge)(?P<prefix>\d*)", normalized, flags=re.IGNORECASE)
if match is not None:
tokens.extend(
self._relation_matching_object_labels(
match.group("kind"),
match.group("prefix"),
limit=RELATION_FORMULA_OBJECT_COMPLETION_LIMIT,
)
)
elif prefix_kinds:
for kind in prefix_kinds:
tokens.extend(self._relation_matching_object_labels(kind, "", limit=RELATION_FORMULA_OBJECT_COMPLETION_LIMIT))
if kind_only and not tokens:
tokens.append(normalized)
return tokens
def _relation_formula_object_label_cache_data(self) -> dict[str, object]:
model = self.model
if model is None:
return {"Face": [], "Edge": [], "FaceSet": set(), "EdgeSet": set()}
faces = getattr(model, "faces", []) or []
edges = getattr(model, "edges", []) or []
logical_ids = tuple(int(item) for item in (getattr(model, "face_logical_ids", []) or []))
cache_key = (id(model), id(getattr(model, "shape", None)), len(faces), len(edges), logical_ids)
if (
getattr(self, "_relation_formula_object_label_cache_key", None) == cache_key
and isinstance(getattr(self, "_relation_formula_object_label_cache", None), dict)
):
return self._relation_formula_object_label_cache
if len(logical_ids) >= len(faces):
face_ids = {int(logical_ids[index]) for index in range(len(faces))}
else:
face_ids = set(range(len(faces)))
edge_ids = set(range(len(edges)))
face_labels = self._sort_relation_formula_completions([f"Face{face_id}" for face_id in face_ids])
edge_labels = self._sort_relation_formula_completions([f"Edge{edge_id}" for edge_id in edge_ids])
cache = {
"Face": face_labels,
"Edge": edge_labels,
"FaceSet": set(face_labels),
"EdgeSet": set(edge_labels),
}
self._relation_formula_object_label_cache_key = cache_key
self._relation_formula_object_label_cache = cache
return cache
def _relation_matching_object_labels(self, kind: str, id_prefix: str, *, limit: int | None = None) -> list[str]:
normalized_kind = "Face" if str(kind).lower() == "face" else "Edge" if str(kind).lower() == "edge" else ""
if not normalized_kind:
return []
labels = list(self._relation_formula_object_label_cache_data().get(normalized_kind, []) or [])
id_text = str(id_prefix or "")
if id_text:
prefix = f"{normalized_kind}{id_text}".lower()
labels = [label for label in labels if str(label).lower().startswith(prefix)]
if limit is not None:
return labels[: max(0, int(limit))]
return labels
def _relation_formula_fragment_is_existing_object(self, fragment: str) -> bool:
match = re.fullmatch(r"(?P<kind>Face|Edge)(?P<object_id>\d+)", str(fragment or ""), flags=re.IGNORECASE)
if match is None:
return False
kind = match.group("kind").lower()
object_id = int(match.group("object_id"))
visible_labels = {label.lower() for label, _row, _spec in self._relation_visible_parameter_specs() if label}
if str(fragment).lower() in visible_labels:
return True
normalized_kind = "Face" if kind == "face" else "Edge"
cached = self._relation_formula_object_label_cache_data()
cached_set = cached.get(f"{normalized_kind}Set")
if isinstance(cached_set, set) and f"{normalized_kind}{object_id}" in cached_set:
return True
if self.model is None:
return False
if kind == "face":
if hasattr(self.model, "resolve_face_selection_id"):
try:
face_id = self.model.resolve_face_selection_id(object_id)
except Exception:
face_id = None
return face_id is not None
return 0 <= object_id < len(getattr(self.model, "faces", []) or [])
if kind == "edge":
return 0 <= object_id < len(getattr(self.model, "edges", []) or [])
return False
def _relation_formula_ref_completion_tokens(self, fragment: str = "") -> list[str]:
tokens = self._relation_formula_completion_tokens()
match = re.match(
r"^\s*(?P<kind>Face|Edge)(?P<object_id>\d+)\.(?P<parameter>[A-Za-z0-9_\u4e00-\u9fff]*)$",
str(fragment or ""),
)
if match is not None:
prefix = f"{match.group('kind')}{match.group('object_id')}."
tokens.extend(f"{prefix}{name}" for name in self._relation_known_parameter_names(match.group("kind"), int(match.group("object_id"))))
return tokens
def _relation_formula_completion_tokens(self) -> list[str]:
tokens: list[str] = []
for object_label, _row, spec in self._relation_visible_parameter_specs():
for name in self._relation_parameter_names_for_spec(spec):
if self._relation_parameter_name_is_safe(name):
tokens.append(f"{object_label}.{name}")
if self.selected_kind == "multi_feature":
for entry in getattr(self, "multi_selected_hole_entries", []) or []:
if not isinstance(entry, dict):
continue
logical_id = _int_or_none(entry.get("logical_id"))
if logical_id is None:
continue
for name in ("直径", "半径", "位置", "位置X", "位置Y", "位置Z"):
tokens.append(f"Face{logical_id}.{name}")
return tokens
def _relation_known_parameter_names(self, kind: str, object_id: int) -> tuple[str, ...]:
if kind == "Edge":
return ("长度", "直径", "半径", "位置", "位置X", "位置Y", "位置Z")
if kind != "Face":
return ()
names = ["直径", "半径", "位置", "位置X", "位置Y", "位置Z", "偏移", "面内长度", "面内宽度", "高度", "深度", "槽宽", "宽度"]
if self.model is not None and hasattr(self.model, "resolve_face_selection_id"):
face_id = self.model.resolve_face_selection_id(int(object_id))
if face_id is not None:
try:
info = self.model.quick_face_info(face_id)
except Exception:
info = {}
surface = str(info.get("surface") or "")
if surface == "plane":
names = ["偏移", "面内长度", "面内宽度", "位置", "位置X", "位置Y", "位置Z"]
elif surface == "cylinder":
names = ["直径", "半径", "位置", "位置X", "位置Y", "位置Z", "高度", "深度", "槽宽", "宽度"]
compact: list[str] = []
for name in names:
if name not in compact:
compact.append(name)
return tuple(compact)
def _relation_visible_parameter_specs(self) -> list[tuple[str, int, dict[str, object]]]:
if not hasattr(self, "property_table"):
return []
rows: list[tuple[str, int, dict[str, object]]] = []
for row, spec in enumerate(getattr(self, "property_editor_specs", []) or []):
effective = self._effective_property_spec(spec, row=row)
if not effective.get("action") or str(effective.get("value_type", "number")) == "command":
continue
object_label = self._relation_object_label_for_spec(effective)
if not object_label:
continue
rows.append((object_label, row, effective))
return rows
def _relation_object_label_for_spec(self, spec: dict[str, object]) -> str:
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None and self.selected_kind in {"face", "feature"}:
source_face_id = _int_or_none(self.selected_face_id)
model_faces = getattr(self.model, "faces", None) if self.model is not None else None
if source_face_id is not None and model_faces is not None and 0 <= source_face_id < len(model_faces):
try:
return f"Face{int(self.model.face_logical_id(source_face_id))}"
except Exception:
return f"Face{int(source_face_id)}"
if source_face_id is not None:
return f"Face{int(source_face_id)}"
if self.selected_kind == "edge" and self.selected_edge_id is not None:
return f"Edge{int(self.selected_edge_id)}"
return ""
def _relation_parameter_names_for_spec(self, spec: dict[str, object]) -> tuple[str, ...]:
names: list[str] = []
label = str(spec.get("label") or "").strip()
if label:
names.append(label)
key = str(spec.get("key") or "")
names.extend(RELATION_SPEC_ALIASES.get(key, ()))
value_type = str(spec.get("value_type", "number"))
for name in tuple(names):
if value_type == "vector3":
if name == "位置":
names.extend(("位置X", "位置Y", "位置Z"))
elif name == "中心":
names.extend(("中心X", "中心Y", "中心Z"))
compact: list[str] = []
for name in names:
if name and name not in compact:
compact.append(name)
return tuple(compact)
@staticmethod
def _relation_parameter_name_is_safe(name: str) -> bool:
return bool(name) and all(char == "_" or char.isalnum() for char in name)
def _update_relation_formula_buttons(self) -> None:
formula_backend_ready = self.model is not None and not (self.scan_in_progress or self.load_in_progress)
can_type_formula = not (self.scan_in_progress or self.load_in_progress)
can_modify_formula_set = formula_backend_ready and not (
self.operation_in_progress or bool(getattr(self, "relation_formula_replay_active", False))
)
formula_input = getattr(self, "relation_formula_input", None)
if isinstance(formula_input, QLineEdit):
formula_input.setEnabled(bool(can_type_formula))
formula_input.setReadOnly(False)
if hasattr(self, "_set_help_tip"):
self._set_help_tip(
formula_input,
"输入关系式;如果当前已有公式正在计算,新公式会先加入队列。",
)
else:
formula_input.setToolTip("输入关系式;如果当前已有公式正在计算,新公式会先加入队列。")
add_button = getattr(self, "add_relation_formula_button", None)
if isinstance(add_button, QPushButton):
text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else ""
self._set_control_state(
add_button,
bool(formula_backend_ready and text),
"添加当前关系式,并立即按当前公式组重新计算模型。",
"请先导入模型,并输入类似 Face87.直径 = Face85.直径 的关系式。",
)
remove_button = getattr(self, "remove_relation_formula_button", None)
toggle_button = getattr(self, "toggle_relation_formula_button", None)
formula_list = getattr(self, "relation_formula_list", None)
selected_ids = self._selected_relation_formula_ids()
selected_items = [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if int(item.get("id", -1)) in selected_ids
]
selected_has_enabled = any(bool(item.get("enabled", True)) for item in selected_items)
if isinstance(toggle_button, QPushButton):
toggle_button.setText("停用公式" if selected_has_enabled else "启用公式")
self._set_control_state(
toggle_button,
bool(can_modify_formula_set and selected_items),
"临时停用或重新启用选中的关系式,并按当前启用公式重新计算模型。",
"请先在已有关系式列表里选择一条公式;公式计算中暂不能切换。",
)
if isinstance(remove_button, QPushButton):
self._set_control_state(
remove_button,
bool(can_modify_formula_set and selected_items),
"删除选中的关系式,并按剩余公式重新计算模型。",
"请先在已有关系式列表里选择一条公式;公式计算中暂不能删除。",
)
import_button = getattr(self, "import_relation_formula_button", None)
if isinstance(import_button, QPushButton):
self._set_control_state(
import_button,
bool(can_modify_formula_set),
"从 JSON 导入一组关系式,替换当前公式组,并按启用公式重新计算模型。",
"请先导入模型;公式计算中暂不能导入。",
)
export_button = getattr(self, "export_relation_formula_button", None)
if isinstance(export_button, QPushButton):
has_formulas = bool(getattr(self, "relation_formula_items", []) or [])
self._set_control_state(
export_button,
bool(has_formulas),
"把当前关系式导出为 JSON,便于复用或交给外部流程。",
"当前还没有可导出的关系式。",
)
def add_relation_formula(self) -> None:
if self.model is None:
self.statusBar().showMessage("请先导入模型后再添加关系式。")
return
replay_active = bool(getattr(self, "relation_formula_replay_active", False))
if not replay_active and self._edit_busy("请等待当前几何计算完成后再添加公式。"):
return
text = self.relation_formula_input.text().strip() if hasattr(self, "relation_formula_input") else ""
try:
formula = parse_relation_formula(text)
self._validate_relation_formula_graph(formula)
if not replay_active:
self._validate_relation_formula_references(formula)
except RelationFormulaError as exc:
QMessageBox.information(self, "关系式无效", str(exc))
return
self._ensure_relation_formula_base_snapshot()
formula_id = int(getattr(self, "relation_formula_next_id", 1) or 1)
self.relation_formula_next_id = formula_id + 1
item = {
"id": formula_id,
"text": formula.text,
"enabled": True,
"status": "ready",
"message": "等待当前公式完成后计算。" if replay_active else "等待立即计算。",
"signatures": {},
}
try:
item["signatures"] = self._relation_signatures_for_formula(formula)
except RelationFormulaError:
item["signatures"] = {}
self.relation_formula_items = [*list(getattr(self, "relation_formula_items", []) or []), item]
self.relation_formula_input.clear()
self._refresh_relation_formula_list()
self._update_property_apply_state()
QTimer.singleShot(0, self._restore_relation_formula_input_focus)
if replay_active:
queue = list(getattr(self, "relation_formula_replay_queue", []) or [])
queue.append(dict(item))
self.relation_formula_replay_queue = queue
self.relation_formula_replay_total = int(getattr(self, "relation_formula_replay_total", 0) or 0) + 1
self.statusBar().showMessage(f"已添加关系式:{formula.text},当前公式完成后继续计算。")
return
self.statusBar().showMessage(f"已添加关系式:{formula.text},正在更新模型...")
self._start_relation_formula_reapply(reason="add")
def remove_selected_relation_formula(self) -> None:
formula_list = getattr(self, "relation_formula_list", None)
if formula_list is None:
return
if self._edit_busy("请等待当前几何计算完成后再删除公式。"):
return
ids = self._selected_relation_formula_ids()
if not ids:
return
self.relation_formula_items = [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if int(item.get("id", -1)) not in ids
]
self._refresh_relation_formula_list()
self._update_property_apply_state()
if self._enabled_relation_formula_items():
self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,正在按剩余公式更新模型...")
self._start_relation_formula_reapply(reason="remove")
elif self.relation_formula_items:
restored = self._restore_relation_formula_base_snapshot()
message = "当前没有启用公式,模型已恢复到公式基准状态。" if restored else "当前没有启用公式。"
self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}")
self._refresh_relation_formula_list()
self._update_property_apply_state()
else:
restored = self._restore_relation_formula_base_snapshot()
self._clear_relation_formula_runtime_state(clear_items=False)
message = "模型已恢复到添加公式前状态。" if restored else "已删除关系式。"
self.statusBar().showMessage(f"已删除 {len(ids)} 条关系式,{message}")
def toggle_selected_relation_formula(self) -> None:
formula_list = getattr(self, "relation_formula_list", None)
if formula_list is None:
return
if self._edit_busy("请等待当前几何计算完成后再切换公式。"):
return
ids = self._selected_relation_formula_ids()
if not ids:
return
selected_items = [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if int(item.get("id", -1)) in ids
]
enable_selected = not any(bool(item.get("enabled", True)) for item in selected_items)
changed: list[dict[str, object]] = []
for raw_item in getattr(self, "relation_formula_items", []) or []:
item = dict(raw_item)
if int(item.get("id", -1)) in ids:
item["enabled"] = enable_selected
if enable_selected:
item["status"] = "ready"
item["message"] = "已启用,等待重新计算。"
else:
item["status"] = "disabled"
item["message"] = "已停用,不参与计算。"
changed.append(item)
self.relation_formula_items = changed
self._refresh_relation_formula_list()
self._update_property_apply_state()
action = "启用" if enable_selected else "停用"
if self._enabled_relation_formula_items():
self.statusBar().showMessage(f"已{action} {len(ids)} 条关系式,正在按启用公式更新模型...")
self._start_relation_formula_reapply(reason="toggle")
else:
restored = self._restore_relation_formula_base_snapshot()
message = "模型已恢复到公式基准状态。" if restored else "当前没有启用公式。"
self.statusBar().showMessage(f"已{action} {len(ids)} 条关系式,{message}")
self._refresh_relation_formula_list()
self._update_property_apply_state()
def export_relation_formulas(self, path: str | Path | bool | None = None) -> bool:
interactive = path is None or isinstance(path, bool)
if isinstance(path, bool):
path = None
formulas = list(getattr(self, "relation_formula_items", []) or [])
if not formulas:
self.statusBar().showMessage("当前没有可导出的关系式。")
if interactive:
QMessageBox.information(self, "导出公式", "当前没有可导出的关系式。")
return False
if path is None:
start_dir = Path(getattr(self, "step_path", "") or Path.cwd()).parent
selected, _ = QFileDialog.getSaveFileName(
self,
"导出关系式",
str(start_dir / "relation_formulas.json"),
"JSON (*.json);;所有文件 (*.*)",
)
if not selected:
return False
path = selected
output_path = Path(path)
output_path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"schema": "python-occt.relation-formulas.v1",
"exportedAt": datetime.now().isoformat(timespec="seconds"),
"sourceStep": str(getattr(self, "step_path", "") or ""),
"formulas": self._relation_formula_export_rows(),
}
output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
self.statusBar().showMessage(f"已导出 {len(payload['formulas'])} 条关系式:{output_path}")
return True
def import_relation_formulas(self, path: str | Path | bool | None = None) -> bool:
interactive = path is None or isinstance(path, bool)
if isinstance(path, bool):
path = None
if self.model is None:
self.statusBar().showMessage("请先导入模型后再导入关系式。")
if interactive:
QMessageBox.information(self, "导入公式", "请先导入模型后再导入关系式。")
return False
if self._edit_busy("请等待当前几何计算完成后再导入公式。"):
return False
if path is None:
start_dir = Path(getattr(self, "step_path", "") or Path.cwd()).parent
selected, _ = QFileDialog.getOpenFileName(
self,
"导入关系式",
str(start_dir),
"JSON (*.json);;所有文件 (*.*)",
)
if not selected:
return False
path = selected
input_path = Path(path)
try:
payload = json.loads(input_path.read_text(encoding="utf-8"))
imported_items = self._relation_formula_items_from_import_payload(payload)
except Exception as exc:
message = f"导入公式失败:{exc}"
self.statusBar().showMessage(message)
if interactive:
QMessageBox.information(self, "导入公式失败", message)
return False
self._ensure_relation_formula_base_snapshot()
self.relation_formula_items = imported_items
next_ids = [int(item.get("id", 0) or 0) for item in imported_items]
self.relation_formula_next_id = (max(next_ids) + 1) if next_ids else 1
self._refresh_relation_formula_list()
self._update_property_apply_state()
enabled_count = len(self._enabled_relation_formula_items())
if enabled_count:
self.statusBar().showMessage(f"已导入 {len(imported_items)} 条关系式,正在按 {enabled_count} 条启用公式更新模型...")
self._start_relation_formula_reapply(reason="import")
else:
self.statusBar().showMessage(f"已导入 {len(imported_items)} 条关系式;当前没有启用公式。")
return True
def _relation_formula_export_rows(self) -> list[dict[str, object]]:
rows: list[dict[str, object]] = []
for index, raw_item in enumerate(getattr(self, "relation_formula_items", []) or [], start=1):
item = dict(raw_item)
rows.append(
{
"index": index,
"text": str(item.get("text") or "").strip(),
"enabled": bool(item.get("enabled", True)),
"status": str(item.get("status") or "").strip(),
"message": str(item.get("message") or "").strip(),
}
)
return rows
def _relation_formula_items_from_import_payload(self, payload: object) -> list[dict[str, object]]:
raw_items = payload.get("formulas") if isinstance(payload, dict) else payload
if not isinstance(raw_items, list):
raise RelationFormulaError("JSON 必须是公式数组,或包含 formulas 数组。")
formulas = []
items: list[dict[str, object]] = []
for index, raw_item in enumerate(raw_items, start=1):
if isinstance(raw_item, str):
text = raw_item
enabled = True
elif isinstance(raw_item, dict):
text = str(raw_item.get("text") or raw_item.get("formula") or "").strip()
enabled = bool(raw_item.get("enabled", True))
else:
raise RelationFormulaError(f"第 {index} 条公式格式不正确。")
if not text:
raise RelationFormulaError(f"第 {index} 条公式为空。")
try:
formula = parse_relation_formula(text)
except RelationFormulaError as exc:
raise RelationFormulaError(f"第 {index} 条公式无效:{exc}") from exc
formulas.append(formula)
items.append(
{
"id": index,
"text": formula.text,
"enabled": enabled,
"status": "ready" if enabled else "disabled",
"message": "导入后等待计算。" if enabled else "已停用,不参与计算。",
"signatures": {},
}
)
if not items:
raise RelationFormulaError("JSON 里没有关系式。")
try:
validate_relation_formula_graph(formulas)
except RelationFormulaError as exc:
raise RelationFormulaError(f"导入的公式组无效:{exc}") from exc
return items
def _selected_relation_formula_ids(self) -> set[int]:
formula_list = getattr(self, "relation_formula_list", None)
if formula_list is None:
return set()
ids: set[int] = set()
for item in formula_list.selectedItems():
value = item.data(Qt.ItemDataRole.UserRole)
if value is None:
continue
try:
ids.add(int(value))
except (TypeError, ValueError):
continue
return ids
def _enabled_relation_formula_items(self) -> list[dict[str, object]]:
return [
dict(item)
for item in (getattr(self, "relation_formula_items", []) or [])
if bool(item.get("enabled", True))
]
def _clear_relation_formula_runtime_state(self, *, clear_items: bool) -> None:
if clear_items:
self.relation_formula_items = []
self.relation_formula_next_id = 1
self.relation_formula_base_snapshot = None
self.relation_formula_replay_queue = []
self.relation_formula_replay_total = 0
self.relation_formula_replay_done = 0
self.relation_formula_replay_active = False
self.relation_formula_replay_current_id = None
self.relation_formula_replay_callback_seen = False
self._relation_formula_replay_running_action = False
self._refresh_relation_formula_list()
self._update_property_apply_state()
def _ensure_relation_formula_base_snapshot(self) -> None:
if self.model is None or getattr(self, "relation_formula_base_snapshot", None) is not None:
return
if not hasattr(self.model, "snapshot"):
return
try:
self.relation_formula_base_snapshot = self.model.snapshot()
except Exception:
self.relation_formula_base_snapshot = None
def _restore_relation_formula_base_snapshot(self) -> bool:
snapshot = getattr(self, "relation_formula_base_snapshot", None)
if self.model is None or not isinstance(snapshot, dict):
return False
try:
if hasattr(self, "_restore_snapshot"):
self._restore_snapshot(snapshot)
else:
self.model.restore_snapshot(snapshot)
except Exception as exc:
QMessageBox.information(self, "关系式恢复失败", f"恢复到公式基准状态失败:{exc}")
return False
return True
def _queue_relation_formula_dependency_reapply(self, formula_ids: Iterable[int]) -> None:
ids = {int(item) for item in formula_ids if int(item) >= 0}
if not ids:
return
pending = set(getattr(self, "_pending_relation_formula_dependency_ids", []) or [])
pending.update(ids)
self._pending_relation_formula_dependency_ids = sorted(pending)
def _run_pending_relation_formula_dependency_reapply(self) -> None:
ids = list(getattr(self, "_pending_relation_formula_dependency_ids", []) or [])
self._pending_relation_formula_dependency_ids = []
if not ids:
return
if bool(getattr(self, "operation_in_progress", False)) or bool(getattr(self, "relation_formula_replay_active", False)):
self._queue_relation_formula_dependency_reapply(ids)
return
self._start_relation_formula_reapply(
reason="dependency",
restore_base=False,
formula_ids=ids,
)
def _start_relation_formula_reapply(
self,
*,
reason: str,
restore_base: bool = True,
formula_ids: Iterable[int] | None = None,
) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前几何计算完成后再更新公式。"):
return
if restore_base:
self._ensure_relation_formula_base_snapshot()
if restore_base and getattr(self, "relation_formula_base_snapshot", None) is not None:
if not self._restore_relation_formula_base_snapshot():
return
selected_ids = {int(item) for item in formula_ids} if formula_ids is not None else None
self.relation_formula_replay_queue = [
dict(item)
for item in getattr(self, "relation_formula_items", []) or []
if bool(item.get("enabled", True))
and (selected_ids is None or int(item.get("id", -1)) in selected_ids)
and str(item.get("status") or "") != "invalid"
]
self.relation_formula_replay_total = len(self.relation_formula_replay_queue)
self.relation_formula_replay_done = 0
self.relation_formula_replay_active = bool(self.relation_formula_replay_queue)
self.relation_formula_replay_current_id = None
self.relation_formula_replay_callback_seen = False
if not self.relation_formula_replay_active:
self._refresh_relation_formula_list()
self._update_property_apply_state()
return
if reason == "add":
label = "添加"
elif reason == "dependency":
label = "依赖参数变化"
elif reason == "toggle":
label = "切换"
elif reason == "import":
label = "导入"
else:
label = "删除"
self.statusBar().showMessage(f"{label}公式后正在重新计算:共 {self.relation_formula_replay_total} 条。")
QTimer.singleShot(0, self._run_next_relation_formula)
def _run_next_relation_formula(self) -> None:
if not bool(getattr(self, "relation_formula_replay_active", False)):
return
if self.model is None:
self._finish_relation_formula_replay("关系式计算停止:当前没有模型。")
return
queue = list(getattr(self, "relation_formula_replay_queue", []) or [])
while queue:
item = dict(queue.pop(0))
self.relation_formula_replay_queue = queue
formula_id = int(item.get("id", -1))
self.relation_formula_replay_current_id = formula_id
try:
row = self._prepare_relation_formula_item_for_edit(item)
except RelationFormulaError as exc:
self._update_relation_formula_item(formula_id, status="invalid", message=str(exc))
self._finish_relation_formula_replay(f"关系式计算已停止:{exc}")
return
if row is None:
self.relation_formula_replay_done = int(getattr(self, "relation_formula_replay_done", 0) or 0) + 1
continue
self._update_relation_formula_item(formula_id, status="running", message="正在修改模型。")
done = int(getattr(self, "relation_formula_replay_done", 0) or 0)
total = int(getattr(self, "relation_formula_replay_total", 0) or 0)
self.statusBar().showMessage(f"关系式正在修改模型:{done + 1}/{total} - {item.get('text', '')}")
self._relation_formula_replay_running_action = True
self.relation_formula_replay_callback_seen = False
try:
self.apply_property_row_edit(row)
finally:
self._relation_formula_replay_running_action = False
if bool(getattr(self, "operation_in_progress", False)):
return
if bool(getattr(self, "relation_formula_replay_callback_seen", False)):
return
self._update_relation_formula_item(formula_id, status="invalid", message="公式没有启动可执行的参数化操作。")
self._finish_relation_formula_replay("关系式没有启动可执行的参数化操作。")
return
self._finish_relation_formula_replay("关系式已全部生效。")
def _prepare_relation_formula_item_for_edit(self, item: dict[str, object]) -> int | None:
formula = parse_relation_formula(str(item.get("text") or ""))
self._ensure_relation_target_visible(formula.target)
value = evaluate_relation_formula(formula, self._relation_value_for_ref)
row = self._set_relation_target_value(formula.target, value)
spec = self._effective_property_spec(self.property_editor_specs[row], row=row)
text = self._property_target_text(row)
if not self._property_target_changed(spec, text):
self._update_relation_formula_item(
int(item.get("id", -1)),
status="applied",
message=f"{relation_value_to_text(value)},无需变化",
signatures=self._relation_signatures_for_formula(formula),
)
return None
self._update_relation_formula_item(
int(item.get("id", -1)),
status="ready",
message=relation_value_to_text(value),
signatures=self._relation_signatures_for_formula(formula),
)
return row
def _ensure_relation_target_visible(self, ref: ObjectParameterRef) -> None:
if self._relation_visible_spec_for_ref(ref) is not None:
return
if self.model is None:
raise RelationFormulaError("当前没有已导入模型。")
if ref.kind == "Face":
face_id = self.model.resolve_face_selection_id(int(ref.object_id)) if hasattr(self.model, "resolve_face_selection_id") else int(ref.object_id)
if face_id is None or not (0 <= int(face_id) < len(getattr(self.model, "faces", []))):
raise RelationFormulaError(f"找不到 {ref.kind}{ref.object_id}。")
if hasattr(self, "_set_selection_mode"):
self._set_selection_mode("Feature")
try:
self.select_feature(int(face_id))
except Exception:
self.select_face(int(face_id), feature_mode=True)
elif ref.kind == "Edge":
edge_id = int(ref.object_id)
if edge_id < 0 or edge_id >= len(getattr(self.model, "edges", [])):
raise RelationFormulaError(f"找不到 Edge{edge_id}。")
if hasattr(self, "_set_selection_mode"):
self._set_selection_mode("Edge")
self.select_edge(edge_id)
else:
raise RelationFormulaError(f"不支持的对象类型:{ref.kind}")
QApplication.processEvents()
if self._relation_visible_spec_for_ref(ref) is None:
raise RelationFormulaError(f"{ref.token} 当前没有可执行的参数化操作。")
def _update_relation_formula_item(self, formula_id: int, **updates: object) -> None:
changed: list[dict[str, object]] = []
for item in getattr(self, "relation_formula_items", []) or []:
next_item = dict(item)
if int(next_item.get("id", -1)) == int(formula_id):
next_item.update(updates)
changed.append(next_item)
self.relation_formula_items = changed
self._refresh_relation_formula_list()
self._update_property_apply_state()
def _after_relation_formula_edit_finished(self, *, success: bool) -> None:
self.relation_formula_replay_callback_seen = True
formula_id = getattr(self, "relation_formula_replay_current_id", None)
if formula_id is not None:
self._update_relation_formula_item(
int(formula_id),
status="applied" if success else "invalid",
message="已修改模型。" if success else "模型修改失败,已停止后续公式。",
)
if not success:
self._finish_relation_formula_replay("关系式修改失败,模型已保持在失败前状态。")
return
self.relation_formula_replay_done = int(getattr(self, "relation_formula_replay_done", 0) or 0) + 1
self._refresh_relation_formula_replay_queue_from_current_items()
QTimer.singleShot(0, self._run_next_relation_formula)
def _refresh_relation_formula_replay_queue_from_current_items(self) -> None:
remaining_ids = [int(item.get("id", -1)) for item in getattr(self, "relation_formula_replay_queue", []) or []]
if not remaining_ids:
return
current_by_id = {
int(item.get("id", -1)): dict(item)
for item in getattr(self, "relation_formula_items", []) or []
}
self.relation_formula_replay_queue = [
dict(current_by_id[item_id])
for item_id in remaining_ids
if item_id in current_by_id
]
def _finish_relation_formula_replay(self, message: str) -> None:
self.relation_formula_replay_queue = []
self.relation_formula_replay_total = 0
self.relation_formula_replay_done = 0
self.relation_formula_replay_active = False
self.relation_formula_replay_current_id = None
self.relation_formula_replay_callback_seen = False
self._relation_formula_replay_running_action = False
self._refresh_relation_formula_list()
self._update_property_apply_state()
self.statusBar().showMessage(message)
QTimer.singleShot(0, self._restore_relation_formula_input_focus)
def _refresh_relation_formula_list(self) -> None:
formula_list = getattr(self, "relation_formula_list", None)
if formula_list is None:
return
formula_list.clear()
for item in getattr(self, "relation_formula_items", []) or []:
status = str(item.get("status") or "ready")
message = str(item.get("message") or "").strip()
text = str(item.get("text") or "")
suffix = ""
enabled = bool(item.get("enabled", True))
if not enabled:
suffix = f" [停用:{message or '不参与计算'}]"
elif status == "invalid":
suffix = f" [失效:{message or '需要重新选择'}]"
elif status == "pending":
suffix = f" [待选择:{message or '目标不在当前参数表'}]"
elif status == "running":
suffix = f" [正在修改]"
elif status == "applied":
suffix = f" [已计算:{message}]"
row_item = QListWidgetItem(f"{text}{suffix}")
row_item.setData(Qt.ItemDataRole.UserRole, int(item.get("id", -1)))
row_item.setToolTip(message or text)
if not enabled:
row_item.setForeground(QColor("#6b7280"))
elif status == "invalid":
row_item.setForeground(QColor("#b91c1c"))
elif status == "pending":
row_item.setForeground(QColor("#92400e"))
elif status == "running":
row_item.setForeground(QColor("#1d4ed8"))
elif status == "applied":
row_item.setForeground(QColor("#166534"))
formula_list.addItem(row_item)
self._update_relation_formula_buttons()
editor = getattr(self, "relation_formula_input", None)
if isinstance(editor, QLineEdit):
editor.setEnabled(not (self.scan_in_progress or self.load_in_progress))
editor.setReadOnly(False)
def _validate_relation_formula_references(self, formula) -> None:
self._relation_parameter_supported(formula.target, target=True)
for ref in formula.references:
self._relation_value_for_ref(ref)
def _validate_relation_formula_graph(self, new_formula) -> None:
formulas = []
for item in getattr(self, "relation_formula_items", []) or []:
if not bool(item.get("enabled", True)):
continue
try:
formulas.append(parse_relation_formula(str(item.get("text") or "")))
except RelationFormulaError:
continue
formulas.append(new_formula)
validate_relation_formula_graph(formulas)
def _relation_parameter_supported(self, ref: ObjectParameterRef, *, target: bool = False) -> None:
if target and self._relation_visible_spec_for_ref(ref) is not None:
return
try:
self._relation_value_for_ref(ref)
except RelationFormulaError as exc:
if target:
raise RelationFormulaError(f"关系式目标暂不支持:{ref.token}{exc}") from exc
raise
def _relation_formulas_have_current_targets(self) -> bool:
for item in getattr(self, "relation_formula_items", []) or []:
if not bool(item.get("enabled", True)):
continue
try:
formula = parse_relation_formula(str(item.get("text") or ""))
except RelationFormulaError:
continue
if self._relation_visible_spec_for_ref(formula.target) is None:
continue
try:
for ref in formula.references:
self._relation_value_for_ref(ref)
except RelationFormulaError:
continue
return True
return False
def _apply_relation_formulas_to_property_targets(self) -> int:
applied = 0
changed_items: list[dict[str, object]] = []
for raw_item in getattr(self, "relation_formula_items", []) or []:
item = dict(raw_item)
if not bool(item.get("enabled", True)):
changed_items.append(item)
continue
try:
formula = parse_relation_formula(str(item.get("text") or ""))
if self._relation_visible_spec_for_ref(formula.target) is None:
item["status"] = "pending"
item["message"] = "目标对象当前没有显示在特征参数表里。"
changed_items.append(item)
continue
value = evaluate_relation_formula(formula, self._relation_value_for_ref)
self._set_relation_target_value(formula.target, value)
item["status"] = "applied"
item["message"] = relation_value_to_text(value)
item["signatures"] = self._relation_signatures_for_formula(formula)
applied += 1
except RelationFormulaError as exc:
item["status"] = "invalid"
item["message"] = str(exc)
changed_items.append(item)
self.relation_formula_items = changed_items
self._refresh_relation_formula_list()
if applied:
self.statusBar().showMessage(f"已根据关系式回填 {applied} 个目标值。")
return applied
def _relation_visible_spec_for_ref(self, ref: ObjectParameterRef) -> tuple[int, dict[str, object]] | None:
base_name, _component = self._relation_base_parameter(ref.parameter)
for _object_label, row, spec in self._relation_visible_parameter_specs():
if not bool(spec.get("enabled")):
continue
if not self._relation_ref_matches_spec_object(ref, spec):
continue
names = self._relation_parameter_names_for_spec(spec)
if base_name in names or ref.parameter in names:
return row, spec
if base_name == "半径":
for _object_label, row, spec in self._relation_visible_parameter_specs():
if not bool(spec.get("enabled")):
continue
if not self._relation_ref_matches_spec_object(ref, spec):
continue
names = self._relation_parameter_names_for_spec(spec)
if "直径" not in names and "参考直径" not in names:
continue
proxy_spec = dict(spec)
proxy_spec["_relation_proxy_kind"] = "radius_via_diameter"
return row, proxy_spec
return None
def _relation_ref_matches_spec_object(self, ref: ObjectParameterRef, spec: dict[str, object]) -> bool:
if ref.kind == "Face":
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None and self.selected_kind in {"face", "feature"}:
source_face_id = _int_or_none(self.selected_face_id)
if source_face_id is None:
return False
return self._relation_face_ref_matches_face_id(ref, source_face_id)
if ref.kind == "Edge":
if self.selected_kind != "edge" or self.selected_edge_id is None:
return False
return int(ref.object_id) == int(self.selected_edge_id)
return False
def _relation_face_ref_matches_face_id(self, ref: ObjectParameterRef, face_id: int) -> bool:
if self.model is None or not hasattr(self.model, "resolve_face_selection_id"):
return int(ref.object_id) == int(face_id)
ref_face_id = self.model.resolve_face_selection_id(int(ref.object_id))
if ref_face_id is None:
return False
try:
ref_region = set(self.model.face_region_ids(ref_face_id) or [ref_face_id])
except Exception:
ref_region = {ref_face_id}
try:
target_region = set(self.model.face_region_ids(int(face_id)) or [int(face_id)])
except Exception:
target_region = {int(face_id)}
return bool(ref_region & target_region)
@staticmethod
def _relation_base_parameter(parameter: str) -> tuple[str, int | None]:
if parameter in RELATION_VECTOR_COMPONENTS:
return RELATION_VECTOR_COMPONENTS[parameter]
return parameter, None
def _relation_value_for_ref(self, ref: ObjectParameterRef) -> object:
visible = self._relation_visible_spec_for_ref(ref)
if visible is not None:
row, spec = visible
return self._relation_value_from_spec(ref, row, spec)
if ref.kind == "Face":
return self._relation_value_from_face(ref)
if ref.kind == "Edge":
return self._relation_value_from_edge(ref)
raise RelationFormulaError(f"不支持的对象类型:{ref.kind}")
def _relation_value_from_spec(self, ref: ObjectParameterRef, row: int, spec: dict[str, object]) -> object:
value_type = str(spec.get("value_type", "number"))
text = self._property_target_text(row)
use_target = bool(text and self._property_target_changed(spec, text) and not self._property_target_validation_error(spec, text))
if use_target:
if value_type == "vector3":
value: object = self._parse_property_vector3(text)
elif value_type == "number_pair":
value = self._parse_property_number_pair(text)
elif value_type in {"integer", "integer_or_empty"}:
value = int(text) if text else 0
else:
value = float(text)
else:
value = spec.get("current_raw")
if spec.get("_relation_proxy_kind") == "radius_via_diameter":
diameter = _float_or_none(value)
if diameter is None:
raise RelationFormulaError(f"{ref.token} 无法从当前直径参数换算半径。")
value = diameter * 0.5
_base_name, component = self._relation_base_parameter(ref.parameter)
if component is not None:
vector = _triple_or_none(value)
if vector is None:
raise RelationFormulaError(f"{ref.token} 不是可用的三维坐标。")
return float(vector[component])
return value
def _relation_value_from_face(self, ref: ObjectParameterRef) -> object:
if self.model is None:
raise RelationFormulaError("当前没有已导入模型。")
face_id = self.model.resolve_face_selection_id(int(ref.object_id))
if face_id is None:
raise RelationFormulaError(f"找不到 {ref.kind}{ref.object_id}。")
try:
info = self.model.cached_feature_info(face_id) or self.model.feature_info(face_id)
except Exception:
info = self.model.quick_face_info(face_id)
base_name, component = self._relation_base_parameter(ref.parameter)
value: object
if base_name == "直径":
diameter = _float_or_none(info.get("diameter"))
if diameter is None:
radius = _float_or_none(info.get("radius"))
diameter = radius * 2.0 if radius is not None else None
value = diameter
elif base_name == "半径":
value = _float_or_none(info.get("radius"))
elif base_name in {"位置", "中心"}:
value = (
self._axis_center_from_feature_info(info)
or _triple_or_none(info.get("area_center"))
or _triple_or_none(info.get("bbox_center"))
)
elif base_name == "偏移":
value = _float_or_none(info.get("normal_position")) or _float_or_none(info.get("plane_position"))
elif base_name == "面内长度":
value = _float_or_none(info.get("local_face_width"))
elif base_name == "面内宽度":
value = _float_or_none(info.get("local_face_height"))
elif base_name == "高度":
value = _float_or_none(info.get("height_estimate")) or _float_or_none(info.get("same_domain_height_estimate"))
elif base_name == "深度":
value = _float_or_none(info.get("hole_depth_estimate")) or _float_or_none(info.get("slot_sagitta_depth_estimate"))
elif base_name in {"槽宽", "宽度"}:
value = _float_or_none(info.get("slot_chord_width_estimate")) or _float_or_none(info.get("local_face_height"))
else:
raise RelationFormulaError(f"{ref.token} 不是当前已知的 Face 参数。")
if value in {None, ""}:
raise RelationFormulaError(f"{ref.token} 当前没有可用数值。")
if component is not None:
vector = _triple_or_none(value)
if vector is None:
raise RelationFormulaError(f"{ref.token} 不是可用的三维坐标。")
return float(vector[component])
return value
def _relation_value_from_edge(self, ref: ObjectParameterRef) -> object:
if self.model is None:
raise RelationFormulaError("当前没有已导入模型。")
edge_id = int(ref.object_id)
if edge_id < 0 or edge_id >= len(self.model.edges):
raise RelationFormulaError(f"找不到 Edge{edge_id}。")
info = self.model.edge_info(edge_id)
base_name, component = self._relation_base_parameter(ref.parameter)
if base_name == "长度":
value: object = _float_or_none(info.get("length"))
elif base_name == "半径":
value = _float_or_none(info.get("radius"))
elif base_name == "直径":
diameter = _float_or_none(info.get("diameter"))
if diameter is None:
radius = _float_or_none(info.get("radius"))
diameter = radius * 2.0 if radius is not None else None
value = diameter
elif base_name in {"位置", "中心"}:
value = _triple_or_none(info.get("center")) or _triple_or_none(info.get("length_center"))
else:
raise RelationFormulaError(f"{ref.token} 不是当前已知的 Edge 参数。")
if value in {None, ""}:
raise RelationFormulaError(f"{ref.token} 当前没有可用数值。")
if component is not None:
vector = _triple_or_none(value)
if vector is None:
raise RelationFormulaError(f"{ref.token} 不是可用的三维坐标。")
return float(vector[component])
return value
def _set_relation_target_value(self, ref: ObjectParameterRef, value: object) -> int:
visible = self._relation_visible_spec_for_ref(ref)
if visible is None:
raise RelationFormulaError(f"{ref.token} 当前没有显示在特征参数表里。")
row, spec = visible
base_name, component = self._relation_base_parameter(ref.parameter)
value_type = str(spec.get("value_type", "number"))
if component is not None:
scalar = float(value)
current_vector = self._relation_current_vector_target(row, spec)
values = [current_vector[0], current_vector[1], current_vector[2]]
values[component] = scalar
text = relation_value_to_text(Vector3(values))
elif value_type == "vector3":
text = relation_value_to_text(value)
else:
if isinstance(value, Vector3) or isinstance(value, (tuple, list)):
raise RelationFormulaError(f"{ref.token} 需要数字结果,不能写入三维坐标。")
if spec.get("_relation_proxy_kind") == "radius_via_diameter":
radius = _float_or_none(value)
if radius is None:
raise RelationFormulaError(f"{ref.token} 的公式结果不能换算为目标直径。")
value = radius * 2.0
text = relation_value_to_text(value)
validation_error = self._property_target_validation_error(spec, text)
if validation_error:
raise RelationFormulaError(f"{ref.token} 的公式结果无效:{validation_error}")
widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
if isinstance(widget, QLineEdit):
widget.setText(text)
else:
item = self.property_table.item(row, PROPERTY_TARGET_COLUMN)
if item is not None:
item.setText(text)
return row
def _relation_current_vector_target(self, row: int, spec: dict[str, object]) -> tuple[float, float, float]:
text = self._property_target_text(row)
if text and not self._property_target_validation_error(spec, text):
try:
return self._parse_property_vector3(text)
except ValueError:
pass
current = _triple_or_none(spec.get("current_raw"))
if current is None:
raise RelationFormulaError(f"{spec.get('label', '目标')} 当前不是可用三维坐标。")
return current
def _relation_signatures_for_formula(self, formula) -> dict[str, object]:
refs = [formula.target, *formula.references]
signatures: dict[str, object] = {}
for ref in refs:
signatures[ref.token] = self._relation_signature_for_ref(ref)
return signatures
def _relation_signature_for_ref(self, ref: ObjectParameterRef) -> dict[str, object]:
if self.model is None or ref.kind != "Face" or not hasattr(self.model, "resolve_face_selection_id"):
return {"kind": ref.kind, "object_id": ref.object_id, "parameter": ref.parameter}
face_id = self.model.resolve_face_selection_id(int(ref.object_id))
if face_id is None:
return {"kind": "Face", "object_id": ref.object_id, "parameter": ref.parameter, "status": "missing"}
try:
info = self.model.quick_face_info(face_id)
except Exception:
info = {}
axis_center = self._axis_center_from_feature_info(info)
return {
"kind": "Face",
"object_id": int(ref.object_id),
"parameter": ref.parameter,
"face_id": int(face_id),
"logical_id": int(ref.object_id),
"current_logical_id": self._relation_face_display_id(face_id),
"part_id": info.get("part_id"),
"solid_id": info.get("solid_id"),
"surface": info.get("surface"),
"area": info.get("area"),
"center": _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center")),
"axis_center": axis_center,
"radius": info.get("radius"),
"diameter": info.get("diameter"),
"axis": _triple_or_none(info.get("axis")),
}
def _relation_face_display_id(self, face_id: int) -> int:
if self.model is None:
return int(face_id)
try:
return int(self.model.face_region_logical_id(face_id))
except Exception:
try:
return int(self.model.face_logical_id(face_id))
except Exception:
return int(face_id)
def _refresh_relation_formulas_after_model_edit(self, context: dict[str, object] | None = None) -> str:
self._last_relation_formula_refresh_affected_ids = []
if self.model is None or not getattr(self, "relation_formula_items", None):
return ""
updated_items: list[dict[str, object]] = []
changed_count = 0
invalid_count = 0
affected_ids: set[int] = set()
for raw_item in self.relation_formula_items:
item = dict(raw_item)
try:
parsed_formula = parse_relation_formula(str(item.get("text") or ""))
except RelationFormulaError:
parsed_formula = None
signatures = item.get("signatures")
if not isinstance(signatures, dict):
try:
if parsed_formula is None:
parsed_formula = parse_relation_formula(str(item.get("text") or ""))
signatures = self._relation_signatures_for_formula(parsed_formula)
except RelationFormulaError:
signatures = {}
face_id_map: dict[int, int] = {}
item_invalid = False
target_token = parsed_formula.target.token if parsed_formula is not None else ""
for token, signature in signatures.items():
if not isinstance(signature, dict) or signature.get("kind") != "Face":
continue
old_id = _int_or_none(signature.get("object_id"))
if old_id is None:
continue
edited_token = self._relation_signature_matches_edit_context(signature, context)
if edited_token:
affected_ids.add(int(item.get("id", -1)))
allow_changed_geometry = bool(edited_token) or (
context is None and str(token) == target_token
)
new_id = self._relation_current_face_id_from_signature(
signature,
allow_changed_geometry=allow_changed_geometry,
)
if new_id is None:
item_invalid = True
continue
if int(new_id) != int(old_id):
face_id_map[int(old_id)] = int(new_id)
if face_id_map:
new_text = rewrite_relation_formula_ids(str(item.get("text") or ""), face_id_map)
if new_text != item.get("text"):
item["text"] = new_text
changed_count += 1
if item_invalid:
item["status"] = "invalid"
item["message"] = "编辑后没有找到唯一匹配的 Face,需要重新选择。"
invalid_count += 1
else:
try:
item["signatures"] = self._relation_signatures_for_formula(parse_relation_formula(str(item.get("text") or "")))
if item.get("status") == "invalid":
item["status"] = "ready"
item["message"] = "已重新定位引用对象。"
except RelationFormulaError as exc:
item["status"] = "invalid"
item["message"] = str(exc)
invalid_count += 1
updated_items.append(item)
self.relation_formula_items = updated_items
self._last_relation_formula_refresh_affected_ids = sorted(
item_id for item_id in affected_ids if item_id >= 0
)
self._refresh_relation_formula_list()
parts: list[str] = []
if changed_count:
parts.append(f"关系式已更新 {changed_count} 处 Face ID")
if invalid_count:
parts.append(f"{invalid_count} 条关系式需要重新选择")
return "".join(parts)
def _relation_signature_matches_edit_context(
self,
signature: dict[str, object],
context: dict[str, object] | None,
) -> bool:
if not isinstance(context, dict):
return False
if str(context.get("target_kind") or "") not in {"face", "feature"}:
return False
target_logical_id = _int_or_none(context.get("target_logical_id"))
target_id = _int_or_none(context.get("target_id"))
signature_ids = {
item
for item in (
_int_or_none(signature.get("object_id")),
_int_or_none(signature.get("logical_id")),
_int_or_none(signature.get("current_logical_id")),
)
if item is not None
}
if target_logical_id is not None and target_logical_id in signature_ids:
return True
if target_id is not None and target_id in signature_ids:
return True
face_id = _int_or_none(signature.get("face_id"))
return target_id is not None and face_id is not None and int(target_id) == int(face_id)
def _relation_current_face_id_from_signature(
self,
signature: dict[str, object],
*,
allow_changed_geometry: bool = False,
) -> int | None:
if self.model is None:
return None
old_logical = _int_or_none(signature.get("logical_id", signature.get("object_id")))
if old_logical is not None:
try:
matches = self.model.face_ids_for_logical_id(old_logical)
except Exception:
matches = []
compatible_matches = [
int(item)
for item in matches
if self._relation_face_signature_is_compatible(
signature,
int(item),
allow_changed_geometry=allow_changed_geometry,
)
]
if len(compatible_matches) == 1:
return self._relation_face_display_id(compatible_matches[0])
if len(compatible_matches) > 1:
display_ids = {self._relation_face_display_id(item) for item in compatible_matches}
if len(display_ids) == 1:
return next(iter(display_ids))
matched = self._relation_match_face_signature(signature, allow_changed_geometry=allow_changed_geometry)
if matched is None:
matched = self._relation_match_face_signature(
signature,
allow_part_mismatch=True,
allow_changed_geometry=allow_changed_geometry,
)
if matched is not None:
return self._relation_face_display_id(matched)
return None
def _relation_match_face_signature(
self,
signature: dict[str, object],
*,
allow_part_mismatch: bool = False,
allow_changed_geometry: bool = False,
) -> int | None:
if self.model is None:
return None
candidates: list[tuple[float, int]] = []
for face_id in range(len(self.model.faces)):
score = self._relation_face_signature_score(
signature,
face_id,
allow_part_mismatch=allow_part_mismatch,
allow_changed_geometry=allow_changed_geometry,
)
if score is not None:
candidates.append((score, face_id))
if not candidates:
return None
candidates.sort(key=lambda item: item[0])
best_score, best_face_id = candidates[0]
if best_score > self._relation_face_signature_score_limit(
signature,
allow_changed_geometry=allow_changed_geometry,
):
return None
if len(candidates) > 1 and abs(candidates[1][0] - best_score) <= max(best_score * 0.05, 1e-6):
close_face_ids = [
face_id
for score, face_id in candidates
if abs(score - best_score) <= max(best_score * 0.05, 1e-6)
]
display_ids = {self._relation_face_display_id(face_id) for face_id in close_face_ids}
if len(display_ids) == 1:
return close_face_ids[0]
return None
return best_face_id
def _relation_face_signature_is_compatible(
self,
signature: dict[str, object],
face_id: int,
*,
allow_changed_geometry: bool,
) -> bool:
score = self._relation_face_signature_score(
signature,
face_id,
allow_part_mismatch=True,
allow_changed_geometry=allow_changed_geometry,
)
if score is None:
return False
return score <= self._relation_face_signature_score_limit(
signature,
allow_changed_geometry=allow_changed_geometry,
)
def _relation_face_signature_score(
self,
signature: dict[str, object],
face_id: int,
*,
allow_part_mismatch: bool,
allow_changed_geometry: bool,
) -> float | None:
if self.model is None or face_id < 0 or face_id >= len(getattr(self.model, "faces", [])):
return None
part_id = _int_or_none(signature.get("part_id"))
if part_id is not None and hasattr(self.model, "face_part_ids") and 0 <= face_id < len(self.model.face_part_ids):
same_part = int(self.model.face_part_ids[face_id]) == int(part_id)
if not same_part and not allow_part_mismatch:
return None
else:
same_part = True
try:
info = self.model.quick_face_info(face_id)
except Exception:
return None
surface = str(signature.get("surface") or "")
if surface and str(info.get("surface") or "") != surface:
return None
score = 0.0 if same_part else 0.05
center = _triple_or_none(signature.get("axis_center")) or _triple_or_none(signature.get("center"))
candidate_center = (
self._axis_center_from_feature_info(info)
or _triple_or_none(info.get("area_center"))
or _triple_or_none(info.get("bbox_center"))
)
if center is not None and candidate_center is not None:
score += math.sqrt(sum((candidate_center[index] - center[index]) ** 2 for index in range(3)))
elif center is not None:
return None
axis = _triple_or_none(signature.get("axis"))
candidate_axis = _triple_or_none(info.get("axis"))
if axis is not None and candidate_axis is not None:
axis_dot = abs(sum(axis[index] * candidate_axis[index] for index in range(3)))
score += max(0.0, 1.0 - min(axis_dot, 1.0)) * 0.25
radius = _float_or_none(signature.get("radius"))
candidate_radius = _float_or_none(info.get("radius"))
if radius is not None and candidate_radius is not None and not allow_changed_geometry:
score += abs(candidate_radius - radius) * 4.0
elif radius is not None and candidate_radius is None and not allow_changed_geometry:
return None
area = _float_or_none(signature.get("area"))
candidate_area = _float_or_none(info.get("area"))
if area is not None and area > 0 and candidate_area is not None and not allow_changed_geometry:
score += abs(candidate_area - area) / max(abs(area), 1.0) * max(math.sqrt(abs(area)) * 0.05, 0.02)
return score
@staticmethod
def _relation_face_signature_score_limit(
signature: dict[str, object],
*,
allow_changed_geometry: bool,
) -> float:
radius = _float_or_none(signature.get("radius"))
area = _float_or_none(signature.get("area"))
size_terms = [1e-4]
if radius is not None and radius > 0:
size_terms.append(radius * (4.0 if allow_changed_geometry else 1.5))
if area is not None and area > 0:
size_terms.append(math.sqrt(area) * (1.5 if allow_changed_geometry else 0.3))
return max(size_terms)
def _accept_relation_formula_completion(self) -> bool:
completer = getattr(self, "relation_formula_completer", None)
editor = getattr(self, "relation_formula_input", None)
if not isinstance(completer, QCompleter) or not isinstance(editor, QLineEdit):
return False
completion = self._relation_formula_popup_current_completion()
if not completion:
self._update_relation_formula_completions()
completion = completer.currentCompletion()
model = completer.model()
completion_prefix = str(completer.completionPrefix() or "")
if model is not None and model.rowCount() > 0:
if (
not completion
or (completion_prefix and not str(completion).lower().startswith(completion_prefix.lower()))
or str(completion) == completion_prefix
):
for row in range(model.rowCount()):
candidate = str(model.index(row, 0).data() or "")
if (
(not completion_prefix or candidate.lower().startswith(completion_prefix.lower()))
and candidate.lower() != completion_prefix.lower()
):
completion = candidate
break
if not completion:
completion = str(model.index(0, 0).data() or "")
if not completion:
return False
return self._insert_relation_formula_completion(str(completion))
def _insert_relation_formula_completion(self, completion: str) -> bool:
editor = getattr(self, "relation_formula_input", None)
if not isinstance(editor, QLineEdit):
return False
text = editor.text()
cursor = editor.cursorPosition()
if completion.strip() in {"=", "+", "-", "*", "/", "."}:
new_text = f"{text[:cursor]}{completion}{text[cursor:]}"
editor.setText(new_text)
editor.setCursorPosition(cursor + len(completion))
self._hide_relation_formula_completion_popup()
editor.setFocus(Qt.FocusReason.OtherFocusReason)
QTimer.singleShot(0, self._restore_relation_formula_input_focus)
if completion.strip() == ".":
QTimer.singleShot(0, self._update_relation_formula_completions)
return True
prefix_start = int(getattr(self, "_relation_formula_completion_prefix_start", -1))
if prefix_start < 0 or prefix_start > cursor:
prefix_start = self._relation_formula_fragment_start(text[:cursor])
prefix = text[prefix_start:cursor]
if prefix and completion.lower().startswith(prefix.lower()):
new_text = f"{text[:prefix_start]}{completion}{text[cursor:]}"
new_cursor = prefix_start + len(completion)
else:
new_text = f"{text[:cursor]}{completion}{text[cursor:]}"
new_cursor = cursor + len(completion)
editor.setText(new_text)
editor.setCursorPosition(new_cursor)
self._hide_relation_formula_completion_popup()
editor.setFocus(Qt.FocusReason.OtherFocusReason)
QTimer.singleShot(0, self._restore_relation_formula_input_focus)
if (
completion in {"Face", "Edge"}
or re.fullmatch(r"(Face|Edge)\d+", str(completion), flags=re.IGNORECASE)
or RELATION_REF_PATTERN.fullmatch(str(completion))
):
QTimer.singleShot(0, self._refresh_relation_formula_completion_popup)
return True
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, ...],
*,
editable: bool,
spec: dict[str, object] | None = None,
) -> None:
label_item, current_item, target_item, scope_item = items[:4]
input_item = items[4] if len(items) > 4 else None
if spec is not None and bool(spec.get("pin_top")):
for item in items:
item.setBackground(QColor("#eef6ff"))
label_item.setForeground(QColor("#075985"))
current_item.setForeground(QColor("#0f172a"))
scope_item.setForeground(QColor("#0369a1"))
target_item.setForeground(QColor("#64748b"))
if input_item is not None:
input_item.setForeground(QColor("#64748b"))
label_font = label_item.font()
label_font.setBold(True)
label_item.setFont(label_font)
current_font = current_item.font()
current_font.setBold(True)
current_item.setFont(current_font)
return
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"))
if input_item is not None:
input_item.setForeground(QColor("#7c2d12"))
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)
return
scope_item.setForeground(QColor("#64748b"))
target_item.setBackground(QColor("#eef2f6"))
target_item.setForeground(QColor("#8f99a8"))
if input_item is not None:
input_item.setBackground(QColor("#eef2f6"))
input_item.setForeground(QColor("#8f99a8"))
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 ""
card_widget = self._property_card_widgets(row).get("scope_combo")
if isinstance(card_widget, NoWheelComboBox):
value = card_widget.currentData()
if value in modes:
return str(value)
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(self.apply_current_property_edit)
self.property_table.setCellWidget(row, PROPERTY_TARGET_COLUMN, editor)
def _set_property_input_checkbox(self, row: int, *, exportable: bool) -> None:
if not hasattr(self, "property_table"):
return
container = QWidget()
container.setObjectName("propertyInputParameterCell")
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
checkbox = QCheckBox(container)
checkbox.setObjectName("propertyInputParameterCheckbox")
checkbox.setChecked(False)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
tip = "勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py。"
if not exportable:
tip = "当前行不是可导出的尺寸输入参数。"
checkbox.setToolTip(tip)
container.setToolTip(tip)
checkbox.toggled.connect(lambda _checked=False: self._update_parameter_export_state())
layout.addWidget(checkbox)
self.property_table.setCellWidget(row, PROPERTY_INPUT_COLUMN, container)
def _property_input_checkbox(self, row: int) -> QCheckBox | None:
if not hasattr(self, "property_table"):
return None
widget = self.property_table.cellWidget(row, PROPERTY_INPUT_COLUMN)
if isinstance(widget, QCheckBox):
return widget
if isinstance(widget, QWidget):
return widget.findChild(QCheckBox)
return None
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:
return
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"
card_widgets = self._property_card_widgets(row)
card_scope_widget = card_widgets.get("scope_combo")
if isinstance(card_scope_widget, NoWheelComboBox):
selected_scope = card_scope_widget.currentData()
card_scope_widget.setToolTip(self._property_scope_tooltip(specs[row], self._property_scope_value(row, specs[row])))
card_target_widget = card_widgets.get("target_editor")
if isinstance(card_target_widget, QLineEdit):
card_target_widget.setEnabled(input_editable)
card_target_widget.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
hint_label = card_widgets.get("hint_label")
if isinstance(hint_label, QLabel):
hint_text = self._property_card_hint_text(effective_spec, editable=editable)
hint_label.setText(hint_text)
hint_label.setVisible(bool(hint_text))
scope_widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN)
if isinstance(scope_widget, NoWheelComboBox):
if "selected_scope" in locals():
target_index = scope_widget.findData(selected_scope)
if target_index >= 0 and target_index != scope_widget.currentIndex():
was_blocked = scope_widget.blockSignals(True)
try:
scope_widget.setCurrentIndex(target_index)
finally:
scope_widget.blockSignals(was_blocked)
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))
checkbox = self._property_input_checkbox(row)
if checkbox is not None:
exportable = bool(input_editable and effective_spec.get("label"))
if not exportable and checkbox.isChecked():
was_blocked = checkbox.blockSignals(True)
try:
checkbox.setChecked(False)
finally:
checkbox.blockSignals(was_blocked)
tip = (
"勾选后点击“导出参数”,会写入根目录 data.json,并把参数列表嵌入组件 main.py。"
if exportable
else "当前行不是可导出的尺寸输入参数。"
)
checkbox.setEnabled(exportable)
checkbox.setCursor(Qt.CursorShape.PointingHandCursor if exportable else Qt.CursorShape.ArrowCursor)
checkbox.setToolTip(tip)
self._update_current_capability_panel()
self._update_property_apply_state()
def _on_property_card_target_changed(self, row: int) -> None:
card_widget = self._property_card_widgets(row).get("target_editor")
table_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN) if hasattr(self, "property_table") else None
if isinstance(card_widget, QLineEdit) and isinstance(table_widget, QLineEdit):
text = card_widget.text()
if table_widget.text() != text:
was_blocked = table_widget.blockSignals(True)
try:
table_widget.setText(text)
finally:
table_widget.blockSignals(was_blocked)
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 = len(getattr(self, "property_editor_specs", []) or [])
collapsed_rows = int(getattr(self, "property_table_collapsed_rows", 5) or 5)
collapsed_rows = max(collapsed_rows, 1)
min_visible_rows = int(getattr(self, "property_table_min_visible_rows", 5) or 5)
min_visible_rows = max(min_visible_rows, 1)
expanded = bool(getattr(self, "property_table_expanded", False))
if expanded:
visible_rows = max(row_count, 1)
elif row_count > 0:
visible_rows = min(max(row_count, min_visible_rows), collapsed_rows)
else:
visible_rows = 1
default_row_height = max(int(self.property_table.verticalHeader().defaultSectionSize()), 22)
row_heights = [
max(int(self.property_table.rowHeight(row)), default_row_height)
for row in range(min(row_count, visible_rows))
]
if len(row_heights) < visible_rows:
row_heights.extend([default_row_height] * (visible_rows - len(row_heights)))
header_height = int(self.property_table.horizontalHeader().height())
frame = int(self.property_table.frameWidth()) * 2
height = header_height + frame + sum(row_heights) + 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_card_scroll"):
self.property_card_scroll.setVisible(False)
self.property_card_scroll.setMinimumHeight(0)
self.property_card_scroll.setMaximumHeight(0)
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("展开完整参数列表;参数化建模按钮会继续留在下方。")
self._resize_property_table_columns()
def _resize_property_table_columns(self) -> None:
if not hasattr(self, "property_table"):
return
table = self.property_table
viewport_width = int(table.viewport().width()) if table.viewport() is not None else int(table.width())
if viewport_width <= 0:
return
widths = _property_table_column_widths(viewport_width)
for column, width in enumerate(widths):
table.setColumnWidth(column, width)
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]]:
self.scdm_selection_status_message = ""
if self.selected_kind == "multi_feature" and str(action_info.get("multi_selection_kind") or "") == "holes":
return self._multi_hole_property_specs(action_info)
scdm_specs = self._scdm_property_specs_for_selection()
scdm_specs, scdm_filter_message = self._filter_scdm_specs_for_local_recognition(scdm_specs, action_info)
if self._prefer_local_face_offset_over_scdm(scdm_specs, action_info):
scdm_specs = [
spec
for spec in scdm_specs
if str(spec.get("scdm_capability_key") or "") != "face.offset"
]
self.scdm_selection_status_message = (
"SCDM 已识别当前偏移参数;当前大模型多内孔平面优先使用本软件已验证的快速一级边界重建,"
"避免 SCDM 长时间直接建模计算。"
)
else:
self.scdm_selection_status_message = scdm_filter_message or self._scdm_selection_status_message(scdm_specs)
if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs):
return [spec for spec in scdm_specs if bool(spec.get("enabled"))]
if scdm_filter_message:
return []
if self._should_hold_local_cylindrical_specs_for_scdm(action_info):
self.scdm_selection_status_message = (
"SCDM 正在按需识别当前大模型对象;圆柱、槽、圆角这类容易误分的对象,"
"识别完成前不开放本地兜底孔/槽参数。"
)
return []
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)
return [
dict(spec)
for spec in editable_specs
if bool(spec.get("editable"))
and bool(spec.get("enabled"))
and bool(spec.get("action"))
and str(spec.get("value_type", "number")) != "command"
and _property_parameter_is_visible(spec)
]
def _filter_scdm_specs_for_local_recognition(
self,
scdm_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> tuple[list[dict[str, object]], str]:
if not scdm_specs or self.selected_kind not in {"feature", "face"}:
return scdm_specs, ""
conflicting_keys = {"round.radius", "chamfer.distance", "feature.delete_round_or_chamfer"}
if not any(str(spec.get("scdm_capability_key") or "") in conflicting_keys for spec in scdm_specs):
return scdm_specs, ""
feature_guess = str(action_info.get("feature_guess") or "").strip()
feature_type = str(action_info.get("feature_type") or "").strip()
if feature_guess in {"round/fillet candidate", "chamfer candidate"} or any(
token in feature_type for token in ("圆角", "倒圆", "倒角")
):
return scdm_specs, ""
local_slot_or_pocket = bool(
action_info.get("blind_split_cylindrical_pocket")
or action_info.get("blind_split_cylindrical_groove")
or "复杂槽" in feature_type
or ("槽" in feature_type and "圆柱孔" not in feature_type)
)
if not local_slot_or_pocket:
return scdm_specs, ""
filtered = [
spec
for spec in scdm_specs
if str(spec.get("scdm_capability_key") or "") not in conflicting_keys
]
if len(filtered) == len(scdm_specs):
return scdm_specs, ""
return (
filtered,
"SCDM 返回了圆角/倒角删除能力,但当前对象本地识别为盲孔/槽凹坑或复杂槽;"
"为避免把槽误删成圆角,已隐藏该冲突操作。",
)
def _prefer_local_face_offset_over_scdm(
self,
scdm_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> bool:
if self.selected_kind not in {"feature", "face"} or self.model is None:
return False
if not any(
bool(spec.get("enabled")) and str(spec.get("scdm_capability_key") or "") == "face.offset"
for spec in scdm_specs
):
return False
if str(action_info.get("surface") or "").lower() != "plane":
return False
face_count = len(getattr(self.model, "faces", ()) or ())
edge_count = len(getattr(self.model, "edges", ()) or ())
if face_count <= 1000 and edge_count <= 2500:
return False
inner_wires = _int_or_none(action_info.get("inner_boundary_wires")) or 0
boundary_edges = _int_or_none(action_info.get("boundary_edges")) or _int_or_none(
action_info.get("first_level_boundary_edge_count")
) or 0
return inner_wires > 0 or boundary_edges >= 16
def _should_hold_local_cylindrical_specs_for_scdm(self, action_info: dict[str, object]) -> bool:
if self.selected_kind not in {"feature", "face"} or self.model is None:
return False
if not bool(getattr(self, "_large_model_interaction_mode", lambda: False)()):
return False
state = str(getattr(self, "scdm_feature_cache_state", "") or "")
if state not in {"deferred", "running", "empty", "stale"}:
return False
surface = str(action_info.get("surface") or "").strip().lower()
if surface == "cylinder":
return True
feature_guess = str(action_info.get("feature_guess") or "").strip().lower()
feature_type = str(action_info.get("feature_type") or "").strip()
cylindrical_markers = (
"cylindrical_feature_side_face_count",
"diameter",
"radius",
"axis",
"axis_point",
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"existing_fillet_radius_estimate",
)
if any(action_info.get(key) is not None and str(action_info.get(key)) != "" for key in cylindrical_markers):
return True
return any(token in feature_guess for token in ("hole", "groove", "round", "fillet", "boss")) or any(
token in feature_type for token in ("孔", "槽", "圆角", "倒圆", "凸台", "外圆")
)
def _scdm_selection_status_message(self, scdm_specs: list[dict[str, object]]) -> str:
if self.selected_kind not in {"feature", "face", "edge", "solid", "part"}:
return ""
if self.model is None:
return ""
if scdm_specs and any(bool(spec.get("enabled")) for spec in scdm_specs):
return "SCDM 已识别当前对象,参数表优先显示 SCDM 可执行能力。"
if scdm_specs:
reasons = [
str(spec.get("disabled_tip") or "").strip()
for spec in scdm_specs
if not bool(spec.get("enabled")) and str(spec.get("disabled_tip") or "").strip()
]
if reasons:
return f"SCDM 已识别当前对象,但当前能力未开放:{self._compact_status_message(reasons[0])}"
return "SCDM 已识别当前对象,但当前能力还没有完成执行验证。"
state = str(getattr(self, "scdm_feature_cache_state", "") or "")
cache_message = str(getattr(self, "scdm_feature_cache_message", "") or "").strip()
if state == "running":
return "SCDM 正在后台识别可修改参数,当前先显示本软件已有能力。"
if state == "failed":
return f"SCDM 识别失败,当前使用本软件已有能力:{self._compact_status_message(cache_message)}"
if state in {"empty", "stale", ""}:
return "SCDM 识别缓存未就绪,当前先显示本软件已有能力。"
return "SCDM 未在当前对象上识别到已产品化参数,当前使用本软件已有能力。"
@staticmethod
def _compact_status_message(text: str, limit: int = 96) -> str:
value = " ".join(str(text or "").split())
if len(value) <= limit:
return value
return f"{value[: max(0, limit - 1)]}..."
def _scdm_property_specs_for_selection(self) -> list[dict[str, object]]:
cache = self._current_scdm_feature_cache()
if not isinstance(cache, dict):
return []
face_ids: set[int] = set()
edge_ids: set[int] = set()
solid_ids: set[int] = set()
if self.selected_kind in {"feature", "face"} and self.selected_face_id is not None:
face_ids.update(self._scdm_selection_face_match_ids(int(self.selected_face_id)))
if self.selected_kind == "edge" and self.selected_edge_id is not None:
edge_ids.add(int(self.selected_edge_id))
if self.selected_kind == "solid" and self.selected_solid_id is not None:
solid_ids.add(int(self.selected_solid_id))
if not face_ids and not edge_ids and not solid_ids:
return []
return property_specs_from_scdm_cache(
cache,
selected_face_ids=face_ids,
selected_edge_ids=edge_ids,
selected_solid_ids=solid_ids,
execution_ready=getattr(self, "scdm_edit_runner_ready", False),
)
def _current_scdm_feature_cache(self) -> dict[str, object] | None:
for owner in (self, self.model):
if owner is None:
continue
for attr in ("scdm_feature_cache", "_scdm_feature_cache"):
value = getattr(owner, attr, None)
if callable(value):
try:
value = value()
except Exception:
value = None
if isinstance(value, dict):
return dict(value)
return None
def _scdm_selection_face_match_ids(self, face_id: int) -> set[int]:
ids = {int(face_id)}
if self.model is None:
return ids
for method_name in ("face_logical_id", "face_region_logical_id"):
method = getattr(self.model, method_name, None)
if callable(method):
try:
ids.add(int(method(face_id)))
except Exception:
pass
region_method = getattr(self.model, "face_region_ids", None)
if callable(region_method):
try:
for region_face_id in region_method(face_id):
ids.add(int(region_face_id))
for method_name in ("face_logical_id", "face_region_logical_id"):
method = getattr(self.model, method_name, None)
if callable(method):
try:
ids.add(int(method(int(region_face_id))))
except Exception:
pass
except Exception:
pass
return ids
def _multi_hole_property_specs(self, action_info: dict[str, object]) -> list[dict[str, object]]:
entries = list(getattr(self, "multi_selected_hole_entries", []) or [])
count = len(entries)
if count < 2:
return []
diameters = [
float(item["diameter"])
for item in entries
if _float_or_none(item.get("diameter")) is not None and float(item.get("diameter")) > 0
]
common_diameter = None
if diameters and all(abs(value - diameters[0]) <= PROPERTY_VALUE_TOLERANCE for value in diameters):
common_diameter = diameters[0]
diameter_current = (
_format_float(common_diameter)
if common_diameter is not None
else f"{min(diameters):g} ~ {max(diameters):g}" if diameters else ""
)
diameter_target = _format_float(common_diameter) if common_diameter is not None else ""
radii = [value * 0.5 for value in diameters]
common_radius = common_diameter * 0.5 if common_diameter is not None else None
radius_current = (
_format_float(common_radius)
if common_radius is not None
else f"{min(radii):g} ~ {max(radii):g}" if radii else ""
)
radius_target = _format_float(common_radius) if common_radius is not None else ""
multi_hole_refs = tuple(
{
"face_id": _int_or_none(item.get("face_id")),
"logical_id": _int_or_none(item.get("logical_id")),
"diameter": _float_or_none(item.get("diameter")),
"axis_center": _triple_or_none(item.get("axis_center")),
"part_id": item.get("part_id"),
"solid_id": item.get("solid_id"),
}
for item in entries
if isinstance(item, dict)
)
return [
{
"key": "multi_hole_diameter",
"label": "孔径",
"current_raw": common_diameter if common_diameter is not None else "",
"current_text": diameter_current,
"target_text": diameter_target,
"editable": True,
"enabled": bool(diameters),
"status_text": "可修改" if diameters else "不可修改",
"scope_text": "统一孔径",
"action": "resize_multi_selected_holes",
"target_attr": "hole_diameter_input",
"value_type": "positive",
"multi_hole_refs": multi_hole_refs,
"enabled_tip": "把当前多选孔统一改成同一个目标孔径;失败时整次批量操作会回滚。",
"disabled_tip": "当前多选对象里没有稳定圆柱孔径。",
"range_hint": "请输入大于 0 的目标孔径;单位同模型。",
},
{
"key": "multi_hole_radius",
"label": "半径",
"current_raw": common_radius if common_radius is not None else "",
"current_text": radius_current,
"target_text": radius_target,
"editable": True,
"enabled": bool(radii),
"status_text": "可修改" if radii else "不可修改",
"scope_text": "统一半径",
"action": "resize_multi_selected_holes",
"target_attr": "hole_diameter_input",
"target_transform": "radius_to_diameter",
"value_type": "positive",
"multi_hole_refs": multi_hole_refs,
"enabled_tip": "按半径输入,程序会换算成目标孔径后批量修改当前多选孔;失败时整次批量操作会回滚。",
"disabled_tip": "当前多选对象里没有稳定圆柱孔半径。",
"range_hint": "请输入大于 0 的目标半径;单位同模型。",
},
{
"key": "multi_hole_position_delta",
"label": "位置偏移",
"current_raw": (0.0, 0.0, 0.0),
"current_text": "(0, 0, 0)",
"target_text": "(0, 0, 0)",
"editable": True,
"enabled": True,
"status_text": "可修改",
"scope_text": "相同偏移",
"action": "move_multi_selected_holes_by_offset",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"value_type": "vector3",
"multi_hole_refs": multi_hole_refs,
"enabled_tip": "输入 ΔX, ΔY, ΔZ;每个孔会保持自身孔径并整体平移相同偏移量。",
"disabled_tip": "",
"range_hint": "这是偏移量,不是绝对坐标。例如 (0, 0, -3.5) 表示所有选中孔沿 Z 方向移动 -3.5。",
},
{
"key": "multi_hole_suppress",
"label": "封堵",
"current_raw": "未封堵",
"current_text": "未封堵",
"target_text": "无需输入",
"editable": True,
"enabled": True,
"status_text": "可执行",
"scope_text": "批量封堵",
"action": "suppress_multi_selected_holes",
"value_type": "command",
"multi_hole_refs": multi_hole_refs,
"enabled_tip": "用补料体批量封堵当前多选孔;任意一个孔失败时整次批量操作会回滚。",
"disabled_tip": "",
"range_hint": "该操作会直接改变几何并写入历史;复杂孔失败时会恢复到操作前状态。",
"button_text": "封堵",
},
]
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}
prismatic_semantics = str(action_info.get("prismatic_feature_semantics") or "")
embedded_prismatic = prismatic_semantics in {"additive-boss", "subtractive-pocket"}
prismatic_length = _float_or_none(action_info.get("prismatic_length"))
prismatic_width = _float_or_none(action_info.get("prismatic_width"))
prismatic_depth = _float_or_none(action_info.get("prismatic_extrusion_estimate"))
prismatic_reference_face_id = _int_or_none(action_info.get("prismatic_reference_face_id"))
part_solid_count = _int_or_none(action_info.get("part_solid_count"))
prismatic_size_supported = bool(
embedded_prismatic
and action_info.get("prismatic_profile_status") == "candidate"
and action_info.get("prismatic_extrusion_status") == "candidate"
and bool(action_info.get("local_face_size_edit_ready"))
and prismatic_length is not None
and prismatic_length > 0
and prismatic_width is not None
and prismatic_width > 0
and prismatic_depth is not None
and prismatic_depth > 0
and prismatic_reference_face_id is not None
and (part_solid_count is None or part_solid_count == 1)
and len(_int_values(action_info.get("prismatic_connected_side_face_ids"))) == 4
)
dimensions: list[dict[str, object]] = []
for key in allowed_keys:
spec = spec_by_key.get(key)
prismatic_size_key = embedded_prismatic and key in {"local_face_width", "local_face_height"}
prismatic_center_key = embedded_prismatic and key == "face_center_position"
if (
spec is None
or not bool(spec.get("editable"))
or (not bool(spec.get("enabled")) and not prismatic_size_key and not prismatic_center_key)
or str(spec.get("value_type", "number")) == "command"
or not _property_parameter_is_visible(spec)
):
continue
dimension = dict(spec)
dimension["parameter_role"] = "dimension"
if action_info.get("prismatic_profile_status") == "candidate":
if prismatic_size_key and prismatic_size_supported:
modes = spec.get("scope_modes")
local_mode = modes.get("local") if isinstance(modes, dict) else None
local_mode = dict(local_mode) if isinstance(local_mode, dict) else {}
is_width_axis = key == "local_face_width"
current_limit = prismatic_width if is_width_axis else prismatic_length
size_blocker = str(
action_info.get("local_face_size_edit_blocker")
or "当前矩形特征的长宽、深度、参考面、单 Solid 条件或四个侧壁没有稳定识别,暂不开放长宽修改。"
)
local_mode.update(
{
"label": "局部重建",
"action": "resize_face_width_local" if is_width_axis else "resize_face_height_local",
"target_attr": "face_width_input" if is_width_axis else "face_height_input",
"enabled": True,
"enabled_tip": (
"输入矩形凸台/口袋的目标长度或宽度;程序会先移除/补回旧矩形包络,"
"再重建目标矩形特征。"
),
"disabled_tip": size_blocker,
"range_hint": "当前版本不在一次编辑中交换长度和宽度方向。",
}
)
if current_limit is not None and current_limit > 0:
if is_width_axis:
local_mode["min_value"] = current_limit
local_mode["min_exclusive"] = True
else:
local_mode["max_value"] = current_limit
local_mode["max_exclusive"] = True
dimension["scope_modes"] = {"local": local_mode}
dimension["scope_default"] = "local"
dimension["scope_text"] = "局部重建"
dimension["enabled"] = True
dimension["editable"] = True
if prismatic_center_key:
if not prismatic_size_supported:
continue
modes = spec.get("scope_modes")
local_mode = modes.get("local") if isinstance(modes, dict) else None
local_mode = dict(local_mode) if isinstance(local_mode, dict) else {}
current_center = _triple_or_none(action_info.get("area_center")) or _triple_or_none(
action_info.get("bbox_center")
)
local_mode.update(
{
"label": "移动特征",
"action": "move_selected_face_center_local",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": current_center is not None,
"enabled_tip": (
"输入矩形凸台/口袋的目标中心;当前只支持在基准平面内移动,"
"沿高度/深度方向请修改高度/深度。"
),
"disabled_tip": "当前矩形特征缺少稳定中心,暂不开放中心移动。",
"range_hint": "移动中心会局部重建矩形包络,不会平移整个零件。",
"target_transform": "target_center_to_translation",
"transform_context": {"current_center": current_center},
}
)
dimension["scope_modes"] = {"local": local_mode}
dimension["scope_default"] = "local"
dimension["scope_text"] = "移动特征"
dimension["enabled"] = current_center is not None
dimension["editable"] = True
label_overrides = {
"local_face_width": "长度",
"local_face_height": "宽度",
"shell_thickness_estimate": "高度/深度",
"face_center_position": "中心",
}
if key in label_overrides:
dimension["label"] = label_overrides[key]
dimensions.append(dimension)
return dimensions
def _feature_context_property_specs(
self,
root_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
def no_editable_feature_dimensions_spec() -> dict[str, object]:
reason = str(
self._no_editable_feature_dimension_reason(action_info)
or action_info.get("freeform_face_blockers")
or action_info.get("recognition_blockers")
or action_info.get("local_face_deform_blocker")
or action_info.get("feature_edit_actions")
or "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。"
).strip()
if not reason:
reason = "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。"
existing_message = str(getattr(self, "scdm_selection_status_message", "") or "").strip()
if not existing_message or existing_message.startswith("SCDM 未在当前对象上识别到"):
self.scdm_selection_status_message = reason
return {
"key": "no_editable_feature_dimensions",
"label": "可修改参数",
"current_text": "当前无可修改参数",
"current_raw": "",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "不可修改",
"disabled_tip": reason,
"span_value_columns": True,
"parameter_role": "empty_state",
}
root_rows = self._feature_property_specs(root_specs, action_info)
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
related_rows: list[dict[str, object]] = []
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_dimensions or [no_editable_feature_dimensions_spec()]
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"))
feature_label = str(related_info.get("association_label") or feature_label)
feature_label = self._feature_display_label(feature_label)
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)
rows = root_dimensions + related_rows
return rows or [no_editable_feature_dimensions_spec()]
def _no_editable_feature_dimension_reason(self, action_info: dict[str, object]) -> str:
feature_type = str(action_info.get("feature_type") or "").strip()
feature_guess = str(action_info.get("feature_guess") or "").strip()
surface = str(action_info.get("surface") or "").strip()
slot_status = str(action_info.get("slot_status") or "").strip()
slot_blockers = str(action_info.get("slot_blockers") or action_info.get("recognition_blockers") or "").strip()
slotish = bool(
"槽" in feature_type
or bool(action_info.get("blind_split_cylindrical_groove"))
or bool(action_info.get("blind_split_cylindrical_pocket"))
or (
surface == "cylinder"
and feature_guess == "hole/groove candidate"
and not _is_effectively_full_cylinder(action_info)
)
)
if not slotish:
return ""
if slot_status == "blocked":
return slot_blockers or "当前槽属于复杂槽/多槽组,暂时不能稳定修改。"
if slot_status != "candidate":
state = str(getattr(self, "scdm_feature_cache_state", "") or "").strip()
if state in {"deferred", "running", "empty", "stale"}:
return (
"当前对象只识别为圆柱孔/槽候选,还没有确认成可编辑的简单槽/长圆槽;"
"需要等 SCDM 识别或本地计划拿到稳定的槽宽、槽深、槽底面/方向或成对端面证据后,才会显示槽参数。"
)
return (
"当前对象只识别为圆柱孔/槽候选,SCDM/cache 没有返回可执行的槽宽、槽深或槽位置能力;"
"软件进度里的槽编辑只针对证据完整的简单槽/长圆槽。"
)
missing: list[str] = []
if _float_or_none(action_info.get("slot_chord_width_estimate")) is None:
missing.append("槽宽")
if _float_or_none(action_info.get("slot_sagitta_depth_estimate")) is None:
missing.append("槽深")
if not _int_values(action_info.get("feature_bottom_face_ids")) and not _int_values(action_info.get("feature_slot_face_ids")):
missing.append("槽底面/槽组")
if missing:
return (
f"当前槽候选缺少可稳定执行的{'、'.join(missing)}证据;"
"为避免把复杂槽误改坏,暂不在参数表开放槽参数。"
)
return ""
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 FREEFORM_FACE_SURFACES
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"
and str(action_info.get("existing_fillet_status") or "") != "blocked"
)
is_existing_chamfer = (
is_plane
and feature_guess == "chamfer candidate"
and str(action_info.get("existing_chamfer_status") or "") == "candidate"
)
recognition_fields_present = any(
key in action_info
for key in (
"recognition_score",
"recognition_confidence",
"recognition_risk",
"recognition_blockers",
)
)
recognition_score = _int_or_none(action_info.get("recognition_score"))
recognition_confidence = str(action_info.get("recognition_confidence") or "").strip()
recognition_risk = str(action_info.get("recognition_risk") or "").strip()
recognition_blockers = str(action_info.get("recognition_blockers") or "").strip()
analytic_surface_recognition_ready = (
not recognition_fields_present
or (
recognition_risk != "blocked"
and not recognition_blockers
and recognition_confidence not in {"low", "none"}
and (recognition_score is None or recognition_score >= 56)
)
)
analytic_surface_recognition_reason = ""
if not analytic_surface_recognition_ready:
score_text = _format_float(float(recognition_score)) if recognition_score is not None else "未知"
confidence_text = recognition_confidence or "未知"
risk_text = recognition_risk or "未知"
analytic_surface_recognition_reason = (
f"当前解析曲面识别还不够稳定:评分 {score_text},置信度 {confidence_text},风险 {risk_text}。"
)
if recognition_blockers:
analytic_surface_recognition_reason = f"{analytic_surface_recognition_reason} 限制:{recognition_blockers}"
is_full_cylinder = _is_effectively_full_cylinder(action_info)
is_slot_or_half_hole = (
is_hole_or_groove
and not is_full_cylinder
and angular_span is not None
and angular_span < math.tau * 0.92
and str(action_info.get("slot_status") or "") == "candidate"
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) and not is_existing_chamfer)
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()
local_face_size_edit_ready = bool(action_info.get("local_face_size_edit_ready"))
local_face_size_edit_blocker = str(action_info.get("local_face_size_edit_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_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,
pin_top: bool = False,
) -> 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,
"pin_top": pin_top,
"span_value_columns": True,
}
)
def cad_modeling_form() -> tuple[str, str]:
if has_face:
if is_hole_or_groove:
if is_slot_or_half_hole:
return (
"工程特征 / 槽:按槽宽、槽深、弧长、总长或位置做局部重建。",
"这类对象优先按 Creo 式槽特征理解;当前只传播直接相邻的一级关系,复杂二级/三级联动后续再开放。",
)
return (
"工程特征 / 孔:按孔径、位置、封堵或盲孔/盲槽深度做重切/补料。",
"这类对象优先按 Creo 式孔特征理解;通孔、盲孔、锥孔和槽孔会根据识别结果开放不同参数。",
)
if is_boss:
return (
"工程特征 / 凸台:按直径、高度或位置做局部重建。",
"凸台修改会尽量按局部补料、切除端盖或重建包络处理;也可以选择缩放所属对象这类整体语义。",
)
if is_existing_fillet:
return (
"工程特征 / 倒圆角:移除已有圆角后按目标半径重新倒圆。",
"STEP 没有原始倒圆角历史时,只能基于当前圆角面和支撑面推断;复杂 blend 会阻止或回滚。",
)
if is_existing_chamfer:
return (
"工程特征 / 倒角:移除已有倒角斜面后按目标距离重新倒角。",
"STEP 没有原始倒角历史时,只能基于当前斜面、支撑面和恢复出的锐边推断;倒角链或不等距倒角先保持受限。",
)
if is_shell_candidate:
return (
"工程特征 / 抽壳:按壳体厚度或相对面距离调整薄壁区域。",
"壳体厚度修改本质上是移动当前平面或沿厚度方向缩放所属对象,需要相对面和厚度方向识别稳定。",
)
if is_plane:
return (
"柔性建模 / 拉伸切除 / 偏移:平面 Face 可按不同意图改变位置或尺寸。",
"平面 Face 不是直接暴露 B-Rep 参数;界面会把目标值映射为拉伸/切除、局部重建、移动特征或缩放特征。",
)
if is_cone:
return (
"工程特征 / 拔模或锥孔:只在简单圆锥或可识别锥孔上开放半角和参考半径。",
"复杂圆锥面可能只是拔模、过渡或导入后的自由裁剪面,当前不会把不可靠参数伪装成可修改特征。",
)
if is_sphere:
return (
"解析曲面 / 缩放特征:球面半径通过缩放所属对象调整。",
"当前不是恢复 CAD 历史里的球面特征,而是围绕识别到的中心做受限几何缩放。",
)
if is_torus:
return (
"解析曲面 / 缩放特征:环面主半径和小半径通过缩放所属对象调整。",
"当前不是恢复 CAD 历史里的环面特征,而是围绕识别到的环面中心和轴线做受限几何缩放。",
)
if is_generic_surface:
surface_name = _format_info_value("surface", surface)
return (
f"自由/复杂曲面:{surface_name} 当前只读,不开放参数化修改。",
"自由曲面需要单独的曲面替换、重拟合或控制点编辑语义;当前一级阶段不把底层 UV 参数当成用户尺寸。",
)
if is_cylinder:
return (
"解析圆柱 / 拉伸切除:可按直径、高度或轴向缩放处理普通圆柱面。",
"如果识别不到孔、槽、凸台或圆角语义,当前只按普通圆柱几何入口开放有限参数。",
)
return (
"导入几何 Face:当前只显示可识别参数。",
"STEP 通常没有完整建模历史;只有能映射到稳定 CAD 建模形式的参数才会开放修改。",
)
if has_edge:
if curve == "line":
return (
"柔性建模 / 移动几何:直线 Edge 可改长度、端点,并作为倒角/倒圆角入口。",
"Edge 长度有多种 CAD 语义:只改当前边、移动端面、相邻圆柱联动或缩放所属对象,界面会让用户选择。",
)
if curve == "circle":
return (
"工程特征入口 / 圆 Edge:可作为孔槽、圆角或相邻圆柱尺寸的参考。",
"圆 Edge 本身通常不是独立历史特征;修改时会优先寻找相邻圆柱、孔槽或圆角语义。",
)
return (
"曲线 Edge:当前只开放能稳定映射的尺寸或倒角/圆角入口。",
"复杂曲线 Edge 暂不直接做自由形变,避免把拓扑边参数误当成 CAD 设计尺寸。",
)
if self.selected_solid_id is not None or self.selected_part_id is not None:
return (
"整体特征 / 移动几何 / 缩放特征:当前对象按整体变换处理。",
"零件或 Solid 层级不代表单个 CAD 历史特征;当前只开放清晰的整体移动、旋转或缩放语义。",
)
return "", ""
def cad_recommended_operation() -> tuple[str, str]:
if has_face:
if is_hole_or_groove:
if is_slot_or_half_hole:
return (
"优先改槽宽、槽深或槽孔总长度;要换位置时改位置。",
"槽类修改先走局部重建;如果用户选择缩放特征,会连同所属对象上的其它尺寸一起变化。",
)
return (
"优先改孔径/半径;盲孔改深度;要换位置时改位置。",
"孔类修改会尽量先补旧孔再按目标重新切孔;螺纹孔、复杂孔组和底面识别不稳的盲孔仍可能受限。",
)
if is_boss:
return (
"优先改凸台直径或高度;要换位置时改位置。",
"凸台高度更接近拉伸/切除端盖;凸台直径和位置更接近移除旧包络后重建局部凸台。",
)
if is_existing_fillet:
return (
"优先改圆角半径;复杂圆角链先不要用整体缩放替代。",
"已有圆角需要先恢复支撑锐边再重新倒圆,支撑面不明确时失败回滚是正常保护。",
)
if is_existing_chamfer:
return (
"优先改倒角距离;复杂倒角链先保持只读或重新选 Edge 新增倒角。",
"已有倒角需要先移除斜面、恢复原始锐边再重新倒角;如果支撑面或长边不明确,会在计划阶段阻止。",
)
if is_shell_candidate:
return (
"优先改壳体厚度;普通位置变化再用偏移。",
"壳体厚度需要相对面识别稳定;如果只想移动整个对象,请选择移动特征或缩放特征语义。",
)
if is_plane:
return (
"优先改偏移,并选择拉伸/切除;只想让当前面变形时再改面内长度、面内宽度或中心。",
"拉伸/切除更接近常见 CAD 加料/切料;局部重建会让相邻面自然跟随变形,不一定保持垂直。",
)
if is_cone:
return (
"简单圆锥优先改半角或参考半径;复杂拔模面先保持只读。",
"锥面可能来自拔模、沉孔或导入裁剪曲面,识别不稳时不直接开放大参数修改。",
)
if is_sphere:
return (
"优先改球面半径,并确认这是缩放所属对象。",
"当前没有恢复球面历史特征,只能按识别中心做受限几何缩放。",
)
if is_torus:
return (
"优先改环面主半径或小半径,并确认这是缩放所属对象。",
"当前没有恢复环面历史特征,只能按识别中心和轴线做受限几何缩放。",
)
if is_generic_surface:
return (
"当前建议只查看,不直接改自由曲面参数。",
"自由曲面需要曲面替换、重拟合或控制点编辑,不能用面积或包围盒伪装成稳定 CAD 尺寸。",
)
if is_cylinder:
return (
"优先改直径或高度;若它其实是孔、槽、凸台或圆角,请先提高特征探测级别。",
"普通圆柱入口是兜底几何编辑,不等同于已经识别到完整 CAD 特征。",
)
return (
"先查看识别摘要,再只修改标记为可修改的参数。",
"未映射到明确建模形式的 STEP Face 不应强行参数化。",
)
if has_edge:
if curve == "line":
return (
"优先改长度;想保持端面垂直选移动端面,想只动当前边选只改当前Edge。",
"同一条 Edge 改长可能对应多种 CAD 结果,当前必须显式选择建模意图。",
)
if curve == "circle":
return (
"优先让圆 Edge 关联到孔、槽、凸台或圆角直径,不直接自由变形这条边。",
"圆 Edge 往往是相邻特征的边界,直接改边可能破坏孔槽或圆角语义。",
)
return (
"只修改已明确开放的 Edge 参数;复杂曲线先保持只读。",
"复杂曲线需要约束或曲线重拟合,不适合直接用边长驱动。",
)
if self.selected_solid_id is not None or self.selected_part_id is not None:
return (
"优先使用整体移动、旋转或缩放;不要把整体对象误当成单个历史特征。",
"STEP 里零件/Solid 层级通常缺少装配约束和特征历史,整体操作要明确影响范围。",
)
return "", ""
cad_form_text, cad_form_tip = cad_modeling_form()
if cad_form_text:
add_readonly_spec(
key="cad_modeling_form",
label="建模形式",
text=cad_form_text,
tip=PROPERTY_EXPLANATION_TOOLTIPS["cad_modeling_form"],
pin_top=True,
)
recommended_text, recommended_tip = cad_recommended_operation()
if recommended_text:
add_readonly_spec(
key="cad_recommended_operation",
label="推荐操作",
text=recommended_text,
tip=PROPERTY_EXPLANATION_TOOLTIPS["cad_recommended_operation"],
pin_top=True,
)
if has_face:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
is_cylindrical_topology = bool(
is_cylinder
and topology_depth == 1
and (
"cylindrical_feature_side_face_count" in action_info
or "cylindrical-feature" in str(action_info.get("topology_relation_model") or "")
)
)
if is_cylindrical_topology:
side_count = _int_or_none(action_info.get("cylindrical_feature_side_face_count")) or 0
boundary_edge_count = _int_or_none(action_info.get("cylindrical_feature_boundary_edge_count")) or 0
boundary_vertex_count = _int_or_none(action_info.get("cylindrical_feature_boundary_vertex_count")) or (
_int_or_none(action_info.get("first_level_boundary_vertex_count")) or 0
)
adjacent_face_count = _int_or_none(action_info.get("cylindrical_feature_adjacent_face_count")) or 0
end_face_count = _int_or_none(action_info.get("cylindrical_feature_end_face_count")) or 0
bottom_face_count = _int_or_none(action_info.get("cylindrical_feature_bottom_face_count")) or 0
opening_face_count = _int_or_none(action_info.get("cylindrical_feature_opening_face_count")) or 0
slot_boundary_face_count = (
_int_or_none(action_info.get("cylindrical_feature_slot_boundary_face_count")) or 0
)
relation_parts = [
f"侧壁 Face {side_count} 个",
f"边界 Edge {boundary_edge_count} 条",
f"边界 Vertex {boundary_vertex_count} 个",
f"共享边相邻 Face {adjacent_face_count} 个",
]
detail_parts = []
if end_face_count:
detail_parts.append(f"端面/开口 Face {end_face_count} 个")
if bottom_face_count:
detail_parts.append(f"底面 Face {bottom_face_count} 个")
if opening_face_count:
detail_parts.append(f"开口 Face {opening_face_count} 个")
if slot_boundary_face_count:
detail_parts.append(f"槽边界 Face {slot_boundary_face_count} 个")
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
status_message = str(action_info.get("topology_relation_message") or "").strip()
topology_tip = "\n".join(
item
for item in (
topology_note,
ignored_note,
status_message,
"当前阶段只把圆柱侧壁及其共享边直接相邻 Face 作为一级关系,不会自动沿相邻面继续传播到二级、三级关系。",
)
if item
)
add_readonly_spec(
key="cylindrical_feature_first_level_topology",
label="一级关系",
text="".join(relation_parts + detail_parts) + "。",
tip=topology_tip,
)
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_existing_chamfer:
add_readonly_spec(
key="existing_chamfer_edit_semantics",
label="建模意图",
text="倒角:移除已有倒角斜面,再尝试按目标距离重新倒角。",
tip=(
"已有倒角修改不是恢复 CAD 历史参数;当前会先 defeature 当前倒角斜面,"
"再寻找恢复出的原始锐边并按目标距离重新倒角。复杂倒角链、不等距倒角或支撑面不稳定时会阻止或回滚。"
),
)
elif is_cone or is_sphere or is_torus:
add_readonly_spec(
key="analytic_surface_edit_semantics",
label="建模意图",
text="解析曲面:围绕中心或轴线缩放所属对象,会影响同对象其它尺寸。",
tip=(
"圆锥、球面、环面这类曲面当前走几何缩放语义,"
"不是只替换单个曲面的历史参数;执行前需要确认建模意图。"
),
)
elif is_generic_surface:
surface_name = _format_info_value("surface", surface)
blocker = str(
action_info.get("freeform_face_blockers")
or action_info.get("local_face_deform_blocker")
or "当前一级阶段不开放自由曲面参数化编辑。"
)
add_readonly_spec(
key="freeform_surface_edit_semantics",
label="建模意图",
text=f"{surface_name}:暂不支持参数化编辑。",
tip=(
f"{blocker} 当前不会把自由曲面的面积、中心、包围盒或底层 UV 参数伪装成可修改尺寸;"
"后续需要单独设计控制点编辑、曲面替换或重拟合曲面的语义。"
),
)
elif is_plane or is_shell_candidate:
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 在自身平面内两个主方向上的投影尺寸;"
"偏移使用 Creo 柔性建模里的 Offset 语义,表示沿当前 Face 法向得到的目标位置。"
"建模意图决定这次修改是局部重建当前 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:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
if topology_depth == 1:
selected_edge_count = _int_or_none(action_info.get("selected_edge_count")) or 1
vertex_count = _int_or_none(action_info.get("first_level_vertex_count")) or 0
adjacent_edge_count = _int_or_none(action_info.get("first_level_adjacent_edge_count")) or 0
adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0
included_edge_count = _int_or_none(action_info.get("first_level_edge_count")) or 0
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
fact_summary = str(action_info.get("first_level_fact_summary") or "").strip()
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
topology_tip = "\n".join(
item
for item in (
topology_note,
fact_summary,
ignored_note,
"当前阶段只把被选 Edge、端点 Vertex、共享端点相邻 Edge 和直接包含该 Edge 的 Face 作为一级关系;不会自动递归传播到二级、三级关系。",
)
if item
)
add_readonly_spec(
key="edge_first_level_topology",
label="一级关系",
text=(
f"当前 Edge {selected_edge_count} 条;端点 Vertex {vertex_count} 个;"
f"共享端点相邻 Edge {adjacent_edge_count} 条;直接相邻 Face {adjacent_face_count} 个;"
f"一级范围 Edge {included_edge_count} 条。"
),
tip=topology_tip,
)
add_readonly_spec(
key="edge_edit_semantics",
label="建模意图",
text="Edge:长度修改可选择局部形变、移动端面或缩放所属对象。",
tip=(
"只改当前Edge会重建局部相邻面;移动端面更像整体尺寸变化;"
"缩放所属会影响同一特征或 Solid 上的其它尺寸。"
),
)
if show_generic_face_edit_specs:
current_face_center = (
_triple_or_none(action_info.get("area_center"))
or _triple_or_none(action_info.get("bbox_center"))
)
keep_relation_fields_present = any(
key in action_info
for key in (
"first_level_planar_relation_status",
"first_level_planar_relation_count",
"first_level_planar_relation_angled_count",
"first_level_planar_relation_non_planar_face_ids",
"first_level_adjacent_surface_types",
)
)
face_keep_relation_enabled = bool(is_plane)
face_keep_relation_disabled_tip = "当前对象不是平面 Face,不能保持一级平面关系。"
if face_keep_relation_enabled and keep_relation_fields_present:
relation_status = str(action_info.get("first_level_planar_relation_status") or "")
relation_count = _int_or_none(action_info.get("first_level_planar_relation_count")) or 0
angled_count = _int_or_none(action_info.get("first_level_planar_relation_angled_count")) or 0
non_planar_face_ids = _int_values(action_info.get("first_level_planar_relation_non_planar_face_ids"))
adjacent_surfaces = tuple(action_info.get("first_level_adjacent_surface_types") or ())
non_plane_adjacent_surfaces = tuple(
item
for item in adjacent_surfaces
if isinstance(item, (tuple, list))
and len(item) >= 2
and str(item[1]) != "plane"
)
if non_planar_face_ids or non_plane_adjacent_surfaces:
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "一级相邻 Face 中包含非平面/曲面,当前“保持关系”只支持平面邻域。"
elif angled_count > 0:
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "一级相邻平面中存在斜交关系,当前“保持关系”只支持平行/垂直。"
elif relation_status != "ready":
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "当前一级平面关系还不可验证,不能使用“保持关系”。"
elif relation_count <= 0:
face_keep_relation_enabled = False
face_keep_relation_disabled_tip = "当前一级邻域没有识别到可保持的平行/垂直平面关系。"
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_size_owner_tip = (
"面内长度/面内宽度是矩形平面特征的两个驱动尺寸,"
"不是面积,也不是任意曲面的通用尺寸。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
)
if local_face_size_edit_ready:
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(
local_face_size_edit_blocker
or "当前对象还没有识别为稳定矩形平面特征,不能修改面内长度。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_width_keep_relations",
"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)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内长度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内长度、中心坐标或所属对象,不能保持关系地修改面内长度。"
),
"range_hint": (
"保持关系会把面内长度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{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(
local_face_size_edit_blocker
or "当前对象还没有识别为稳定矩形平面特征,不能修改面内宽度。"
),
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_height, 0.25, 0.8, face_linear_hard_limit)}",
},
"keep_relations": {
"label": "保持关系",
"action": "resize_face_height_keep_relations",
"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)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入目标面内宽度;程序会沿该方向缩放所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 Face 缺少稳定面内宽度、中心坐标或所属对象,不能保持关系地修改面内宽度。"
),
"range_hint": (
"保持关系会把面内宽度修改转换为所属对象单向缩放,并在结果上反查一级平面关系;"
f"{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 才能尝试局部重建中心位置。"
),
"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),
},
"keep_relations": {
"label": "保持关系",
"action": "move_selected_face_center_keep_relations",
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
"enabled": bool(
is_plane
and current_face_center is not None
and (self.selected_part_id is not None or self.selected_solid_id is not None)
and face_keep_relation_enabled
),
"enabled_tip": (
"输入这个 Face 中心要移动到的目标 X, Y, Z 坐标;程序会平移所属特征或 Solid,"
"并要求一级相邻平面的平行/垂直关系在执行前可验证、执行后可反查。"
),
"disabled_tip": (
face_keep_relation_disabled_tip
if not face_keep_relation_enabled
else "当前 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 and not is_existing_chamfer:
plane_origin, plane_direction = self._plane_offset_info_for_property(action_info)
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]
)
keep_relation_fields_present = any(
key in action_info
for key in (
"first_level_planar_relation_status",
"first_level_planar_relation_count",
"first_level_planar_relation_angled_count",
"first_level_planar_relation_non_planar_face_ids",
"first_level_adjacent_surface_types",
)
)
keep_relation_enabled = current_plane_position is not None
keep_relation_disabled_tip = "当前平面缺少稳定移动方向或基准点,不能保持一级平面关系。"
if keep_relation_enabled and keep_relation_fields_present:
relation_status = str(action_info.get("first_level_planar_relation_status") or "")
relation_count = _int_or_none(action_info.get("first_level_planar_relation_count")) or 0
angled_count = _int_or_none(action_info.get("first_level_planar_relation_angled_count")) or 0
non_planar_face_ids = _int_values(action_info.get("first_level_planar_relation_non_planar_face_ids"))
adjacent_surfaces = tuple(action_info.get("first_level_adjacent_surface_types") or ())
non_plane_adjacent_surfaces = tuple(
item
for item in adjacent_surfaces
if isinstance(item, (tuple, list))
and len(item) >= 2
and str(item[1]) != "plane"
)
if non_planar_face_ids or non_plane_adjacent_surfaces:
keep_relation_enabled = False
keep_relation_disabled_tip = "一级相邻 Face 中包含非平面/曲面,当前“保持关系”只支持平面邻域。"
elif angled_count > 0:
keep_relation_enabled = False
keep_relation_disabled_tip = "一级相邻平面中存在斜交关系,当前“保持关系”只支持平行/垂直。"
elif relation_status != "ready":
keep_relation_enabled = False
keep_relation_disabled_tip = "当前一级平面关系还不可验证,不能使用“保持关系”。"
elif relation_count <= 0:
keep_relation_enabled = False
keep_relation_disabled_tip = "当前一级邻域没有识别到可保持的平行/垂直平面关系。"
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},
},
"keep_relations": {
"label": "保持关系",
"action": "push_pull_face_keep_relations",
"target_attr": "offset_input",
"enabled": keep_relation_enabled,
"enabled_tip": (
"输入偏移的目标位置;程序会沿判定的材料外侧法向拉伸/切除,"
"并要求一级相邻平面的平行/垂直关系可验证。"
),
"disabled_tip": keep_relation_disabled_tip,
"range_hint": (
"这是带约束守门的拉伸/切除:只支持直接相邻平面关系清楚、且关系为平行/垂直的一级邻域;"
"遇到非平面相邻面、斜交关系或复杂多边界慢计划会直接阻止。"
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": (
"这是移动所属对象,不是拉伸/切除当前 Face;如果想改变形状或厚度,请选择“拉伸/切除”。"
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 = _triple_or_none(action_info.get("axis_center"))
if (
current_axis_center is None
and
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
depth_status = str(action_info.get("depth_status") or "")
auto_blind_depth_ready = bool(is_blind and current_depth is not None and depth_status != "blocked")
explicit_bottom_ready = bool(has_bottom or manual_bottom_id is not None)
can_depth = bool(current_depth is not None and (auto_blind_depth_ready or manual_bottom_id is not None))
local_depth_tip = "输入盲孔或盲槽的目标深度;加深会切削,变浅会补料到新的底面位置。"
local_depth_range_hint = relative_range_hint(current_depth, 0.35, 1.0)
if can_depth and not explicit_bottom_ready:
local_depth_tip = (
"快速识别已判断为盲孔/盲槽并估算出深度;点击修改时会重新确认底面 Face,"
"确认失败会立即阻止并说明原因。"
)
local_depth_range_hint = (
f"{local_depth_range_hint} 执行时会重新确认底面 Face;确认失败会立即阻止。"
)
depth_disabled_tip = (
"当前对象没有稳定盲孔/盲槽深度估算,或端部不是明确盲孔;"
"自动识别失败时可手动填写底面 Face ID。"
)
owning_can_depth = bool(can_depth and explicit_bottom_ready)
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": local_depth_tip,
"disabled_tip": depth_disabled_tip,
"range_hint": local_depth_range_hint,
},
"owning": {
"label": "缩放特征",
"action": "resize_hole_depth_owning_scale",
"target_attr": "hole_depth_input",
"enabled": bool(
owning_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"))
boss_height_can_local = bool(
is_full_cylinder
and current_boss_height is not None
and (
_int_values(action_info.get("feature_start_end_face_ids"))
or _int_values(action_info.get("feature_end_end_face_ids"))
)
)
boss_height_can_scale = 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
)
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="owning" if boss_height_can_scale else "local",
scope_modes={
"local": {
"label": "拉伸/切除端盖",
"action": "resize_boss_height",
"target_attr": "boss_height_input",
"enabled": boss_height_can_local,
"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": boss_height_can_scale,
"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"))
cylinder_height_can_scale = 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
)
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="owning" if cylinder_height_can_scale else "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": cylinder_height_can_scale,
"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_existing_chamfer:
current_distance = _float_or_none(action_info.get("existing_chamfer_distance_estimate"))
chamfer_hint = relative_range_hint(current_distance, 0.35, 1.0)
cross_length = _float_or_none(action_info.get("existing_chamfer_cross_edge_length_estimate"))
if cross_length is not None and cross_length > 0:
chamfer_hint = (
f"{chamfer_hint} 当前倒角截面边长约 {_format_float(cross_length)}"
"目标距离过大时恢复锐边再倒角更容易失败。"
)
add_spec(
key="existing_chamfer_distance_estimate",
label="倒角距离",
current_raw=current_distance if current_distance is not None else "",
target_text=numeric_text(current_distance),
action="resize_existing_chamfer",
target_attr="edge_chamfer_distance_input",
enabled=bool(current_distance is not None and current_distance > 0),
enabled_tip="输入已有倒角面的目标距离;程序会先确认支撑面,再尝试移除旧倒角后重新倒角。",
disabled_tip="当前倒角候选缺少稳定距离估算,暂不放行修改。",
value_type="positive",
range_hint=chamfer_hint,
used=(
"existing_chamfer_distance_estimate",
"existing_chamfer_cross_edge_length_estimate",
"feature_existing_chamfer_support_face_ids",
"feature_existing_chamfer_long_edge_ids",
),
**positive_minimum(),
)
def analytic_surface_enabled(value_present: bool, capability_supported: bool = True) -> bool:
return bool(value_present and capability_supported and analytic_surface_recognition_ready)
def analytic_surface_disabled_tip(base: str) -> str:
if not analytic_surface_recognition_ready and analytic_surface_recognition_reason:
return analytic_surface_recognition_reason
return base
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 = analytic_surface_enabled(
current_reference_radius is not None,
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=analytic_surface_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=analytic_surface_enabled(
current_reference_diameter is not None,
reference_radius_supported,
),
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切。",
disabled_tip=analytic_surface_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
and analytic_surface_recognition_ready
)
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=analytic_surface_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=analytic_surface_enabled(current_sphere_radius is not None),
enabled_tip="输入球面的目标半径;程序会围绕球心均匀缩放所属对象。",
disabled_tip=analytic_surface_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=analytic_surface_enabled(current_sphere_diameter is not None),
enabled_tip="输入球面的目标直径;程序会换算为半径后围绕球心均匀缩放所属对象。",
disabled_tip=analytic_surface_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=analytic_surface_enabled(current_major_radius is not None),
enabled_tip="输入环面的目标主半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。",
disabled_tip=analytic_surface_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=analytic_surface_enabled(current_major_diameter is not None),
enabled_tip="输入环面的目标主直径;程序会换算为主半径后整体缩放所属对象。",
disabled_tip=analytic_surface_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=analytic_surface_enabled(current_minor_radius is not None),
enabled_tip="输入环面的目标小半径;当前版本会围绕环面中心均匀缩放所属对象,主半径和小半径会等比例变化。",
disabled_tip=analytic_surface_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=analytic_surface_enabled(current_minor_diameter is not None),
enabled_tip="输入环面的目标小直径;程序会换算为小半径后整体缩放所属对象。",
disabled_tip=analytic_surface_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_line_edge = curve == "line"
is_circle_edge = curve == "circle"
is_ellipse_edge = curve == "ellipse"
edge_length_supported = bool(is_line_edge or is_circle_edge)
unsupported_edge_length_tip = (
"当前阶段只把直线Edge长度、圆Edge周长/相邻圆柱半径作为稳定可改入口;"
"椭圆Edge请改主半径或小半径,B-spline/Bezier/其它复杂曲线Edge暂未实现通用约束长度编辑。"
)
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": "移动端面/保持垂直:端面和端面上的相关边一起移动,相邻平面尽量保持垂直,正方体会更像变成长方体。",
"keep-first-level-planar-relations": "保持关系:保持一级平面平行/垂直关系;只在直接相邻平面和移动端面能验证时执行,不能验证就直接阻止。",
"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": bool(edge_length_supported and current_length is not None),
"enabled_tip": "输入当前Edge的目标长度;程序会自动选择局部形变、端面移动、相邻圆柱编辑或缩放所属对象。",
"disabled_tip": "当前Edge没有稳定长度信息。" if edge_length_supported else unsupported_edge_length_tip,
"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"),
},
"keep-first-level-planar-relations": {
"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才能尝试保持一级平面关系;圆Edge请用相邻圆柱,复杂曲线暂未实现。",
"range_hint": (
f"{edge_strategy_effect_text('keep-first-level-planar-relations', current_anchor_mode)} "
f"{relative_range_hint(current_length, 0.25, 0.5)}"
),
"preselect_combos": edge_length_strategy_preselect("keep-first-level-planar-relations"),
},
"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": bool(edge_length_supported and current_length is not None),
"enabled_tip": "输入当前Edge的目标长度;程序会缩放所属特征或 Solid,其它尺寸也会跟随变化。",
"disabled_tip": "当前Edge没有稳定长度信息,不能缩放所属对象。" if edge_length_supported else unsupported_edge_length_tip,
"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(),
)
if edge_length_supported:
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 hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda: self._update_property_apply_state(has_model)):
return
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
or bool(getattr(self, "relation_formula_replay_active", False))
)
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
effective_spec = self._effective_property_spec(spec, row=row)
card_widgets = self._property_card_widgets(row)
target_widget = card_widgets.get("target_editor")
if not isinstance(target_widget, QLineEdit):
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))
hint_label = card_widgets.get("hint_label")
if isinstance(hint_label, QLabel):
hint_text = self._property_card_hint_text(
effective_spec,
editable=bool(effective_spec.get("editable") and effective_spec.get("enabled")),
)
hint_label.setText(hint_text)
hint_label.setVisible(bool(hint_text))
card = card_widgets.get("card")
card_changed = False
card_invalid = False
if str(effective_spec.get("value_type", "number")) != "command":
text = self._property_target_text(row)
card_changed = self._property_target_changed(effective_spec, text)
card_invalid = bool(text.strip() and self._property_target_validation_error(effective_spec, text))
if isinstance(card, QFrame):
card_state = (bool(card_changed and not card_invalid), bool(card_invalid))
if getattr(card, "_geom_param_card_state", None) != card_state:
card.setProperty("changed", card_state[0])
card.setProperty("invalid", card_state[1])
card.style().unpolish(card)
card.style().polish(card)
setattr(card, "_geom_param_card_state", card_state)
widget = card_widgets.get("action_button")
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))
card = card_widgets.get("card")
if isinstance(card, QFrame):
card.setProperty("changed", bool(row_changed and not validation_error))
card.setProperty("invalid", bool(validation_error))
button_state = (
widget.text(),
bool(enabled),
bool(row_changed and not validation_error),
bool(validation_error),
tooltip,
)
if getattr(widget, "_geom_param_button_state", None) != button_state:
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 isinstance(card, QFrame):
card.style().unpolish(card)
card.style().polish(card)
setattr(widget, "_geom_param_button_state", button_state)
if hasattr(self, "apply_property_button"):
changed = self._changed_property_rows()
commands = self._selected_command_property_rows()
pending = changed + commands
enabled = bool(has_model and pending)
disabled_tip = "请先选择对象,并在属性表中修改目标值;命令型行需要先选中该行。关系式会在点击“添加公式”后立即执行。"
if pending:
count = len(pending)
command_count = len(commands)
enabled_tip = (
"应用当前被修改的 1 个参数。"
if count == 1 and command_count == 0
else (
"执行当前选中的命令型参数。"
if count == 1 and command_count == 1
else f"按表格顺序依次应用当前 {count} 个参数/命令;失败时会停止后续修改。"
)
)
else:
enabled_tip = "应用当前被修改的参数,或执行当前选中的命令型参数。"
self._set_control_state(
self.apply_property_button,
enabled,
enabled_tip,
disabled_tip,
)
self._update_parameter_export_state(has_model)
def _selected_parameter_export_rows(self) -> list[dict[str, str]]:
if not hasattr(self, "property_table"):
return []
rows: list[dict[str, str]] = []
specs = getattr(self, "property_editor_specs", [])
row_count = min(self.property_table.rowCount(), len(specs))
for row in range(row_count):
checkbox = self._property_input_checkbox(row)
if checkbox is None or not checkbox.isEnabled() or not checkbox.isChecked():
continue
spec = self._effective_property_spec(specs[row], row=row)
if str(spec.get("value_type", "number")) == "command":
continue
label_item = self.property_table.item(row, PROPERTY_LABEL_COLUMN)
current_item = self.property_table.item(row, PROPERTY_CURRENT_COLUMN)
label = (label_item.text() if label_item is not None else str(spec.get("label", ""))).strip()
current = (
current_item.text()
if current_item is not None
else str(spec.get("current_text", spec.get("current_raw", "")))
).strip()
if not label:
continue
rows.append(
{
"name": label,
"displayName": label,
"type": "number",
"ioRole": "input",
"default": current,
}
)
return rows
def _selected_parameter_component_edits(self, rows: list[dict[str, str]] | None = None) -> list[dict[str, object]]:
if not hasattr(self, "property_table"):
return []
specs = getattr(self, "property_editor_specs", [])
row_count = min(self.property_table.rowCount(), len(specs))
export_rows = list(rows or self._selected_parameter_export_rows())
edits: list[dict[str, object]] = []
export_index = 0
step_path = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None
for row in range(row_count):
checkbox = self._property_input_checkbox(row)
if checkbox is None or not checkbox.isEnabled() or not checkbox.isChecked():
continue
if export_index >= len(export_rows):
break
spec = self._effective_property_spec(specs[row], row=row)
if str(spec.get("value_type", "number")) == "command":
continue
edit = component_edit_config_from_spec(
parameter_row=export_rows[export_index],
spec=spec,
selected_kind=str(getattr(self, "selected_kind", "") or ""),
selected_face_id=getattr(self, "selected_face_id", None),
selected_edge_id=getattr(self, "selected_edge_id", None),
step_path=step_path,
)
if edit is not None:
edits.append(edit)
export_index += 1
return edits
def _update_parameter_export_state(self, has_model: bool | None = None) -> None:
if not hasattr(self, "export_parameters_button"):
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
)
selected_rows = self._selected_parameter_export_rows()
if selected_rows:
disabled_tip = "请等待当前后台任务完成后再导出参数。"
else:
disabled_tip = "请先在“输入参数”列勾选至少一个尺寸参数。"
self._set_control_state(
self.export_parameters_button,
bool(has_model and selected_rows),
f"导出已勾选的 {len(selected_rows)} 个输入参数到 data.json,并生成参数化组件 main.py。",
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 _selected_command_property_rows(self) -> list[tuple[int, dict[str, object], str]]:
if not hasattr(self, "property_table"):
return []
selection_model = self.property_table.selectionModel()
selected_rows = (
{
int(index.row())
for index in selection_model.selectedRows()
if index is not None and index.row() >= 0
}
if selection_model is not None
else set()
)
current_row = int(self.property_table.currentRow())
if current_row >= 0:
selected_rows.add(current_row)
commands: list[tuple[int, dict[str, object], str]] = []
specs = getattr(self, "property_editor_specs", [])
for row in sorted(selected_rows):
if row < 0 or row >= len(specs):
continue
effective_spec = self._effective_property_spec(specs[row], row=row)
if str(effective_spec.get("value_type", "number")) != "command":
continue
if not effective_spec.get("action") or not effective_spec.get("enabled"):
continue
commands.append((row, effective_spec, self._property_target_text(row)))
return commands
def _property_batch_item_from_row(self, row: int, spec: dict[str, object], text: str) -> dict[str, object]:
return {
"key": str(spec.get("key", "")),
"label": str(spec.get("label", "")),
"action": str(spec.get("action", "")),
"scope_key": str(spec.get("scope_key", self._property_scope_value(row, spec))),
"target_text": str(text),
"source_face_id": spec.get("source_face_id", self.selected_face_id),
"source_edge_id": spec.get("source_edge_id", self.selected_edge_id),
"selected_kind": self.selected_kind,
}
def _property_batch_row_for_item(self, item: dict[str, object]) -> int | None:
specs = getattr(self, "property_editor_specs", [])
target_key = str(item.get("key", ""))
target_label = str(item.get("label", ""))
target_action = str(item.get("action", ""))
target_scope = str(item.get("scope_key", ""))
for row, spec in enumerate(specs):
if target_key and str(spec.get("key", "")) != target_key:
continue
if target_label and str(spec.get("label", "")) != target_label:
continue
modes = spec.get("scope_modes")
mode: dict[str, object] | None = None
if target_scope and isinstance(modes, dict):
candidate_mode = modes.get(target_scope)
if not isinstance(candidate_mode, dict):
continue
mode = dict(candidate_mode)
effective = self._effective_property_spec(spec, row=row)
if target_action and str(effective.get("action", "")) != target_action:
scoped_action = str((mode or {}).get("action", ""))
if scoped_action != target_action:
continue
if target_scope and not isinstance(modes, dict):
if str(effective.get("scope_key", "")) not in {"", target_scope}:
continue
return row
return None
def _set_property_scope_value(self, row: int, scope_key: str) -> bool:
if not scope_key or not hasattr(self, "property_table"):
return True
widget = self.property_table.cellWidget(row, PROPERTY_SCOPE_COLUMN)
if not isinstance(widget, NoWheelComboBox):
return True
index = widget.findData(scope_key)
if index < 0:
index = widget.findText(scope_key)
if index < 0:
return False
if widget.currentIndex() == index:
return True
widget.setCurrentIndex(index)
return True
def _property_target_text(self, row: int) -> str:
if not hasattr(self, "property_table"):
return ""
card_widget = self._property_card_widgets(row).get("target_editor")
if isinstance(card_widget, QLineEdit):
return card_widget.text().strip()
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:
pending = self._pending_property_edit_rows()
if not pending:
self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值,或选中一个命令型参数行。")
return
if self.selected_kind == "multi_feature" and hasattr(self, "apply_multi_selected_hole_property_edit"):
self.apply_multi_selected_hole_property_edit(pending)
return
if len(pending) > 1:
self._start_property_batch_edit(pending)
return
row, _spec, _text = pending[0]
self.apply_property_row_edit(row)
def _pending_property_edit_rows(self) -> list[tuple[int, dict[str, object], str]]:
rows: dict[int, tuple[int, dict[str, object], str]] = {}
for row, spec, text in self._changed_property_rows() + self._selected_command_property_rows():
rows[int(row)] = (int(row), spec, text)
return [rows[row] for row in sorted(rows)]
def _start_property_batch_edit(self, changed: list[tuple[int, dict[str, object], str]]) -> None:
if self._edit_busy("请等待当前编辑完成后再执行批量参数化建模。"):
return
items = [self._property_batch_item_from_row(row, spec, text) for row, spec, text in changed]
self.property_batch_queue = items
self.property_batch_total = len(items)
self.property_batch_done = 0
self.property_batch_active = True
self.statusBar().showMessage(f"开始批量参数化建模:共 {len(items)} 个参数。")
self._run_next_property_batch_item()
def _run_next_property_batch_item(self) -> None:
queue = list(getattr(self, "property_batch_queue", []) or [])
if not queue:
total = int(getattr(self, "property_batch_total", 0) or 0)
self._clear_property_batch_state()
self.statusBar().showMessage(f"批量参数化建模完成:已应用 {total} 个参数。")
self._update_property_apply_state()
if hasattr(self, "_run_pending_relation_formula_dependency_reapply"):
self._run_pending_relation_formula_dependency_reapply()
return
item = queue.pop(0)
self.property_batch_queue = queue
# 每改完一个参数都会重新加载/识别模型,原来的表格行可能已经失效;
# 所以下一个批量项必须用 key/能力信息重新定位当前行。
row = self._property_batch_row_for_item(item)
if row is None:
self._clear_property_batch_state()
QMessageBox.information(
self,
"批量参数化建模已停止",
f"找不到后续参数“{item.get('label', '')}”,模型拓扑可能已变化。已停止后续修改。",
)
self._update_property_apply_state()
return
if not self._set_property_scope_value(row, str(item.get("scope_key", ""))):
self._clear_property_batch_state()
QMessageBox.information(
self,
"批量参数化建模已停止",
f"参数“{item.get('label', '')}”的建模意图已经不可用。已停止后续修改。",
)
self._update_property_apply_state()
return
target_widget = self.property_table.cellWidget(row, PROPERTY_TARGET_COLUMN)
if isinstance(target_widget, QLineEdit):
target_widget.setText(str(item.get("target_text", "")))
done = int(getattr(self, "property_batch_done", 0) or 0)
total = int(getattr(self, "property_batch_total", 0) or 0)
self.statusBar().showMessage(f"批量参数化建模:正在执行 {done + 1}/{total} - {item.get('label', '')}")
self._property_batch_running_action = True
self._property_batch_item_callback_seen = False
try:
self.apply_property_row_edit(row)
finally:
self._property_batch_running_action = False
if (
bool(getattr(self, "property_batch_active", False))
and not bool(getattr(self, "_property_batch_item_callback_seen", False))
and not bool(getattr(self, "operation_in_progress", False))
):
remaining = len(getattr(self, "property_batch_queue", []) or [])
done = int(getattr(self, "property_batch_done", 0) or 0)
self._clear_property_batch_state()
QMessageBox.information(
self,
"批量参数化建模已停止",
f"参数“{item.get('label', '')}”没有启动可执行修改。已完成 {done} 个,剩余 {remaining} 个未执行。",
)
self._update_property_apply_state()
def _clear_property_batch_state(self) -> None:
self.property_batch_queue = []
self.property_batch_total = 0
self.property_batch_done = 0
self.property_batch_active = False
self._property_batch_running_action = False
self._property_batch_item_callback_seen = False
def _after_property_edit_finished(self, *, success: bool) -> None:
if bool(getattr(self, "relation_formula_replay_active", False)):
self._after_relation_formula_edit_finished(success=success)
return
if not bool(getattr(self, "property_batch_active", False)):
return
self._property_batch_item_callback_seen = True
if not success:
remaining = len(getattr(self, "property_batch_queue", []) or [])
done = int(getattr(self, "property_batch_done", 0) or 0)
self._clear_property_batch_state()
self.statusBar().showMessage(f"批量参数化建模已停止:已完成 {done} 个,剩余 {remaining} 个未执行。")
self._update_property_apply_state()
return
self.property_batch_done = int(getattr(self, "property_batch_done", 0) or 0) + 1
QTimer.singleShot(0, self._run_next_property_batch_item)
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)
info.update(self._selection_identity_fields(source_face_id, "特征来源 Face"))
logical_id = int(info["selection_display_id"])
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]))
if hasattr(self, "set_info"):
self.set_info(self._with_pick_info(info, None))
else:
self.current_info_values = dict(info)
self.current_info_text = _info_to_text(info)
self._sync_id_picker("Feature", logical_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
action_name = str(spec.get("action", ""))
try:
self._activate_property_source_feature(spec)
if not is_command and action_name != "apply_scdm_property_edit":
self._sync_property_edit_target(spec, text)
self._sync_property_preselects(spec)
except ValueError as exc:
QMessageBox.information(self, "目标值无效", str(exc))
return
action = getattr(self, action_name, None)
if action is None:
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
return
if action_name == "apply_scdm_property_edit":
self.apply_scdm_property_edit(spec, text)
return
self._run_parametric_property_action_directly(action)
def _run_parametric_property_action_directly(self, action) -> None:
original_question = QMessageBox.question
def auto_yes(*_args, **_kwargs):
return QMessageBox.StandardButton.Yes
try:
QMessageBox.question = auto_yes
action()
finally:
QMessageBox.question = original_question
def apply_scdm_property_edit(self) -> None:
QMessageBox.information(
self,
"SCDM 修改暂未接入",
"SCDM 已能把这个参数识别到产品化缓存里,但 S5 修改执行器还没有接入。\n\n"
"当前不会启动长时间计算,也不会改动模型。下一步需要生成 scdm_edit_job.json"
"再调用 SpaceClaim.exe /RunScript 后台执行并做 OCCT 回测。",
)
self._after_property_edit_finished(success=False)
def apply_scdm_property_edit(self, spec: dict[str, object] | None = None, target_text: str | None = None) -> None:
if spec is None:
spec = {}
capability_key = str(spec.get("scdm_capability_key") or "").strip()
if not capability_key:
self.statusBar().showMessage("SCDM 参数缺少能力 key,无法执行。")
self._after_property_edit_finished(success=False)
return
ready = getattr(self, "scdm_edit_runner_ready", False)
if isinstance(ready, bool):
capability_ready = ready
else:
try:
capability_ready = capability_key in {str(item) for item in ready}
except TypeError:
capability_ready = False
if not capability_ready:
QMessageBox.information(self, "暂未开放", f"{spec.get('label', capability_key)} 的 SCDM 修改命令还没有完成真机验证。")
self._after_property_edit_finished(success=False)
return
if self._edit_busy("请等待当前编辑完成后再执行 SCDM 参数化建模。"):
self._after_property_edit_finished(success=False)
return
source_step = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None
if source_step is None or not source_step.is_file():
QMessageBox.information(self, "缺少 STEP", "当前模型缺少可交给 SCDM 修改的 STEP 文件,请重新导入模型。")
self._after_property_edit_finished(success=False)
return
try:
target_value = self._scdm_property_target_value(spec, str(target_text or ""))
except ValueError as exc:
QMessageBox.information(self, "目标值无效", str(exc))
self._after_property_edit_finished(success=False)
return
operation_name = f"SCDM {spec.get('label', capability_key)}"
selected_kind = self.selected_kind
selected_face_id = self.selected_face_id
selected_edge_id = self.selected_edge_id
selected_pick_position = self.selected_pick_position
target_id = selected_face_id if selected_kind in {"face", "feature"} else selected_edge_id
object_id = str(spec.get("scdm_object_id") or "")
backend_operation = str(spec.get("scdm_backend_operation") or "")
signature = spec.get("scdm_geometry_signature")
object_signature = dict(signature) if isinstance(signature, dict) else {}
context = {
"operation_name": operation_name,
"target": str(spec.get("label") or capability_key),
"parameters": {
"backend": "SCDM",
"capability_key": capability_key,
"target_value": target_value,
"object_id": object_id,
"object_signature": object_signature,
},
"target_kind": selected_kind,
"target_id": target_id,
"target_logical_id": self._edit_target_logical_id(selected_kind, target_id) if hasattr(self, "_edit_target_logical_id") else None,
"pick_position": selected_pick_position,
}
self._begin_edit_task(operation_name)
self.pending_edit_context = context
thread = QThread(self)
def action() -> dict[str, object]:
# Worker 线程只做外部 SCDM 调用和文件等待,不碰 Qt 控件和当前 StepModel。
# 完成后的模型替换、弹窗、cache 刷新都在 queued slot 里回 UI 线程处理。
return run_scdm_edit_job(
source_step,
capability_key=capability_key,
target_value=target_value,
object_signature=object_signature,
object_id=object_id,
backend_operation=backend_operation,
project_root=Path(__file__).resolve().parent.parent,
timeout_seconds=180.0,
context={
"selectedKind": selected_kind,
"selectedFaceId": selected_face_id,
"selectedEdgeId": selected_edge_id,
"source": "property_table",
},
)
worker = EditWorker(action)
worker.moveToThread(thread)
thread.started.connect(worker.run)
worker.finished.connect(self._finish_scdm_edit_action, Qt.ConnectionType.QueuedConnection)
worker.failed.connect(self._fail_scdm_edit_action, Qt.ConnectionType.QueuedConnection)
thread.finished.connect(worker.deleteLater)
thread.finished.connect(thread.deleteLater)
thread.finished.connect(self._forget_edit_thread)
self.edit_thread = thread
self.edit_worker = worker
try:
thread.start(QThread.Priority.LowPriority)
except TypeError:
thread.start()
def _scdm_property_target_value(self, spec: dict[str, object], text: str) -> object:
value_type = str(spec.get("value_type") or "number")
unit_scale = _float_or_none(spec.get("scdm_unit_scale"))
if unit_scale is None or unit_scale <= 0:
unit_scale = 1.0
if value_type == "vector3":
values = list(self._parse_property_vector3(text))
return [value * unit_scale for value in values] if self._scdm_property_uses_length_units(spec) else values
if value_type in {"number", "positive"}:
try:
value = float(text)
except (TypeError, ValueError) as exc:
raise ValueError("请输入数字形式的目标值。") from exc
if value_type == "positive" and value <= 0:
raise ValueError("请输入大于 0 的目标值。")
return value * unit_scale if self._scdm_property_uses_length_units(spec) else value
if value_type == "command":
return True
return text
@staticmethod
def _scdm_property_uses_length_units(spec: dict[str, object]) -> bool:
key = str(spec.get("scdm_capability_key") or spec.get("key") or "")
value_type = str(spec.get("value_type") or "")
if value_type == "vector3":
return True
suffixes = (
".diameter",
".radius",
".offset",
".width",
".depth",
".height",
".distance",
".thickness",
".spacing",
".segment_spacing",
".position",
)
return key.endswith(suffixes)
@Slot(object)
def _finish_scdm_edit_action(self, result: object) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda result=result: self._finish_scdm_edit_action(result)):
return
if not isinstance(result, dict):
self._end_edit_task(clear_preview=True)
QMessageBox.information(self, "SCDM 修改失败", "SCDM 后台返回了无法识别的结果。")
self._after_property_edit_finished(success=False)
return
if isinstance(result.get("backend"), dict):
self.scdm_backend_status = dict(result["backend"])
self._update_current_capability_panel()
if result.get("ok") is not True:
self._end_edit_task(clear_preview=True)
reason = str(result.get("reason") or "unknown")
message = str(result.get("message") or "SCDM 修改失败。")
QMessageBox.information(self, "SCDM 修改失败", f"不能修改。\n类型:{reason}\n原因:{message}\n模型未修改。")
self._after_property_edit_finished(success=False)
return
output_step = Path(str(result.get("output_step") or ""))
if not output_step.is_file():
self._end_edit_task(clear_preview=True)
QMessageBox.information(self, "SCDM 修改失败", "SCDM 报告成功,但没有生成结果 STEP。模型未修改。")
self._after_property_edit_finished(success=False)
return
source_step = self.step_path if isinstance(getattr(self, "step_path", None), Path) else None
before_snapshot = None
if self.model is not None:
try:
before_snapshot = self.model.snapshot()
except Exception:
before_snapshot = None
before_cache = self._current_scdm_feature_cache() if hasattr(self, "_current_scdm_feature_cache") else None
pending_context = dict(self.pending_edit_context or {})
context_parameters = pending_context.get("parameters")
if not isinstance(context_parameters, dict):
context_parameters = {}
self.pending_scdm_edit_reload = {
"edit_result": dict(result),
"edit_context": pending_context,
"before_cache": dict(before_cache) if isinstance(before_cache, dict) else {},
"before_snapshot": before_snapshot,
"source_step": str(source_step) if source_step is not None else "",
"object_id": str(result.get("object_id") or context_parameters.get("object_id") or ""),
"capability_key": str(result.get("capability_key") or context_parameters.get("capability_key") or ""),
"target_value": context_parameters.get("target_value"),
"object_signature": context_parameters.get("object_signature"),
}
self._end_edit_task(clear_preview=True)
self.statusBar().showMessage("SCDM 修改完成,正在重新读取结果模型...")
# 先把 result.step 加载到当前模型,再进入 _finish_pending_scdm_edit_reload。
# 只有加载后的 OCCT B-Rep 和新 SCDM cache 都通过校验,才真正记录历史。
loaded = False
if hasattr(self, "_load_step_sync"):
loaded = bool(
self._load_step_sync(
output_step,
deflection=float(getattr(self, "preview_load_deflection", 0.35) or 0.35),
show_internal_edges=bool(self._show_same_domain_internal_edges()) if hasattr(self, "_show_same_domain_internal_edges") else False,
status_prefix="读取 SCDM 修改结果",
defer_edges=True,
reset_camera=False,
)
)
else:
self.load_step(output_step, background=False)
loaded = True
if not loaded and isinstance(getattr(self, "pending_scdm_edit_reload", None), dict):
self.pending_scdm_edit_reload = None
self._after_property_edit_finished(success=False)
@Slot(str)
def _fail_scdm_edit_action(self, message: str) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_scdm_edit_action(message)):
return
self._end_edit_task(clear_preview=True)
QMessageBox.information(self, "SCDM 修改失败", f"SCDM 后台执行失败:{message}")
self._after_property_edit_finished(success=False)
def _finish_pending_scdm_edit_reload(self, *, cache_ready: bool, message: str = "") -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(
lambda cache_ready=cache_ready, message=message: self._finish_pending_scdm_edit_reload(cache_ready=cache_ready, message=message)
):
return
pending = getattr(self, "pending_scdm_edit_reload", None)
if not isinstance(pending, dict):
return
self.pending_scdm_edit_reload = None
notes: list[str] = []
before_cache = pending.get("before_cache")
after_cache = self._current_scdm_feature_cache() if hasattr(self, "_current_scdm_feature_cache") else None
if not cache_ready:
self._reject_scdm_result_after_reload(
pending,
{
"ok": False,
"reason": "cache-not-ready",
"message": message or "SCDM 修改后没有拿到新的可修改参数 cache,结果无法验证。",
},
)
return
if isinstance(before_cache, dict) and isinstance(after_cache, dict):
signature = pending.get("object_signature")
# 结果模型已经显示出来了,但还没承诺成功;验证失败会立即恢复修改前快照。
validation = validate_scdm_edit_result(
pending.get("edit_result") if isinstance(pending.get("edit_result"), dict) else {},
before_signature=signature if isinstance(signature, dict) else None,
before_cache=before_cache,
after_cache=after_cache,
capability_key=str(pending.get("capability_key") or ""),
expected_target=pending.get("target_value"),
edited_object_id=str(pending.get("object_id") or ""),
brep_validator=self._validate_current_scdm_result_brep,
)
if validation.get("ok") is not True:
self._reject_scdm_result_after_reload(pending, validation)
return
mapping = build_scdm_id_mapping(
before_cache,
after_cache,
capability_key=str(pending.get("capability_key") or ""),
)
note = self._rewrite_relation_formulas_from_scdm_mapping(mapping)
if note:
notes.append(note)
self._record_successful_scdm_edit(pending, validation)
if hasattr(self, "_refresh_relation_formulas_after_model_edit"):
note = self._refresh_relation_formulas_after_model_edit(
context=pending.get("edit_context") if isinstance(pending.get("edit_context"), dict) else None
)
if note:
notes.append(note)
if message:
notes.append(str(message))
if notes:
self.statusBar().showMessage("".join(notes))
self._after_property_edit_finished(success=True)
def _validate_current_scdm_result_brep(self, _path: Path) -> dict[str, object]:
if self.model is None:
return {"ok": False, "reason": "model-not-loaded", "message": "结果 STEP 还没有成功加载到当前模型。"}
try:
info = dict(self.model.export_quality_info("all", None))
except Exception as exc:
return {"ok": False, "reason": "brep-check-failed", "message": str(exc)}
if info.get("brep_valid") is not True:
warning = str(info.get("quality_warnings") or "").strip()
return {
"ok": False,
"reason": "brep-invalid",
"message": warning or "OCCT B-Rep 校验未通过。",
"quality": info,
}
try:
face_count = int(info.get("faces") or 0)
except (TypeError, ValueError):
face_count = 0
if face_count <= 0:
return {
"ok": False,
"reason": "empty-topology",
"message": "结果模型没有可用 Face。",
"quality": info,
}
return {"ok": True, "reason": "ok", "message": "OCCT B-Rep 校验通过。", "quality": info}
def _reject_scdm_result_after_reload(self, pending: dict[str, object], validation: dict[str, object]) -> None:
reason = str(validation.get("reason") or "result-check-failed")
message = str(validation.get("message") or "SCDM 修改结果没有通过校验。")
rollback_message = self._restore_scdm_before_snapshot(pending)
QMessageBox.information(
self,
"SCDM 修改结果已回滚",
f"不能修改。\n类型:{reason}\n原因:{message}\n{rollback_message}\n模型未修改。",
)
self.statusBar().showMessage(f"SCDM 修改结果未通过校验,已回滚:{reason}")
self._after_property_edit_finished(success=False)
def _restore_scdm_before_snapshot(self, pending: dict[str, object]) -> str:
source_step_text = str(pending.get("source_step") or "").strip()
source_step = Path(source_step_text) if source_step_text else None
snapshot = pending.get("before_snapshot")
if source_step is not None and source_step.is_file():
self.step_path = source_step
if hasattr(self, "path_label"):
path_text = str(source_step)
self.path_label.setText(path_text)
self.path_label.setToolTip(path_text)
self.path_label.setCursorPosition(0)
if isinstance(snapshot, dict) and self.model is not None:
try:
if hasattr(self, "_restore_snapshot"):
self._restore_snapshot(snapshot)
self._invalidate_scdm_feature_cache("SCDM 修改结果未通过校验,已恢复到修改前,cache 已失效。")
QTimer.singleShot(350, self._start_scdm_probe_preload)
return "已恢复到修改前的模型快照。"
except Exception as exc:
if source_step is not None and source_step.is_file():
QTimer.singleShot(0, lambda path=source_step: self.load_step(path, background=True))
return f"快照恢复失败:{exc}。正在重新加载修改前 STEP。"
return f"快照恢复失败:{exc}。请重新导入修改前 STEP。"
if source_step is not None and source_step.is_file():
QTimer.singleShot(0, lambda path=source_step: self.load_step(path, background=True))
return "未找到可用快照,正在重新加载修改前 STEP。"
return "未找到可用快照或修改前 STEP,请重新导入模型确认状态。"
def _record_successful_scdm_edit(self, pending: dict[str, object], validation: dict[str, object]) -> None:
if self.model is None:
return
before_snapshot = pending.get("before_snapshot")
if not isinstance(before_snapshot, dict):
return
edit_context = pending.get("edit_context") if isinstance(pending.get("edit_context"), dict) else {}
parameters = dict(edit_context.get("parameters") if isinstance(edit_context.get("parameters"), dict) else {})
target_value = pending.get("target_value")
capability_key = str(pending.get("capability_key") or parameters.get("capability_key") or "")
operation_name = str(edit_context.get("operation_name") or f"SCDM {capability_key}")
target = str(edit_context.get("target") or capability_key)
after_snapshot = self.model.snapshot()
summary = f"{operation_name}: {target} -> {_format_value(target_value)}"
detail_lines = [
summary,
"backend: SCDM",
f"capability: {capability_key}",
f"target: {_format_value(target_value)}",
f"result: {validation.get('message') or 'SCDM edit result passed validation.'}",
]
topology_check = validation.get("topologyCheck")
if isinstance(topology_check, dict):
detail_lines.append(f"unchanged objects checked: {topology_check.get('checked', 0)}")
record = OperationRecord(
summary=summary,
detail="\n".join(detail_lines),
operation_name=operation_name,
target=target,
parameters=parameters,
result_message=str(validation.get("message") or "SCDM edit result passed validation."),
target_kind=edit_context.get("target_kind") if isinstance(edit_context.get("target_kind"), str) else None,
target_id=_int_or_none(edit_context.get("target_id")),
target_logical_id=_int_or_none(edit_context.get("target_logical_id")),
pick_position=edit_context.get("pick_position") if isinstance(edit_context.get("pick_position"), tuple) else None,
before_snapshot=before_snapshot,
after_snapshot=after_snapshot,
)
self.undo_stack.append(before_snapshot)
self.redo_stack.clear()
self.operation_history.append(record)
self.redo_history.clear()
if hasattr(self, "_refresh_history_list"):
self._refresh_history_list()
self._update_action_states()
def _fail_pending_scdm_edit_reload(self, message: str) -> None:
if hasattr(self, "_reroute_to_ui_thread") and self._reroute_to_ui_thread(lambda message=message: self._fail_pending_scdm_edit_reload(message)):
return
if not isinstance(getattr(self, "pending_scdm_edit_reload", None), dict):
return
self.pending_scdm_edit_reload = None
QMessageBox.information(self, "SCDM 结果加载失败", message)
self._after_property_edit_finished(success=False)
def _rewrite_relation_formulas_from_scdm_mapping(self, mapping: dict[str, object]) -> str:
if not getattr(self, "relation_formula_items", None):
return ""
if not isinstance(mapping, dict):
return ""
if not mapping.get("faceIdMap") and not mapping.get("edgeIdMap"):
return ""
updated_items: list[dict[str, object]] = []
changed_count = 0
for raw_item in getattr(self, "relation_formula_items", []) or []:
item = dict(raw_item)
old_text = str(item.get("text") or "")
new_text = rewrite_scdm_relation_formula_ids(old_text, mapping)
if new_text != old_text:
item["text"] = new_text
changed_count += 1
try:
item["signatures"] = self._relation_signatures_for_formula(parse_relation_formula(new_text))
if item.get("status") == "invalid":
item["status"] = "ready"
item["message"] = "SCDM 已重新定位关系式对象。"
except RelationFormulaError as exc:
item["status"] = "invalid"
item["message"] = str(exc)
updated_items.append(item)
self.relation_formula_items = updated_items
self._refresh_relation_formula_list()
if not changed_count:
return ""
return f"SCDM 已更新 {changed_count} 条关系式里的 Face/Edge ID"
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 _plane_offset_info_for_property(
self,
action_info: dict[str, object],
) -> tuple[tuple[float, float, float] | None, tuple[float, float, float] | None]:
plane_origin = _triple_or_none(action_info.get("plane_origin")) or _triple_or_none(action_info.get("origin"))
plane_direction = _triple_or_none(action_info.get("push_pull_outward_direction"))
selected_face_id = _int_or_none(action_info.get("feature_source_face_id"))
if selected_face_id is None:
selected_face_id = _int_or_none(action_info.get("topological_face_id"))
if selected_face_id is None:
selected_face_id = _int_or_none(action_info.get("face_id"))
model_faces = getattr(self.model, "faces", None) if self.model is not None else None
large_model_fast_display = bool(getattr(self, "_large_model_interaction_mode", lambda: False)())
needs_precise_direction = plane_direction is None and not large_model_fast_display
if (
self.model is not None
and model_faces is not None
and selected_face_id is not None
and 0 <= selected_face_id < len(model_faces)
and (plane_origin is None or needs_precise_direction)
):
try:
full_info = self.model.face_info(selected_face_id)
except Exception:
full_info = {}
if plane_origin is None:
plane_origin = _triple_or_none(full_info.get("plane_origin"))
if plane_direction is None:
plane_direction = _triple_or_none(full_info.get("push_pull_outward_direction"))
if plane_direction is None:
plane_direction = _triple_or_none(full_info.get("oriented_normal"))
if plane_direction is None:
plane_direction = _triple_or_none(action_info.get("normal"))
if plane_direction is None:
plane_direction = _triple_or_none(action_info.get("oriented_normal"))
return plane_origin, plane_direction
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 {}
multi_key = tuple(
(
(_int_or_none(item.get("logical_id")) if _int_or_none(item.get("logical_id")) is not None else -1),
(_int_or_none(item.get("face_id")) if _int_or_none(item.get("face_id")) is not None else -1),
)
for item in (getattr(self, "multi_selected_hole_entries", []) or [])
if isinstance(item, dict)
)
cache_key = (
id(self.model),
self.selected_kind,
self.selected_part_id,
self.selected_solid_id,
self.selected_face_id,
self.selected_edge_id,
multi_key,
id(getattr(self, "current_info_values", None)),
getattr(self, "feature_detection_level", "current-only"),
)
if (
getattr(self, "_selected_action_info_cache_key", None) == cache_key
and getattr(self, "_selected_action_info_cache_value", None) is not None
):
return dict(self._selected_action_info_cache_value)
current_info = dict(getattr(self, "current_info_values", {}) or {})
if self.selected_kind == "multi_feature" and len(getattr(self, "multi_selected_hole_entries", []) or []) >= 2:
result = self._multi_hole_selection_info()
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return dict(result)
current_face_id = _int_or_none(
current_info.get("feature_source_face_id", current_info.get("topological_face_id", current_info.get("face_id")))
)
result: dict[str, object]
if (
self.selected_face_id is not None
and current_face_id == self.selected_face_id
and str(current_info.get("surface", "") or "")
):
result = self._feature_info_for_selected_face(self.selected_face_id, current_info)
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return result
try:
if self.selected_kind == "feature" and self.selected_face_id is not None:
result = self._feature_info_for_selected_face(
self.selected_face_id,
self.model.quick_face_info(self.selected_face_id),
)
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return result
if self.selected_kind == "face" and self.selected_face_id is not None:
info = self.model.quick_face_info(self.selected_face_id)
result = self._feature_info_for_selected_face(self.selected_face_id, info)
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return result
if self.selected_kind == "edge" and self.selected_edge_id is not None:
result = self.model.edge_info(self.selected_edge_id)
result.update(self._edge_first_level_selection_fields(self.selected_edge_id))
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return result
except Exception:
result = dict(self.current_info_values)
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return result
result = dict(self.current_info_values)
self._selected_action_info_cache_key = cache_key
self._selected_action_info_cache_value = dict(result)
return result
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:
if self._large_model_fast_record_locator():
base_info = self.model.quick_face_info(resolved_face_id)
info = self._feature_info_for_selected_face(resolved_face_id, base_info)
if record.target_kind == "feature":
info["kind"] = "feature"
else:
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))
)
info.update(
self._selection_identity_fields(
resolved_face_id,
"特征来源 Face" if record.target_kind == "feature" else "Face",
)
)
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._record_locator_face_ids(resolved_face_id, info)
)
if not face_ids:
face_ids = self._record_locator_face_ids(resolved_face_id, info)
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)
info.update(self._edge_first_level_selection_fields(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 _large_model_fast_record_locator(self) -> bool:
if self.model is None:
return False
mode = getattr(self, "_large_model_interaction_mode", None)
if callable(mode):
try:
return bool(mode())
except Exception:
pass
try:
return len(getattr(self.model, "faces", ()) or ()) > 1000 or len(getattr(self.model, "edges", ()) or ()) > 2500
except Exception:
return False
def _record_locator_face_ids(self, face_id: int, info: dict[str, object]) -> list[int]:
selector = getattr(self, "_selection_same_domain_face_ids", None)
if callable(selector):
try:
return list(selector(face_id, info))
except Exception:
pass
try:
return list(self.model.face_region_ids(face_id)) if self.model is not None else [face_id]
except Exception:
return [face_id]
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
for key in ("verified_face", "nearest_face", "matched_face", "retained_face"):
value = _record_message_field(record.result_message, key)
if value is None:
continue
try:
face_id = int(value)
except (TypeError, ValueError):
continue
if not (0 <= face_id < len(self.model.faces)):
continue
if self._record_face_semantic_matches(record, face_id):
return face_id
return 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",
*FREEFORM_FACE_SURFACES,
}:
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
plane_position = self.model.face_plane_position_along(int(face_id), unit)
if plane_position is None:
continue
position, normal = plane_position
if abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85:
continue
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_scdm_cache_after_snapshot_restore("撤销后模型已恢复,SCDM cache 已失效。")
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_scdm_cache_after_snapshot_restore("重做后模型已恢复,SCDM cache 已失效。")
self._refresh_history_list()
self._update_action_states()
self.statusBar().showMessage(f"已重做:{redone.summary}")
def _refresh_scdm_cache_after_snapshot_restore(self, message: str) -> None:
if not hasattr(self, "_invalidate_scdm_feature_cache") or not hasattr(self, "_start_scdm_probe_preload"):
return
self._invalidate_scdm_feature_cache(message)
QTimer.singleShot(350, self._start_scdm_probe_preload)
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 "模型已恢复到操作前状态,历史记录未移动。"