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
+742 -26
View File
@@ -375,7 +375,11 @@ class WindowActionMixin:
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消推拉平面")
return
self._show_push_pull_preview(face_id, distance, plan=plan)
isolation = self._isolation_for_plan(plan, "push_pull_face", [face_id, distance])
if isolation is None:
self._show_push_pull_preview(face_id, distance, plan=plan)
else:
self.clear_edit_preview(render=False)
def action():
return self.model.push_pull_face(face_id, distance)
@@ -410,7 +414,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=self._isolation_for_plan(plan, "push_pull_face", [face_id, distance]),
isolation=isolation,
)
def _quick_push_pull_plan(self, face_id: int, distance: float) -> dict[str, object]:
@@ -467,10 +471,9 @@ class WindowActionMixin:
warnings.append("面移动距离相对当前面尺寸偏大,请确认预览范围。")
outward_tuple = tuple(float(item) for item in outward)
inward_material_depth = None
inward_material_depth = _float_or_none(info.get("push_pull_inward_material_depth"))
inward_cut_ratio = None
if distance < 0 and self.model is not None:
inward_material_depth = self.model._push_pull_inward_material_depth(face_id, outward_tuple)
if distance < 0:
if inward_material_depth is not None and inward_material_depth > 1e-9:
inward_cut_ratio = distance_abs / inward_material_depth
depth_tolerance = max(
@@ -488,6 +491,10 @@ class WindowActionMixin:
elif inward_cut_ratio >= 0.6 and risk != "high":
risk = "medium"
warnings.append("向内切削距离超过当前面背后材料厚度的 60%,请确认不会切穿。")
else:
if risk == "low":
risk = "medium"
warnings.append("向内推拉的材料厚度尚未缓存;完整切穿检查会放到后台计算。")
if status != "blocked" and risk in {"medium", "high"}:
status = "caution"
@@ -605,10 +612,11 @@ class WindowActionMixin:
self.statusBar().showMessage("已取消薄壁厚度调整")
return
if str(plan.get("risk")) == "high":
isolation = self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness])
if isolation is not None:
self.clear_edit_preview(render=False)
else:
self._show_shell_thickness_preview(face_id, target_thickness)
self._show_shell_thickness_preview(face_id, target_thickness, plan=plan)
def action():
return self.model.resize_shell_thickness(face_id, target_thickness)
@@ -647,7 +655,7 @@ class WindowActionMixin:
},
target_kind="feature" if self.selected_kind == "feature" else "face",
target_id=face_id,
isolation=self._isolation_for_plan(plan, "resize_shell_thickness", [face_id, target_thickness]),
isolation=isolation,
)
def resize_shell_thickness_owning_scale(self) -> None:
@@ -984,13 +992,31 @@ class WindowActionMixin:
*,
timeout_seconds: float = 180.0,
) -> dict[str, object] | None:
if str(plan.get("risk", "")) != "high":
risk = str(plan.get("risk", ""))
isolated_face_operations = {
"push_pull_face",
"move_face_plane_offset_local",
"resize_face_area_local",
"resize_face_area",
"resize_face_size_local",
"resize_face_size_owning_scale",
"move_face_center_local",
"resize_shell_thickness",
"resize_shell_thickness_owning_scale",
"resize_cone_reference_radius",
"resize_cone_semi_angle",
"resize_sphere_radius",
"resize_torus_radius",
}
if operation not in isolated_face_operations:
return None
if risk not in {"low", "medium", "high"}:
return None
return {
"operation": operation,
"args": args,
"timeout_seconds": timeout_seconds,
"reason": "high-risk-occ-edit",
"reason": f"{risk}-risk-face-occ-edit",
}
def resize_hole(self) -> None:
@@ -1067,6 +1093,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_cylindrical_owning_scale(self) -> None:
@@ -1130,6 +1157,12 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"embedded_cone_recut_mode": plan.get("embedded_cone_recut_mode"),
"embedded_cone_current_small_radius": plan.get("embedded_cone_current_small_radius"),
"embedded_cone_target_small_radius": plan.get("embedded_cone_target_small_radius"),
"embedded_cone_current_large_radius": plan.get("embedded_cone_current_large_radius"),
"embedded_cone_target_large_radius": plan.get("embedded_cone_target_large_radius"),
"embedded_cone_height": plan.get("embedded_cone_height"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
@@ -2564,6 +2597,13 @@ class WindowActionMixin:
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"embedded_cone_recut_mode": plan.get("embedded_cone_recut_mode"),
"embedded_cone_fixed_small_radius": plan.get("embedded_cone_fixed_small_radius"),
"embedded_cone_current_small_radius": plan.get("embedded_cone_current_small_radius"),
"embedded_cone_target_small_radius": plan.get("embedded_cone_target_small_radius"),
"embedded_cone_current_large_radius": plan.get("embedded_cone_current_large_radius"),
"embedded_cone_target_large_radius": plan.get("embedded_cone_target_large_radius"),
"embedded_cone_height": plan.get("embedded_cone_height"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
@@ -4112,7 +4152,7 @@ class WindowActionMixin:
def resize_cone_reference_radius(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改圆锥面(整体)"):
if self._edit_busy("请等待当前编辑完成后再修改圆锥面参数"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥面 Face 或圆锥面特征。")
@@ -4127,16 +4167,22 @@ class WindowActionMixin:
face_id = self.selected_face_id
plan = self.model.conical_reference_radius_plan(face_id, target_radius)
isolation = self._isolation_for_plan(plan, "resize_cone_reference_radius", [face_id, target_radius])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改圆锥面(整体)", str(plan["message"]))
self.statusBar().showMessage("圆锥面(整体)修改已阻止")
QMessageBox.information(self, "不能修改圆锥面参数", str(plan["message"]))
self.statusBar().showMessage("圆锥面参数修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改圆锥面(整体)",
"确认修改圆锥面参数",
(
f"Face: {face_id}\n"
f"当前参考半径: {_format_value(plan.get('current_reference_radius'))}\n"
@@ -4150,13 +4196,14 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆锥面(整体)修改")
self.statusBar().showMessage("已取消圆锥面参数修改")
return
self.clear_edit_preview(render=False)
@@ -4165,7 +4212,7 @@ class WindowActionMixin:
self._run_edit_action(
action,
operation_name="修改圆锥面参数(整体)",
operation_name="修改圆锥面参数",
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
@@ -4198,6 +4245,104 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_cone_semi_angle(self) -> None:
if self.model is None:
return
if self._edit_busy("请等待当前编辑完成后再修改圆锥半角。"):
return
if self.selected_face_id is None:
QMessageBox.information(self, "未选择圆锥面", "请先选择一个圆锥 Face 或圆锥特征。")
return
if not hasattr(self, "cone_reference_radius_input"):
return
try:
target_angle_degrees = float(self.cone_reference_radius_input.text())
except ValueError:
QMessageBox.critical(self, "圆锥半角无效", "请输入数字形式的目标半角,单位是度。")
return
face_id = self.selected_face_id
plan = self.model.conical_semi_angle_plan(face_id, target_angle_degrees)
isolation = self._isolation_for_plan(plan, "resize_cone_semi_angle", [face_id, target_angle_degrees])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改圆锥半角", str(plan["message"]))
self.statusBar().showMessage("圆锥半角修改已阻止")
return
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改圆锥半角",
(
f"Face: {face_id}\n"
f"当前半角: {_format_value(plan.get('semi_angle_degrees'))}\n"
f"目标半角: {_format_value(plan.get('target_semi_angle_degrees'))}\n"
f"目标参考半径: {_format_value(plan.get('target_reference_radius'))}\n"
f"变化比例: {_format_percent(plan.get('reference_radius_delta_ratio'))}\n"
f"编辑策略: {_format_value(plan.get('edit_strategy_label'))}\n"
f"影响范围: {_format_value(plan.get('edit_semantics'))}\n"
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if result != QMessageBox.StandardButton.Yes:
self.statusBar().showMessage("已取消圆锥半角修改")
return
self.clear_edit_preview(render=False)
def action():
return self.model.resize_conical_semi_angle(face_id, target_angle_degrees)
self._run_edit_action(
action,
operation_name="修改圆锥半角(整体)",
target=f"Face {face_id}",
parameters={
"part_id": plan.get("part_id"),
"solid_id": plan.get("solid_id"),
"surface": "cone",
"current_reference_radius": plan.get("current_reference_radius"),
"target_reference_radius": plan.get("target_reference_radius"),
"current_reference_diameter": plan.get("current_reference_diameter"),
"target_reference_diameter": plan.get("target_reference_diameter"),
"delta_reference_radius": plan.get("delta_reference_radius"),
"reference_radius_delta_ratio": plan.get("reference_radius_delta_ratio"),
"semi_angle": plan.get("semi_angle"),
"semi_angle_degrees": plan.get("semi_angle_degrees"),
"target_semi_angle": plan.get("target_semi_angle"),
"target_semi_angle_degrees": plan.get("target_semi_angle_degrees"),
"affine_scale": plan.get("affine_scale"),
"affine_transform_kind": plan.get("affine_transform_kind"),
"affine_transform_label": plan.get("affine_transform_label"),
"affine_axis_point": plan.get("affine_axis_point"),
"affine_axis_direction": plan.get("affine_axis_direction"),
"affine_target_kind": plan.get("affine_target_kind"),
"resize_strategy": plan.get("resize_strategy"),
"edit_strategy_label": plan.get("edit_strategy_label"),
"edit_semantics": plan.get("edit_semantics"),
"resize_status": plan.get("status"),
"resize_risk": plan.get("risk"),
"resize_message": plan.get("message"),
"resize_warnings": plan.get("warnings"),
"resize_blockers": plan.get("blockers"),
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_sphere_radius(self) -> None:
@@ -4218,6 +4363,7 @@ class WindowActionMixin:
face_id = self.selected_face_id
plan = self.model.spherical_radius_plan(face_id, target_radius)
isolation = self._isolation_for_plan(plan, "resize_sphere_radius", [face_id, target_radius])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改球面(整体)", str(plan["message"]))
self.statusBar().showMessage("球面(整体)修改已阻止")
@@ -4225,6 +4371,11 @@ class WindowActionMixin:
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
"确认修改球面(整体)",
@@ -4241,6 +4392,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4285,6 +4437,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_torus_major_radius(self) -> None:
@@ -4313,6 +4466,7 @@ class WindowActionMixin:
mode_label = "主半径(整体)" if mode_key == "major" else "小半径(整体)"
face_id = self.selected_face_id
plan = self.model.toroidal_radius_plan(face_id, target_radius, mode_key)
isolation = self._isolation_for_plan(plan, "resize_torus_radius", [face_id, target_radius, mode_key])
if plan["status"] == "blocked":
QMessageBox.information(self, "不能修改环面(整体)", str(plan["message"]))
self.statusBar().showMessage("环面(整体)修改已阻止")
@@ -4320,6 +4474,11 @@ class WindowActionMixin:
if plan["risk"] != "low":
warnings = str(plan.get("warnings", ""))
warnings_line = f"警告: {warnings}\n\n" if warnings else ""
isolation_line = (
"本次会在隔离子进程里执行高风险 OCCT 计算;如果子进程卡死或崩溃,主程序和原模型会保持不变。\n\n"
if isolation is not None
else ""
)
result = QMessageBox.question(
self,
f"确认修改环面{mode_label}",
@@ -4338,6 +4497,7 @@ class WindowActionMixin:
f"风险: {plan['risk']}\n\n"
f"{warnings_line}"
f"{plan['message']}\n\n"
f"{isolation_line}"
"继续操作会修改当前 B-Rep 结果几何,并支持失败回滚/撤销。确定继续吗?"
),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
@@ -4386,6 +4546,7 @@ class WindowActionMixin:
},
target_kind="face",
target_id=face_id,
isolation=isolation,
)
def resize_face_area(self) -> None:
@@ -6252,6 +6413,7 @@ class WindowActionMixin:
target_part_id = self._edit_context_part_id(context)
before_stats = self.model.stats()
before_part_stats = self._part_stats_or_none(target_part_id)
before_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
before_geometry = {}
isolation = context.get("isolation")
if isinstance(isolation, dict) and isolation:
@@ -6261,6 +6423,7 @@ class WindowActionMixin:
snapshot=snapshot,
before_stats=before_stats,
before_part_stats=before_part_stats,
before_quality=before_quality,
before_geometry=before_geometry,
)
try:
@@ -6268,6 +6431,17 @@ class WindowActionMixin:
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
after_quality = self._edit_quality_info_or_none(self.model, context, target_part_id)
quality_warnings = self._verified_edit_quality_warnings(
context,
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality,
after_quality,
after_model=self.model,
)
after_geometry = {}
except Exception as exc:
try:
@@ -6298,7 +6472,7 @@ class WindowActionMixin:
"after_snapshot": after_snapshot,
"after_stats": after_stats,
"after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
"quality_warnings": quality_warnings,
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
@@ -6314,6 +6488,7 @@ class WindowActionMixin:
snapshot: dict[object, object],
before_stats,
before_part_stats,
before_quality: dict[str, object] | None,
before_geometry: dict[str, object],
) -> dict[str, object]:
if self.model is None:
@@ -6346,7 +6521,7 @@ class WindowActionMixin:
encoding="utf-8",
)
command = [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
command = self._isolated_edit_command(request_path)
try:
completed = subprocess.run(
command,
@@ -6385,11 +6560,23 @@ class WindowActionMixin:
new_model.filename = self.step_path
except Exception:
pass
child_message = str(response.get("message") or "隔离子进程编辑完成。")
self._preserve_isolated_face_logical_id(new_model, context, child_message)
after_snapshot = new_model.snapshot()
after_stats = new_model.stats()
after_part_stats = self._part_stats_or_none_for_model(new_model, target_part_id)
after_quality = self._edit_quality_info_or_none(new_model, context, target_part_id)
quality_warnings = self._verified_edit_quality_warnings(
context,
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality,
after_quality,
after_model=new_model,
)
self.model = new_model
after_snapshot = self.model.snapshot()
after_stats = self.model.stats()
after_part_stats = self._part_stats_or_none(target_part_id)
after_geometry: dict[str, object] = {}
model_polydata = None
edge_polydata = None
@@ -6404,7 +6591,6 @@ class WindowActionMixin:
model_polydata = None
edge_polydata = None
child_message = str(response.get("message") or "隔离子进程编辑完成。")
return {
"message": f"{child_message} 已通过隔离子进程完成;如果 OCC 崩溃,主程序不会被带崩。",
"snapshot": snapshot,
@@ -6414,12 +6600,47 @@ class WindowActionMixin:
"after_snapshot": after_snapshot,
"after_stats": after_stats,
"after_part_stats": after_part_stats,
"quality_warnings": _edit_quality_warnings(before_part_stats, after_part_stats),
"quality_warnings": quality_warnings,
"after_geometry": after_geometry,
"model_polydata": model_polydata,
"edge_polydata": edge_polydata,
}
def _isolated_edit_command(self, request_path: Path) -> list[str]:
if getattr(sys, "frozen", False):
return [sys.executable, "--isolated-edit-worker", str(request_path)]
return [sys.executable, "-m", "step_editor.isolated_edit_worker", str(request_path)]
def _preserve_isolated_face_logical_id(
self,
model: StepModel,
context: dict[str, object],
result_message: str,
) -> None:
if not self._context_is_face_parameter_edit(context):
return
target_logical_id = context.get("target_logical_id")
if target_logical_id is None:
return
located_text = _message_field(result_message, "nearest_face") or _message_field(result_message, "verified_face")
if located_text is None:
return
try:
face_id = int(float(located_text))
logical_id = int(target_logical_id)
except (TypeError, ValueError):
return
if not (0 <= face_id < len(model.faces)):
return
try:
face_ids = model.face_region_ids(face_id) or [face_id]
except Exception:
face_ids = [face_id]
try:
model.assign_logical_face_region_exclusive(logical_id, face_ids)
except Exception:
return
def _edit_context_part_id(self, context: dict[str, object]) -> int | None:
if self.model is None:
return None
@@ -6452,13 +6673,508 @@ class WindowActionMixin:
return None
def _part_stats_or_none(self, part_id: int | None):
if self.model is None or part_id is None:
return self._part_stats_or_none_for_model(self.model, part_id)
def _part_stats_or_none_for_model(self, model: StepModel | None, part_id: int | None):
if model is None or part_id is None:
return None
try:
return self.model.part_topology_stats(part_id)
return model.part_topology_stats(part_id)
except Exception:
return None
def _edit_quality_info_or_none(
self,
model: StepModel | None,
context: dict[str, object],
target_part_id: int | None,
) -> dict[str, object] | None:
if model is None:
return None
try:
if target_part_id is not None:
return model.export_quality_info("part", target_part_id)
return model.export_quality_info("all", None)
except Exception:
try:
return model.export_quality_info("all", None)
except Exception:
return None
def _verified_edit_quality_warnings(
self,
context: dict[str, object],
before_stats,
after_stats,
before_part_stats,
after_part_stats,
before_quality: dict[str, object] | None,
after_quality: dict[str, object] | None,
*,
after_model: StepModel | None = None,
) -> list[str]:
warnings = _edit_quality_warnings(before_part_stats, after_part_stats)
blockers: list[str] = []
if getattr(after_stats, "solids", 0) <= 0:
blockers.append("编辑结果里没有检测到 Solid,模型已保持在修改前状态。")
if getattr(after_stats, "faces", 0) <= 0:
blockers.append("编辑结果里没有检测到 Face,模型已保持在修改前状态。")
if getattr(after_stats, "edges", 0) <= 0:
blockers.append("编辑结果里没有检测到 Edge,模型已保持在修改前状态。")
if after_quality is not None:
quality_status = str(after_quality.get("quality_status", ""))
after_brep_valid = after_quality.get("brep_valid")
before_brep_valid = before_quality.get("brep_valid") if before_quality is not None else None
quality_warnings = str(after_quality.get("quality_warnings", "")).strip()
if quality_status == "blocked":
blockers.append(quality_warnings or "编辑结果质量检查被判定为不可用。")
elif after_brep_valid is False and before_brep_valid is not False:
blockers.append(quality_warnings or "编辑结果没有通过 B-Rep 校验。")
elif quality_warnings:
warnings.append(quality_warnings)
if self._context_is_face_parameter_edit(context):
if (
before_part_stats is not None
and after_part_stats is not None
and getattr(before_part_stats, "solids", 0) > 0
and getattr(after_part_stats, "solids", 0) != getattr(before_part_stats, "solids", 0)
):
blockers.append(
"Face 参数化编辑后目标特征的 Solid 数量发生变化,已阻止接受该结果。"
f" Solid: {before_part_stats.solids} -> {after_part_stats.solids}"
)
target_blocker = self._face_target_integrity_blocker(after_model, context)
if target_blocker:
blockers.append(target_blocker)
if blockers:
raise RuntimeError(" ".join(blockers))
return warnings
def _face_target_integrity_blocker(
self,
model: StepModel | None,
context: dict[str, object],
) -> str:
if model is None:
return ""
parameters = context.get("parameters")
if not isinstance(parameters, dict):
return ""
surface = str(parameters.get("surface") or "")
face_ids = self._face_target_candidate_ids(model, context)
if not face_ids:
if surface in {"cone", "sphere", "torus"} and any(
parameters.get(key) not in {"", None}
for key in (
"target_reference_radius",
"target_semi_angle_degrees",
"target_radius",
"target_major_radius",
"target_minor_radius",
)
):
return f"Face 编辑结果里没有找到目标 {surface} 面,模型已恢复到修改前状态。"
return ""
checks: list[tuple[str, bool]] = []
target_area = _float_or_none(parameters.get("target_area"))
if target_area is not None and target_area > 0:
checks.append(("面积", self._face_target_area_matches(model, face_ids, target_area)))
target_width = _float_or_none(parameters.get("target_face_width"))
target_height = _float_or_none(parameters.get("target_face_height"))
if target_width is not None or target_height is not None:
checks.append(("面宽/面高", self._face_target_size_matches(model, face_ids, target_width, target_height)))
target_center = _triple_or_none(parameters.get("target_face_center"))
if target_center is not None:
checks.append(("中心", self._face_target_center_matches(model, face_ids, target_center)))
target_position = _float_or_none(parameters.get("target_plane_position"))
plane_direction = (
_unit_triple_or_none(parameters.get("plane_direction"))
or _unit_triple_or_none(parameters.get("outward_direction"))
)
if target_position is not None and plane_direction is not None:
checks.append(
(
"面偏移",
self._face_target_plane_position_matches(
model,
face_ids,
target_position,
plane_direction,
_float_or_none(parameters.get("bbox_diagonal")),
),
)
)
target_thickness = _float_or_none(parameters.get("shell_target_thickness"))
if target_thickness is not None and target_thickness > 0:
checks.append(
(
"薄壁厚度",
self._face_target_shell_thickness_matches(model, face_ids, target_thickness),
)
)
if surface == "sphere":
target_radius = _float_or_none(parameters.get("target_radius"))
if target_radius is not None and target_radius > 0:
checks.append(("半径", self._face_target_numeric_info_matches(model, face_ids, "radius", target_radius)))
elif surface == "cone":
resize_strategy = str(parameters.get("resize_strategy") or "")
embedded_target_small_radius = _float_or_none(parameters.get("embedded_cone_target_small_radius"))
embedded_target_large_radius = _float_or_none(parameters.get("embedded_cone_target_large_radius"))
if (
embedded_target_small_radius is not None
and embedded_target_small_radius > 0
and embedded_target_large_radius is not None
and embedded_target_large_radius > embedded_target_small_radius
):
checks.append(
(
"锥孔边界半径",
self._face_target_cone_boundary_radii_match(
model,
face_ids,
embedded_target_small_radius,
embedded_target_large_radius,
),
)
)
target_reference_radius = _float_or_none(parameters.get("target_reference_radius"))
if (
target_reference_radius is not None
and target_reference_radius > 0
and "semi-angle" not in resize_strategy
and "bounded-cone-recut" not in resize_strategy
):
checks.append(
(
"参考半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"reference_radius",
target_reference_radius,
),
)
)
target_semi_angle_degrees = _float_or_none(parameters.get("target_semi_angle_degrees"))
if target_semi_angle_degrees is not None and target_semi_angle_degrees > 0:
checks.append(
(
"半角",
self._face_target_angle_degrees_matches(model, face_ids, target_semi_angle_degrees),
)
)
elif surface == "torus":
target_major_radius = _float_or_none(parameters.get("target_major_radius"))
if target_major_radius is not None and target_major_radius > 0:
checks.append(
(
"主半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"major_radius",
target_major_radius,
),
)
)
target_minor_radius = _float_or_none(parameters.get("target_minor_radius"))
if target_minor_radius is not None and target_minor_radius > 0:
checks.append(
(
"小半径",
self._face_target_numeric_info_matches(
model,
face_ids,
"minor_radius",
target_minor_radius,
),
)
)
failed = [name for name, ok in checks if not ok]
if not failed:
return ""
return (
"Face 编辑结果没有达到目标值;"
f"未通过的检查: {', '.join(failed)}"
"模型已恢复到修改前状态。"
)
def _face_target_candidate_ids(self, model: StepModel, context: dict[str, object]) -> list[int]:
parameters = context.get("parameters")
parameters = parameters if isinstance(parameters, dict) else {}
candidates: list[int] = []
logical_id = context.get("target_logical_id")
if logical_id is not None:
try:
candidates.extend(model.face_ids_for_logical_id(int(logical_id)))
except Exception:
pass
target_id = context.get("target_id")
try:
numeric_target_id = int(target_id) if target_id is not None else -1
except (TypeError, ValueError):
numeric_target_id = -1
if 0 <= numeric_target_id < len(model.faces):
candidates.append(numeric_target_id)
if not candidates:
candidates = list(range(len(model.faces)))
part_id = self._edit_integrity_int_or_none(parameters.get("part_id"))
solid_id = self._edit_integrity_int_or_none(parameters.get("solid_id"))
surface = str(parameters.get("surface") or "")
target_keys = (
"target_area",
"target_face_width",
"target_face_height",
"target_face_center",
"target_plane_position",
"shell_target_thickness",
"target_reference_radius",
"target_semi_angle_degrees",
"embedded_cone_target_small_radius",
"embedded_cone_target_large_radius",
"target_radius",
"target_major_radius",
"target_minor_radius",
)
has_target_value = any(parameters.get(key) not in {"", None} for key in target_keys)
def filtered(source_ids, *, require_solid: bool = True) -> list[int]:
result: list[int] = []
for face_id in source_ids:
if not (0 <= int(face_id) < len(model.faces)):
continue
if part_id is not None and int(model.face_part_ids[int(face_id)]) != part_id:
continue
if (
require_solid
and solid_id is not None
and solid_id >= 0
and int(model.face_solid_ids[int(face_id)]) != solid_id
):
continue
if surface in {"plane", "cylinder", "cone", "sphere", "torus"}:
try:
if model.face_surface_kind(int(face_id)) != surface:
continue
except Exception:
continue
if int(face_id) not in result:
result.append(int(face_id))
return result
result = filtered(candidates)
if result:
return result
if surface in {"plane", "cylinder", "cone", "sphere", "torus"} and has_target_value:
result = filtered(range(len(model.faces)))
if result:
return result
if solid_id is not None and solid_id >= 0:
result = filtered(range(len(model.faces)), require_solid=False)
return result
def _edit_integrity_int_or_none(self, value: object) -> int | None:
if value in {"", None}:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _face_target_area_matches(self, model: StepModel, face_ids: list[int], target_area: float) -> bool:
tolerance = max(abs(target_area) * 0.02, 1e-4)
for face_id in face_ids:
try:
area = _float_or_none(model.face_info(face_id).get("area"))
except Exception:
area = None
if area is not None and abs(area - target_area) <= tolerance:
return True
return False
def _face_target_size_matches(
self,
model: StepModel,
face_ids: list[int],
target_width: float | None,
target_height: float | None,
) -> bool:
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
width = _float_or_none(info.get("local_face_width"))
height = _float_or_none(info.get("local_face_height"))
if target_width is not None:
if width is None or abs(width - target_width) > max(abs(target_width) * 0.02, 1e-4):
continue
if target_height is not None:
if height is None or abs(height - target_height) > max(abs(target_height) * 0.02, 1e-4):
continue
return True
return False
def _face_target_center_matches(
self,
model: StepModel,
face_ids: list[int],
target_center: tuple[float, float, float],
) -> bool:
reference = max(max(abs(item) for item in target_center), 1.0)
tolerance = max(reference * 1e-5, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
center = _triple_or_none(info.get("area_center")) or _triple_or_none(info.get("bbox_center"))
if center is None:
continue
distance = math.sqrt(sum((center[index] - target_center[index]) ** 2 for index in range(3)))
if distance <= tolerance:
return True
return False
def _face_target_plane_position_matches(
self,
model: StepModel,
face_ids: list[int],
target_position: float,
direction: tuple[float, float, float],
bbox_diagonal: float | None,
) -> bool:
tolerance = max((bbox_diagonal or 1.0) * 1e-4, abs(target_position) * 1e-5, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
except Exception:
continue
origin = _triple_or_none(info.get("plane_origin"))
if origin is None:
continue
position = sum(origin[index] * direction[index] for index in range(3))
if abs(position - target_position) <= tolerance:
return True
return False
def _face_target_shell_thickness_matches(
self,
model: StepModel,
face_ids: list[int],
target_thickness: float,
) -> bool:
tolerance = max(abs(target_thickness) * 0.03, 1e-4)
for face_id in face_ids:
try:
info = model.feature_info(face_id)
except Exception:
continue
current = _float_or_none(info.get("shell_thickness_estimate"))
if current is not None and abs(current - target_thickness) <= tolerance:
return True
return False
def _face_target_numeric_info_matches(
self,
model: StepModel,
face_ids: list[int],
info_key: str,
target_value: float,
) -> bool:
tolerance = max(abs(target_value) * 0.01, 1e-4)
for face_id in face_ids:
try:
value = _float_or_none(model.face_info(face_id).get(info_key))
except Exception:
value = None
if value is not None and abs(value - target_value) <= tolerance:
return True
return False
def _face_target_cone_boundary_radii_match(
self,
model: StepModel,
face_ids: list[int],
target_small_radius: float,
target_large_radius: float,
) -> bool:
tolerance = max(abs(target_large_radius) * 0.01, abs(target_small_radius) * 0.01, 1e-4)
for face_id in face_ids:
try:
info = model.face_info(face_id)
axis_point = _triple_or_none(info.get("axis_point"))
axis_direction = _unit_triple_or_none(info.get("axis"))
if axis_point is None or axis_direction is None:
continue
circles = model._conical_face_circle_boundaries(face_id, axis_point, axis_direction)
except Exception:
continue
radii = sorted(float(circle["radius"]) for circle in circles)
if len(radii) != 2:
continue
if abs(radii[0] - target_small_radius) <= tolerance and abs(radii[1] - target_large_radius) <= tolerance:
return True
return False
def _face_target_angle_degrees_matches(
self,
model: StepModel,
face_ids: list[int],
target_degrees: float,
) -> bool:
tolerance = max(abs(target_degrees) * 0.01, 0.05)
for face_id in face_ids:
try:
angle = _float_or_none(model.face_info(face_id).get("semi_angle"))
except Exception:
angle = None
if angle is None:
continue
degrees = abs(math.degrees(angle))
if abs(degrees - target_degrees) <= tolerance:
return True
return False
def _context_is_face_parameter_edit(self, context: dict[str, object]) -> bool:
parameters = context.get("parameters")
if not isinstance(parameters, dict):
return False
strategy = str(parameters.get("resize_strategy") or parameters.get("translate_strategy") or "")
if any(
token in strategy
for token in (
"face",
"plane-offset",
"push-pull-planar",
"push-pull-shell",
"shell-thickness",
"cone",
"sphere",
"torus",
"radial-affine-scale",
)
):
return True
operation = str(context.get("operation_name") or "")
target_kind = str(context.get("target_kind") or "")
return target_kind in {"face", "feature"} and any(
token in operation for token in ("Face", "", "薄壁", "推拉", "圆锥", "球面", "环面")
)
def _begin_edit_task(self, operation_name: str) -> None:
self.operation_in_progress = True
self._update_action_states()