"""Session-based CDSL execution with atomic executor registry. The runtime was split into focused modules (behavior-preserving move): - ``runtime_base``: shared error types and the ``ExtentVector`` value. - ``session``: ``ExecutionSession`` and the ``GeometryAdapter`` protocol. - ``extents``: end-condition planning. - ``pattern_transform``: translate/mirror/rotate parameter algebra for replay. This module keeps the executor registry, the per-atomic executors, and the ``analyze_cdsl`` / ``rebuild_cdsl`` entry points. The moved names are re-exported so every historical ``cdsl_engine.runtime`` import keeps working. """ from __future__ import annotations from copy import deepcopy import math from pathlib import Path from typing import Any, Callable, Protocol from .build123d_adapter import Build123dGeometryAdapter # noqa: F401 (historical re-export) from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract from .extents import ( _extent_reference, _extent_vectors, _extent_vectors_from_normal, _normal_from_sketch, _side_extent_vectors, _targeted_extent_vector, ) from .pattern_transform import ( _box_circular_is_exact, _coordinate_axis_direction, _mirrored_node, _mirrored_sketch, _normal_is_coordinate_axis, _owner_plane_frame, _pattern_operation_node, _reflect_point, _rotated_node, _rotated_point, _rotated_sketch, _rotated_vector, _transformed_loft_profiles, _translated_node, _translated_sketch, ) from .runtime_base import ExtentVector, FeatureExecutionError, RuntimeExecutionError from .session import ExecutionSession, GeometryAdapter from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches from .specs import ( AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit, ) from .topology import ( CapabilityResult, FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry, ) ALL_ATOMIC_IDS = frozenset({ "extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface", "extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror", "pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", }) class AtomicExecutor(Protocol): atomic_id: str def preflight(self, node: FeaturePlanNode, session: "ExecutionSession") -> CapabilityResult: ... def execute(self, node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: ... 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 _execute_extrude_from_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: 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 "derived profile face is unresolved") face = resolved[0].record.value tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session) return _apply_primary_tool( node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta, ) def _execute_revolve_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与 # 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。 sketch = session.sketches.get(str(node.sketch_id)) if sketch is None: raise ValueError("surface revolve has no resolved sketch") faces = session.adapter.faces_for_sketch(sketch) if len(faces) != 1 or faces[0].inner_wires(): raise ValueError("surface revolve requires exactly one closed profile without holes") axis = _revolve_axis(node, session) _validate_revolve_axis_in_sketch_plane(axis, sketch) angle = float(node.params.get("angle_deg") or 0.0) if angle <= 0: raise ValueError("surface revolve requires angle_deg > 0") if bool(node.params.get("reverse")): angle = -angle surface_id = session.register_surface( node.feature_id, session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis), ) return session.result(node, include_body=False, surface_id=surface_id) def _execute_extrude_surface(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为 # shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。 sketch = session.sketches.get(str(node.sketch_id)) if sketch is None: raise ValueError("surface extrude has no resolved sketch") direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal") if bool(node.params.get("reverse")): direction = vector_scale(direction, -1) distance = float(node.params.get("distance_mm") or 0.0) if distance <= 0: raise ValueError("surface extrude requires distance_mm > 0") wires = session.adapter.surface_wires_for_sketch(sketch) surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance)) reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0) if reverse_distance > 0: opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance)) surface = session.adapter.combine_surfaces(surface, opposite) surface_id = session.register_surface(node.feature_id, surface) return session.result(node, include_body=False, surface_id=surface_id) 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 _execute_boolean_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为 # 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。 params = node.params target_ids = _member_sources( node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs", ) tool_ids = _member_sources( node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs", ) targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids} tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids} target = _combine_members(session, targets) tool = _combine_members(session, tools) operation = str(params.get("operation") or "") topology_delta: TopologyDelta | None = None if operation == "union": result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool) elif operation == "subtract": result, topology_delta = session.adapter.cut_with_topology_delta(target, tool) elif operation == "intersect": result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool) else: raise ValueError(f"unsupported booleanBodies operation {operation!r}") members = { feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(target_ids + tool_ids) } members[node.feature_id] = result if bool(params.get("keep_tools")): members.update(tools) session.register_body( node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, ) return session.result(node) 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 _execute_transform_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # FeatureScript transform targets explicit bodies. Do not move the # aggregate session body, because it may include unrelated members. source_ids = _member_sources( node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True, ) make_copy = bool(node.params.get("make_copy")) direct_sources = node.params.get("source_feature_ids") or [] if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1: # The aggregate is only an export compound. Each source transform has # its own B-rep builder and is the only output a later COPY query may # select. Do not attach an aggregate topology delta to source members. members = dict(session.body_members) members.update({ transform_copy_member_id(node.feature_id, source_id): session.adapter.transform( session.body_members[source_id], dict(node.params.get("transform") or {}), ) for source_id in source_ids }) session.register_body( node.feature_id, _combine_members(session, members), body_members=members, ) return session.result(node) source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids}) transformed, topology_delta = session.adapter.transform_with_topology_delta( source, dict(node.params.get("transform") or {}), ) members = dict(session.body_members) if not make_copy: for feature_id in source_ids: members.pop(feature_id) members[node.feature_id] = transformed session.register_body( node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta, ) return session.result(node) def _execute_delete_bodies(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # Deletion is a body-graph operation, never a Boolean subtraction. A # selected member can be disjoint or overlap another independent body. source_ids = _member_sources(node, session, "target_feature_ids") members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)} if members: session.register_body(node.feature_id, _combine_members(session, members), body_members=members) else: session.clear_body() return session.result(node) def _execute_loft_add(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析 # 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。 profile_ids = node.params.get("profile_sketch_ids") or [] profiles: list[dict[str, Any]] = [] for sketch_id in profile_ids: sketch = session.sketches.get(str(sketch_id)) if sketch is None: raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") profiles.append(sketch) solid, topology_delta = session.adapter.loft_with_topology_delta(profiles) body = session.adapter.fuse(session.body, solid) # Fusing a loft into an existing body replaces its subshapes through a # different builder. Only an initial direct loft can expose this builder's # cap evidence for the final B-rep snapshot. if session.body is not None: topology_delta = None session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node) def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: 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 "cap-face loft selector is unresolved") profile_ids = node.params.get("profile_sketch_ids") or [] profiles: list[dict[str, Any]] = [] for sketch_id in profile_ids: sketch = session.sketches.get(str(sketch_id)) if sketch is None: raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved") profiles.append(sketch) solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles) session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node) return session.result(node) 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 _execute_sweep_add(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None = None) -> FeatureResult: profile = sketch or session.sketches.get(str(node.sketch_id)) if profile is None: raise ValueError("sweep has no resolved profile sketch") faces = session.adapter.faces_for_sketch(profile) if len(faces) != 1: raise ValueError("sweep requires exactly one closed profile region") solid, topology_delta = session.adapter.sweep_with_topology_delta( faces[0], _sweep_path(node, session), is_frenet=bool(node.params.get("is_frenet", False)), ) is_new_body = node.params.get("result_mode") == "new_body" body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid) # A union rebuilds topology, so the pipe-shell builder cannot prove the # final aggregate's relations. The independent-body path retains its exact # subshape identity and may expose evidence for the new member. if session.body is not None and not is_new_body: topology_delta = None session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node) def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。 # 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。 plane = PlaneSpec.from_mapping(node.params.get("plane") or {}) # 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。 session.topology.register_context(node.feature_id, plane) # 3. 返回结果对象,并将该平面作为上下文一并携带。 return session.result(node, context=plane) def _execute_reference_axis(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。 # 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。 params = node.params.get("axis") or {} if params.get("origin_mm") and params.get("direction"): axis = AxisSpec.from_mapping(params) else: # 2. 否则从特征选择器中筛选出已解析的基准平面。 planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"] resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)] # 3. 校验:轴需要两个非平行的平面,不足两个则报错。 if len(resolved) < 2: raise ValueError("reference axis requires two uniquely resolved planes") # 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。 first, second = resolved[0], resolved[1] n1, n2 = first.normal, second.normal direction = vector_cross(n1, n2) squared_length = vector_dot(direction, direction) if squared_length <= 1e-18: raise ValueError("reference planes are parallel and cannot define an axis") # 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。 d1 = vector_dot(n1, first.origin_mm) d2 = vector_dot(n2, second.origin_mm) point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length) # 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。 axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis")) # 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。 session.topology.register_context(node.feature_id, axis) return session.result(node, context=axis) def _register_added_solid( session: ExecutionSession, node: FeaturePlanNode, solid: Any, ) -> 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) return fused = session.adapter.fuse(session.body, solid) session.register_body(node.feature_id, fused, replay_node=node) def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。 # 1. 解析参数:半径 radius_mm 与球心 center_mm。 radius = float(node.params.get("radius_mm") or 0.0) center = node.params.get("center_mm") or [] # 2. 校验:半径必须大于 0,球心必须是三维坐标。 if radius <= 0 or len(center) != 3: raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm") # 3. 由适配器创建球体实体。 solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2]))) # 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) return session.result(node) def _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。 # 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。 try: length = float(node.params.get("length_mm") or 0.0) width = float(node.params.get("width_mm") or 0.0) height = float(node.params.get("height_mm") or 0.0) center = node.params.get("center_mm") or [] except (TypeError, ValueError) as error: raise ValueError("box dimensions must be numeric") from error if length <= 0 or width <= 0 or height <= 0 or len(center) != 3: raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm") # 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半 # 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。 corner = ( float(center[0]) - length / 2, float(center[1]) - width / 2, float(center[2]) - height / 2, ) plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}) solid = session.adapter.box(length, width, height, plane) # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) return session.result(node) def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向; # axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。 # 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。 try: radius = float(node.params.get("radius_mm") or 0.0) height = float(node.params.get("height_mm") or 0.0) except (TypeError, ValueError) as error: raise ValueError("cylinder dimensions must be numeric") from error if radius <= 0 or height <= 0: raise ValueError("cylinder_add requires positive radius_mm and height_mm") raw_axis = node.params.get("axis") if raw_axis is not None and not ( isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None ): raise ValueError("cylinder_add axis must define origin_mm and direction") axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None # 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。 solid = session.adapter.cylinder(radius, height, axis) # 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) return session.result(node) def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。 # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 spec = ThreadSpec.from_feature(node.atomic_id, node.params) # 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。 solid = session.adapter.thread_solid(spec) # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) return session.result(node) def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: # 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。 # thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。 del sketch if node.atomic_id == "thread_cut": return _execute_thread_cut(node, session) return _execute_thread(node, session) def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置 # internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿 # spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔, # 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。 # 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。 spec = ThreadSpec.from_feature(node.atomic_id, node.params) # 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。 tool = session.adapter.thread_solid(spec) # 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。 session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) return session.result(node) def _execute_bend(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。 # 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。 spec = BendSpec.from_feature(node.params) # 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。 solid = session.adapter.bend_solid(spec) # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) return session.result(node) def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: # 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。 del sketch return _execute_bend(node, session) def _execute_gear(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。 # 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。 spec = GearSpec.from_feature(node.params) # 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。 solid = session.adapter.gear_solid(spec) # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) # 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。 diagnostics: list[RuntimeDiagnostic] = [] if spec.teeth_count < 17: diagnostics.append(RuntimeDiagnostic( code="undercut_risk", message=( f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; " "standard involute geometry is generated without profile shift" ), feature_id=node.feature_id, )) return session.result(node, diagnostics=diagnostics) def _gear_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: # 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。 del sketch return _execute_gear(node, session) def _execute_rack(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: # 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。 # 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。 spec = RackSpec.from_feature(node.params) # 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。 solid = session.adapter.rack_solid(spec) # 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。 _register_added_solid(session, node, solid) return session.result(node) def _rack_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: # 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。 del sketch return _execute_rack(node, session) 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 _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: bool = False) -> FeatureResult: # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 # 1. 校验:孔是切除操作,必须先有主体。 if session.body is None: raise ValueError("hole feature has no body") # 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)) positions_are_local = False # 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(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, )) session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node) return session.result(node, diagnostics=diagnostics) 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: if item.record.kind == "edge": edges.append(item.record.value) elif item.record.kind == "face": edges.extend(edge for edge in item.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 = [item.record for item in resolved if item.record is not None] 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 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) 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 相切传播),并执行圆角。 body, topology_delta = session.adapter.fillet_with_topology_delta( session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))), ) # 4. 登记新主体并返回结果。 session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) return session.result(node) 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) return session.result(node, diagnostics=diagnostics) def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: # 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。 # 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。 params = node.params sources = session.replay_sources(params.get("source_feature_ids") or []) if not sources: raise ValueError("pattern source features have no replay definitions") # 2. 解析两个方向的实例数量。 count_1 = int(params.get("pattern_count_1") or 1) count_2 = int(params.get("pattern_count_2") or 1) # 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。 direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0)) direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0)) # 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。 for first in range(count_1): for second in range(count_2): if first == 0 and second == 0: continue # 计算当前实例相对源特征的偏移向量。 offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second)) for source in sources: # 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。 dependency = pattern_transform_blocker(source) if dependency: raise ValueError(f"pattern source uses an unsupported {dependency}") cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session) sketch = session.sketches.get(str(source.sketch_id)) execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None) # 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放, # 而非复制当前主体做近似。 # A later pattern may select this pattern feature. The definition is # replayed recursively, never approximated by copying the current body. session.replay_definitions[node.feature_id] = node return session.result(node) def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: mirror = node.params.get("mirror_plane") or {} resolution = session.resolve(mirror) if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec): raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved") source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()] if ( source_ids and all(source_id in session.body_members for source_id in source_ids) and all( (source := session.nodes.get(source_id)) is not None and source.params.get("result_mode") == "new_body" for source_id in source_ids ) ): # Only a direct NEW body has a standalone source identity after a # mirror. A hole, dress-up, or ordinary additive source is merely an # aggregate successor and must use the feature-replay path below. # Keeping this condition identical to capability preflight prevents a # downstream COPY body query from selecting an arbitrary aggregate. members = dict(session.body_members) body = session.body for source_id in source_ids: mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value) members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored body = session.adapter.fuse(body, mirrored) if body is None: raise ValueError("mirror pattern produced no body") session.register_body(node.feature_id, body, replay_node=node, body_members=members) return session.result(node) if node.params.get("mirror_current_body"): # CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制 # 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会 # 丢失后续 cut/fillet 并生成独立错误实体。 if session.body is None: raise ValueError("mirror current body has no active body") mirrored = session.adapter.mirror(session.body, resolution.record.value) session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node) return session.result(node) sources = session.replay_sources(node.params.get("source_feature_ids") or []) if not sources: raise ValueError("mirror pattern source features have no replay definitions") for source in sources: dependency = pattern_transform_blocker(source) if dependency: raise ValueError(f"mirror pattern source uses an unsupported {dependency}") if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal): # box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向, # 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面 # (法向平行于任一坐标轴)的镜像仍然精确。 raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes") cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session) sketch = session.sketches.get(str(source.sketch_id)) _execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None) session.replay_definitions[node.feature_id] = node return session.result(node) def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: ExecutionSession, axis: AxisSpec) -> bool: """Whether rotating a direct circular extrusion creates no new geometry.""" if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}: return False sketch = session.sketches.get(str(node.sketch_id)) if sketch is None: return False profile = sketch.get("profile") or {} circle = profile if profile.get("type") == "circle" else None if circle is None: contours = profile.get("contours") or [] segments = (contours[0] or {}).get("segments") if len(contours) == 1 else [] circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None center = (circle or {}).get("center") if not isinstance(center, list) or len(center) != 2: return False try: plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) except (TypeError, ValueError): return False if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7: return False world_center = vector_add( plane.origin_mm, vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))), ) offset = vector_subtract(world_center, axis.origin_mm) radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction))) return math.sqrt(vector_dot(radial, radial)) <= 1e-6 def _advance_copy_topology_records( records: list[TopologyRecord], topology_delta: TopologyDelta | None, ) -> list[TopologyRecord]: """Carry COPY provenance through one exact adapter-history operation. Pattern copies are separate CDSL results even when their solids fuse into a single final body. The temporary records here are never selector candidates themselves. They only retain instance ownership while opaque OCC history proves a unique subshape continuation to the final snapshot. """ if topology_delta is None: return [] advanced: list[TopologyRecord] = [] for record in records: values: list[Any] = [] for relation in topology_delta.relations: if ( relation.kind != record.kind or relation.event not in {"preserved", "modified"} or not TopologyRegistry._same_topology_value(record.value, relation.source_value) ): continue for value in relation.result_values: if not any(TopologyRegistry._same_topology_value(value, known) for known in values): values.append(value) # A split/merge has no unique COPY owner in the present selector # contract. Keep the executable model, but do not make a claim that a # later COPY selector can bind one arbitrary descendant. if len(values) != 1: continue advanced.append(TopologyRecord( record_id=record.record_id, kind=record.kind, feature_id=record.feature_id, body_id=record.body_id, geometry=dict(record.geometry), value=values[0], owner_feature_ids=record.owners, output_roles=record.output_roles, output_role_sources=record.output_role_sources, )) return advanced def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None: """Bridge traced final COPY handles into the one registered body snapshot.""" if not records: return None return TopologyDelta( operation="pattern_circular_copy_snapshot", relations=tuple( # ``record.value`` has already passed through every transform/fuse # builder in this pattern and is an actual final-B-rep handle. The # identity relation merely connects that evidence to the fresh # adapter snapshot; it is not a geometric rebinding shortcut. TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,)) for record in records ), ) def _has_usable_pattern_body(session: ExecutionSession, body: Any | None) -> bool: """Reject a formally valid but empty OCC boolean result before publishing it.""" if body is None or not session.adapter.body_solids(body): return False try: return abs(float(body.volume)) > 1e-12 except (AttributeError, TypeError, ValueError): return False def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult: # 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征 # 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。 params = node.params raw_axis = params.get("axis") if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None): raise ValueError("circular pattern requires an explicit axis with origin_mm and direction") axis = AxisSpec.from_mapping(raw_axis) count = int(params.get("pattern_count") or 1) if count < 1: raise ValueError("circular pattern pattern_count must be >= 1") sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0) operation_mode = str(params.get("operation_mode") or "add") if operation_mode not in {"add", "remove"}: raise ValueError("circular pattern operation_mode must be add or remove") excluded = {int(value) for value in params.get("excluded_instance_indices") or []} if any(instance < 1 or instance >= count for instance in excluded): raise ValueError("circular pattern excluded instance is outside the generated range") sources = session.replay_sources(params.get("source_feature_ids") or []) if not sources: raise ValueError("circular pattern source features have no replay definitions") source_ids = [source.feature_id for source in sources] pre_pattern_members = dict(session.body_members) if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids): # A pattern over explicit NEW/kept body members has a stronger contract # than replay: each copy is an independently addressable rigid image of # the named source member. Keep the instance keys in the body graph so # a later CADFS COPY(BODY) transform/delete can name exactly one copy. members = dict(session.body_members) body = session.body traced_copy_records: list[TopologyRecord] = [] for instance in range(1, count): if instance in excluded: continue angle_deg = sweep_angle_deg * instance / count transform = { "type": "rotation", "axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)}, "angle_deg": angle_deg, } for source_id in source_ids: member_id = pattern_instance_member_id(node.feature_id, source_id, instance) owner_id = f"{node.feature_id}.c{instance}.{source_id}" source_body = session.body_members[source_id] copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform) source_records = session.adapter.topology_records( source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source", ) copy_records = _advance_copy_topology_records(source_records, transform_delta) members[member_id] = copy body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy) traced_copy_records = _advance_copy_topology_records( [*traced_copy_records, *copy_records], fuse_delta, ) if _has_usable_pattern_body(session, body): session.register_body( node.feature_id, body, replay_node=node, body_members=members, topology_delta=_copy_snapshot_topology_delta(traced_copy_records), topology_predecessors=traced_copy_records, ) return session.result(node) # An OCC boolean may report IsDone/valid for an empty result when a # copied fused body contains coincident internal topology. The normal # pattern contract can replay the source feature contribution instead; # it is the only sound fallback because it keeps source operation, # sketch frame, and body lifecycle semantics intact. for instance in range(1, count): if instance in excluded: continue # 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。 angle_deg = sweep_angle_deg * instance / count angle_rad = math.radians(angle_deg) for source in sources: # 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与 # 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse, # 后续非轴对称 source 可能因此丢失已生成的实体分支。 if _circular_source_is_axisymmetric(source, session, axis): continue dependency = pattern_transform_blocker(source) if dependency: raise ValueError(f"circular pattern source uses an unsupported {dependency}") if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad): raise ValueError( "box_add circular pattern is exact only for coordinate-axis rotation " "by multiples of 180 degrees" ) cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session) cloned = _pattern_operation_node(cloned, operation_mode) # CADFS pattern instances are copies of the source result, not # independent `NEW` operations. Replay them through normal add # semantics: intersecting or face-sharing instances fuse, while # spatially separate copies remain separate solids in the result. if cloned.params.get("result_mode") == "new_body": cloned = FeaturePlanNode( cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on, {key: value for key, value in cloned.params.items() if key != "result_mode"}, cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature, ) sketch = session.sketches.get(str(source.sketch_id)) execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None) # 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新 # active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续 # selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。 if session.body is None: raise ValueError("circular pattern produced no body") # Replaying a fused sole-body source may be more robust than copying its # full aggregate B-rep (for example, when a rotationally invariant base # would otherwise be unioned with itself). If that replay still has one # physical body, the direct source remains a proven alias of the current # member. Preserve it for a following parts-scoped operation such as # shell; do not extend this alias across multi-body patterns or multiple # source members. members = {node.feature_id: session.body} if ( len(source_ids) == 1 and len(pre_pattern_members) == 1 and source_ids[0] in pre_pattern_members and _has_usable_pattern_body(session, session.body) and len(session.adapter.body_solids(session.body)) == 1 ): members[source_ids[0]] = session.body session.register_body(node.feature_id, session.body, replay_node=node, body_members=members) return session.result(node) def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_circular_pattern(node, session, _execute_node) def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult: executor = EXECUTORS.get(node.atomic_id) if executor is None: raise ValueError(f"No executor registered for {node.atomic_id!r}") previous_feature_id = session.active_feature_id session.active_feature_id = node.feature_id try: return executor(node, session, sketch_override) finally: session.active_feature_id = previous_feature_id ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: return _shape_from_primary(node, session, sketch=sketch) def _revolve_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_revolve_surface(node, session) def _extrude_surface_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_extrude_surface(node, session) def _loft_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_loft_add(node, session) def _loft_cap_face_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_loft_add_with_cap_face(node, session) def _sweep_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: return _execute_sweep_add(node, session, sketch) def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_reference_plane(node, session) def _reference_axis_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_reference_axis(node, session) def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_sphere(node, session) def _box_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_box(node, session) def _cylinder_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_cylinder(node, session) def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_hole(node, session) def _hole_wizard_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_hole(node, session, wizard=True) def _fillet_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_fillet(node, session) def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_chamfer(node, session) def _shell_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_shell(node, session) def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_linear_pattern(node, session, _execute_node) def _mirror_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult: del sketch return _execute_mirror_pattern(node, session) EXECUTORS: dict[str, ExecutorFunction] = { "reference_plane": _reference_plane_executor, "reference_axis": _reference_axis_executor, "sphere_add": _sphere_executor, "box_add": _box_executor, "cylinder_add": _cylinder_executor, "thread_add": _thread_executor, "thread_cut": _thread_executor, "bend_add": _bend_executor, "gear_add": _gear_executor, "rack_add": _rack_executor, "extrude_add_blind": _primary_executor, "extrude_add_blind_with_hole": _primary_executor, "extrude_add_two_sided": _primary_executor, "extrude_cut_blind": _primary_executor, "extrude_cut_two_sided": _primary_executor, "extrude_cut_through": _primary_executor, "extrude_from_face": lambda node, session, _sketch: _execute_extrude_from_face(node, session), "loft_add": _loft_executor, "loft_add_with_cap_face": _loft_cap_face_executor, "sweep_add": _sweep_executor, "revolve_add": _primary_executor, "revolve_cut": _primary_executor, "revolve_surface": _revolve_surface_executor, "extrude_surface": _extrude_surface_executor, "hole_blind": _hole_executor, "hole_countersink": _hole_executor, "hole_counterbore": _hole_executor, "hole_wizard": _hole_wizard_executor, "fillet": _fillet_executor, "chamfer": _chamfer_executor, "shell": _shell_executor, "boolean_bodies": lambda node, session, sketch: _execute_boolean_bodies(node, session), "transform_bodies": lambda node, session, sketch: _execute_transform_bodies(node, session), "delete_bodies": lambda node, session, sketch: _execute_delete_bodies(node, session), "pattern_linear": _linear_pattern_executor, "pattern_mirror": _mirror_pattern_executor, "pattern_circular": _circular_pattern_executor, } def analyze_cdsl(cdsl: dict[str, Any]): """Resolve profiles and return the current runtime capability analysis.""" sketch_errors: dict[str, str] = {} resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS) return analyzer.analyze(resolved, sketch_errors=sketch_errors) def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]: """Rebuild CDSL through session-scoped atomic executors only.""" sketch_errors: dict[str, str] = {} resolved = resolve_required_sketches( deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors, ) analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze( resolved, sketch_errors=sketch_errors, ) if strict and not analysis.runtime_eligible: first = next((result for result in analysis.feature_results if not result.executable), None) if first is None: raise ValueError(analysis.document_blockers[0].code) if any(blocker.code == "unknown_atomic" for blocker in first.blockers): raise ValueError(f"unsupported atomic_id: {first.atomic_id}") detail = "; ".join(blocker.code for blocker in first.blockers) raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}") session = ExecutionSession( sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []}, nodes={node.feature_id: node for node in analysis.plan}, ) diagnostics: list[RuntimeDiagnostic] = [] for node, preflight in zip(analysis.plan, analysis.feature_results): if not preflight.executable: diagnostics.extend(preflight.blockers) if strict: break continue try: _execute_node(node, session) except Exception as error: failed_resolution = next( (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None, ) diagnostic = ( RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail) if isinstance(error, FeatureExecutionError) else RuntimeDiagnostic( failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"], feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {}, ) if failed_resolution and failed_resolution.get("diagnostic") else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id) ) diagnostics.append(diagnostic) if strict: raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error output = session.body surface_geometry: dict[str, Any] | None = None if output is None: if not session.surface_members: raise ValueError("CDSL execution produced no body") # 纯曲面文档没有 active solid,但依然是可执行的 CAD 结果。只有在 # 没有实体时才将 surface members 作为 STEP 输出,混合模型继续只导出 # 实体,避免曲面意外改变既有实体比较和下游消费语义。 output = session.adapter.combine_surfaces(*session.surface_members.values()) surface_geometry = session.adapter.surface_geometry(output) out_step.parent.mkdir(parents=True, exist_ok=True) session.adapter.export(output, str(out_step)) geometry = session.adapter.body_geometry(session.body) if session.body is not None else surface_geometry if geometry is None: raise ValueError("CDSL execution produced no exportable geometry") bbox = geometry["bbox_mm"] return { "engine": "cdsl_session_runtime", "out_step": str(out_step), "volume_mm3": float(geometry.get("volume_mm3") or 0.0), "bbox_mm": {"min": bbox[:3], "max": bbox[3:]}, # #7 multi-body:重建结果里的独立实体数(Compound 成员数), # 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。 "solid_count": len(session.adapter.body_solids(session.body)) if session.body is not None else 0, "surface_count": len(session.surface_members), "surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0, "surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0, "feature_results": [result.as_dict() for result in session.results.values()], "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], "topology_records": [record.public_dict() for record in session.topology.records()], "topology_deltas": list(session.topology.topology_deltas()), "selector_resolution": session.selector_resolutions, }