"""Dress-up executors (fillet / chamfer / shell). These mutate an existing body through edge/face selectors resolved from the current B-rep snapshot. """ from __future__ import annotations import math from typing import TYPE_CHECKING, Any from ..registry import atomic_executor from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta from .common import _replace_shell_target, _selector_edges, _shell_target if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession def _single_member_dressup_members( node: FeaturePlanNode, session: "ExecutionSession", body: Any, selected_edges: list[Any], ) -> dict[str, Any] | None: """Preserve unchanged body members after one exact-member dress-up. The adapter only reports Compound history when every selected edge maps to one source solid. Keep the same proof at the body-graph layer: all other members must appear unchanged in the result and exactly one result solid must remain for the changed member. Otherwise aggregate replay remains valid, but no member-lifecycle transfer is asserted. """ if not selected_edges or len(session.body_members) < 2: return None source_members: dict[str, Any] = {} for member_id, member in session.body_members.items(): solids = session.adapter.body_solids(member) if len(solids) != 1: return None source_members[member_id] = solids[0] selected_members = { member_id for edge in selected_edges for member_id, member in source_members.items() if any(edge.is_same(candidate) for candidate in member.edges()) } if len(selected_members) != 1: return None changed_member_id = next(iter(selected_members)) result_solids = session.adapter.body_solids(body) unchanged: dict[str, Any] = {} matched_result_indexes: set[int] = set() for member_id, member in source_members.items(): if member_id == changed_member_id: continue matches = [ index for index, result in enumerate(result_solids) if session.topology._same_topology_value(member, result) ] if len(matches) != 1 or matches[0] in matched_result_indexes: return None matched_result_indexes.add(matches[0]) unchanged[member_id] = result_solids[matches[0]] changed = [ result for index, result in enumerate(result_solids) if index not in matched_result_indexes ] if len(changed) != 1: return None return {**unchanged, node.feature_id: changed[0]} def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: # 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。 # 1. 校验:圆角作用于已有主体,必须先有主体。 if session.body is None: raise ValueError("fillet has no body") # 2. 解析圆角半径并校验必须大于 0。 radius = float(node.params.get("radius_mm") or 0) if radius <= 0: raise ValueError("fillet radius_mm must be > 0") # 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。 edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) body, topology_delta = session.adapter.fillet_with_topology_delta( session.body, radius, edges, ) # 4. 登记新主体并返回结果。 session.register_body( node.feature_id, body, replay_node=node, topology_delta=topology_delta, body_members=_single_member_dressup_members(node, session, body, edges), ) return session.result(node) @atomic_executor("fillet") def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_fillet(node, session) def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: # 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。 # 1. 校验:倒角作用于已有主体,必须先有主体。 if session.body is None: raise ValueError("chamfer has no body") # 2. 解析主距离并校验必须大于 0。 distance = float(node.params.get("distance_mm") or 0) if distance <= 0: raise ValueError("chamfer distance_mm must be > 0") # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 # tan=1,退化为等距倒角(与历史行为一致,零回归)。 # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° # 倒角仅保证量级正确,距离所在侧可能反转。 distance_2 = node.params.get("distance_2_mm") angle_rad = node.params.get("angle_rad") if distance_2 is None and angle_rad is not None: distance_2 = distance * math.tan(float(angle_rad)) # 4. 解析目标边(支持相切传播),执行倒角。 edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) diagnostics: list[RuntimeDiagnostic] = [] topology_delta: TopologyDelta | None = None try: body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) except ValueError as error: # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 if distance_2 is not None or not session.surface_members: raise try: body = session.adapter.surface_limited_chamfer( session.body, distance, edges, list(session.surface_members.values()), ) except ValueError: raise error diagnostics.append(RuntimeDiagnostic( "chamfer_surface_limited", "Chamfer was limited by an explicit coaxial surface boundary", feature_id=node.feature_id, detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, )) # 5. 登记新主体并返回结果。 session.register_body( node.feature_id, body, replay_node=node, topology_delta=topology_delta, body_members=_single_member_dressup_members(node, session, body, edges), ) return session.result(node, diagnostics=diagnostics) @atomic_executor("chamfer") def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_chamfer(node, session) def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 if session.body is None: raise ValueError("shell has no body") thickness = float(node.params.get("thickness_mm") or 0) if thickness <= 0: raise ValueError("shell thickness_mm must be > 0") target, faces = _shell_target(node, session) result, topology_delta = session.adapter.shell_with_topology_delta( target, faces, thickness, inward=bool(node.params.get("inward", True)), ) session.register_body( node.feature_id, _replace_shell_target(session, target, result), replay_node=node, topology_delta=topology_delta, ) return session.result(node) @atomic_executor("shell") def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_shell(node, session)