"""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 from dataclasses import replace 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, TopologyDeltaRelation, 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, ) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]: """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. Each non-empty member result retains its own OCC builder history; callers may compose those disjoint exact relations into one operation-wide delta. """ members: dict[str, Any] = {} deltas: list[TopologyDelta] = [] for feature_id, body in session.body_members.items(): result, delta = session.adapter.cut_with_topology_delta(body, tool) if delta is not None: deltas.append(delta) if result is not None and abs(float(result.volume)) > 1e-12: members[feature_id] = result return members, tuple(deltas) def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None: """Combine exact independent member-cut histories without inventing links.""" if not deltas or any(delta.history_status != "proven" for delta in deltas): return None return TopologyDelta( operation="subtract", relations=tuple(relation for delta in deltas for relation in delta.relations), section_values=tuple(value for delta in deltas for value in delta.section_values), section_relations=tuple(relation for delta in deltas for relation in delta.section_relations), blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations), history_reason="per_member_exact_cut_history", ) def _can_register_primary_cut_tool_history( session: "ExecutionSession", tool: Any, topology_delta: TopologyDelta | None, topology_anchors: list[TopologyRecord] | None, ) -> bool: """Return whether a primary REMOVE can retain a transient tool snapshot. The implicit CADFS primary boolean normally has no independently active tool body. It may retain its direct-prism input snapshot whenever the tool, profile anchors, and prism builder history are singular and exact. The target may contain several independent members: that changes the cut result cardinality but not the identity of the one transient tool input. This gate does not make any multi-member selector executable; a later resolver must still prove its complete operation-wide relation and active member. The cut builder can independently prove a target-side continuation even when a trimmed or fallback tool has no direct-prism history. """ if ( session.body is None or topology_delta is None or topology_delta.operation != "extrude" or topology_delta.history_status != "proven" or not topology_delta.relations or not topology_anchors or len(session.adapter.body_solids(tool)) != 1 ): return False return all( anchor.kind in {"edge", "vertex"} and (anchor.source_entity is not None or anchor.source_entities) for anchor in topology_anchors ) def _extruded_tool( node: FeaturePlanNode, faces: list[Any], profile_normal: Vector3, session: "ExecutionSession", *, record_multiface_prism_history: bool = False, use_operation_wide_prism_history: bool = False, ) -> tuple[Any, TopologyDelta | None]: """Build an extrude tool, retaining complete direct builder history.""" 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 if ( use_operation_wide_prism_history and draft is None and len(faces) > 1 and len(extents) == 1 and extents[0].trim_to is None ): composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector) if composed is not None: return composed topology_deltas: list[tuple[int, TopologyDelta]] = [] solids: list[Any] = [] exact_two_sided_prism = ( node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} and draft is None and len(faces) == 1 and len(extents) == 2 and all(extent.trim_to is None for extent in extents) ) for face in faces: for extent_index, extent in enumerate(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, ) if topology_delta is not None: topology_deltas.append((extent_index, topology_delta)) solids.append(solid) else: solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and ( len(faces) == 1 or record_multiface_prism_history ): # Each independently constructed profile face has its own OCC # prism history only when the adapter retained direct source # anchors for every participating profile path. Other complex # multi-face profiles keep the established general-extrude # path; forcing them through MakePrism can make a previously # executable profile invalid without adding usable evidence. try: solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) except ValueError: # Keep the established executable profile result when a # selected IMPRINT region is valid as a face but cannot be # a standalone valid prism. Its later fuse may still be # valid. There is no complete builder witness in this # case, so the entire multi-region topology delta is # withheld below instead of mixing proven and guessed # source anchors. solid = session.adapter.extrude(face, extent.vector) else: topology_deltas.append((extent_index, topology_delta)) 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") if len(topology_deltas) != len(solids): return tool, None relations: list[TopologyDeltaRelation] = [] for extent_index, topology_delta in topology_deltas: for relation in topology_delta.relations: # Both prism builders start on the source plane. Its two source # caps are internal to the fused two-sided result. The reverse # extent's far ``LastShape`` is the FeatureScript start cap; map # that exact builder handle before the registry checks final # membership. No source-plane or geometry-derived relation is # promoted to a CAP role. if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end": relation = replace(relation, output_role="extrude.start") relations.append(relation) return tool, TopologyDelta( operation="extrude", relations=tuple(relations), ) def _apply_primary_tool( node: FeaturePlanNode, session: "ExecutionSession", tool: Any, *, cutting: bool, topology_delta: TopologyDelta | None = None, topology_anchors: list[TopologyRecord] | None = None, ) -> FeatureResult: """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" topology_predecessors: list[TopologyRecord] | None = None if cutting: if session.body is None: raise ValueError("cut feature has no body") members, member_cut_deltas = _cut_explicit_body_members(session, tool) if not members: session.clear_body() return session.result(node) tool_delta = topology_delta tool_anchors = list(topology_anchors or ()) retain_transient_tool = _can_register_primary_cut_tool_history( session, tool, tool_delta, topology_anchors, ) if len(session.body_members) == 1: member_id = next(iter(session.body_members)) body = members[member_id] cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None # The target-side boolean history is independent of the source # tool's construction history. A trimmed tool cannot support a # source-qualified section query, but its exact BRepAlgoAPI_Cut # Modified/Preserved facts can still prove a unique continuation # of the active target face or edge. topology_anchors = None if cut_delta is not None: if retain_transient_tool: try: topology_predecessors = session.register_transient_prism_tool( node.feature_id, tool, topology_delta=tool_delta, topology_anchors=tool_anchors, ) except ValueError: # Retain cut history as partial diagnostic evidence. The # absent transient source records prevent it from proving # a source-qualified section edge. topology_predecessors = None topology_delta = cut_delta else: topology_delta = None members = {member_id: body} else: if retain_transient_tool: try: topology_predecessors = session.register_transient_prism_tool( node.feature_id, tool, topology_delta=tool_delta, topology_anchors=tool_anchors, ) except ValueError: # A transient tool is optional evidence. Keep the exact # per-member target history when its tool snapshot cannot # be registered completely. topology_predecessors = None body = session.adapter.combine(None, next(iter(members.values()))) for member in list(members.values())[1:]: body = session.adapter.combine(body, member) topology_delta = _compose_member_cut_deltas(member_cut_deltas) topology_anchors = 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: # An ADD can replace both the prior active solid and its direct prism # tool. Retain a selector relation only when both snapshots are # singular and OCC supplies the exact union history. The direct-prism # snapshot remains transient: its source anchors cannot be selected # until the union proves a complete successor in the active result. can_trace_add = ( session.body is not None and topology_delta is not None and topology_delta.operation == "extrude" and topology_delta.history_status == "proven" and bool(topology_delta.relations) and bool(topology_anchors) and len(session.adapter.body_solids(session.body)) == 1 and len(session.adapter.body_solids(tool)) == 1 ) if can_trace_add: fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool) if fuse_delta is not None: topology_predecessors = session.register_transient_prism_tool( node.feature_id, tool, topology_delta=topology_delta, topology_anchors=list(topology_anchors or ()), ) body = fused topology_delta = fuse_delta topology_anchors = None else: body = fused topology_delta = None topology_anchors = None else: body = session.adapter.fuse(session.body, tool) members = {node.feature_id: body} session.register_body( node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, topology_predecessors=topology_predecessors, topology_anchors=topology_anchors, ) 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),没有闭合区域就无法生成实体。 support_face = session.sketch_attachment_faces.get(str(node.sketch_id)) external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id)) faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors( selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges, ) 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 topology_anchors: list[TopologyRecord] = [] profile = selected_sketch.get("profile") or {} contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all( isinstance(contour, dict) and bool(contour.get("closed")) and len(contour.get("segments") or []) == 1 and (contour.get("segments") or [{}])[0].get("type") == "circle" for contour in contours ) planar_imprint_profile = profile.get("type") == "planar_imprint" # 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, # A multi-region prism gets per-region builder history only when # every profile region has at least one exact source boundary. # This covers direct circles and the adapter's bounded IMPRINT # splitter path, without turning arbitrary multi-face profiles # into a different construction algorithm. record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile) and len(source_anchor_specs) >= len(faces), use_operation_wide_prism_history=planar_imprint_profile and len(source_anchor_specs) >= len(faces), ) if topology_delta is not None: for index, spec in enumerate(source_anchor_specs): kind = spec.get("kind") value = spec.get("value") if kind not in {"edge", "vertex"} or value is None: continue source_entity = spec.get("source_entity") source_entities = tuple(spec.get("source_entities") or ()) if source_entity is None and not source_entities: continue topology_anchors.append(TopologyRecord( record_id=f"anchor:{node.feature_id}:{kind}:{index}", kind=kind, feature_id=node.feature_id, geometry={}, value=value, source_entity=source_entity if isinstance(source_entity, tuple) else None, source_entities=source_entities, )) 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 can_record_revolve_history = ( node.atomic_id == "revolve_add" and node.params.get("result_mode") == "new_body" and len(faces) == 1 ) for face in faces: if can_record_revolve_history: try: solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis) except ValueError: # Retain the established executable revolve when OCC # cannot expose a complete builder-history witness. solid = session.adapter.revolve(face, angle, axis) topology_delta = None else: solid = session.adapter.revolve(face, angle, axis) tool = solid if tool is None else session.adapter.fuse(tool, solid) if tool is None: raise ValueError("revolve produced no solid") if topology_delta is not None: for index, spec in enumerate(source_anchor_specs): kind = spec.get("kind") value = spec.get("value") if kind not in {"edge", "vertex"} or value is None: continue source_entity = spec.get("source_entity") source_entities = tuple(spec.get("source_entities") or ()) if source_entity is None and not source_entities: continue topology_anchors.append(TopologyRecord( record_id=f"anchor:{node.feature_id}:{kind}:{index}", kind=kind, feature_id=node.feature_id, geometry={}, value=value, source_entity=source_entity if isinstance(source_entity, tuple) else None, source_entities=source_entities, )) return _apply_primary_tool( node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, topology_anchors=topology_anchors, ) 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", parameter: str = "transform_copy_refs", ) -> list[str]: """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" resolved: list[str] = [] for reference in node.params.get(parameter) 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, transform_copy_parameter: str = "transform_copy_refs", ) -> 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, transform_copy_parameter)) 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") spatial = path.get("workplane") is None plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {}) segment = path.get("segment") or {} segments = path.get("segments") def local_point(value: Any) -> Vector3: if plane is None: raise ValueError("planar sweep path requires a workplane") if not isinstance(value, list) or len(value) != 2: raise ValueError("sweep path requires two-dimensional points") 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 local_vector(value: Any) -> Vector3: if plane is None: raise ValueError("planar sweep path requires a workplane") 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]))) if segments is not None: if segment: raise ValueError("sweep path cannot mix segment and segments") if not isinstance(segments, list) or len(segments) < 2: raise ValueError("sweep segmented path requires at least two segments") materialized: list[dict[str, Any]] = [] for index, source in enumerate(segments): if not isinstance(source, dict): raise ValueError("sweep path segment must be an object") kind = str(source.get("type") or "") if kind not in {"line", "arc", "bspline"}: raise ValueError(f"unsupported sweep path segment {kind!r}") target: dict[str, Any] = {"type": kind} if spatial: if kind in {"line", "arc"}: target["start_mm"] = source.get("start_mm") target["end_mm"] = source.get("end_mm") if kind == "arc": target["center_mm"] = source.get("center_mm") target["normal"] = source.get("normal") target["radius_mm"] = source.get("radius_mm") target["clockwise"] = bool(source.get("clockwise", False)) if kind == "bspline": target["points_mm"] = source.get("points_mm") if source.get("start_tangent_mm") is not None: target["start_tangent_mm"] = source.get("start_tangent_mm") if source.get("end_tangent_mm") is not None: target["end_tangent_mm"] = source.get("end_tangent_mm") if source.get("parameters") is not None: target["parameters"] = [float(value) for value in source.get("parameters") or []] if source.get("periodic") is not None: target["periodic"] = bool(source.get("periodic")) materialized.append(target) continue if kind in {"line", "arc"}: target["start_mm"] = local_point(source.get("start")) target["end_mm"] = local_point(source.get("end")) if kind == "arc": target["center_mm"] = local_point(source.get("center")) target["normal"] = plane.normal target["clockwise"] = bool(source.get("clockwise", False)) if kind == "bspline": points = source.get("points") if not isinstance(points, list) or len(points) < 2: raise ValueError("sweep B-spline path requires at least two interpolation points") target["points_mm"] = [local_point(value) for value in points] if source.get("start_tangent") is not None: target["start_tangent_mm"] = local_vector(source.get("start_tangent")) if source.get("end_tangent") is not None: target["end_tangent_mm"] = local_vector(source.get("end_tangent")) if source.get("parameters") is not None: target["parameters"] = [float(value) for value in source.get("parameters") or []] if source.get("periodic") is not None: target["periodic"] = bool(source.get("periodic")) materialized.append(target) return session.adapter.sweep_path_segments(materialized) if spatial: raise ValueError("spatial sweep path requires captured segments") 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 == "circle": return session.adapter.sweep_circle_path( local_point(segment.get("center")), plane.x_dir, plane.normal, radius_mm=float(segment["radius_mm"]), ) elif kind == "arc": return session.adapter.sweep_arc_path( local_point(segment.get("start")), local_point(segment.get("end")), local_point(segment.get("center")), plane.normal, radius_mm=float(segment["radius_mm"]), clockwise=bool(segment["clockwise"]), ) elif kind == "bspline": local_points = segment.get("points") or [] else: raise ValueError(f"unsupported sweep path segment {kind!r}") if len(local_points) < 2: raise ValueError("sweep path requires two-dimensional points") return session.adapter.sweep_path( [local_point(value) for value in local_points], start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None, end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None, 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, adapter: Any) -> PlaneSpec: if resolution.record is None: raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") return adapter.planar_face_workplane(resolution.record.value) 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