"""Helpers shared by executor family modules. Every function here is imported by two or more executor modules. Anything used by exactly one family lives in that family's module instead. """ from __future__ import annotations import math from typing import TYPE_CHECKING, Any, Callable from ..extents import _extent_vectors_from_normal, _normal_from_sketch from ..runtime_base import ExtentVector, FeatureExecutionError from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord if TYPE_CHECKING: # pragma: no cover - import for type checkers only from ..session import ExecutionSession def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec: raw_axis = node.params.get("axis") or {} if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: return AxisSpec.from_mapping(raw_axis) selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None if not isinstance(selector, dict): selector = next((item for item in node.selectors if item.get("kind") == "axis"), None) if not isinstance(selector, dict): raise FeatureExecutionError( "missing_revolve_axis", "Revolve requires an explicit axis or an owner-qualified reference-axis selector", ) resolution = session.resolve(selector) if resolution.status != "resolved" or resolution.record is None: raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved") if not isinstance(resolution.record.value, AxisSpec): raise FeatureExecutionError( "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind, ) return resolution.record.value def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: """Defend direct CDSL execution from an out-of-plane revolve axis.""" plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) if direction_normal_dot > 1e-7: raise ValueError( "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" ) origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) if origin_plane_offset > 1e-6: raise ValueError( "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" ) def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]: """Apply a cut to each independently owned body without erasing ownership. A CADFS NEW body stays independently addressable even when a later REMOVE feature affects several active bodies. Cutting the aggregate first loses that identity, so this path uses the equivalent per-member set difference and drops only members that the tool removes completely. """ members: dict[str, Any] = {} for feature_id, body in session.body_members.items(): result = session.adapter.cut(body, tool) if abs(float(result.volume)) > 1e-12: members[feature_id] = result return members def _extruded_tool( node: FeaturePlanNode, faces: list[Any], profile_normal: Vector3, session: "ExecutionSession", ) -> tuple[Any, TopologyDelta | None]: """Build one extrude tool, retaining caps only from one exact builder result.""" extents = _extent_vectors_from_normal(node, faces, profile_normal, session) draft = node.params.get("draft") taper_deg = 0.0 if isinstance(draft, dict): taper_deg = float(draft["angle_deg"]) if not bool(draft["pull_direction"]): taper_deg = -taper_deg topology_delta: TopologyDelta | None = None solids: list[Any] = [] for face in faces: for extent in extents: if draft is not None: if len(faces) == 1 and len(extents) == 1: solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( face, extent.vector, taper_deg, ) solids.append(solid) else: solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) solids.append(solid) elif extent.trim_to is None: solids.append(session.adapter.extrude(face, extent.vector)) else: solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) tool = None for solid in solids: tool = session.adapter.fuse(tool, solid) if tool is None: raise ValueError("extrude produced no solid") return tool, topology_delta def _apply_primary_tool( node: FeaturePlanNode, session: "ExecutionSession", tool: Any, *, cutting: bool, topology_delta: TopologyDelta | None = None, ) -> FeatureResult: """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" if cutting: if session.body is None: raise ValueError("cut feature has no body") members = _cut_explicit_body_members(session, tool) if not members: session.clear_body() return session.result(node) body = session.adapter.cut(session.body, tool) topology_delta = None elif node.params.get("result_mode") == "new_body": body = session.adapter.combine(session.body, tool) members = {**session.body_members, node.feature_id: tool} else: body = session.adapter.fuse(session.body, tool) members = {node.feature_id: body} # A fuse rebuilds subshape identity. Builder evidence belongs only to # an unchanged standalone/new-body prism snapshot. if session.body is not None: topology_delta = None session.register_body( node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, ) return session.result(node) def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult: # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), # 否则按 sketch_id 从会话草图表中取原始草图。 selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) if selected_sketch is None: raise ValueError("primary feature has no resolved sketch") # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 faces = session.adapter.faces_for_sketch(selected_sketch) if not faces: raise ValueError("sketch does not create a closed profile region") if node.atomic_id == "extrude_add_blind_with_hole": resolved = [session.resolve(selector) for selector in node.selectors] failed = next((item for item in resolved if item.status != "resolved"), None) if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") if len(faces) != 1: raise ValueError("profile hole extrusion requires exactly one outer sketch region") faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] topology_delta: TopologyDelta | None = None # 3. 按特征类型生成子实体: if node.atomic_id.startswith("extrude_"): # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 tool, topology_delta = _extruded_tool( node, faces, _normal_from_sketch(selected_sketch), session, ) else: # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 axis = _revolve_axis(node, session) _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) angle = float(node.params.get("angle_deg") or 0.0) if angle <= 0: raise ValueError("revolve requires angle_deg > 0") # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 # 语义。profile_schema.json 已声明 revolve.* optional_params 含 # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime # 侧实现,使三方合同一致。 if bool(node.params.get("reverse")): angle = -angle tool = None for solid in (session.adapter.revolve(face, angle, axis) for face in faces): tool = session.adapter.fuse(tool, solid) if tool is None: raise ValueError("revolve produced no solid") return _apply_primary_tool( node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, ) def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any: body = None for member in members.values(): body = session.adapter.combine(body, member) if body is None: raise ValueError("booleanBodies produced no result bodies") return body def _pattern_instance_sources( node: FeaturePlanNode, session: "ExecutionSession", parameter: str = "pattern_instance_refs", ) -> list[str]: """Resolve CDSL pattern-instance refs to their internal body-member keys.""" resolved: list[str] = [] for reference in node.params.get(parameter) or (): if not isinstance(reference, dict): raise ValueError("pattern instance reference must be an object") pattern_id = str(reference.get("pattern_feature_id") or "") source_id = str(reference.get("source_feature_id") or "") instance = reference.get("instance_index") if not pattern_id or not source_id or not isinstance(instance, int): raise ValueError("pattern instance reference is incomplete") pattern = session.nodes.get(pattern_id) if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") params = pattern.params if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: raise ValueError("pattern instance source is not selected by its pattern") count = int(params.get("pattern_count") or 0) excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} if ( pattern.atomic_id == "pattern_mirror" and instance != 1 ) or ( pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) ): raise ValueError("pattern instance is outside the pattern's surviving instances") member_id = pattern_instance_member_id(pattern_id, source_id, instance) if member_id not in session.body_members: raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") if member_id not in resolved: resolved.append(member_id) return resolved def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]: """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" resolved: list[str] = [] for reference in node.params.get("transform_copy_refs") or (): if not isinstance(reference, dict): raise ValueError("transform COPY reference must be an object") transform_id = str(reference.get("transform_feature_id") or "") source_id = str(reference.get("source_feature_id") or "") if not transform_id or not source_id: raise ValueError("transform COPY reference is incomplete") transform = session.nodes.get(transform_id) params = transform.params if transform is not None else {} sources = params.get("source_feature_ids") or [] if ( transform is None or transform.atomic_id != "transform_bodies" or not bool(params.get("make_copy")) or not isinstance(sources, list) or len(sources) < 2 or source_id not in {str(value) for value in sources} ): raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") member_id = transform_copy_member_id(transform_id, source_id) if member_id not in session.body_members: raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") if member_id not in resolved: resolved.append(member_id) return resolved def _member_sources( node: FeaturePlanNode, session: "ExecutionSession", parameter: str, *, pattern_instance_parameter: str | None = None, allow_transform_copies: bool = False, ) -> list[str]: source_ids = [str(value) for value in node.params.get(parameter) or []] if pattern_instance_parameter is not None: source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) if allow_transform_copies: source_ids.extend(_transform_copy_sources(node, session)) if not source_ids: raise ValueError(f"{node.atomic_id} requires explicit {parameter}") missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] if missing: raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) return source_ids def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any: # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 path = node.params.get("path") or {} if not isinstance(path, dict): raise ValueError("sweep path must be an object") plane = PlaneSpec.from_mapping(path.get("workplane") or {}) segment = path.get("segment") or {} if not isinstance(segment, dict): raise ValueError("sweep path segment must be an object") kind = str(segment.get("type") or "") if kind == "line": local_points = [segment.get("start"), segment.get("end")] elif kind == "bspline": local_points = segment.get("points") or [] else: raise ValueError(f"unsupported sweep path segment {kind!r}") if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): raise ValueError("sweep path requires two-dimensional points") def point(value: list[float]) -> Vector3: return vector_add( plane.origin_mm, vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), ) def tangent(value: Any) -> Vector3 | None: if value is None: return None if not isinstance(value, list) or len(value) != 2: raise ValueError("sweep path tangent must contain two coordinates") return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) return session.adapter.sweep_path( [point(value) for value in local_points], start_tangent=tangent(segment.get("start_tangent")), end_tangent=tangent(segment.get("end_tangent")), parameters=[float(value) for value in segment.get("parameters") or []] or None, ) def _register_added_solid( session: "ExecutionSession", node: FeaturePlanNode, solid: Any, *, topology_delta: TopologyDelta | None = None, ) -> None: """Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend). When ``node.params['result_mode'] == "new_body"`` the primitive is kept as an independent body member so that downstream ``boolean_bodies`` can reference it without pulling in the accumulated fuse history. The current body is replaced by a Compound that preserves both, matching the ``extrude_add_blind`` ``new_body`` semantics. Any other value (including missing) falls back to the legacy fuse-into-body behavior. """ if node.params.get("result_mode") == "new_body": combined = session.adapter.combine(session.body, solid) members = {**session.body_members, node.feature_id: solid} session.register_body( node.feature_id, combined, replay_node=node, body_members=members, topology_delta=topology_delta, ) return if session.body is None: session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta) return if topology_delta is None: session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) return # Preserve primitive output roles only through the exact OCC fuse history. # The transient records provide source handles, not selectable snapshots. role_records = _direct_output_role_records(session, node, solid, topology_delta) fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid) session.register_body( node.feature_id, fused, replay_node=node, topology_delta=fuse_delta, topology_predecessors=role_records, ) def _direct_output_role_records( session: "ExecutionSession", node: FeaturePlanNode, solid: Any, topology_delta: TopologyDelta, ) -> list[TopologyRecord]: """Attach only builder-proven output roles to a transient primitive snapshot.""" records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}") result: list[TopologyRecord] = [] for record in records: roles = { relation.output_role for relation in topology_delta.relations if relation.output_role is not None and relation.kind == record.kind and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values) } if roles: result.append(TopologyRecord( record_id=record.record_id, kind=record.kind, feature_id=record.feature_id, body_id=record.body_id, geometry=record.geometry, value=record.value, owner_feature_ids=(node.feature_id,), output_roles=tuple(sorted(roles)), )) return result def _host_plane(resolution: SelectorResolution) -> PlaneSpec: if resolution.record is None: raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") geometry = resolution.record.geometry return PlaneSpec.from_mapping({ "origin_mm": geometry["center_mm"], "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], "normal": geometry["normal"], }) def _hole_starts( spec: HoleSpec, *, host_plane: PlaneSpec, positions_are_local: bool, ) -> list[Vector3]: starts: list[Vector3] = [] for point in spec.positions_mm: if positions_are_local: start = vector_add( vector_add( vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])), vector_scale(host_plane.y_dir, point[1]), ), vector_scale(host_plane.normal, point[2]), ) else: start = point starts.append(start) return starts def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]: resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors] failed = next((item for item in resolved if item.status != "resolved"), None) if failed: raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") def is_body_boundary(edge: Any) -> bool: # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 # 单边选择,所以这里只约束由 FACE 展开的候选边。 face_count = sum( 1 for face in session.body.faces() if any(candidate.is_same(edge) for candidate in face.edges()) ) return face_count >= 2 edges: list[Any] = [] for item in resolved: records = item.records or ((item.record,) if item.record is not None else ()) for record in records: if record.kind == "edge": edges.append(record.value) elif record.kind == "face": edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge)) if not edges: raise ValueError("selectors did not resolve any edges") return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]: # shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个 # Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测 # 应抽壳的成员。 resolved = [session.resolve(selector) for selector in node.selectors] failed = next((item for item in resolved if item.status != "resolved"), None) if failed: raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") records = [ record for item in resolved for record in (item.records or ((item.record,) if item.record is not None else ())) ] if not records or any(record.kind != "face" for record in records): raise ValueError("shell selectors must resolve to faces") target_ids = {record.body_id for record in records} if len(target_ids) != 1: raise ValueError("shell faces must belong to one target body") target_id = next(iter(target_ids)) members = session.adapter.body_solids(session.body) if len(members) == 1: target = members[0] else: if target_id is None or session.body_id is None: raise ValueError("shell target body is unresolved") prefix = f"{session.body_id}:" if not target_id.startswith(prefix): raise ValueError("shell target body is outside the active body set") try: member_index = int(target_id[len(prefix):]) except ValueError as error: raise ValueError("shell target body has an invalid member id") from error if member_index < 0 or member_index >= len(members): raise ValueError("shell target body member is unavailable") target = members[member_index] target_feature_id = node.params.get("target_feature_id") if target_feature_id is not None: if not isinstance(target_feature_id, str) or not target_feature_id: raise ValueError("shell target_feature_id is invalid") declared = session.body_members.get(target_feature_id) if declared is None: raise ValueError("shell target body is no longer an independently selectable member") declared_solids = session.adapter.body_solids(declared) if len(declared_solids) != 1: raise ValueError("shell target body must resolve to exactly one active solid") if not declared_solids[0].is_same(target): raise ValueError("shell target body does not match the resolved face member") return target, [record.value for record in records] def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any: # 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。 members = session.adapter.body_solids(session.body) if len(members) == 1: return replacement replaced = False result = None for member in members: if member.is_same(target): result = session.adapter.combine(result, replacement) replaced = True else: result = session.adapter.combine(result, member) if not replaced or result is None: raise ValueError("shell target solid is no longer part of the active body") return result