feat: 完善 Edge 一级编辑与 CAD 建模语义

This commit is contained in:
2026-08-05 18:06:15 +08:00
parent ed1faf51d8
commit 57d75541c3
19 changed files with 2365 additions and 367 deletions
+342 -105
View File
@@ -49,8 +49,10 @@ from OCC.Core.GeomAbs import (
)
from OCC.Core.GProp import GProp_GProps
from OCC.Core.GC import GC_MakeArcOfCircle
from OCC.Core.GeomAPI import GeomAPI_PointsToBSplineSurface
from OCC.Core.ShapeFix import ShapeFix_Shape
from OCC.Core.ShapeUpgrade import ShapeUpgrade_UnifySameDomain
from OCC.Core.TColgp import TColgp_Array2OfPnt
from OCC.Core.TopAbs import (
TopAbs_EDGE,
TopAbs_EXTERNAL,
@@ -342,6 +344,28 @@ class OperationMixin:
f"edges {before_part_stats.edges}->{after_part_stats.edges}."
)
def _edge_first_level_plan_fields(self, edge_id: int) -> dict[str, object]:
try:
return self.edge_first_level_facts(edge_id)
except Exception as exc:
return {
"first_level_fact_model": "STEP/B-Rep first-level fact graph",
"first_level_fact_source_model": "edge",
"first_level_fact_status": "unavailable",
"first_level_fact_relation_depth": 1,
"first_level_fact_relation_boundary": "shared-vertex/shared-face",
"first_level_fact_scope": "edge",
"first_level_fact_subject_role": "selected 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"Edge first-level facts are temporarily unavailable: {exc}",
}
def edge_fillet_plan(self, edge_id: int, radius: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
raise ValueError(f"Unknown edge id {edge_id}")
@@ -366,7 +390,7 @@ class OperationMixin:
length = float(info.get("length", 0.0))
radius_to_length_ratio = radius / max(length, 1e-9)
return {
plan = {
"status": readiness["fillet_status"],
"risk": readiness["fillet_risk"],
"message": readiness["fillet_note"],
@@ -391,6 +415,8 @@ class OperationMixin:
"不是修改已有圆角面。"
),
}
plan.update(self._edge_first_level_plan_fields(edge_id))
return plan
def edge_chamfer_plan(self, edge_id: int, distance: float) -> dict[str, object]:
if edge_id < 0 or edge_id >= len(self.edges):
@@ -416,7 +442,7 @@ class OperationMixin:
length = float(info.get("length", 0.0))
distance_to_length_ratio = distance / max(length, 1e-9)
return {
plan = {
"status": readiness["chamfer_status"],
"risk": readiness["chamfer_risk"],
"message": readiness["chamfer_note"],
@@ -440,6 +466,8 @@ class OperationMixin:
"在当前直线 Edge 及其相邻 Face 上调用 OCCT 对称倒角;会替换这条边附近的局部拓扑。"
),
}
plan.update(self._edge_first_level_plan_fields(edge_id))
return plan
def edge_asymmetric_chamfer_plan(
self,
@@ -669,6 +697,7 @@ class OperationMixin:
"end_point": info.get("end_point"),
"length_center": info.get("length_center"),
}
base.update(self._edge_first_level_plan_fields(edge_id))
warnings: list[str] = [
"Edge长度修改基于当前 STEP/B-Rep 结果几何,不是 CAD 建模历史里的参数编辑。"
]
@@ -693,7 +722,7 @@ class OperationMixin:
warnings.append("长度变化超过当前Edge长度的 25%,请确认预览范围。")
if not blockers and force_local and curve != "line":
blockers.append("“只当前Edge”策略当前只支持直线Edge。")
blockers.append("“只当前Edge”策略当前只支持直线Edge。")
if not blockers and force_end_face and curve != "line":
blockers.append("“移动端面/整体尺寸”策略当前只支持直线Edge。")
if not blockers and force_cylinder and curve != "circle":
@@ -711,7 +740,7 @@ class OperationMixin:
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
status = "caution" if risk != "low" else status
warnings.append(
"将优先只移动当前 Edge 的端点并重建相邻平面;非共面的四边面会拆成三角面。"
"将优先只移动当前 Edge 的端点并重建相邻平面;四点非共面面会优先重建为连续曲面。"
)
base.update(local_candidate)
elif local_skip_note:
@@ -945,7 +974,7 @@ class OperationMixin:
"local_edge_deform_moved_endpoint_count": 2 if anchor_mode == "center" else 1,
"local_edge_deform_risk": local_risk,
"local_edge_deform_note": (
"只移动当前 Edge 的端点并重建所属平面多面体;非共面 Face 会被拆成三角面。"
"只移动当前 Edge 的端点并重建所属平面多面体;四点非共面 Face 会优先重建为连续曲面。"
),
"part_solid_count": part_solid_count,
},
@@ -999,6 +1028,7 @@ class OperationMixin:
"length_center": info.get("length_center"),
"resize_strategy": "local-edge-endpoint-deform",
}
base.update(self._edge_first_level_plan_fields(edge_id))
warnings = [
"Edge endpoint coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history."
]
@@ -1082,7 +1112,7 @@ class OperationMixin:
)
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
warnings.append(
"The selected Edge endpoint will be moved and the surrounding planar faces will be rebuilt; non-planar faces may be split into triangles."
"The selected Edge endpoint will be moved and the surrounding planar faces will be rebuilt; four-point non-planar faces are rebuilt as continuous surfaces when possible."
)
if blockers:
@@ -1136,6 +1166,7 @@ class OperationMixin:
"target_edge_center": target,
"resize_strategy": "local-edge-center-deform",
}
base.update(self._edge_first_level_plan_fields(edge_id))
warnings = [
"Edge center coordinate edit rebuilds the current STEP/B-Rep result geometry; it is not recovered CAD history."
]
@@ -1205,7 +1236,7 @@ class OperationMixin:
)
risk = _max_risk(risk, str(local_candidate.get("local_edge_deform_risk", "medium")))
warnings.append(
"Both endpoints of the selected Edge will be moved and the surrounding planar faces will be rebuilt; non-planar faces may be split into triangles."
"Both endpoints of the selected Edge will be moved and the surrounding planar faces will be rebuilt; four-point non-planar faces are rebuilt as continuous surfaces when possible."
)
if blockers:
@@ -1324,7 +1355,7 @@ class OperationMixin:
if strategy == "local-edge-only-deform":
return (
"只改当前Edge:只移动被选Edge的端点并重建相邻平面;相邻面会自然变斜,"
"必要时非共面四边面会拆成三角面,整体端面不会一起平移。"
"四点非共面面会优先重建为连续曲面,整体端面不会一起平移。"
)
if strategy == "move-edge-end-plane-by-push-pull":
end_face = plan.get("end_face_id")
@@ -1557,6 +1588,7 @@ class OperationMixin:
}
if candidate:
result.update(candidate)
result.update(self._edge_first_level_plan_fields(edge_id))
return result
def _circular_edge_axis_move_cylinder_candidate(
@@ -2019,7 +2051,7 @@ class OperationMixin:
warnings.append(f"目标{axis_label}变化超过当前值的 50%,周边几何变形或修复失败的概率较高。")
elif delta_ratio > 0.25:
risk = _max_risk(risk, "medium")
warnings.append(f"目标{axis_label}变化超过当前值的 25%,请确认影响范围")
warnings.append(f"目标{axis_label}变化超过当前值的 25%,请确认建模意图")
part_id = int(info.get("part_id", -1))
solid_id = int(info.get("solid_id", -1))
@@ -2042,7 +2074,7 @@ class OperationMixin:
target_major = target_radius if axis_kind == "major" else info.get("major_radius")
target_minor = target_radius if axis_kind == "minor" else info.get("minor_radius")
return {
plan = {
"edge_id": edge_id,
"part_id": part_id,
"solid_id": solid_id,
@@ -2081,6 +2113,8 @@ class OperationMixin:
"affine_target_kind": target_kind,
"part_solid_count": part_solid_count,
}
plan.update(self._edge_first_level_plan_fields(edge_id))
return plan
def resize_ellipse_edge_axis_radius(
self,
@@ -2857,7 +2891,7 @@ class OperationMixin:
except (TypeError, ValueError):
distance = 0.0
frame = None
blockers = ["目标偏移曲面必须是数字。"]
blockers = ["目标偏移变换位置必须是数字。"]
else:
frame = self.face_plane_offset_frame(face_id)
blockers = []
@@ -2865,7 +2899,7 @@ class OperationMixin:
if face_id < 0 or face_id >= len(self.faces):
blockers.append(f"Unknown face id {face_id}")
if frame is None:
blockers.append("当前 Face 缺少稳定平面方向或基准点,不能执行偏移曲面(局部重建)。")
blockers.append("当前 Face 缺少稳定平面方向或基准点,不能执行偏移变换(局部重建)。")
current_center = None
target_center = None
@@ -2905,8 +2939,8 @@ class OperationMixin:
"target_face_center": target_center,
"face_center_move_vector": move_vector,
"resize_strategy": "local-face-plane-offset-deform",
"edit_strategy_label": "偏移曲面(局部重建)",
"edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的移动量,移动当前 Face 并重建相邻平面。",
"edit_strategy_label": "偏移变换(局部重建)",
"edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 并重建相邻平面。",
}
plan = self.face_center_local_move_plan(face_id, target_center)
@@ -2918,9 +2952,9 @@ class OperationMixin:
"plane_direction": plane_direction,
"plane_offset_distance": distance,
"resize_strategy": "local-face-plane-offset-deform",
"edit_strategy_label": "偏移曲面(局部重建)",
"edit_strategy_label": "偏移变换(局部重建)",
"edit_semantics": (
"把目标偏移曲面换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;"
"把目标偏移变换位置换算成沿当前面垂直方向的移动量,移动当前 Face 的顶点并重建相邻平面;"
"不拉伸/切除加料/切削,也不平移所属对象。"
),
}
@@ -2933,7 +2967,7 @@ class OperationMixin:
except (TypeError, ValueError):
distance = 0.0
frame = None
blockers = ["目标偏移曲面必须是数字。"]
blockers = ["目标偏移变换位置必须是数字。"]
else:
frame = self.face_plane_offset_frame(face_id)
blockers = []
@@ -2944,7 +2978,7 @@ class OperationMixin:
else:
info = self.face_info(face_id)
if frame is None:
blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按偏移曲面平移所属对象。")
blockers.append("当前 Face 缺少稳定平面方向或基准点,不能按偏移变换平移所属对象。")
plane_origin = None
plane_direction = None
@@ -2987,8 +3021,8 @@ class OperationMixin:
"translation_vector": vector,
"resize_strategy": "translate-owning-shape-from-plane-offset",
"translate_strategy": f"translate-{target_kind}",
"edit_strategy_label": "按偏移曲面平移所属对象",
"edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。",
"edit_strategy_label": "按偏移变换平移所属对象",
"edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid。",
}
plan = self.translate_solid_plan(solid_id, vector) if target_kind == "solid" else self.translate_part_plan(part_id, vector)
@@ -2997,7 +3031,7 @@ class OperationMixin:
if diagonal > 1e-9:
distance_ratio = abs(distance) / diagonal
if distance_ratio > 5.0:
blocker = "目标偏移曲面需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。"
blocker = "目标偏移变换位置需要移动的距离超过所属对象尺寸的 5 倍,容易把特征移动到远离模型的位置。"
blockers = [part for part in str(plan.get("blockers") or "").split("") if part]
blockers.append(blocker)
plan["status"] = "blocked"
@@ -3017,8 +3051,8 @@ class OperationMixin:
"face_offset_distance_ratio": distance_ratio,
"resize_strategy": "translate-owning-shape-from-plane-offset",
"translate_strategy": f"translate-{target_kind}",
"edit_strategy_label": "按偏移曲面平移所属对象",
"edit_semantics": "把目标偏移曲面换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。",
"edit_strategy_label": "按偏移变换平移所属对象",
"edit_semantics": "把目标偏移变换位置换算成沿当前面垂直方向的平移量,并平移所属特征或 Solid;不拉伸/切除,不切削,也不补料。",
}
)
return plan
@@ -3466,7 +3500,7 @@ class OperationMixin:
for face in solid_faces:
surf = BRepAdaptor_Surface(face)
if surf.GetType() != GeomAbs_Plane:
blockers.append("局部 Face 移动暂只支持全平面实体;含曲面的模型请使用其它编辑方式")
blockers.append("局部 Face 移动暂只支持全平面实体;含曲面的模型请使用其它建模意图")
break
if len(_explore(face, TopAbs_WIRE)) != 1:
blockers.append("局部 Face 移动暂不处理带内孔的 Face;请使用孔/槽专门入口。")
@@ -6762,67 +6796,94 @@ class OperationMixin:
)
if plan["status"] == "blocked":
raise ValueError(str(plan["message"]))
part_id = int(plan.get("part_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
old_part_shape = part.shape if part is not None else None
if plan.get("resize_strategy") == "local-edge-only-deform":
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by local edge-only deformation: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"anchor={plan.get('local_edge_deform_anchor')}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
try:
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary_or_raise(plan)
return (
"Edge length resize completed by local edge-only deformation: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"anchor={plan.get('local_edge_deform_anchor')}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
except Exception:
if part is not None and old_part_shape is not None:
part.shape = old_part_shape
self.refresh_topology()
raise
if plan.get("resize_strategy") == "move-edge-end-plane-by-push-pull":
push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"]))
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by end-face push/pull: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"end_face={int(plan['end_face_id'])}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"risk={plan['risk']}. {result_check} {push_result}"
)
try:
push_result = self.push_pull_face(int(plan["end_face_id"]), float(plan["push_pull_distance"]))
result_check = self._edge_length_result_summary_or_raise(plan)
return (
"Edge length resize completed by end-face push/pull: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"end_face={int(plan['end_face_id'])}, "
f"push_pull_distance={float(plan['push_pull_distance']):g}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"risk={plan['risk']}. {result_check} {push_result}"
)
except Exception:
if part is not None and old_part_shape is not None:
part.shape = old_part_shape
self.refresh_topology()
raise
if plan.get("resize_strategy") == "resize-adjacent-cylinder-from-circular-edge-length":
face_id = int(plan["cylinder_resize_face_id"])
target_diameter = float(plan["cylinder_resize_target_diameter"])
if plan.get("circular_edge_cylinder_mode") == "boss":
resize_result = self.resize_cylindrical_boss(face_id, target_diameter)
else:
resize_result = self.resize_cylindrical_hole(face_id, target_diameter)
result_check = self._edge_length_result_summary(plan)
try:
face_id = int(plan["cylinder_resize_face_id"])
target_diameter = float(plan["cylinder_resize_target_diameter"])
if plan.get("circular_edge_cylinder_mode") == "boss":
resize_result = self.resize_cylindrical_boss(face_id, target_diameter)
else:
resize_result = self.resize_cylindrical_hole(face_id, target_diameter)
result_check = self._edge_length_result_summary_or_raise(plan)
return (
"Edge length resize completed by adjacent cylinder diameter edit: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"cylinder_face={face_id}, "
f"target_diameter={target_diameter:g}, "
f"mode={plan.get('circular_edge_cylinder_mode_label')}, "
f"risk={plan['risk']}. {result_check} {resize_result}"
)
except Exception:
if part is not None and old_part_shape is not None:
part.shape = old_part_shape
self.refresh_topology()
raise
try:
self._apply_edge_length_affine_transform(plan)
result_check = self._edge_length_result_summary_or_raise(plan)
return (
"Edge length resize completed by adjacent cylinder diameter edit: "
"Edge length resize completed by geometric scale fallback: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"cylinder_face={face_id}, "
f"target_diameter={target_diameter:g}, "
f"mode={plan.get('circular_edge_cylinder_mode_label')}, "
f"risk={plan['risk']}. {result_check} {resize_result}"
f"scale={float(plan['affine_scale']):g}, "
f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, "
f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, "
f"axis_source={plan.get('affine_axis_source')}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
self._apply_edge_length_affine_transform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge length resize completed by geometric scale fallback: "
f"edge {edge_id}, current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"scale={float(plan['affine_scale']):g}, "
f"transform={plan.get('affine_transform_label') or plan.get('affine_transform_kind')}, "
f"predicted_edge_length={float(plan.get('affine_predicted_edge_length') or 0.0):g}, "
f"axis_source={plan.get('affine_axis_source')}, "
f"anchor={plan.get('edge_length_anchor_label')}, "
f"target={plan.get('affine_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
except Exception:
if part is not None and old_part_shape is not None:
part.shape = old_part_shape
self.refresh_topology()
raise
def move_edge_endpoint(
self,
@@ -6836,22 +6897,31 @@ class OperationMixin:
if plan.get("resize_strategy") != "local-edge-endpoint-deform":
raise ValueError("Unsupported Edge endpoint move strategy.")
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge endpoint move completed by local edge deformation: "
f"edge {edge_id}, "
f"endpoint={plan.get('edge_endpoint_role')}, "
f"current_endpoint={plan.get('current_endpoint_point')}, "
f"target_endpoint={plan.get('target_endpoint_point')}, "
f"move={plan.get('moved_endpoint_delta')}, "
f"current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
part_id = int(plan.get("part_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
old_part_shape = part.shape if part is not None else None
try:
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary_or_raise(plan)
return (
"Edge endpoint move completed by local edge deformation: "
f"edge {edge_id}, "
f"endpoint={plan.get('edge_endpoint_role')}, "
f"current_endpoint={plan.get('current_endpoint_point')}, "
f"target_endpoint={plan.get('target_endpoint_point')}, "
f"move={plan.get('moved_endpoint_delta')}, "
f"current_length={float(plan['current_length']):g}, "
f"target_length={float(plan['target_length']):g}, "
f"delta={float(plan['delta_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
except Exception:
if part is not None and old_part_shape is not None:
part.shape = old_part_shape
self.refresh_topology()
raise
def move_edge_center(
self,
@@ -6864,19 +6934,28 @@ class OperationMixin:
if plan.get("resize_strategy") != "local-edge-center-deform":
raise ValueError("Unsupported Edge center move strategy.")
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary(plan)
return (
"Edge center move completed by local edge deformation: "
f"edge {edge_id}, "
f"current_center={plan.get('current_edge_center')}, "
f"target_center={plan.get('target_edge_center')}, "
f"move={plan.get('moved_edge_center_delta')}, "
f"edge_length={float(plan['current_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
part_id = int(plan.get("part_id", -1))
part = self.part_by_id(part_id) if part_id >= 0 else None
old_part_shape = part.shape if part is not None else None
try:
self._apply_local_edge_deform(plan)
result_check = self._edge_length_result_summary_or_raise(plan)
return (
"Edge center move completed by local edge deformation: "
f"edge {edge_id}, "
f"current_center={plan.get('current_edge_center')}, "
f"target_center={plan.get('target_edge_center')}, "
f"move={plan.get('moved_edge_center_delta')}, "
f"edge_length={float(plan['current_length']):g}, "
f"rebuilt_faces={plan.get('local_edge_deform_face_count')}, "
f"target={plan.get('local_edge_deform_target_kind')}, "
f"risk={plan['risk']}. {result_check}"
)
except Exception:
if part is not None and old_part_shape is not None:
part.shape = old_part_shape
self.refresh_topology()
raise
def face_center_local_move_preview_polydata(
self,
@@ -7255,12 +7334,29 @@ class OperationMixin:
action: Callable[[], object],
) -> tuple[object, str]:
snapshot = self._face_edit_snapshot(plan)
previous_logical_ids = tuple(getattr(self, "face_logical_ids", ()))
previous_faces = tuple(self.faces)
target_logical_id = _int_or_none(plan.get("target_logical_id"))
if target_logical_id is None:
plan_face_id = _int_or_none(plan.get("face_id"))
if plan_face_id is not None and 0 <= plan_face_id < len(self.faces):
try:
target_logical_id = self.face_region_logical_id(plan_face_id)
except Exception:
target_logical_id = None
excluded_logical_ids = (target_logical_id,)
try:
action_result = action()
self._apply_preserved_face_logical_ids_by_shape_identity(
previous_faces,
previous_logical_ids,
excluded_logical_ids=excluded_logical_ids,
)
result_check = self._checked_face_edit_result_summary(plan)
return action_result, result_check
except Exception as exc:
self._restore_face_edit_snapshot(snapshot)
self._restore_face_logical_ids_if_count_matches(previous_logical_ids)
raise RuntimeError(f"Face edit failed and the model was restored: {exc}") from exc
def _face_edit_result_summary(
@@ -7579,6 +7675,41 @@ class OperationMixin:
f"scope={check['scope']}."
)
def _edge_length_result_summary_or_raise(self, plan: dict[str, object]) -> str:
check = self._edge_length_result_check(plan)
if check is None:
raise RuntimeError("Edge长度编辑结果无法校验,模型已恢复到修改前状态。")
target_length = float(plan.get("target_length", 0.0) or 0.0)
length_tolerance = max(_shape_diagonal(self.shape) * 1e-5, target_length * 1e-4, 1e-5)
if float(check.get("target_error", 0.0) or 0.0) > length_tolerance:
raise RuntimeError(
"Edge长度编辑结果没有达到目标值,模型已恢复到修改前状态。"
f"最近Edge {check.get('edge_id')} 的长度约 {float(check.get('nearest_length', 0.0) or 0.0):g}"
f"目标长度 {target_length:g},误差 {float(check.get('target_error', 0.0) or 0.0):g}"
)
expected = self._edge_length_expected_endpoints(plan)
endpoint_error = float(check.get("endpoint_error", -1.0) or -1.0)
if expected is not None and endpoint_error >= 0.0:
endpoint_tolerance = max(_shape_diagonal(self.shape) * 1e-4, target_length * 1e-3, 1e-4)
if endpoint_error > endpoint_tolerance:
raise RuntimeError(
"Edge长度编辑后的目标端点位置不符合所选建模意图,模型已恢复到修改前状态。"
f"最近Edge {check.get('edge_id')} 的端点误差约 {endpoint_error:g}"
f"允许误差 {endpoint_tolerance:g}"
)
return (
"Result check: "
f"match={check['match_method']}, "
f"nearest_edge={check['edge_id']}, "
f"nearest_length={float(check['nearest_length']):g}, "
f"target_error={float(check['target_error']):g}, "
f"relative_error={float(check['relative_error']):g}, "
f"endpoint_error={float(check['endpoint_error']):g}, "
f"scope={check['scope']}."
)
def _edge_length_result_check(self, plan: dict[str, object]) -> dict[str, object] | None:
try:
target_length = float(plan.get("target_length", 0.0))
@@ -7788,6 +7919,8 @@ class OperationMixin:
points = self._orient_local_polygon_outward(points, center)
if self._local_points_are_planar(points, tolerance):
moved_faces.append(self._make_local_polygon_face(points))
elif len(points) == 4:
moved_faces.append(self._make_local_bilinear_quad_face(points, tolerance))
else:
for index in range(1, len(points) - 1):
triangle = [points[0], points[index], points[index + 1]]
@@ -8042,6 +8175,89 @@ class OperationMixin:
if region_ids:
self.assign_logical_face_region_exclusive(int(logical_id), region_ids)
def _apply_preserved_face_logical_ids_by_shape_identity(
self,
previous_faces: Iterable[TopoDS_Shape],
previous_logical_ids: Iterable[int],
*,
excluded_logical_ids: Iterable[int | None] = (),
) -> int:
excluded = {int(item) for item in excluded_logical_ids if item is not None}
old_faces = list(previous_faces)
old_logical_ids = [int(item) for item in previous_logical_ids]
if not old_faces or len(old_faces) != len(old_logical_ids):
return 0
unmatched_new_ids: set[int] = set(range(len(self.faces)))
pending: list[tuple[TopoDS_Shape, int]] = []
matched_by_logical_id: dict[int, set[int]] = {}
for old_face_id, old_face in enumerate(old_faces):
logical_id = old_logical_ids[old_face_id]
if logical_id in excluded:
continue
if (
old_face_id < len(self.faces)
and old_face_id in unmatched_new_ids
and _same_shape(old_face, self.faces[old_face_id])
):
matched_by_logical_id.setdefault(logical_id, set()).add(old_face_id)
unmatched_new_ids.remove(old_face_id)
else:
pending.append((old_face, logical_id))
for old_face, logical_id in pending:
if logical_id in excluded:
continue
match_id: int | None = None
for new_face_id in tuple(unmatched_new_ids):
if _same_shape(old_face, self.faces[new_face_id]):
match_id = new_face_id
break
if match_id is None:
continue
matched_by_logical_id.setdefault(logical_id, set()).add(match_id)
unmatched_new_ids.remove(match_id)
reassigned = 0
assigned_face_ids: set[int] = set()
logical_targets: dict[int, set[int]] = {}
for logical_id, face_ids in sorted(matched_by_logical_id.items()):
valid_face_ids = set(face_ids) - assigned_face_ids
if not valid_face_ids:
continue
logical_targets[int(logical_id)] = valid_face_ids
assigned_face_ids.update(valid_face_ids)
reassigned += len(valid_face_ids)
if not logical_targets:
return 0
new_logical_ids = list(self.face_logical_ids)
replacement_id = max(
[len(self.faces), *[int(item) for item in new_logical_ids], *logical_targets.keys()],
default=len(self.faces),
) + 1
for face_id, logical_id in enumerate(list(new_logical_ids)):
target_face_ids = logical_targets.get(int(logical_id))
if target_face_ids is None or face_id in target_face_ids:
continue
new_logical_ids[face_id] = replacement_id
replacement_id += 1
for logical_id, face_ids in logical_targets.items():
for face_id in face_ids:
if 0 <= face_id < len(new_logical_ids):
new_logical_ids[face_id] = logical_id
self.face_logical_ids = new_logical_ids
self._quick_face_info_cache.clear()
self._face_info_cache.clear()
self._feature_info_cache.clear()
self._same_domain_face_ids_cache.clear()
self._face_first_level_topology_cache.clear()
self._cylindrical_first_level_topology_cache.clear()
self._face_first_level_fact_cache.clear()
self._local_face_deform_readiness_cache.clear()
return reassigned
def _local_face_deform_target(
self,
plan: dict[str, object],
@@ -8177,6 +8393,27 @@ class OperationMixin:
raise RuntimeError("Local edge deformation created an empty face.")
return face
def _make_local_bilinear_quad_face(
self,
points: list[tuple[float, float, float]],
tolerance: float,
) -> TopoDS_Shape:
if len(points) != 4:
raise RuntimeError("Local edge deformation bilinear face needs exactly four points.")
grid = TColgp_Array2OfPnt(1, 2, 1, 2)
grid.SetValue(1, 1, gp_Pnt(*points[0]))
grid.SetValue(2, 1, gp_Pnt(*points[1]))
grid.SetValue(1, 2, gp_Pnt(*points[3]))
grid.SetValue(2, 2, gp_Pnt(*points[2]))
surface = GeomAPI_PointsToBSplineSurface(grid).Surface()
maker = BRepBuilderAPI_MakeFace(surface, max(float(tolerance), 1e-7))
if hasattr(maker, "IsDone") and not maker.IsDone():
raise RuntimeError("Local edge deformation could not create a bilinear quad face.")
face = maker.Face()
if face.IsNull():
raise RuntimeError("Local edge deformation created an empty bilinear quad face.")
return face
def _dedupe_local_points(
self,
points: list[tuple[float, float, float]],