feat: 完善 Face 一级关系编辑和稳定性

This commit is contained in:
2026-08-04 09:35:39 +08:00
parent bb44e3920d
commit 5799d5d813
29 changed files with 6295 additions and 189 deletions
+481 -28
View File
@@ -28,6 +28,57 @@ PROPERTY_SCOPE_COLUMN = 2
PROPERTY_TARGET_COLUMN = 3
PROPERTY_ACTION_COLUMN = 4
FEATURE_EDIT_SEMANTICS_KEYS = {
"face_first_level_topology",
"slot_edit_semantics",
"hole_edit_semantics",
"boss_edit_semantics",
"existing_fillet_edit_semantics",
"analytic_surface_edit_semantics",
"face_edit_semantics",
}
def _feature_dimension_keys(action_info: dict[str, object]) -> tuple[str, ...]:
"""Return the independent, user-facing dimensions for a feature candidate."""
surface = str(action_info.get("surface", "") or "")
feature_guess = str(action_info.get("feature_guess", "") or "")
angular_span = _float_or_none(action_info.get("angular_span"))
if surface == "plane":
if action_info.get("prismatic_profile_status") == "candidate":
keys = ["local_face_width", "local_face_height"]
if action_info.get("prismatic_extrusion_status") == "candidate":
keys.append("shell_thickness_estimate")
return tuple(keys)
if action_info.get("shell_region_status") == "candidate":
return ("shell_thickness_estimate",)
return ("face_target_normal_position",)
if surface == "cylinder":
is_partial = angular_span is not None and angular_span < math.tau * 0.92
if feature_guess == "hole/groove candidate":
if is_partial:
return (
"slot_chord_width_estimate",
"slot_sagitta_depth_estimate",
"slot_total_length_estimate",
)
return ("diameter", "hole_depth_estimate")
if feature_guess == "boss/outer-round candidate":
return ("boss_diameter", "boss_height")
if feature_guess == "round/fillet candidate":
return ("existing_fillet_radius_estimate",)
return ("generic_cylinder_diameter", "cylinder_height")
if surface == "cone":
return ("cone_reference_radius", "cone_semi_angle_degrees")
if surface == "sphere":
return ("sphere_radius",)
if surface == "torus":
return ("torus_major_radius", "torus_minor_radius")
return ()
def _record_message_field(message: str | None, key: str) -> str | None:
if not message:
@@ -109,9 +160,51 @@ class WindowStateMixin:
enriched["pick_position"] = pick_position
return enriched
def _face_first_level_selection_fields(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
try:
topology = self.model.face_first_level_topology(face_id)
except Exception as exc:
return {
"topology_relation_depth": 1,
"topology_relation_model": "STEP/B-Rep shared-edge first-level",
"topology_relation_status": "unavailable",
"topology_relation_message": str(exc),
"same_domain_face_count": 1,
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_count": 0,
"first_level_topology_note": (
"当前 Face 的一级拓扑关系暂时无法确认;只改当前面的局部重建会在计划阶段再次检查。"
),
}
return {
"topology_relation_depth": topology.get("topology_relation_depth", 1),
"topology_relation_model": topology.get("topology_relation_model"),
"topology_relation_scope": topology.get("topology_relation_scope"),
"topology_relation_boundary": topology.get("topology_relation_boundary"),
"topology_relation_status": "ready",
"topology_ignored_relation_depths": topology.get("topology_ignored_relation_depths", ()),
"topology_ignored_relation_note": topology.get("topology_ignored_relation_note", ""),
"same_domain_face_ids": topology.get("same_domain_face_ids", (face_id,)),
"same_domain_face_count": topology.get("same_domain_face_count", 1),
"same_domain_region_kind": topology.get("same_domain_region_kind", "single-face"),
"first_level_boundary_edge_ids": topology.get("first_level_boundary_edge_ids", ()),
"first_level_boundary_edge_count": topology.get("first_level_boundary_edge_count", 0),
"first_level_boundary_vertex_count": topology.get("first_level_boundary_vertex_count", 0),
"first_level_adjacent_face_ids": topology.get("first_level_adjacent_face_ids", ()),
"first_level_adjacent_face_count": topology.get("first_level_adjacent_face_count", 0),
"first_level_face_ids": topology.get("first_level_face_ids", (face_id,)),
"first_level_face_count": topology.get("first_level_face_count", 1),
"first_level_topology_note": topology.get("first_level_topology_note", ""),
}
def _feature_info_for_selected_face(self, face_id: int, fallback_info: dict[str, object]) -> dict[str, object]:
if self.model is None:
return dict(fallback_info)
if "associated_feature_infos" in fallback_info:
return dict(fallback_info)
surface = str(fallback_info.get("surface", "") or "")
if surface not in FACE_SELECTION_FEATURE_INFO_SURFACES:
return dict(fallback_info)
@@ -153,7 +246,7 @@ class WindowStateMixin:
info.setdefault("feature_edit_actions", "可查看圆柱直径/半径;复杂语义需要手动扫描或执行计划确认")
elif surface == "cone":
info.setdefault("feature_type", "圆锥面候选")
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径(整体缩放)")
info.setdefault("feature_edit_actions", "修改圆锥参考半径/直径/半角;程序会按几何选择重建或局部重切")
elif surface == "sphere":
info.setdefault("feature_type", "球面候选")
info.setdefault("feature_edit_actions", "修改球面半径/直径(整体缩放)")
@@ -168,8 +261,139 @@ class WindowStateMixin:
):
if key in fallback_info:
info.setdefault(key, fallback_info[key])
if surface == "plane":
info.update(self._face_first_level_selection_fields(face_id))
return info
def _feature_context_info(self, face_id: int) -> dict[str, object]:
if self.model is None:
return {}
detection_level = self._current_feature_detection_level()
if detection_level == "current-only":
root_info = self._feature_info_for_selected_face(face_id, self.model.quick_face_info(face_id))
else:
root_info = self.model.feature_info(face_id)
if str(root_info.get("surface", "") or "") == "plane":
root_info.update(self._face_first_level_selection_fields(face_id))
associated: list[dict[str, object]] = []
if detection_level in {"associated-only", "secondary"}:
try:
associated = self.model.associated_feature_infos(face_id)
except Exception:
associated = []
if detection_level == "secondary":
associated = self._secondary_associated_feature_infos(face_id, associated)
highlight_ids = set(_int_values(root_info.get("feature_highlight_face_ids")) or [face_id])
for item in associated:
highlight_ids.update(_int_values(item.get("feature_highlight_face_ids")))
source_id = _int_or_none(item.get("association_source_face_id"))
if source_id is not None:
highlight_ids.add(source_id)
level_label = (
"二级特征"
if detection_level == "secondary"
else ("相邻特征" if detection_level == "associated-only" else "当前特征")
)
info = dict(root_info)
info.update(
{
"associated_feature_infos": associated,
"associated_feature_count": len(associated),
"feature_detection_level": level_label,
"associated_feature_face_ids": tuple(
sorted(
{
int(item.get("association_source_face_id", -1))
for item in associated
if _int_or_none(item.get("association_source_face_id")) is not None
}
)
),
"feature_highlight_face_ids": tuple(sorted(highlight_ids)),
"feature_context_note": (
f"已按“{level_label}”沿共享边拓扑探测当前特征及 {len(associated)} 个局部关联特征;关联尺寸可在同一参数表中直接修改。"
if associated
else (
"当前为轻量识别:只读取被点击对象本身,不自动扫描周边拓扑;需要更多关联时可切换到“探测相邻特征”。"
if detection_level == "current-only"
else f"已按“{level_label}”沿共享边拓扑探测局部邻域,未发现额外的可参数化关联特征。"
)
),
}
)
return info
def _current_feature_detection_level(self) -> str:
combo = getattr(self, "feature_detection_combo", None)
if isinstance(combo, NoWheelComboBox):
value = combo.currentData()
if isinstance(value, str) and value:
return value
return str(getattr(self, "feature_detection_level", "current-only") or "current-only")
def _on_feature_detection_level_changed(self) -> None:
self.feature_detection_level = self._current_feature_detection_level()
if self.model is None or self.selected_kind != "feature" or self.selected_face_id is None:
return
try:
info = self._feature_context_info(self.selected_face_id)
except Exception:
return
self.current_info_values = dict(info)
self.current_info_text = "\n".join(f"{INFO_LABELS.get(key, key)}: {_format_value(value)}" for key, value in info.items())
self._refresh_property_editor()
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [self.selected_face_id])
self._update_selected_object_title()
def _secondary_associated_feature_infos(
self,
source_face_id: int,
direct_infos: list[dict[str, object]],
) -> list[dict[str, object]]:
if self.model is None:
return list(direct_infos)
results: list[dict[str, object]] = []
seen: set[tuple[str, tuple[int, ...]]] = set()
def add(info: dict[str, object]) -> None:
source_id = _int_or_none(info.get("association_source_face_id"))
if source_id is None or source_id == source_face_id:
return
face_ids = tuple(sorted(_int_values(info.get("feature_highlight_face_ids")) or [source_id]))
identity = (str(info.get("feature_type", "") or info.get("feature_guess", "")), face_ids)
if identity in seen:
return
seen.add(identity)
results.append(dict(info))
for info in direct_infos:
add(info)
for info in list(results):
parent_id = _int_or_none(info.get("association_source_face_id"))
if parent_id is None:
continue
try:
for nested in self.model.associated_feature_infos(
parent_id,
max_depth=2,
max_scan_faces=48,
max_features=6,
):
add(nested)
except Exception:
continue
results.sort(
key=lambda item: (
int(item.get("association_priority", 9)),
int(item.get("association_hop_count", 99)),
int(item.get("association_source_face_id", 0)),
)
)
return results[:14]
def _selection_status(self, message: str, pick_position: tuple[float, float, float] | None) -> str:
if pick_position is None:
return message
@@ -238,7 +462,7 @@ class WindowStateMixin:
)
self._set_control_state(
self.reload_button,
has_loaded_model and not busy,
not busy and bool(getattr(self, "step_path", None)),
"按当前显示路径读取 STEP 模型。",
wait_or_load_tip,
)
@@ -788,13 +1012,22 @@ class WindowStateMixin:
def _update_selected_object_title(self) -> None:
if not hasattr(self, "object_edit_box"):
return
self.object_edit_box.setTitle(f"当前选中对象:{self._selected_object_title_suffix()}")
section_label = "几何对象(高级)" if self.selected_kind in {"face", "edge", "solid", "part"} else "特征参数"
self.object_edit_box.setTitle(f"{section_label}{self._selected_object_title_suffix()}")
def _selected_object_title_suffix(self) -> str:
if self.selected_kind == "part" and self.selected_part_id is not None:
return f"零件 {self.selected_part_id}"
if self.selected_kind == "solid" and self.selected_solid_id is not None:
return f"Solid {self.selected_solid_id}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label() or "特征"
confidence = str(self.current_info_values.get("confidence", "") or "")
confidence_label = {"high": "", "medium": "", "low": ""}.get(confidence, "")
confidence_suffix = f"(置信度:{confidence_label}" if confidence_label else ""
related_count = int(self.current_info_values.get("associated_feature_count", 0) or 0)
related_suffix = f" · 关联 {related_count}" if related_count else ""
return f"{feature_label}{confidence_suffix} · 来源 Face {self.selected_face_id}{related_suffix}"
if self.selected_kind == "feature" and self.selected_face_id is not None:
feature_label = self._selected_feature_label()
if feature_label:
@@ -1147,10 +1380,10 @@ class WindowStateMixin:
self.property_expand_button.setVisible(has_hidden_rows)
if expanded:
self.property_expand_button.setText(f"收起到前 {collapsed_rows}")
self.property_expand_button.setToolTip("收起当前选中对象属性表,让面板只保留最常用的前几")
self.property_expand_button.setToolTip("收起参数列表,只保留最常用的前几")
else:
self.property_expand_button.setText(f"展开全部属性 ({row_count} 项)")
self.property_expand_button.setToolTip("展开当前选中对象的完整属性表;参数化建模按钮会继续留在下方。")
self.property_expand_button.setText(f"展开全部参数 ({row_count} 项)")
self.property_expand_button.setToolTip("展开完整参数列表;参数化建模按钮会继续留在下方。")
def toggle_property_table_expanded(self) -> None:
if not hasattr(self, "property_table"):
@@ -1172,6 +1405,8 @@ class WindowStateMixin:
action_info: dict[str, object],
) -> list[dict[str, object]]:
editable_specs, used_keys = self._editable_property_specs(action_info)
if self.selected_kind == "feature":
return self._feature_context_property_specs(editable_specs, action_info)
specs = list(editable_specs)
for key, value in self._ordered_property_info_items(info):
if key in used_keys:
@@ -1180,7 +1415,7 @@ class WindowStateMixin:
{
"key": key,
"label": INFO_LABELS.get(key, key),
"current_text": _format_value(value),
"current_text": _format_info_value(key, value),
"current_raw": value,
"target_text": "",
"editable": False,
@@ -1204,6 +1439,86 @@ class WindowStateMixin:
)
return specs
def _feature_property_specs(
self,
specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
allowed_keys = _feature_dimension_keys(action_info)
spec_by_key = {str(spec.get("key", "")): spec for spec in specs}
dimensions: list[dict[str, object]] = []
for key in allowed_keys:
spec = spec_by_key.get(key)
if spec is None or not bool(spec.get("editable")) or not bool(spec.get("enabled")):
continue
dimension = dict(spec)
dimension["parameter_role"] = "dimension"
if action_info.get("prismatic_profile_status") == "candidate":
label_overrides = {
"local_face_width": "长度",
"local_face_height": "宽度",
"shell_thickness_estimate": "高度/深度",
}
if key in label_overrides:
dimension["label"] = label_overrides[key]
dimensions.append(dimension)
explanations = [
dict(spec)
for spec in specs
if str(spec.get("key", "")) in FEATURE_EDIT_SEMANTICS_KEYS
]
if not dimensions:
dimensions.append(
{
"key": "no_editable_feature_dimensions",
"label": "可变尺寸",
"current_text": "未识别到可靠的独立尺寸",
"current_raw": "",
"target_text": "",
"editable": False,
"enabled": False,
"status_text": "说明",
"disabled_tip": (
"当前几何仍可在诊断信息中查看,但不会把面积、中心、包围盒或底层曲面参数"
"伪装成特征设计尺寸。"
),
}
)
return dimensions + explanations
def _feature_context_property_specs(
self,
root_specs: list[dict[str, object]],
action_info: dict[str, object],
) -> list[dict[str, object]]:
root_rows = self._feature_property_specs(root_specs, action_info)
associated = action_info.get("associated_feature_infos")
if not isinstance(associated, (list, tuple)) or not associated:
return root_rows
root_dimensions = [dict(spec) for spec in root_rows if spec.get("parameter_role") == "dimension"]
root_explanations = [dict(spec) for spec in root_rows if spec.get("parameter_role") != "dimension"]
related_rows: list[dict[str, object]] = []
for index, related_info in enumerate(associated, start=1):
if not isinstance(related_info, dict):
continue
related_specs, _used = self._editable_property_specs(related_info)
feature_label = str(related_info.get("feature_type") or related_info.get("feature_guess") or "关联特征")
source_face_id = _int_or_none(related_info.get("association_source_face_id"))
for spec in self._feature_property_specs(related_specs, related_info):
if spec.get("parameter_role") != "dimension":
continue
related = dict(spec)
related["label"] = f"{feature_label} · {spec.get('label', '')}"
related["scope_text"] = f"关联 Face {source_face_id}" if source_face_id is not None else f"关联特征 {index}"
related["source_face_id"] = source_face_id
related["source_feature_info"] = dict(related_info)
related["association_index"] = index
related_rows.append(related)
return root_dimensions + related_rows + root_explanations
def _ordered_property_info_items(self, info: dict[str, object]) -> list[tuple[str, object]]:
items = self._ordered_info_items(info)
if not self._is_feature_like_info(info):
@@ -1456,6 +1771,75 @@ class WindowStateMixin:
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,
@@ -1641,6 +2025,26 @@ class WindowStateMixin:
),
)
elif is_plane or is_shell_candidate:
topology_depth = _int_or_none(action_info.get("topology_relation_depth"))
if topology_depth == 1:
same_domain_count = _int_or_none(action_info.get("same_domain_face_count")) or 0
boundary_edge_count = _int_or_none(action_info.get("first_level_boundary_edge_count")) or 0
boundary_vertex_count = _int_or_none(action_info.get("first_level_boundary_vertex_count")) or 0
adjacent_face_count = _int_or_none(action_info.get("first_level_adjacent_face_count")) or 0
topology_note = str(action_info.get("first_level_topology_note") or "").strip()
ignored_note = str(action_info.get("topology_ignored_relation_note") or "").strip()
topology_tip = "\n".join(item for item in (topology_note, ignored_note) if item) or (
"当前阶段只处理当前 Face、同域碎片、边界 Edge/Vertex 和共享边相邻 Face;二级、三级关系暂不自动传播。"
)
add_readonly_spec(
key="face_first_level_topology",
label="一级关系",
text=(
f"Face 区域 {same_domain_count} 个;边界 Edge {boundary_edge_count} 条;"
f"边界 Vertex {boundary_vertex_count} 个;共享边相邻 Face {adjacent_face_count} 个。"
),
tip=topology_tip,
)
face_semantics_text = "Face:先改目标值,再用“影响范围”选择改当前面、推拉或调整整个特征。"
face_semantics_tip = (
"面积是面的大小;面宽/面高是这个面自身平面里的两个方向尺寸;"
@@ -2896,33 +3300,49 @@ class WindowStateMixin:
current_reference_diameter = (
current_reference_radius * 2.0 if current_reference_radius is not None else None
)
reference_radius_supported, reference_radius_disabled_reason = cone_reference_radius_capability(
current_reference_radius
)
reference_radius_enabled = current_reference_radius is not None and reference_radius_supported
add_spec(
key="cone_reference_radius",
label="参考半径(整体)",
label="参考半径",
current_raw=current_reference_radius if current_reference_radius is not None else "",
target_text=numeric_text(current_reference_radius),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None,
enabled_tip="输入圆锥面的目标参考半径;程序会围绕圆锥轴径向缩放所属对象",
disabled_tip="当前圆锥面缺少稳定参考半径,不能直接修改。",
enabled=reference_radius_enabled,
enabled_tip="输入圆锥面的目标参考半径;简单圆锥会解析重建,嵌入式锥孔会优先局部重切",
disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考半径,不能直接修改。"
),
value_type="positive",
range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_radius, 0.25, 0.6)}",
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="参考直径(整体)",
label="参考直径",
current_raw=current_reference_diameter if current_reference_diameter is not None else "",
target_text=numeric_text(current_reference_diameter),
action="resize_cone_reference_radius",
target_attr="cone_reference_radius_input",
enabled=current_reference_diameter is not None,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后围绕圆锥轴径向缩放所属对象",
disabled_tip="当前圆锥面缺少稳定参考直径,不能直接修改。",
enabled=current_reference_diameter is not None and reference_radius_supported,
enabled_tip="输入圆锥面的目标参考直径;程序会换算为参考半径后选择解析重建或锥孔局部重切",
disabled_tip=(
reference_radius_disabled_reason
or "当前圆锥面缺少稳定参考直径,不能直接修改。"
),
value_type="positive",
range_hint=f"这不是只替换单个圆锥面的历史参数;同一对象上的其它径向尺寸会跟随变化。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先只重切锥孔。"
f"复杂圆锥/拔模面暂不使用整体缩放兜底。 {relative_range_hint(current_reference_diameter, 0.25, 0.6)}"
),
target_transform="diameter_to_radius",
used=("feature_reference_diameter",),
**positive_minimum(),
@@ -2931,25 +3351,32 @@ class WindowStateMixin:
current_semi_angle_degrees = (
abs(math.degrees(current_semi_angle)) if current_semi_angle is not None else None
)
semi_angle_supported, semi_angle_disabled_reason = cone_semi_angle_capability(current_semi_angle_degrees)
semi_angle_enabled = (
current_reference_radius is not None
and current_semi_angle is not None
and semi_angle_supported
)
add_spec(
key="cone_semi_angle_degrees",
label="半角(整体)",
label="圆锥半角",
current_raw=current_semi_angle_degrees if current_semi_angle_degrees is not None else "",
target_text=numeric_text(current_semi_angle_degrees),
action="resize_cone_reference_radius",
action="resize_cone_semi_angle",
target_attr="cone_reference_radius_input",
enabled=current_reference_radius is not None and current_semi_angle is not None,
enabled_tip="输入圆锥面的目标半角,单位是度;程序会换算为目标参考半径后围绕圆锥轴径向缩放所属对象",
disabled_tip="当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。",
enabled=semi_angle_enabled,
enabled_tip="输入圆锥面的目标半角,单位是度;简单圆锥会解析重建,嵌入式锥孔会优先局部重切",
disabled_tip=(
semi_angle_disabled_reason
or "当前圆锥面缺少稳定参考半径或半角,不能直接修改半角。"
),
value_type="positive",
range_hint="这是整体径向缩放所属对象,不是只替换单个圆锥面的历史半角参数;建议先小幅修改,当前版本要求半角大于 0 且小于 89 度。",
range_hint=(
"简单圆锥会解析重建;嵌入式锥孔会优先保持小端半径和深度,只改变锥孔开口。"
"复杂圆锥/拔模面暂不使用整体缩放兜底。当前版本要求半角大于 0 且小于 89 度。"
),
max_value=89.0,
max_exclusive=True,
target_transform="cone_semi_angle_degrees_to_reference_radius",
transform_context={
"current_reference_radius": current_reference_radius,
"current_semi_angle": current_semi_angle,
},
used=("semi_angle",),
**positive_minimum(),
)
@@ -3971,6 +4398,31 @@ class WindowStateMixin:
row, _spec, _text = changed[0]
self.apply_property_row_edit(row)
def _activate_property_source_feature(self, spec: dict[str, object]) -> None:
source_face_id = _int_or_none(spec.get("source_face_id"))
if source_face_id is None or source_face_id == self.selected_face_id:
return
if self.model is None or source_face_id < 0 or source_face_id >= len(self.model.faces):
raise ValueError("关联特征已经失效,请重新选择模型对象。")
info = spec.get("source_feature_info")
if not isinstance(info, dict):
info = self._feature_context_info(source_face_id)
else:
info = dict(info)
self.selected_kind = "feature"
self.selected_face_id = source_face_id
self.selected_edge_id = None
self.selected_part_id = int(info.get("part_id", self.model.face_part_ids[source_face_id]))
self.selected_solid_id = int(info.get("solid_id", self.model.face_solid_ids[source_face_id]))
self.current_info_values = dict(info)
self.current_info_text = "\n".join(
f"{INFO_LABELS.get(key, key)}: {_format_info_value(key, value)}" for key, value in info.items()
)
self._sync_id_picker("Feature", source_face_id)
if hasattr(self, "_highlight_faces"):
self._highlight_faces(_int_values(info.get("feature_highlight_face_ids")) or [source_face_id])
self._update_selected_object_title()
def apply_property_row_edit(self, row: int) -> None:
specs = getattr(self, "property_editor_specs", [])
if row < 0 or row >= len(specs):
@@ -3990,6 +4442,7 @@ class WindowStateMixin:
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)