"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard).""" from __future__ import annotations from typing import TYPE_CHECKING, Any from ..registry import atomic_executor from ..specs import HoleSpec, PlaneSpec, vector_scale from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic from .common import _hole_starts, _host_plane if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession @atomic_executor("hole_blind", "hole_countersink", "hole_counterbore") def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_hole(node, session, wizard=False) @atomic_executor("hole_wizard") def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_hole(node, session, wizard=True) def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult: # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 # 1. 校验:孔是切除操作,必须先有主体。 if session.body is None: raise ValueError("hole feature has no body") scope_feature_id = node.params.get("scope_feature_id") scoped_body = None if scope_feature_id is not None: if not isinstance(scope_feature_id, str) or not scope_feature_id: raise ValueError("hole scope_feature_id is invalid") scoped_body = session.body_members.get(scope_feature_id) if scoped_body is None: raise ValueError("hole scope body is no longer an independently selectable member") scoped_solids = session.adapter.body_solids(scoped_body) if not scoped_solids: raise ValueError("hole scope body has no active solid") # 2. 确定宿主面 host_face: host_selector = node.params.get("host_face") if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict): # 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。 host = PlaneSpec.from_mapping(host_selector["frame"]) positions_are_local = True else: # 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。 selectors = list(node.selectors) if isinstance(host_selector, dict): selectors.append(host_selector) selector = next((item for item in selectors if item.get("kind") == "face"), None) if selector is None: raise ValueError("hole requires host_face selector or frame") host = _host_plane(session.resolve(selector), session.adapter) intent = selector.get("selector_intent") if isinstance(selector, dict) else None # Existing selector-hosted holes store world positions. Only the new # runtime-attached COPY(CAP_FACE) sketch preserves local coordinates # until its exact host relation has materialized. positions_are_local = ( isinstance(intent, dict) and intent.get("copy_contract") == "primary_cut_cap_face_workplane" ) # 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。 spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard) # 4. A host-face normal is an outward B-rep orientation, so its inverse # always enters the material. Inferring direction from the global body # centre fails for concave or multi-leg parts: for example, the top face # of an L bracket can sit below the whole body's centre and the old rule # drilled outward, producing a no-op feature reported as successful. # The selected topology face is the local, authoritative orientation. inward = vector_scale(host.normal, -1) # 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。 tool = session.adapter.hole_tool( spec, _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local), inward, session.adapter.body_span(scoped_body or session.body, inward) + 2.0, ) # 6. 从主体上减去工具实体,登记新主体并返回结果。 # thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔 # 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告 # 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。 diagnostics: list[RuntimeDiagnostic] = [] if wizard and node.params.get("thread"): diagnostics.append(RuntimeDiagnostic( code="thread_decoration_ignored", message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore", feature_id=node.feature_id, )) if scope_feature_id is None: result_body = session.adapter.cut(session.body, tool) members = None else: # CADFS ``scope`` identifies the target body member. Never apply the # cutter to the aggregate merely because unrelated live members share # the exported part; unchanged members remain exact body-graph nodes. result_body = session.adapter.cut(scoped_body, tool) members = {**session.body_members, scope_feature_id: result_body} body = None for member in members.values(): body = session.adapter.combine(body, member) if body is None: raise ValueError("hole scope cut produced no active body") result_body = body session.register_body( node.feature_id, result_body, replay_node=node, body_members=members, ) return session.result(node, diagnostics=diagnostics)