7093 lines
378 KiB
Python
7093 lines
378 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
import math
|
||
from pathlib import Path
|
||
import time
|
||
from PySide6.QtCore import Qt, QThread, QTimer, Slot
|
||
from PySide6.QtWidgets import (
|
||
QApplication,
|
||
QCheckBox,
|
||
QFileDialog,
|
||
QFrame,
|
||
QHBoxLayout,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMessageBox,
|
||
QPushButton,
|
||
QSizePolicy,
|
||
QTableWidgetItem,
|
||
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 .records import OperationRecord
|
||
from .ui_helpers import * # noqa: F403
|
||
from .widgets import NoWheelComboBox
|
||
from .workers import EditWorker, LoadWorker, ScanWorker
|
||
|
||
|
||
PROPERTY_VALUE_TOLERANCE = 1e-9
|
||
PROPERTY_LABEL_COLUMN = 0
|
||
PROPERTY_CURRENT_COLUMN = 1
|
||
PROPERTY_SCOPE_COLUMN = 2
|
||
PROPERTY_TARGET_COLUMN = 3
|
||
PROPERTY_INPUT_COLUMN = 4
|
||
PROPERTY_TABLE_HEADERS = ("尺寸参数", "当前值", "建模意图", "目标值", "输入参数")
|
||
PROPERTY_TABLE_MIN_COLUMN_WIDTHS = (70, 72, 66, 62, 50)
|
||
PROPERTY_TABLE_PREFERRED_COLUMN_WIDTHS = (108, 104, 88, 82, 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": "查看识别依据、一级拓扑关系和当前不支持修改的原因。",
|
||
}
|
||
|
||
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"))
|
||
|
||
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 = ["local_face_width", "local_face_height"]
|
||
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 (
|
||
"local_face_width",
|
||
"local_face_height",
|
||
"face_center_position",
|
||
"face_target_normal_position",
|
||
"shell_thickness_estimate",
|
||
)
|
||
return (
|
||
"local_face_width",
|
||
"local_face_height",
|
||
"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_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.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_current_object_info(self) -> None:
|
||
self.current_info_values = {}
|
||
self.current_info_text = ""
|
||
if hasattr(self, "info_tree"):
|
||
self.info_tree.clear()
|
||
if hasattr(self, "info_text"):
|
||
self.info_text.clear()
|
||
self._clear_property_editor()
|
||
|
||
def _show_pick_marker(self, pick_position: tuple[float, float, float] | None) -> None:
|
||
if getattr(self, "pick_marker_actor", None) is not None and hasattr(self, "renderer"):
|
||
self.renderer.RemoveActor(self.pick_marker_actor)
|
||
self.pick_marker_actor = None
|
||
if getattr(self, "render_window", None) is not None:
|
||
self.render_window.Render()
|
||
|
||
def _with_pick_info(
|
||
self,
|
||
info: dict[str, object],
|
||
pick_position: tuple[float, float, float] | None,
|
||
) -> dict[str, object]:
|
||
if pick_position is None:
|
||
return info
|
||
enriched = dict(info)
|
||
enriched["pick_position"] = pick_position
|
||
return enriched
|
||
|
||
def _selection_identity_fields(self, face_id: int, title_prefix: str = "Face") -> dict[str, object]:
|
||
logical_id = int(face_id)
|
||
if self.model is not None:
|
||
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}"
|
||
if logical_id != int(face_id):
|
||
title += f"(当前拓扑 Face {face_id})"
|
||
return {
|
||
"selection_title": title,
|
||
"selection_display_id": logical_id,
|
||
"selection_topological_face_id": int(face_id),
|
||
}
|
||
|
||
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 _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._current_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":
|
||
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":
|
||
info.update(self._face_first_level_selection_fields(face_id))
|
||
return info
|
||
|
||
def _feature_context_info(self, face_id: int) -> dict[str, object]:
|
||
if self.model is None:
|
||
return {}
|
||
detection_level = self._current_feature_detection_level()
|
||
root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
|
||
if str(root_info.get("surface", "") or "") == "plane" and "topology_relation_depth" not in root_info:
|
||
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"))
|
||
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:
|
||
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)
|
||
|
||
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 == "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 {self.selected_face_id}"
|
||
if logical_id not in {None, "", self.selected_face_id}:
|
||
source_suffix = f"来源 Face {logical_id}(拓扑 {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, "", self.selected_face_id}:
|
||
return f"Face {logical_id}(拓扑 {self.selected_face_id}){feature_suffix}"
|
||
return f"Face {self.selected_face_id}{feature_suffix}"
|
||
if self.selected_kind == "edge" and self.selected_edge_id is not None:
|
||
return f"Edge {self.selected_edge_id}"
|
||
return "未选择"
|
||
|
||
def _selected_feature_label(self) -> str:
|
||
info = dict(getattr(self, "current_info_values", {}) or {})
|
||
explicit_type = str(info.get("feature_type", "") or "").strip()
|
||
if explicit_type:
|
||
return 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 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._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:面内长度/宽度、偏移、壳体厚度。\n"
|
||
"孔/槽:孔径、轴心、封堵、盲孔/盲槽深度、槽宽/槽深/弧长/总长。\n"
|
||
"凸台:圆柱凸台直径/高度/轴心;矩形凸台/矩形槽口袋长宽、高度/深度;多台阶矩形凸台顶层规则台阶。\n"
|
||
"圆角/倒角:简单已有圆角半径/弧长、简单等半径圆角链半径/弧长、已有等距倒角距离,直线 Edge 新增圆角/倒角。\n"
|
||
"Edge/解析曲面:直线 Edge 长度/端点、圆/椭圆 Edge、简单圆锥/球/环面。\n"
|
||
"一级关系:Face/Edge 平行垂直事实、Face 共面/同域碎片、一级同轴圆柱事实;部分 Face/Edge 操作可选择保持关系。\n"
|
||
"受限:复杂链式特征、二级/三级拓扑传播和原 CAD 历史恢复。"
|
||
)
|
||
return headline, detail, tooltip
|
||
|
||
def _update_current_capability_panel(self) -> None:
|
||
if not hasattr(self, "current_capability_headline"):
|
||
return
|
||
headline, _detail, tooltip = self._current_capability_text()
|
||
self.current_capability_headline.setText(headline)
|
||
self.current_capability_headline.setToolTip(tooltip or headline)
|
||
|
||
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 not hasattr(self, "property_table"):
|
||
return
|
||
has_selection = any(
|
||
item is not None
|
||
for item in (
|
||
self.selected_part_id,
|
||
self.selected_solid_id,
|
||
self.selected_face_id,
|
||
self.selected_edge_id,
|
||
)
|
||
)
|
||
if not has_selection:
|
||
self._clear_property_editor()
|
||
return
|
||
info = dict(getattr(self, "current_info_values", {}) or {})
|
||
action_info = self._selected_action_info()
|
||
for key, value in action_info.items():
|
||
info.setdefault(key, value)
|
||
|
||
specs = self._sort_property_specs_for_display(self._property_editor_specs(info, action_info))
|
||
self.property_editor_specs = specs
|
||
self.property_table_expanded = False
|
||
self.property_editor_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)
|
||
scope_item = self._property_table_item(str(effective_spec.get("scope_text", "")), editable=False)
|
||
target_item = self._property_table_item(
|
||
"" if input_editable else str(effective_spec.get("target_text", "")),
|
||
editable=False,
|
||
)
|
||
target_item.setToolTip(self._property_target_tooltip(effective_spec, editable=editable))
|
||
input_item = self._property_table_item("", editable=False)
|
||
input_item.setToolTip("勾选后点击“导出参数”,会把这一行作为输入参数写入 data.json。")
|
||
row_items = (label_item, current_item, scope_item, target_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._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()
|
||
|
||
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, scope_item, target_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。"
|
||
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。"
|
||
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]]:
|
||
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 _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_size_supported = bool(
|
||
embedded_prismatic
|
||
and action_info.get("prismatic_profile_status") == "candidate"
|
||
and action_info.get("prismatic_extrusion_status") == "candidate"
|
||
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 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:
|
||
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 {}
|
||
is_width_axis = key == "local_face_width"
|
||
current_limit = prismatic_width if is_width_axis else prismatic_length
|
||
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": "当前矩形特征的长宽、深度或四个侧壁没有稳定识别,暂不开放长宽修改。",
|
||
"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(
|
||
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 = "当前识别结果没有稳定可修改参数;详细原因请看诊断信息。"
|
||
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 _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()
|
||
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 在自身平面内两个主方向上的投影长度,"
|
||
"不是面积,也不是模型整体高度。局部重建会移动当前 Face 顶点,相邻面会按新顶点重建。"
|
||
)
|
||
face_size_owner_tip = (
|
||
"这里的面内长度/面内宽度是选中 Face 在自身平面内两个主方向上的投影长度,"
|
||
"不是面积,也不是模型整体高度。程序会沿该方向缩放所属特征或 Solid,其它几何会跟随变化。"
|
||
)
|
||
add_scoped_spec(
|
||
key="local_face_width",
|
||
label="面内长度",
|
||
current_raw=current_face_width if current_face_width is not None else "",
|
||
target_text=numeric_text(current_face_width),
|
||
scope_default="local",
|
||
scope_modes={
|
||
"local": {
|
||
"label": "局部重建",
|
||
"action": "resize_face_width_local",
|
||
"target_attr": "face_width_input",
|
||
"enabled": bool(
|
||
local_face_deform_ready
|
||
and current_face_width is not None
|
||
and current_face_width > 0
|
||
and current_face_center is not None
|
||
),
|
||
"enabled_tip": f"输入目标面内长度;{face_size_tip}",
|
||
"disabled_tip": face_local_disabled_tip(
|
||
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改面内长度。"
|
||
),
|
||
"range_hint": f"{face_size_tip} {relative_range_hint(current_face_width, 0.25, 0.8, face_linear_hard_limit)}",
|
||
},
|
||
"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(
|
||
"只有带稳定顶点环和中心坐标的平面 Face 才能尝试修改面内宽度。"
|
||
),
|
||
"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 = _triple_or_none(action_info.get("plane_origin"))
|
||
plane_direction = (
|
||
_triple_or_none(action_info.get("push_pull_outward_direction"))
|
||
or _triple_or_none(action_info.get("normal"))
|
||
)
|
||
current_plane_position = None
|
||
if plane_origin is not None and plane_direction is not None:
|
||
direction_length = math.sqrt(
|
||
plane_direction[0] * plane_direction[0]
|
||
+ plane_direction[1] * plane_direction[1]
|
||
+ plane_direction[2] * plane_direction[2]
|
||
)
|
||
if direction_length > 1e-12:
|
||
plane_direction = (
|
||
plane_direction[0] / direction_length,
|
||
plane_direction[1] / direction_length,
|
||
plane_direction[2] / direction_length,
|
||
)
|
||
current_plane_position = (
|
||
plane_origin[0] * plane_direction[0]
|
||
+ plane_origin[1] * plane_direction[1]
|
||
+ plane_origin[2] * plane_direction[2]
|
||
)
|
||
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": "输入偏移的目标位置;程序会沿当前 Face 法向执行拉伸或切除。",
|
||
"disabled_tip": "当前平面缺少稳定移动方向或基准点,不能按目标位置拉伸/切除。",
|
||
"range_hint": (
|
||
"偏移是沿当前 Face 法向测量的目标位置,不是面积、不是移动距离,也不是 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": (
|
||
"输入偏移的目标位置;程序会沿当前 Face 法向拉伸/切除,"
|
||
"并要求一级相邻平面的平行/垂直关系可验证。"
|
||
),
|
||
"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": (
|
||
"输入偏移的目标位置;程序会沿当前 Face 法向平移所属特征或 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 = None
|
||
if (
|
||
axis_point is not None
|
||
and axis_direction is not None
|
||
and isinstance(axis_range_value, (list, tuple))
|
||
and len(axis_range_value) >= 2
|
||
):
|
||
v_min = _float_or_none(axis_range_value[0])
|
||
v_max = _float_or_none(axis_range_value[1])
|
||
if v_min is not None and v_max is not None:
|
||
v_mid = (v_min + v_max) * 0.5
|
||
current_axis_center = (
|
||
axis_point[0] + axis_direction[0] * v_mid,
|
||
axis_point[1] + axis_direction[1] * v_mid,
|
||
axis_point[2] + axis_direction[2] * v_mid,
|
||
)
|
||
if not is_slot_or_half_hole:
|
||
add_scoped_spec(
|
||
key="hole_axis_center",
|
||
label="轴心",
|
||
current_raw=current_axis_center if current_axis_center is not None else "",
|
||
target_text=vector_text(current_axis_center),
|
||
scope_default="local",
|
||
scope_modes={
|
||
"local": {
|
||
"label": "移动孔",
|
||
"action": "move_cylindrical_hole_axis",
|
||
"target_attrs": ("hole_center_x_input", "hole_center_y_input", "hole_center_z_input"),
|
||
"enabled": bool(is_full_cylinder and current_axis_center is not None and current_diameter is not None),
|
||
"enabled_tip": "输入完整圆柱孔轴心的目标坐标 X, Y, Z;程序会先填旧孔,再按同直径切出新孔。",
|
||
"disabled_tip": "当前只对接近完整圆柱的孔放行轴心坐标修改;槽/半孔会使用扇形槽轴心坐标修改。",
|
||
"range_hint": translation_hint(),
|
||
},
|
||
"owning": {
|
||
"label": "移动特征",
|
||
"action": "move_selected_axis_center_by_translation",
|
||
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
|
||
"enabled": bool(
|
||
current_axis_center is not None
|
||
and (self.selected_part_id is not None or self.selected_solid_id is not None)
|
||
),
|
||
"enabled_tip": (
|
||
"输入目标孔轴心 X, Y, Z 坐标;程序会换算成平移量并移动所属特征或 Solid,"
|
||
"不会填旧孔再切新孔。"
|
||
),
|
||
"disabled_tip": "当前孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。",
|
||
"range_hint": (
|
||
"这不是移动孔本身,而是整体搬动所属对象;"
|
||
f"{translation_hint()}"
|
||
),
|
||
"target_transform": "target_center_to_translation",
|
||
"transform_context": {"current_center": current_axis_center},
|
||
},
|
||
},
|
||
value_type="vector3",
|
||
used=("axis_point", "axis", "same_domain_v_range", "v_range"),
|
||
)
|
||
add_spec(
|
||
key="suppress_cylindrical_hole",
|
||
label="封堵",
|
||
current_raw="未封堵",
|
||
target_text="无需输入",
|
||
action="suppress_hole",
|
||
enabled=bool(is_full_cylinder and current_diameter is not None),
|
||
enabled_tip="用补料体封堵当前完整圆柱孔。点击状态列按钮执行,不需要输入目标值。",
|
||
disabled_tip="只有接近完整圆柱面的孔/槽候选才能直接封堵;槽/半孔请使用槽/孔尺寸或轴心修改。",
|
||
value_type="command",
|
||
range_hint="该操作会直接改变几何并写入历史;复杂孔失败时会回滚到操作前状态。",
|
||
button_text="封堵",
|
||
)
|
||
if is_slot_or_half_hole:
|
||
add_scoped_spec(
|
||
key="slot_axis_center",
|
||
label="轴心",
|
||
current_raw=current_axis_center if current_axis_center is not None else "",
|
||
target_text=vector_text(current_axis_center),
|
||
scope_default="local",
|
||
scope_modes={
|
||
"local": {
|
||
"label": "移动槽/半孔",
|
||
"action": "move_cylindrical_slot_axis",
|
||
"target_attrs": ("slot_center_x_input", "slot_center_y_input", "slot_center_z_input"),
|
||
"enabled": bool(current_axis_center is not None and current_diameter is not None),
|
||
"enabled_tip": "输入槽/半孔轴心的目标坐标 X, Y, Z;程序会先填旧扇形槽,再按同宽度、同角度切出新槽。",
|
||
"disabled_tip": "当前槽/半孔缺少稳定轴心或宽度,不能移动轴心坐标。",
|
||
"range_hint": translation_hint(),
|
||
},
|
||
"owning": {
|
||
"label": "移动特征",
|
||
"action": "move_selected_axis_center_by_translation",
|
||
"target_attrs": ("translate_x_input", "translate_y_input", "translate_z_input"),
|
||
"enabled": bool(
|
||
current_axis_center is not None
|
||
and (self.selected_part_id is not None or self.selected_solid_id is not None)
|
||
),
|
||
"enabled_tip": (
|
||
"输入目标槽/半孔轴心 X, Y, Z 坐标;程序会平移所属特征或 Solid,"
|
||
"不会填旧槽再切新槽。"
|
||
),
|
||
"disabled_tip": "当前槽/半孔缺少稳定轴心或所属对象,不能按轴心平移所属对象。",
|
||
"range_hint": (
|
||
"这不是移动槽/半孔本身,而是整体搬动所属对象;"
|
||
f"{translation_hint()}"
|
||
),
|
||
"target_transform": "target_center_to_translation",
|
||
"transform_context": {"current_center": current_axis_center},
|
||
},
|
||
},
|
||
value_type="vector3",
|
||
used=("axis_point", "axis", "same_domain_v_range", "v_range"),
|
||
)
|
||
current_slot = _float_or_none(action_info.get("slot_chord_width_estimate"))
|
||
slot_metric_angle = (
|
||
_float_or_none(action_info.get("slot_angular_span"))
|
||
or _float_or_none(action_info.get("angular_span"))
|
||
)
|
||
slot_owning_scale_ready = bool(
|
||
current_diameter is not None
|
||
and current_diameter > 0
|
||
and slot_metric_angle is not None
|
||
and slot_metric_angle > 1e-6
|
||
and slot_metric_angle < math.tau * 0.92
|
||
and cylinder_scale_axis_point is not None
|
||
and cylinder_scale_axis_direction is not None
|
||
)
|
||
slot_owning_scale_hint = (
|
||
"这是整体缩放所属特征或 Solid,不是局部重切槽;槽宽、槽深、弧长以及同一对象上的其它尺寸都会按比例跟随变化。"
|
||
)
|
||
add_scoped_spec(
|
||
key="slot_chord_width_estimate",
|
||
label="槽宽",
|
||
current_raw=current_slot if current_slot is not None else "",
|
||
target_text=numeric_text(current_slot),
|
||
scope_default="local",
|
||
scope_modes={
|
||
"local": {
|
||
"label": "只改槽壁",
|
||
"action": "resize_slot_width",
|
||
"target_attr": "slot_width_input",
|
||
"enabled": current_slot is not None,
|
||
"enabled_tip": "输入槽或半孔的目标宽度;程序会局部重建槽壁。",
|
||
"disabled_tip": "当前对象不是稳定的槽/半孔候选。",
|
||
"range_hint": relative_range_hint(current_slot, 0.35, 1.0),
|
||
},
|
||
"owning": {
|
||
"label": "缩放特征",
|
||
"action": "resize_slot_width_owning_scale",
|
||
"target_attr": "slot_width_input",
|
||
"enabled": bool(current_slot is not None and slot_owning_scale_ready),
|
||
"enabled_tip": f"输入目标槽宽;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}",
|
||
"disabled_tip": "当前槽/半孔缺少稳定槽宽、圆弧角度、圆柱直径或缩放中心,不能按槽宽整体缩放所属对象。",
|
||
"range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot, 0.2, 0.5)}",
|
||
},
|
||
},
|
||
value_type="positive",
|
||
used=("slot_chord_width_estimate", "slot_angular_span", "angular_span"),
|
||
**positive_minimum(),
|
||
)
|
||
current_slot_depth = _float_or_none(action_info.get("slot_sagitta_depth_estimate"))
|
||
add_scoped_spec(
|
||
key="slot_sagitta_depth_estimate",
|
||
label="槽深",
|
||
current_raw=current_slot_depth if current_slot_depth is not None else "",
|
||
target_text=numeric_text(current_slot_depth),
|
||
scope_default="local",
|
||
scope_modes={
|
||
"local": {
|
||
"label": "只改槽壁",
|
||
"action": "resize_slot_depth",
|
||
"target_attr": "slot_depth_input",
|
||
"enabled": current_slot_depth is not None,
|
||
"enabled_tip": "输入槽或半孔的目标凹入深度;程序会局部重建槽壁。",
|
||
"disabled_tip": "当前对象没有稳定的槽/半孔深度估算。",
|
||
"range_hint": relative_range_hint(current_slot_depth, 0.35, 1.0),
|
||
},
|
||
"owning": {
|
||
"label": "缩放特征",
|
||
"action": "resize_slot_depth_owning_scale",
|
||
"target_attr": "slot_depth_input",
|
||
"enabled": bool(current_slot_depth is not None and slot_owning_scale_ready),
|
||
"enabled_tip": f"输入目标槽深;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}",
|
||
"disabled_tip": "当前槽/半孔缺少稳定槽深、圆弧角度、圆柱直径或缩放中心,不能按槽深整体缩放所属对象。",
|
||
"range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot_depth, 0.2, 0.5)}",
|
||
},
|
||
},
|
||
value_type="positive",
|
||
used=("slot_sagitta_depth_estimate", "slot_angular_span", "angular_span"),
|
||
**positive_minimum(),
|
||
)
|
||
current_slot_arc = _float_or_none(action_info.get("slot_arc_length_estimate"))
|
||
add_scoped_spec(
|
||
key="slot_arc_length_estimate",
|
||
label="弧长",
|
||
current_raw=current_slot_arc if current_slot_arc is not None else "",
|
||
target_text=numeric_text(current_slot_arc),
|
||
scope_default="local",
|
||
scope_modes={
|
||
"local": {
|
||
"label": "只改槽壁",
|
||
"action": "resize_slot_arc_length",
|
||
"target_attr": "slot_arc_length_input",
|
||
"enabled": current_slot_arc is not None,
|
||
"enabled_tip": "输入槽或半孔的目标圆弧长度;程序会局部重建槽壁。",
|
||
"disabled_tip": "当前对象没有稳定的槽/半孔圆弧长度估算。",
|
||
"range_hint": relative_range_hint(current_slot_arc, 0.35, 1.0),
|
||
},
|
||
"owning": {
|
||
"label": "缩放特征",
|
||
"action": "resize_slot_arc_length_owning_scale",
|
||
"target_attr": "slot_arc_length_input",
|
||
"enabled": bool(current_slot_arc is not None and slot_owning_scale_ready),
|
||
"enabled_tip": f"输入目标弧长;程序会先换算成目标圆柱直径,再整体缩放所属对象。{slot_owning_scale_hint}",
|
||
"disabled_tip": "当前槽/半孔缺少稳定弧长、圆弧角度、圆柱直径或缩放中心,不能按弧长整体缩放所属对象。",
|
||
"range_hint": f"{slot_owning_scale_hint} {relative_range_hint(current_slot_arc, 0.2, 0.5)}",
|
||
},
|
||
},
|
||
value_type="positive",
|
||
used=("slot_arc_length_estimate", "slot_angular_span", "angular_span"),
|
||
**positive_minimum(),
|
||
)
|
||
current_slot_angle = (
|
||
_float_or_none(action_info.get("slot_angular_span"))
|
||
or _float_or_none(action_info.get("angular_span"))
|
||
)
|
||
current_slot_angle_degrees = (
|
||
math.degrees(current_slot_angle) if current_slot_angle is not None else None
|
||
)
|
||
add_spec(
|
||
key="slot_angular_span_degrees",
|
||
label="弧角(度)",
|
||
current_raw=current_slot_angle_degrees if current_slot_angle_degrees is not None else "",
|
||
target_text=numeric_text(current_slot_angle_degrees),
|
||
action="resize_slot_angular_span",
|
||
target_attr="slot_angular_span_input",
|
||
enabled=current_slot_angle is not None,
|
||
enabled_tip="输入槽或半孔的目标圆弧角度;程序会保持当前圆柱半径并重建局部扇形槽。",
|
||
disabled_tip="当前对象没有稳定的槽/半孔圆弧角度估算。",
|
||
value_type="positive",
|
||
range_hint="建议先小幅修改;当前版本允许大于 0 且小于约 331 度的局部圆柱槽角度。",
|
||
max_value=math.degrees(math.tau * 0.92),
|
||
max_exclusive=True,
|
||
target_transform="degrees_to_radians",
|
||
used=("slot_angular_span", "angular_span"),
|
||
**positive_minimum(),
|
||
)
|
||
current_slot_open_angle_degrees = (
|
||
math.degrees(max(math.tau - current_slot_angle, 0.0))
|
||
if current_slot_angle is not None
|
||
else None
|
||
)
|
||
add_spec(
|
||
key="slot_open_angle_degrees",
|
||
label="开口角(度)",
|
||
current_raw=current_slot_open_angle_degrees if current_slot_open_angle_degrees is not None else "",
|
||
target_text=numeric_text(current_slot_open_angle_degrees),
|
||
action="resize_slot_angular_span",
|
||
target_attr="slot_angular_span_input",
|
||
enabled=current_slot_angle is not None,
|
||
enabled_tip="输入槽或半孔的目标开口角度;程序会换算成圆弧角度后重建局部扇形槽。",
|
||
disabled_tip="当前对象没有稳定的槽/半孔开口角度估算。",
|
||
value_type="positive",
|
||
range_hint="开口角越小,槽越接近完整圆柱;当前版本要求开口角大于约 29 度且小于 360 度。",
|
||
min_value=math.degrees(math.tau * 0.08),
|
||
min_exclusive=True,
|
||
max_value=360.0,
|
||
max_exclusive=True,
|
||
target_transform="slot_open_angle_degrees_to_angular_span",
|
||
used=("slot_open_angle",),
|
||
)
|
||
manual_slot_pair_text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else ""
|
||
current_diameter_for_slot = _float_or_none(action_info.get("diameter"))
|
||
slot_pair_target_text = manual_slot_pair_text
|
||
add_spec(
|
||
key="slot_pair_face_id",
|
||
label="配对端 Face",
|
||
current_raw=slot_pair_target_text,
|
||
target_text=slot_pair_target_text,
|
||
action="set_manual_slot_pair_face",
|
||
target_attr="slot_pair_face_input",
|
||
enabled=True,
|
||
enabled_tip="设置长圆槽/槽孔另一个半圆端的 Face ID;自动配对不准时,先填这里再修改槽孔参数。",
|
||
disabled_tip="当前对象不是槽/半孔候选,不能设置槽孔配对端。",
|
||
value_type="integer_or_empty",
|
||
range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在、且不是当前槽端自己的 Face ID。",
|
||
min_value=0.0,
|
||
button_text="设置",
|
||
)
|
||
add_spec(
|
||
key="slot_total_length_estimate",
|
||
label="总长度",
|
||
current_raw="",
|
||
current_text="执行时自动识别",
|
||
target_text="",
|
||
action="resize_slot_total_length",
|
||
target_attr="slot_total_length_input",
|
||
enabled=current_diameter_for_slot is not None,
|
||
enabled_tip="输入长圆槽/槽孔的目标总长度;程序会在点击修改时自动识别另一个半圆槽端,上一行 Face ID 只用于自动识别不准时手动指定。",
|
||
disabled_tip="当前槽/半孔缺少稳定宽度/直径,不能按槽孔总长度修改。",
|
||
value_type="positive",
|
||
range_hint="目标总长度必须大于当前槽宽/直径;点击修改时才会识别配对端,避免选中对象时卡住界面。",
|
||
**positive_minimum(),
|
||
)
|
||
add_spec(
|
||
key="slot_center_distance_estimate",
|
||
label="中心距",
|
||
current_raw="",
|
||
current_text="执行时自动识别",
|
||
target_text="",
|
||
action="resize_slot_center_distance",
|
||
target_attr="slot_center_distance_input",
|
||
enabled=current_diameter_for_slot is not None,
|
||
enabled_tip="输入长圆槽两端半圆中心距;程序会保持槽宽不变,并在执行时自动识别配对端。",
|
||
disabled_tip="当前槽/半孔缺少稳定宽度/直径,不能按中心距修改。",
|
||
value_type="positive",
|
||
range_hint="目标中心距必须大于 0;目标总长度会等于目标中心距加当前槽宽/直径。自动配对失败时,可先在上一行手动填写配对端 Face ID。",
|
||
**positive_minimum(),
|
||
)
|
||
bottom_face_ids = _int_values(action_info.get("feature_bottom_face_ids"))
|
||
auto_bottom_face_id = bottom_face_ids[0] if bottom_face_ids else None
|
||
manual_bottom_text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else ""
|
||
manual_bottom_id = None
|
||
if manual_bottom_text:
|
||
try:
|
||
manual_bottom_id = int(manual_bottom_text)
|
||
except ValueError:
|
||
manual_bottom_id = None
|
||
bottom_target_text = manual_bottom_text or (str(auto_bottom_face_id) if auto_bottom_face_id is not None else "")
|
||
add_spec(
|
||
key="hole_bottom_face_id",
|
||
label="底面 Face",
|
||
current_raw=bottom_target_text,
|
||
target_text=bottom_target_text,
|
||
action="set_manual_hole_bottom_face",
|
||
target_attr="hole_bottom_face_input",
|
||
enabled=True,
|
||
enabled_tip="设置盲孔/盲槽的底面 Face ID;自动识别不准时,先填这里再修改深度。",
|
||
disabled_tip="当前对象不是孔/槽候选,不能设置底面 Face ID。",
|
||
value_type="integer_or_empty",
|
||
range_hint="留空表示使用自动识别结果;填写时必须是当前模型里存在的 Face ID。",
|
||
min_value=0.0,
|
||
button_text="设置",
|
||
)
|
||
current_depth = _float_or_none(action_info.get("hole_depth_estimate"))
|
||
if self.model is not None and self.selected_face_id is not None and manual_bottom_id is not None:
|
||
probe_depth = current_depth if current_depth is not None and current_depth > 0 else 1.0
|
||
try:
|
||
depth_plan = self.model.cylindrical_depth_plan(
|
||
self.selected_face_id,
|
||
probe_depth,
|
||
bottom_face_id=manual_bottom_id,
|
||
)
|
||
manual_depth = _float_or_none(depth_plan.get("current_depth"))
|
||
if manual_depth is not None and manual_depth > 0:
|
||
current_depth = manual_depth
|
||
except Exception:
|
||
pass
|
||
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 not hasattr(self, "property_table"):
|
||
return
|
||
if has_model is None:
|
||
has_model = self.model is not None and not (
|
||
self.operation_in_progress or self.scan_in_progress or self.load_in_progress
|
||
)
|
||
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
|
||
effective_spec = self._effective_property_spec(spec, row=row)
|
||
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()
|
||
enabled = bool(has_model and changed)
|
||
disabled_tip = "请先选择对象,并在属性表中修改一个可编辑目标值。"
|
||
if changed and len(changed) > 1:
|
||
disabled_tip = "当前一次只执行一个几何修改;请只保留一行目标值不同,再点击参数化建模。"
|
||
self._set_control_state(
|
||
self.apply_property_button,
|
||
enabled and len(changed) == 1,
|
||
"应用当前被修改的参数。",
|
||
disabled_tip,
|
||
)
|
||
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 _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。",
|
||
disabled_tip,
|
||
)
|
||
|
||
def _changed_property_rows(self) -> list[tuple[int, dict[str, object], str]]:
|
||
if not hasattr(self, "property_table"):
|
||
return []
|
||
changed: list[tuple[int, dict[str, object], str]] = []
|
||
for row, spec in enumerate(getattr(self, "property_editor_specs", [])):
|
||
effective_spec = self._effective_property_spec(spec, row=row)
|
||
if not effective_spec.get("action") or not effective_spec.get("enabled"):
|
||
continue
|
||
if str(effective_spec.get("value_type", "number")) == "command":
|
||
continue
|
||
if effective_spec.get("action") in {
|
||
"set_manual_hole_bottom_face",
|
||
"set_manual_slot_pair_face",
|
||
"set_edge_length_anchor_mode",
|
||
"set_chamfer_reference_face",
|
||
"set_rotate_axis",
|
||
}:
|
||
continue
|
||
text = self._property_target_text(row)
|
||
if self._property_target_changed(effective_spec, text) and not self._property_target_validation_error(effective_spec, text):
|
||
changed.append((row, effective_spec, text))
|
||
return changed
|
||
|
||
def _property_target_text(self, row: int) -> str:
|
||
if not hasattr(self, "property_table"):
|
||
return ""
|
||
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:
|
||
changed = self._changed_property_rows()
|
||
if not changed:
|
||
self.statusBar().showMessage("请先在当前选中对象表中修改一个可编辑目标值。")
|
||
return
|
||
if len(changed) > 1:
|
||
QMessageBox.information(self, "一次执行一个修改", "请只修改一行目标值,然后再点击参数化建模。")
|
||
return
|
||
row, _spec, _text = changed[0]
|
||
self.apply_property_row_edit(row)
|
||
|
||
def _activate_property_source_feature(self, spec: dict[str, object]) -> None:
|
||
source_face_id = _int_or_none(spec.get("source_face_id"))
|
||
if source_face_id is None or source_face_id == self.selected_face_id:
|
||
return
|
||
if self.model is None or source_face_id < 0 or source_face_id >= len(self.model.faces):
|
||
raise ValueError("关联特征已经失效,请重新选择模型对象。")
|
||
info = spec.get("source_feature_info")
|
||
if not isinstance(info, dict):
|
||
info = self._feature_context_info(source_face_id)
|
||
else:
|
||
info = dict(info)
|
||
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
|
||
try:
|
||
self._activate_property_source_feature(spec)
|
||
if not is_command:
|
||
self._sync_property_edit_target(spec, text)
|
||
self._sync_property_preselects(spec)
|
||
except ValueError as exc:
|
||
QMessageBox.information(self, "目标值无效", str(exc))
|
||
return
|
||
action_name = str(spec.get("action", ""))
|
||
action = getattr(self, action_name, None)
|
||
if action is None:
|
||
QMessageBox.information(self, "暂不支持", f"当前属性没有可用的执行入口:{action_name}")
|
||
return
|
||
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 set_manual_hole_bottom_face(self) -> None:
|
||
self._update_action_states()
|
||
self._refresh_property_editor()
|
||
text = self.hole_bottom_face_input.text().strip() if hasattr(self, "hole_bottom_face_input") else ""
|
||
if text:
|
||
self.statusBar().showMessage(f"已设置盲孔/盲槽底面 Face ID:{text},现在可以继续修改深度。")
|
||
else:
|
||
self.statusBar().showMessage("已清除手动底面 Face ID,将使用自动识别结果。")
|
||
|
||
def set_manual_slot_pair_face(self) -> None:
|
||
self._update_action_states()
|
||
self._refresh_property_editor()
|
||
text = self.slot_pair_face_input.text().strip() if hasattr(self, "slot_pair_face_input") else ""
|
||
if text:
|
||
self.statusBar().showMessage(f"已设置槽孔配对端 Face ID:{text},后续槽孔修改会优先使用这个配对端。")
|
||
else:
|
||
self.statusBar().showMessage("已清除手动槽孔配对端 Face ID,将使用自动识别结果。")
|
||
|
||
def set_edge_length_anchor_mode(self) -> None:
|
||
self._update_action_states()
|
||
self._refresh_property_editor()
|
||
label = self.edge_length_anchor_combo.currentText() if hasattr(self, "edge_length_anchor_combo") else ""
|
||
self.statusBar().showMessage(f"已设置长度基准:{label or '自动'}。")
|
||
|
||
def set_edge_length_strategy_mode(self) -> None:
|
||
self._update_action_states()
|
||
self._refresh_property_editor()
|
||
label = self.edge_length_strategy_combo.currentText() if hasattr(self, "edge_length_strategy_combo") else ""
|
||
self.statusBar().showMessage(f"已设置建模意图:{label or '自动选择'}。")
|
||
|
||
def set_chamfer_reference_face(self) -> None:
|
||
self._update_action_states()
|
||
self._refresh_property_editor()
|
||
text = (
|
||
self.edge_chamfer_reference_face_input.text().strip()
|
||
if hasattr(self, "edge_chamfer_reference_face_input")
|
||
else ""
|
||
)
|
||
if text:
|
||
self.statusBar().showMessage(f"已设置不等距倒角参考 Face ID:{text}。")
|
||
else:
|
||
self.statusBar().showMessage("已清除不等距倒角参考 Face ID,将使用当前Edge的第一个相邻Face。")
|
||
|
||
def set_rotate_axis(self) -> None:
|
||
self._update_action_states()
|
||
self._refresh_property_editor()
|
||
axis = self.rotate_axis_combo.currentText() if hasattr(self, "rotate_axis_combo") else "Z"
|
||
self.statusBar().showMessage(f"已设置旋转轴:{axis}。")
|
||
|
||
def _sync_property_preselects(self, spec: dict[str, object]) -> None:
|
||
preselects = spec.get("preselect_combos")
|
||
if not isinstance(preselects, (list, tuple)):
|
||
return
|
||
for item in preselects:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
target_attr = str(item.get("target_attr") or "")
|
||
value = item.get("value")
|
||
if not target_attr:
|
||
continue
|
||
widget = getattr(self, target_attr, None)
|
||
if widget is None or not hasattr(widget, "setCurrentIndex"):
|
||
raise ValueError(f"这个属性缺少可同步的选项控件:{target_attr}")
|
||
index = widget.findData(value) if hasattr(widget, "findData") else -1
|
||
if index < 0 and hasattr(widget, "findText"):
|
||
index = widget.findText(str(value))
|
||
if index < 0:
|
||
raise ValueError(f"找不到可用选项:{value}")
|
||
widget.setCurrentIndex(index)
|
||
|
||
def _sync_property_edit_target(self, spec: dict[str, object], text: str) -> None:
|
||
value_type = str(spec.get("value_type", "number"))
|
||
if value_type == "command":
|
||
return
|
||
if value_type == "vector3":
|
||
values = self._parse_property_vector3(text)
|
||
values = self._transform_property_vector3_target(spec, values)
|
||
target_attrs = tuple(spec.get("target_attrs") or ())
|
||
if len(target_attrs) != 3:
|
||
raise ValueError("这个属性缺少 X/Y/Z 输入绑定。")
|
||
for attr, value in zip(target_attrs, values):
|
||
getattr(self, str(attr)).setText(_format_float(value))
|
||
return
|
||
if value_type == "choice":
|
||
value = self._property_choice_value(spec, text)
|
||
target_attr = spec.get("target_attr")
|
||
if not target_attr:
|
||
raise ValueError("这个属性缺少目标输入绑定。")
|
||
widget = getattr(self, str(target_attr))
|
||
index = widget.findData(value) if hasattr(widget, "findData") else -1
|
||
if index < 0 and hasattr(widget, "findText"):
|
||
index = widget.findText(str(text))
|
||
if index < 0:
|
||
raise ValueError(f"找不到可用选项:{text}")
|
||
widget.setCurrentIndex(index)
|
||
return
|
||
if value_type == "number_pair":
|
||
values = self._parse_property_number_pair(text)
|
||
if spec.get("positive_pair") and any(value <= 0 for value in values):
|
||
raise ValueError("请输入两个大于 0 的目标值。")
|
||
for value in values:
|
||
range_error = self._property_scalar_range_error(spec, value)
|
||
if range_error:
|
||
raise ValueError(range_error)
|
||
target_attrs = tuple(spec.get("target_attrs") or ())
|
||
if len(target_attrs) != 2:
|
||
raise ValueError("这个属性缺少两个目标输入绑定。")
|
||
for attr, value in zip(target_attrs, values):
|
||
getattr(self, str(attr)).setText(_format_float(value))
|
||
return
|
||
if value_type in {"integer", "integer_or_empty"}:
|
||
target_attr = spec.get("target_attr")
|
||
if not target_attr:
|
||
raise ValueError("这个属性缺少目标输入绑定。")
|
||
if not text and value_type == "integer_or_empty":
|
||
getattr(self, str(target_attr)).setText("")
|
||
return
|
||
try:
|
||
value = int(text)
|
||
except ValueError as exc:
|
||
raise ValueError("请输入整数形式的 Face ID。") from exc
|
||
range_error = self._property_scalar_range_error(spec, float(value))
|
||
if range_error:
|
||
raise ValueError(range_error)
|
||
key = str(spec.get("key", ""))
|
||
if self.model is not None and key in {"hole_bottom_face_id", "slot_pair_face_id", "chamfer_reference_face_id"}:
|
||
if value < 0 or value >= len(self.model.faces):
|
||
label = {
|
||
"hole_bottom_face_id": "底面",
|
||
"slot_pair_face_id": "槽孔配对端",
|
||
"chamfer_reference_face_id": "倒角参考",
|
||
}.get(key, "Face")
|
||
raise ValueError(f"{label} Face ID {value} 不存在。")
|
||
if key == "slot_pair_face_id" and self.selected_face_id is not None and value == self.selected_face_id:
|
||
raise ValueError("槽孔配对端不能和当前槽端使用同一个 Face ID。")
|
||
if key == "chamfer_reference_face_id" and self.selected_edge_id is not None:
|
||
try:
|
||
adjacent_face_ids = _int_values(self.model.edge_info(self.selected_edge_id).get("adjacent_face_ids"))
|
||
except Exception:
|
||
adjacent_face_ids = []
|
||
if adjacent_face_ids and value not in adjacent_face_ids:
|
||
raise ValueError(f"倒角参考 Face ID 必须是当前 Edge 的相邻 Face,可用值:{tuple(adjacent_face_ids)}。")
|
||
getattr(self, str(target_attr)).setText(str(value))
|
||
return
|
||
try:
|
||
value = float(text)
|
||
except ValueError as exc:
|
||
raise ValueError("请输入数字形式的目标值。") from exc
|
||
if value_type == "positive" and value <= 0:
|
||
raise ValueError("请输入大于 0 的目标值。")
|
||
range_error = self._property_scalar_range_error(spec, value)
|
||
if range_error:
|
||
raise ValueError(range_error)
|
||
value = self._transform_property_scalar_target(spec, value)
|
||
target_attr = spec.get("target_attr")
|
||
if not target_attr:
|
||
raise ValueError("这个属性缺少目标输入绑定。")
|
||
getattr(self, str(target_attr)).setText(_format_float(value))
|
||
|
||
def _transform_property_scalar_target(self, spec: dict[str, object], value: float) -> float:
|
||
transform = str(spec.get("target_transform") or "")
|
||
if not transform:
|
||
return value
|
||
context = spec.get("transform_context")
|
||
if not isinstance(context, dict):
|
||
context = {}
|
||
if transform == "radius_to_diameter":
|
||
if value <= 0:
|
||
raise ValueError("目标半径必须大于 0。")
|
||
return value * 2.0
|
||
if transform == "diameter_to_radius":
|
||
if value <= 0:
|
||
raise ValueError("目标直径必须大于 0。")
|
||
return value * 0.5
|
||
if transform == "plane_target_position_to_offset":
|
||
current_position = _float_or_none(context.get("current_plane_position"))
|
||
if current_position is None:
|
||
raise ValueError("当前平面缺少稳定法向位置,不能换算拉伸/切除距离。")
|
||
return value - current_position
|
||
if transform == "degrees_to_radians":
|
||
if value <= 0:
|
||
raise ValueError("目标角度必须大于 0。")
|
||
return math.radians(value)
|
||
if transform == "slot_open_angle_degrees_to_angular_span":
|
||
if value <= 0 or value >= 360.0:
|
||
raise ValueError("槽/半孔开口角度必须大于 0 且小于 360 度。")
|
||
target_span = math.tau - math.radians(value)
|
||
if target_span <= 1e-6 or target_span >= math.tau * 0.92:
|
||
raise ValueError("换算后的槽/半孔圆弧角度必须大于 0 且小于接近完整圆柱的范围。")
|
||
return target_span
|
||
if transform == "cone_semi_angle_degrees_to_reference_radius":
|
||
current_radius = _float_or_none(context.get("current_reference_radius"))
|
||
current_angle = _float_or_none(context.get("current_semi_angle"))
|
||
if current_radius is None or current_radius <= 0 or current_angle is None:
|
||
raise ValueError("当前圆锥面缺少稳定参考半径或半角,不能换算目标半角。")
|
||
if value <= 0 or value >= 89.0:
|
||
raise ValueError("圆锥目标半角必须大于 0 且小于 89 度。")
|
||
current_tangent = abs(math.tan(current_angle))
|
||
target_tangent = math.tan(math.radians(value))
|
||
if current_tangent <= 1e-9 or target_tangent <= 1e-9:
|
||
raise ValueError("圆锥半角过小,不能稳定换算参考半径。")
|
||
return current_radius * target_tangent / current_tangent
|
||
if transform in {"arc_length_to_radius", "arc_length_to_diameter"}:
|
||
angular_span = _float_or_none(context.get("angular_span"))
|
||
if angular_span is None or angular_span <= 1e-6:
|
||
raise ValueError("当前对象缺少稳定圆弧角度,不能把圆弧长度换算为半径。")
|
||
radius = value / angular_span
|
||
return radius * 2.0 if transform == "arc_length_to_diameter" else radius
|
||
if transform == "slot_center_distance_to_total_length":
|
||
diameter = _float_or_none(context.get("slot_current_diameter"))
|
||
if diameter is None or diameter <= 0:
|
||
raise ValueError("当前槽孔缺少稳定宽度/直径,不能把中心距换算为总长度。")
|
||
if value <= 0:
|
||
raise ValueError("槽孔中心距必须大于 0。")
|
||
return value + diameter
|
||
if transform == "volume_to_bbox_diagonal":
|
||
current_volume = _float_or_none(context.get("current_volume"))
|
||
current_diagonal = _float_or_none(context.get("current_bbox_diagonal"))
|
||
if current_volume is None or current_volume <= 0 or current_diagonal is None or current_diagonal <= 0:
|
||
raise ValueError("当前对象缺少稳定体积或整体尺寸,不能换算目标体积。")
|
||
if value <= 0:
|
||
raise ValueError("目标体积必须大于 0。")
|
||
return current_diagonal * ((value / current_volume) ** (1.0 / 3.0))
|
||
if transform == "surface_area_to_bbox_diagonal":
|
||
current_surface_area = _float_or_none(context.get("current_surface_area"))
|
||
current_diagonal = _float_or_none(context.get("current_bbox_diagonal"))
|
||
if current_surface_area is None or current_surface_area <= 0 or current_diagonal is None or current_diagonal <= 0:
|
||
raise ValueError("当前对象缺少稳定表面积或整体尺寸,不能换算目标表面积。")
|
||
if value <= 0:
|
||
raise ValueError("目标表面积必须大于 0。")
|
||
return current_diagonal * math.sqrt(value / current_surface_area)
|
||
if transform in {"circle_edge_radius_to_length", "circle_edge_diameter_to_length"}:
|
||
current_length = _float_or_none(context.get("edge_current_length"))
|
||
current_radius = _float_or_none(context.get("edge_current_radius"))
|
||
if current_length is None or current_length <= 0 or current_radius is None or current_radius <= 0:
|
||
raise ValueError("当前圆Edge缺少稳定的长度或半径,不能换算目标长度。")
|
||
target_radius = value * 0.5 if transform == "circle_edge_diameter_to_length" else value
|
||
if target_radius <= 0:
|
||
raise ValueError("圆Edge目标半径必须大于 0。")
|
||
return current_length * target_radius / current_radius
|
||
raise ValueError(f"这个属性使用了未知的目标换算方式:{transform}")
|
||
|
||
def _transform_property_vector3_target(
|
||
self,
|
||
spec: dict[str, object],
|
||
values: tuple[float, float, float],
|
||
) -> tuple[float, float, float]:
|
||
transform = str(spec.get("target_transform") or "")
|
||
if not transform:
|
||
return values
|
||
context = spec.get("transform_context")
|
||
if not isinstance(context, dict):
|
||
context = {}
|
||
if transform == "target_center_to_translation":
|
||
current_center = _triple_or_none(context.get("current_center")) or _triple_or_none(spec.get("current_raw"))
|
||
if current_center is None:
|
||
raise ValueError("当前对象缺少稳定中心坐标,不能换算平移量。")
|
||
return (
|
||
values[0] - current_center[0],
|
||
values[1] - current_center[1],
|
||
values[2] - current_center[2],
|
||
)
|
||
raise ValueError(f"这个属性使用了未知的向量换算方式:{transform}")
|
||
|
||
def _property_choice_value(self, spec: dict[str, object], text: str) -> object:
|
||
choices = spec.get("choices")
|
||
if not isinstance(choices, dict) or not choices:
|
||
raise ValueError("这个属性缺少可用选项。")
|
||
normalized = str(text).strip()
|
||
if not normalized:
|
||
raise ValueError("请输入一个选项。")
|
||
if normalized in choices:
|
||
return choices[normalized]
|
||
lowered = normalized.lower()
|
||
for label, value in choices.items():
|
||
if str(label).strip().lower() == lowered:
|
||
return value
|
||
raise ValueError(f"不支持的选项:{text}")
|
||
|
||
def _property_scalar_range_error(self, spec: dict[str, object], value: float) -> str:
|
||
label = str(spec.get("label", "目标值"))
|
||
min_value = _float_or_none(spec.get("min_value"))
|
||
max_value = _float_or_none(spec.get("max_value"))
|
||
if min_value is not None:
|
||
min_exclusive = bool(spec.get("min_exclusive"))
|
||
if (min_exclusive and value <= min_value) or (not min_exclusive and value < min_value):
|
||
operator = "大于" if min_exclusive else "大于或等于"
|
||
return f"{label} 必须{operator} {_format_float(min_value)}。"
|
||
if max_value is not None:
|
||
max_exclusive = bool(spec.get("max_exclusive"))
|
||
if (max_exclusive and value >= max_value) or (not max_exclusive and value > max_value):
|
||
operator = "小于" if max_exclusive else "小于或等于"
|
||
return f"{label} 必须{operator} {_format_float(max_value)}。"
|
||
return ""
|
||
|
||
def _parse_property_number_pair(self, text: str) -> tuple[float, float]:
|
||
text = text.strip().strip("()[]")
|
||
chunks = text.replace(";", ",").replace(";", ",").replace(",", ",").replace(" ", ",").split(",")
|
||
values = [chunk for chunk in chunks if chunk.strip()]
|
||
if len(values) != 2:
|
||
raise ValueError("请输入两个数字,例如 1, 2。")
|
||
try:
|
||
return (float(values[0]), float(values[1]))
|
||
except ValueError as exc:
|
||
raise ValueError("这两个目标值都必须是数字。") from exc
|
||
|
||
def _parse_property_vector3(self, text: str) -> tuple[float, float, float]:
|
||
text = text.strip().strip("()[]")
|
||
chunks = text.replace(",", ",").replace(";", ",").replace(";", ",").replace(" ", ",").split(",")
|
||
values = [chunk for chunk in chunks if chunk.strip()]
|
||
if len(values) != 3:
|
||
raise ValueError("请输入三个数字,例如 10, 0, 0。")
|
||
try:
|
||
return (float(values[0]), float(values[1]), float(values[2]))
|
||
except ValueError as exc:
|
||
raise ValueError("平移 X/Y/Z 必须都是数字。") from exc
|
||
|
||
def _selected_action_info(self) -> dict[str, object]:
|
||
if self.model is None:
|
||
return {}
|
||
cache_key = (
|
||
id(self.model),
|
||
self.selected_kind,
|
||
self.selected_part_id,
|
||
self.selected_solid_id,
|
||
self.selected_face_id,
|
||
self.selected_edge_id,
|
||
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 {})
|
||
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:
|
||
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.model.face_region_ids(resolved_face_id)
|
||
)
|
||
if not face_ids:
|
||
face_ids = self.model.face_region_ids(resolved_face_id)
|
||
self._highlight_faces(face_ids=face_ids)
|
||
self.set_info(self._with_pick_info(info, record.pick_position))
|
||
located = True
|
||
region_note = f";同域面区域 {len(face_ids)} 个Face" if len(face_ids) > 1 else ""
|
||
id_note = (
|
||
f"逻辑 Face ID {current_logical_id}"
|
||
if record.target_logical_id is not None
|
||
else f"Face {record.target_id}"
|
||
)
|
||
if resolved_face_id != record.target_id:
|
||
id_note += f"(当前拓扑Face {resolved_face_id})"
|
||
locator_note = (
|
||
f"定位: 已尝试高亮当前模型中的 {id_note}{region_note}。"
|
||
"布尔编辑后Face ID可能发生语义变化,请结合拾取点确认。"
|
||
)
|
||
else:
|
||
if record.target_logical_id is not None:
|
||
locator_note = f"定位: 原目标逻辑 Face ID {record.target_logical_id} 在当前模型索引中已经不存在。"
|
||
else:
|
||
locator_note = f"定位: 原目标Face {record.target_id} 在当前模型索引中已经不存在。"
|
||
|
||
elif record.target_kind == "edge" and record.target_id is not None:
|
||
resolved_edge_id = self._resolve_record_edge_id(record)
|
||
if resolved_edge_id is not None:
|
||
info = self.model.edge_info(resolved_edge_id)
|
||
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 _resolve_record_face_id(self, record: OperationRecord) -> int | None:
|
||
if self.model is None:
|
||
return None
|
||
verified_face_id = self._record_verified_face_id(record)
|
||
if verified_face_id is not None:
|
||
return verified_face_id
|
||
|
||
moved_face_id = self._record_moved_face_id(record)
|
||
if moved_face_id is not None:
|
||
return moved_face_id
|
||
|
||
plane_position_face_id = self._record_plane_position_face_id(record)
|
||
if plane_position_face_id is not None:
|
||
return plane_position_face_id
|
||
|
||
if not self._record_face_count_changed(record):
|
||
stable_face_id = self._record_stable_face_id(record)
|
||
if stable_face_id is not None:
|
||
return stable_face_id
|
||
|
||
nearest_face_id = self._record_nearest_semantic_face_id(record)
|
||
if nearest_face_id is not None:
|
||
return nearest_face_id
|
||
|
||
stable_face_id = self._record_stable_face_id(record)
|
||
if stable_face_id is not None:
|
||
return stable_face_id
|
||
|
||
return None
|
||
|
||
def _record_verified_face_id(self, record: OperationRecord) -> int | None:
|
||
if self.model is None:
|
||
return None
|
||
value = _record_message_field(record.result_message, "verified_face")
|
||
if value is None:
|
||
return None
|
||
try:
|
||
face_id = int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if not (0 <= face_id < len(self.model.faces)):
|
||
return None
|
||
return face_id if self._record_face_semantic_matches(record, face_id) else None
|
||
|
||
def _record_moved_face_id(self, record: OperationRecord) -> int | None:
|
||
if self.model is None or record.pick_position is None:
|
||
return None
|
||
moved_point = self._record_moved_pick_position(record)
|
||
if moved_point is None:
|
||
return None
|
||
candidates = self._record_candidate_face_ids(record)
|
||
if not candidates:
|
||
return None
|
||
nearest = self.model.nearest_face_id_to_point(candidates, moved_point)
|
||
if nearest is None:
|
||
return None
|
||
return nearest if self._record_face_semantic_matches(record, nearest) else None
|
||
|
||
def _record_moved_pick_position(self, record: OperationRecord) -> tuple[float, float, float] | None:
|
||
parameters = record.parameters if isinstance(record.parameters, dict) else {}
|
||
distance = (
|
||
self._record_float_parameter(record, "semantic_distance")
|
||
if parameters.get("semantic_distance") not in {"", None}
|
||
else self._record_float_parameter(record, "push_pull_distance")
|
||
)
|
||
direction = (
|
||
_triple_or_none(parameters.get("outward_direction"))
|
||
or _triple_or_none(parameters.get("push_pull_outward_direction"))
|
||
or _triple_or_none(parameters.get("shell_desired_movement_vector"))
|
||
)
|
||
if distance is None or direction is None:
|
||
return None
|
||
length = math.sqrt(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
|
||
if length <= 1e-12:
|
||
return None
|
||
unit = (direction[0] / length, direction[1] / length, direction[2] / length)
|
||
px, py, pz = record.pick_position
|
||
return (
|
||
float(px) + unit[0] * float(distance),
|
||
float(py) + unit[1] * float(distance),
|
||
float(pz) + unit[2] * float(distance),
|
||
)
|
||
|
||
def _record_nearest_semantic_face_id(self, record: OperationRecord) -> int | None:
|
||
if self.model is None or record.pick_position is None:
|
||
return None
|
||
candidates = self._record_candidate_face_ids(record)
|
||
if not candidates:
|
||
return None
|
||
nearest = self.model.nearest_face_id_to_point(candidates, record.pick_position)
|
||
if nearest is None:
|
||
return None
|
||
return nearest if self._record_face_semantic_matches(record, nearest) else None
|
||
|
||
def _record_stable_face_id(self, record: OperationRecord) -> int | None:
|
||
if self.model is None:
|
||
return None
|
||
if record.target_logical_id is not None:
|
||
try:
|
||
logical_matches = self.model.face_ids_for_logical_id(int(record.target_logical_id))
|
||
except Exception:
|
||
logical_matches = []
|
||
logical_matches = [
|
||
face_id for face_id in logical_matches if self._record_face_semantic_matches(record, face_id)
|
||
]
|
||
if logical_matches:
|
||
return self.model.nearest_face_id_to_point(logical_matches, record.pick_position)
|
||
|
||
if record.target_id is None:
|
||
return None
|
||
try:
|
||
face_id = int(record.target_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if 0 <= face_id < len(self.model.faces) and self._record_face_semantic_matches(record, face_id):
|
||
return face_id
|
||
return None
|
||
|
||
def _record_face_count_changed(self, record: OperationRecord) -> bool:
|
||
before = record.before_snapshot if isinstance(record.before_snapshot, dict) else {}
|
||
after = record.after_snapshot if isinstance(record.after_snapshot, dict) else {}
|
||
before_ids = before.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
|
||
after_ids = after.get(SNAPSHOT_FACE_LOGICAL_IDS_KEY)
|
||
if isinstance(before_ids, (list, tuple)) and isinstance(after_ids, (list, tuple)):
|
||
return len(before_ids) != len(after_ids)
|
||
return True
|
||
|
||
def _record_face_semantic_matches(self, record: OperationRecord, face_id: int) -> bool:
|
||
if self.model is None or not (0 <= int(face_id) < len(self.model.faces)):
|
||
return False
|
||
part_id = self._record_int_parameter(record, "part_id")
|
||
if part_id is not None and self.model.face_part_ids[int(face_id)] != part_id:
|
||
return False
|
||
surface_hint = self._record_surface_hint(record)
|
||
surface_kind = self.model.face_surface_kind(int(face_id))
|
||
if surface_hint and surface_kind != surface_hint:
|
||
return False
|
||
target_diameter = self._record_target_diameter(record)
|
||
if target_diameter is not None:
|
||
current_diameter = self.model.face_cylinder_diameter(int(face_id))
|
||
if current_diameter is None:
|
||
return False
|
||
diameter_tolerance = max(abs(target_diameter) * 0.02, 1e-5)
|
||
if abs(current_diameter - target_diameter) > diameter_tolerance:
|
||
return False
|
||
return True
|
||
|
||
def _record_surface_hint(self, record: OperationRecord) -> str:
|
||
parameters = record.parameters if isinstance(record.parameters, dict) else {}
|
||
surface = str(parameters.get("surface", "") or "")
|
||
if surface in {
|
||
"plane",
|
||
"cylinder",
|
||
"cone",
|
||
"sphere",
|
||
"torus",
|
||
*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
|
||
try:
|
||
info = self.model.face_info(int(face_id))
|
||
except Exception:
|
||
continue
|
||
if info.get("surface") != "plane":
|
||
continue
|
||
normal = _triple_or_none(info.get("normal")) or _triple_or_none(info.get("oriented_normal"))
|
||
if normal is not None and abs(normal[0] * unit[0] + normal[1] * unit[1] + normal[2] * unit[2]) < 0.85:
|
||
continue
|
||
origin = _triple_or_none(info.get("plane_origin"))
|
||
if origin is None:
|
||
continue
|
||
position = origin[0] * unit[0] + origin[1] * unit[1] + origin[2] * unit[2]
|
||
error = abs(position - target_position)
|
||
if error < best_error:
|
||
best_error = error
|
||
best_face_id = int(face_id)
|
||
if best_face_id is not None and best_error <= tolerance:
|
||
return best_face_id
|
||
return None
|
||
|
||
def _record_target_diameter(self, record: OperationRecord) -> float | None:
|
||
return (
|
||
self._record_float_parameter(record, "new_diameter")
|
||
or self._record_float_parameter(record, "derived_new_diameter")
|
||
or self._record_float_parameter(record, "target_diameter")
|
||
or self._record_float_parameter(record, "diameter")
|
||
)
|
||
|
||
def _resolve_record_edge_id(self, record: OperationRecord) -> int | None:
|
||
if self.model is None:
|
||
return None
|
||
if record.pick_position is not None:
|
||
nearest = self.model.nearest_edge_id_to_point(self._record_candidate_edge_ids(record), record.pick_position)
|
||
if nearest is not None:
|
||
return nearest
|
||
if record.target_id is None:
|
||
return None
|
||
try:
|
||
edge_id = int(record.target_id)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return edge_id if 0 <= edge_id < len(self.model.edges) else None
|
||
|
||
def _record_candidate_face_ids(self, record: OperationRecord) -> list[int]:
|
||
if self.model is None:
|
||
return []
|
||
part_id = self._record_int_parameter(record, "part_id")
|
||
solid_id = self._record_int_parameter(record, "solid_id")
|
||
face_ids = list(range(len(self.model.faces)))
|
||
if part_id is not None:
|
||
face_ids = [face_id for face_id in face_ids if self.model.face_part_ids[face_id] == part_id]
|
||
if solid_id is not None and solid_id >= 0:
|
||
same_solid = [face_id for face_id in face_ids if self.model.face_solid_ids[face_id] == solid_id]
|
||
if same_solid:
|
||
face_ids = same_solid
|
||
surface_hint = self._record_surface_hint(record)
|
||
if surface_hint:
|
||
surface_face_ids: list[int] = []
|
||
for face_id in face_ids:
|
||
if self.model.face_surface_kind(face_id) == surface_hint:
|
||
surface_face_ids.append(face_id)
|
||
if not surface_face_ids:
|
||
return []
|
||
face_ids = surface_face_ids
|
||
target_diameter = self._record_target_diameter(record)
|
||
if target_diameter is not None and target_diameter > 0:
|
||
diameter_tolerance = max(target_diameter * 0.02, 1e-5)
|
||
cylindrical_face_ids: list[int] = []
|
||
for face_id in face_ids:
|
||
current_diameter = self.model.face_cylinder_diameter(face_id)
|
||
if current_diameter is not None:
|
||
if abs(current_diameter - target_diameter) <= diameter_tolerance:
|
||
cylindrical_face_ids.append(face_id)
|
||
if not cylindrical_face_ids:
|
||
return []
|
||
face_ids = cylindrical_face_ids
|
||
return face_ids
|
||
|
||
def _record_candidate_edge_ids(self, record: OperationRecord) -> list[int]:
|
||
if self.model is None:
|
||
return []
|
||
part_id = self._record_int_parameter(record, "part_id")
|
||
solid_id = self._record_int_parameter(record, "solid_id")
|
||
edge_ids = list(range(len(self.model.edges)))
|
||
if part_id is not None:
|
||
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_part_ids[edge_id] == part_id]
|
||
if solid_id is not None and solid_id >= 0:
|
||
edge_ids = [edge_id for edge_id in edge_ids if self.model.edge_solid_ids[edge_id] == solid_id]
|
||
return edge_ids or list(range(len(self.model.edges)))
|
||
|
||
def _record_int_parameter(self, record: OperationRecord, key: str) -> int | None:
|
||
parameters = record.parameters if isinstance(record.parameters, dict) else {}
|
||
value = parameters.get(key)
|
||
if value is None or value == "":
|
||
return None
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
def _record_float_parameter(self, record: OperationRecord, key: str) -> float | None:
|
||
parameters = record.parameters if isinstance(record.parameters, dict) else {}
|
||
return _float_or_none(parameters.get(key))
|
||
|
||
def undo_edit(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能撤销。"):
|
||
return
|
||
if not self.undo_stack:
|
||
self.statusBar().showMessage("没有可撤销的编辑")
|
||
return
|
||
current = self.model.snapshot()
|
||
snapshot = self.undo_stack[-1]
|
||
undone = self.operation_history[-1] if self.operation_history else OperationRecord("编辑", "编辑")
|
||
try:
|
||
self._restore_snapshot(snapshot)
|
||
except Exception as exc:
|
||
rollback_message = self._restore_after_failed_undo_redo(current)
|
||
QMessageBox.critical(self, "撤销失败", f"{exc}\n\n{rollback_message}")
|
||
self.statusBar().showMessage("撤销失败,模型已尽量恢复到撤销前状态")
|
||
return
|
||
self.undo_stack.pop()
|
||
if self.operation_history:
|
||
self.operation_history.pop()
|
||
self.redo_stack.append(current)
|
||
self.redo_history.append(undone)
|
||
self._refresh_history_list()
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(f"已撤销:{undone.summary}")
|
||
|
||
def redo_edit(self) -> None:
|
||
if self.model is None:
|
||
return
|
||
if self._edit_busy("编辑计算中,暂时不能重做。"):
|
||
return
|
||
if not self.redo_stack:
|
||
self.statusBar().showMessage("没有可重做的编辑")
|
||
return
|
||
current = self.model.snapshot()
|
||
snapshot = self.redo_stack[-1]
|
||
redone = self.redo_history[-1] if self.redo_history else OperationRecord("编辑", "编辑")
|
||
try:
|
||
self._restore_snapshot(snapshot)
|
||
except Exception as exc:
|
||
rollback_message = self._restore_after_failed_undo_redo(current)
|
||
QMessageBox.critical(self, "重做失败", f"{exc}\n\n{rollback_message}")
|
||
self.statusBar().showMessage("重做失败,模型已尽量恢复到重做前状态")
|
||
return
|
||
self.redo_stack.pop()
|
||
if self.redo_history:
|
||
self.redo_history.pop()
|
||
self.undo_stack.append(current)
|
||
self.operation_history.append(redone)
|
||
self._refresh_history_list()
|
||
self._update_action_states()
|
||
self.statusBar().showMessage(f"已重做:{redone.summary}")
|
||
|
||
def _restore_snapshot(self, snapshot: dict[int, object]) -> None:
|
||
if self.model is None:
|
||
return
|
||
self.model.restore_snapshot(snapshot)
|
||
self._reset_selection()
|
||
self._populate_part_tree()
|
||
self._rebuild_scene(reset_camera=False)
|
||
self._clear_editable_candidates()
|
||
self._clear_cylinder_candidates()
|
||
stats = self.model.stats()
|
||
self.set_info(
|
||
{
|
||
"parts": stats.parts,
|
||
"solids": stats.solids,
|
||
"faces": stats.faces,
|
||
"edges": stats.edges,
|
||
"vertices": stats.vertices,
|
||
}
|
||
)
|
||
|
||
def _restore_after_failed_undo_redo(self, snapshot: dict[int, object]) -> str:
|
||
try:
|
||
self._restore_snapshot(snapshot)
|
||
except Exception as rollback_exc:
|
||
return f"恢复原状态也失败:{rollback_exc}。建议重新加载 STEP 文件。"
|
||
return "模型已恢复到操作前状态,历史记录未移动。"
|
||
|