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
+252
View File
@@ -64,6 +64,185 @@ from .geometry_utils import * # noqa: F403
class FeatureMixin:
def _cylindrical_feature_first_level_plan_fields(self, face_id: int) -> dict[str, object]:
try:
topology = self.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),
"first_level_boundary_edge_ids": (),
"first_level_boundary_edge_count": 0,
"first_level_boundary_vertex_count": 0,
"first_level_adjacent_face_ids": (),
"first_level_adjacent_face_count": 0,
"cylindrical_feature_side_face_ids": (face_id,),
"cylindrical_feature_side_face_count": 1,
}
fields = {
"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_adjacent_surface_types": topology.get("cylindrical_feature_adjacent_surface_types", ()),
"first_level_shared_edges_by_face": topology.get("cylindrical_feature_shared_edges_by_face", ()),
"first_level_face_ids": topology.get("cylindrical_feature_first_level_face_ids", ()),
"first_level_face_count": topology.get("cylindrical_feature_first_level_face_count", 0),
"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_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,
),
}
fields["first_level_edit_semantics"] = (
"Cylindrical feature edits currently use only first-level shared-edge topology: "
"the selected cylinder/slot wall is rebuilt together with its direct boundary neighbors; "
"second-level and deeper propagation is not automatic yet."
)
return fields
def _cylindrical_first_level_guard_fields(
self,
topology_fields: dict[str, object],
*,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> dict[str, object]:
blockers: list[str] = []
warnings: list[str] = []
risk = "low"
status = str(topology_fields.get("topology_relation_status") or "")
if status != "ready":
blockers.append(
"当前孔/槽的一级关系拓扑无法确认,已阻止局部重建;请换一个更明确的孔壁/槽壁 Face。"
)
side_count = int(topology_fields.get("cylindrical_feature_side_face_count", 0) or 0)
edge_count = int(topology_fields.get("cylindrical_feature_boundary_edge_count", 0) or 0)
vertex_count = int(topology_fields.get("first_level_boundary_vertex_count", 0) or 0)
adjacent_count = int(topology_fields.get("cylindrical_feature_adjacent_face_count", 0) or 0)
slot_boundary_count = int(topology_fields.get("cylindrical_feature_slot_boundary_face_count", 0) or 0)
bottom_count = int(topology_fields.get("cylindrical_feature_bottom_face_count", 0) or 0)
if side_count <= 0:
blockers.append("没有识别到当前孔/槽的圆柱侧壁区域,不能稳定局部修改。")
if edge_count <= 0:
blockers.append("没有识别到当前孔/槽侧壁的边界 Edge,不能确定一级联动范围。")
if adjacent_count <= 0:
blockers.append("没有识别到与当前孔/槽直接共边的相邻 Face,不能保证修改后拓扑闭合。")
if require_slot_boundary and slot_boundary_count <= 0:
blockers.append("当前槽/半孔缺少直接槽边界 Face,不能稳定执行槽的局部重建。")
if require_bottom and bottom_count <= 0:
blockers.append("当前盲孔/盲槽缺少可靠底面 Face,不能稳定执行局部深度修改。")
if not blockers:
if side_count > 4 or edge_count > 20 or adjacent_count > 14:
risk = _max_risk(risk, "high")
warnings.append(
"当前孔/槽的一级关系邻域较复杂,局部布尔重建可能影响多个直接相邻面。"
)
elif side_count > 1 or edge_count > 10 or adjacent_count > 8:
risk = _max_risk(risk, "medium")
warnings.append("当前孔/槽由多个侧壁或较多相邻面组成,修改后请重点检查一级邻域。")
note = (
f"一级关系拓扑检查:侧壁 Face {side_count} 个,边界 Edge {edge_count} 条,"
f"边界 Vertex {vertex_count} 个,直接相邻 Face {adjacent_count} 个。"
)
return {
"first_level_topology_status": "blocked" if blockers else "ready",
"first_level_topology_risk": "blocked" if blockers else risk,
"first_level_topology_blockers": "".join(blockers),
"first_level_topology_warnings": "".join(warnings),
"first_level_topology_guard_note": note,
}
def _apply_cylindrical_first_level_guard(
self,
topology_fields: dict[str, object],
blockers: list[str],
warnings: list[str],
risk: str,
*,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> str:
guard = self._cylindrical_first_level_guard_fields(
topology_fields,
require_slot_boundary=require_slot_boundary,
require_bottom=require_bottom,
)
topology_fields.update(guard)
blocker_text = str(guard.get("first_level_topology_blockers") or "").strip()
warning_text = str(guard.get("first_level_topology_warnings") or "").strip()
if blocker_text:
blockers.append(blocker_text)
if warning_text:
warnings.append(warning_text)
return _max_risk(risk, str(guard.get("first_level_topology_risk") or "low"))
def _apply_cylindrical_first_level_guard_to_readiness(
self,
readiness: dict[str, object],
topology_fields: dict[str, object],
*,
status_key: str,
risk_key: str,
note_key: str,
warnings_key: str,
blockers_key: str,
require_slot_boundary: bool = False,
require_bottom: bool = False,
) -> dict[str, object]:
guard = self._cylindrical_first_level_guard_fields(
topology_fields,
require_slot_boundary=require_slot_boundary,
require_bottom=require_bottom,
)
topology_fields.update(guard)
result = dict(readiness)
blocker_text = str(guard.get("first_level_topology_blockers") or "").strip()
warning_text = str(guard.get("first_level_topology_warnings") or "").strip()
if blocker_text:
result[status_key] = "blocked"
result[risk_key] = "blocked"
result[blockers_key] = _join_nonempty(result.get(blockers_key), blocker_text)
result[note_key] = _join_nonempty(result.get(note_key), blocker_text)
else:
result[risk_key] = _max_risk(str(result.get(risk_key) or "low"), str(guard["first_level_topology_risk"]))
if warning_text:
result[warnings_key] = _join_nonempty(result.get(warnings_key), warning_text)
result[note_key] = _join_nonempty(result.get(note_key), warning_text)
return result
def editable_feature_candidates(
self,
limit: int = 160,
@@ -727,6 +906,7 @@ class FeatureMixin:
}
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -737,6 +917,15 @@ class FeatureMixin:
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_resize_readiness(scoped_info, new_diameter)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="resize_status",
risk_key="resize_risk",
note_key="resize_note",
warnings_key="resize_warnings",
blockers_key="resize_blockers",
)
resize_mode = _resize_mode(current_diameter, new_diameter)
delta_diameter = new_diameter - current_diameter
diameter_delta_ratio = abs(delta_diameter) / max(current_diameter, 1e-9)
@@ -781,6 +970,7 @@ class FeatureMixin:
"resize_strategy": "same-axis-bounded-cylinder-recut",
"edit_strategy_label": "同轴圆柱重切",
"edit_semantics": edit_semantics,
**topology_fields,
**cutter_plan,
**fill_plan,
}
@@ -799,12 +989,14 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
blockers: list[str] = []
warnings: list[str] = [
"Cylinder axis move fills the current cylindrical hole, then cuts a same-diameter hole on the target axis."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(topology_fields, blockers, warnings, risk)
try:
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
@@ -897,6 +1089,7 @@ class FeatureMixin:
return {
**cutter_plan,
**fill_plan,
**topology_fields,
"status": status,
"risk": risk,
"message": message,
@@ -965,6 +1158,7 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None:
@@ -982,6 +1176,13 @@ class FeatureMixin:
"Slot parameter edit keeps the current partial-cylinder angular span, converts the target value to a cylinder diameter, and rebuilds only the local sector volume."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try:
target_value = float(target_value)
@@ -1067,6 +1268,7 @@ class FeatureMixin:
"feature_slot_face_ids": feature.get("feature_slot_face_ids"),
"feature_slot_boundary_face_ids": feature.get("feature_slot_boundary_face_ids"),
"slot_note": feature.get("slot_note"),
**topology_fields,
}
if blockers:
return {
@@ -1141,6 +1343,7 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None:
@@ -1153,6 +1356,13 @@ class FeatureMixin:
"Slot angular-span edit keeps the current cylinder radius, fills the old local sector, then cuts a new local sector around the same angular center."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try:
target_angular_span = float(target_angular_span)
@@ -1241,6 +1451,7 @@ class FeatureMixin:
return {
**cutter_plan,
**fill_plan,
**topology_fields,
"status": status,
"risk": "blocked" if blockers else risk,
"message": message,
@@ -1303,12 +1514,20 @@ class FeatureMixin:
feature = self.feature_info(face_id)
except Exception:
feature = {}
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
blockers: list[str] = []
warnings: list[str] = [
"Slot/half-hole axis move fills the old local sector, then cuts the same sector tool on the target axis."
]
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
try:
target_center = (float(target_center[0]), float(target_center[1]), float(target_center[2]))
@@ -1473,6 +1692,7 @@ class FeatureMixin:
**cutter_plan,
**fill_plan,
**pair_plan,
**topology_fields,
"status": status,
"risk": "blocked" if blockers else risk,
"message": message,
@@ -1723,6 +1943,7 @@ class FeatureMixin:
raise ValueError(f"Unknown face id {face_id}")
info = self.face_info(face_id)
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
current_diameter = _float_or_none(info.get("diameter"))
if current_diameter is None or current_diameter <= 1e-9:
return {
@@ -1749,6 +1970,13 @@ class FeatureMixin:
blockers: list[str] = []
warnings: list[str] = []
risk = "medium"
risk = self._apply_cylindrical_first_level_guard(
topology_fields,
blockers,
warnings,
risk,
require_slot_boundary=True,
)
if not paired_plan:
if pair_face_id is None:
@@ -1815,6 +2043,7 @@ class FeatureMixin:
**cutter_plan,
**fill_plan,
**paired_plan,
**topology_fields,
"status": status,
"risk": risk,
"message": message,
@@ -1918,6 +2147,7 @@ class FeatureMixin:
current_diameter = float(info["diameter"])
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -2130,6 +2360,7 @@ class FeatureMixin:
"message": "当前选中的 Face 不是圆柱面,不能封堵圆柱孔。",
}
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
axis_range = self._cylindrical_axis_range(
face_id,
BRepAdaptor_Surface(self.faces[face_id]),
@@ -2140,6 +2371,15 @@ class FeatureMixin:
scoped_info["v_range"] = (axis_range["v_min"], axis_range["v_max"])
scoped_info.update(self._cylinder_end_opening_info(face_id, BRepAdaptor_Surface(self.faces[face_id]), axis_range))
readiness = _cylinder_suppress_readiness(scoped_info)
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="suppress_status",
risk_key="suppress_risk",
note_key="suppress_note",
warnings_key="suppress_warnings",
blockers_key="suppress_blockers",
)
fill_plan = self._bounded_cylinder_fill_plan(face_id)
fill_plan["fill_strategy"] = "bounded-hole-suppress-fill"
fill_plan["fill_note"] = "按当前圆柱孔范围生成略带重叠的补料圆柱体,用于封堵完整通孔或盲孔。"
@@ -2169,6 +2409,7 @@ class FeatureMixin:
"feature_opening_face_ids": feature.get("feature_opening_face_ids"),
"feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "fill-cylindrical-hole-volume",
**topology_fields,
"edit_strategy_label": "圆柱补料封堵",
"edit_semantics": "按当前孔轴线和估算高度生成补料圆柱体,局部 Fuse 后封堵当前完整圆柱孔。",
**fill_plan,
@@ -2191,6 +2432,7 @@ class FeatureMixin:
}
feature = self.feature_info(face_id)
topology_fields = self._cylindrical_feature_first_level_plan_fields(face_id)
context = self._blind_cylindrical_depth_context(
face_id,
info,
@@ -2212,6 +2454,15 @@ class FeatureMixin:
context.get("context_message"),
)
readiness["depth_note"] = readiness["depth_blockers"]
readiness = self._apply_cylindrical_first_level_guard_to_readiness(
readiness,
topology_fields,
status_key="depth_status",
risk_key="depth_risk",
note_key="depth_note",
warnings_key="depth_warnings",
blockers_key="depth_blockers",
)
current_depth = float(depth_info.get("hole_depth_estimate", 0.0))
delta_depth = target_depth - current_depth
@@ -2249,6 +2500,7 @@ class FeatureMixin:
"feature_bottom_detection": feature.get("feature_bottom_detection"),
"feature_bottom_note": feature.get("feature_bottom_note"),
"resize_strategy": "bounded-blind-depth-cut-or-fill",
**topology_fields,
"edit_strategy_label": "盲孔/盲槽深度切削或补料",
"edit_semantics": "沿识别到的开口到底面方向调整深度:加深时切削,变浅时从新底面到旧底面补料。",
}