From 5ffb106f368b8209b664d652b23d59e7cd4290d8 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Wed, 9 Sep 2026 13:33:06 +0800 Subject: [PATCH] refactor(cdsl_engine): executor registry + per-family executor package Phase 3 of the decoupling refactor (behavior-preserving): - registry.py: atomic_executor decorator, ALL_ATOMIC_IDS with fail-fast registration validation, execute_node dispatcher - executors/: one module per family (extrude, revolve, surfaces, loft_sweep, bodies, context, primitives, parametric, holes, dressup, patterns) + shared helpers in executors/common - executors/__init__: explicit aggregation + completeness check (registry must cover every declared atomic id at import time) - runtime.py: slimmed to entry points (analyze_cdsl/rebuild_cdsl) plus full historical re-exports incl. test-referenced privates Adding an atomic operation now touches only one executor module and its schema contract; the shared registry never changes. Verified against baseline: zero new failures. --- .../engine/cdsl_engine/executors/__init__.py | 28 + .../engine/cdsl_engine/executors/bodies.py | 124 ++ .../engine/cdsl_engine/executors/common.py | 488 ++++++ .../engine/cdsl_engine/executors/context.py | 63 + .../engine/cdsl_engine/executors/dressup.py | 120 ++ .../engine/cdsl_engine/executors/extrude.py | 42 + backend/engine/cdsl_engine/executors/holes.py | 78 + .../cdsl_engine/executors/loft_sweep.py | 89 + .../cdsl_engine/executors/parametric.py | 118 ++ .../engine/cdsl_engine/executors/patterns.py | 382 ++++ .../cdsl_engine/executors/primitives.py | 97 + .../engine/cdsl_engine/executors/revolve.py | 17 + .../engine/cdsl_engine/executors/surfaces.py | 75 + backend/engine/cdsl_engine/registry.py | 83 + backend/engine/cdsl_engine/runtime.py | 1557 +---------------- 15 files changed, 1843 insertions(+), 1518 deletions(-) create mode 100644 backend/engine/cdsl_engine/executors/__init__.py create mode 100644 backend/engine/cdsl_engine/executors/bodies.py create mode 100644 backend/engine/cdsl_engine/executors/common.py create mode 100644 backend/engine/cdsl_engine/executors/context.py create mode 100644 backend/engine/cdsl_engine/executors/dressup.py create mode 100644 backend/engine/cdsl_engine/executors/extrude.py create mode 100644 backend/engine/cdsl_engine/executors/holes.py create mode 100644 backend/engine/cdsl_engine/executors/loft_sweep.py create mode 100644 backend/engine/cdsl_engine/executors/parametric.py create mode 100644 backend/engine/cdsl_engine/executors/patterns.py create mode 100644 backend/engine/cdsl_engine/executors/primitives.py create mode 100644 backend/engine/cdsl_engine/executors/revolve.py create mode 100644 backend/engine/cdsl_engine/executors/surfaces.py create mode 100644 backend/engine/cdsl_engine/registry.py diff --git a/backend/engine/cdsl_engine/executors/__init__.py b/backend/engine/cdsl_engine/executors/__init__.py new file mode 100644 index 00000000..ef3cbcf8 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/__init__.py @@ -0,0 +1,28 @@ +"""Executor modules for the session runtime. + +Importing this package registers every atomic executor into +``cdsl_engine.registry.EXECUTORS`` exactly once, then verifies that the +registry covers the complete declared atomic-id set. Executor modules must +not import each other; shared helpers live in ``common``. +""" + +from __future__ import annotations + +from ..registry import ALL_ATOMIC_IDS, EXECUTORS +from . import ( # noqa: F401 (importing the modules performs registration) + bodies, + context, + dressup, + extrude, + holes, + loft_sweep, + parametric, + patterns, + primitives, + revolve, + surfaces, +) + +_missing = sorted(ALL_ATOMIC_IDS - set(EXECUTORS)) +if _missing: + raise RuntimeError(f"atomic ids without a registered executor: {_missing}") diff --git a/backend/engine/cdsl_engine/executors/bodies.py b/backend/engine/cdsl_engine/executors/bodies.py new file mode 100644 index 00000000..a2b1e17a --- /dev/null +++ b/backend/engine/cdsl_engine/executors/bodies.py @@ -0,0 +1,124 @@ +"""Body-graph executors (boolean_bodies / transform_bodies / delete_bodies). + +These operate on explicitly named body members instead of the aggregate +session body, so adjacent independent solids never accidentally become tools +or targets of one another. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import transform_copy_member_id +from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta +from .common import _combine_members, _member_sources + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +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) + + +@atomic_executor("boolean_bodies") +def _boolean_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_boolean_bodies(node, session) + + +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) + + +@atomic_executor("transform_bodies") +def _transform_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_transform_bodies(node, session) + + +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) + + +@atomic_executor("delete_bodies") +def _delete_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_delete_bodies(node, session) diff --git a/backend/engine/cdsl_engine/executors/common.py b/backend/engine/cdsl_engine/executors/common.py new file mode 100644 index 00000000..e3a6c4f2 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/common.py @@ -0,0 +1,488 @@ +"""Helpers shared by executor family modules. + +Every function here is imported by two or more executor modules. Anything +used by exactly one family lives in that family's module instead. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, Callable + +from ..extents import _extent_vectors_from_normal, _normal_from_sketch +from ..runtime_base import ExtentVector, FeatureExecutionError +from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec: + raw_axis = node.params.get("axis") or {} + if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None: + return AxisSpec.from_mapping(raw_axis) + selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None + if not isinstance(selector, dict): + selector = next((item for item in node.selectors if item.get("kind") == "axis"), None) + if not isinstance(selector, dict): + raise FeatureExecutionError( + "missing_revolve_axis", + "Revolve requires an explicit axis or an owner-qualified reference-axis selector", + ) + resolution = session.resolve(selector) + if resolution.status != "resolved" or resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved") + if not isinstance(resolution.record.value, AxisSpec): + raise FeatureExecutionError( + "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind, + ) + return resolution.record.value + + +def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None: + """Defend direct CDSL execution from an out-of-plane revolve axis.""" + plane = PlaneSpec.from_mapping(sketch.get("workplane") or {}) + direction_normal_dot = abs(vector_dot(axis.direction, plane.normal)) + if direction_normal_dot > 1e-7: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to " + f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}" + ) + origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal)) + if origin_plane_offset > 1e-6: + raise ValueError( + "REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in " + f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}" + ) + + +def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]: + """Apply a cut to each independently owned body without erasing ownership. + + A CADFS NEW body stays independently addressable even when a later REMOVE + feature affects several active bodies. Cutting the aggregate first loses + that identity, so this path uses the equivalent per-member set difference + and drops only members that the tool removes completely. + """ + members: dict[str, Any] = {} + for feature_id, body in session.body_members.items(): + result = session.adapter.cut(body, tool) + if abs(float(result.volume)) > 1e-12: + members[feature_id] = result + return members + + +def _extruded_tool( + node: FeaturePlanNode, + faces: list[Any], + profile_normal: Vector3, + session: "ExecutionSession", +) -> tuple[Any, TopologyDelta | None]: + """Build one extrude tool, retaining caps only from one exact builder result.""" + extents = _extent_vectors_from_normal(node, faces, profile_normal, session) + draft = node.params.get("draft") + taper_deg = 0.0 + if isinstance(draft, dict): + taper_deg = float(draft["angle_deg"]) + if not bool(draft["pull_direction"]): + taper_deg = -taper_deg + topology_delta: TopologyDelta | None = None + solids: list[Any] = [] + for face in faces: + for extent in extents: + if draft is not None: + if len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_taper_with_topology_delta( + face, extent.vector, taper_deg, + ) + solids.append(solid) + else: + solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg)) + elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1: + solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector) + solids.append(solid) + elif extent.trim_to is None: + solids.append(session.adapter.extrude(face, extent.vector)) + else: + solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector)) + tool = None + for solid in solids: + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("extrude produced no solid") + return tool, topology_delta + + +def _apply_primary_tool( + node: FeaturePlanNode, + session: "ExecutionSession", + tool: Any, + *, + cutting: bool, + topology_delta: TopologyDelta | None = None, +) -> FeatureResult: + """Apply a profile-derived tool while preserving only final-snapshot topology evidence.""" + if cutting: + if session.body is None: + raise ValueError("cut feature has no body") + members = _cut_explicit_body_members(session, tool) + if not members: + session.clear_body() + return session.result(node) + body = session.adapter.cut(session.body, tool) + topology_delta = None + elif node.params.get("result_mode") == "new_body": + body = session.adapter.combine(session.body, tool) + members = {**session.body_members, node.feature_id: tool} + else: + body = session.adapter.fuse(session.body, tool) + members = {node.feature_id: body} + # A fuse rebuilds subshape identity. Builder evidence belongs only to + # an unchanged standalone/new-body prism snapshot. + if session.body is not None: + topology_delta = None + session.register_body( + node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta, + ) + return session.result(node) + + +def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult: + # 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。 + + # 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景), + # 否则按 sketch_id 从会话草图表中取原始草图。 + selected_sketch = sketch or session.sketches.get(str(node.sketch_id)) + if selected_sketch is None: + raise ValueError("primary feature has no resolved sketch") + # 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。 + faces = session.adapter.faces_for_sketch(selected_sketch) + if not faces: + raise ValueError("sketch does not create a closed profile region") + if node.atomic_id == "extrude_add_blind_with_hole": + resolved = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face": + raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved") + if len(faces) != 1: + raise ValueError("profile hole extrusion requires exactly one outer sketch region") + faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])] + topology_delta: TopologyDelta | None = None + # 3. 按特征类型生成子实体: + if node.atomic_id.startswith("extrude_"): + # 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量, + # 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在 + # profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪 + # 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。 + tool, topology_delta = _extruded_tool( + node, faces, _normal_from_sketch(selected_sketch), session, + ) + else: + # 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。 + axis = _revolve_axis(node, session) + _validate_revolve_axis_in_sketch_plane(axis, selected_sketch) + angle = float(node.params.get("angle_deg") or 0.0) + if angle <= 0: + raise ValueError("revolve requires angle_deg > 0") + # reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负 + # 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一 + # 语义。profile_schema.json 已声明 revolve.* optional_params 含 + # reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime + # 侧实现,使三方合同一致。 + if bool(node.params.get("reverse")): + angle = -angle + tool = None + for solid in (session.adapter.revolve(face, angle, axis) for face in faces): + tool = session.adapter.fuse(tool, solid) + if tool is None: + raise ValueError("revolve produced no solid") + return _apply_primary_tool( + node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta, + ) + + +def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any: + body = None + for member in members.values(): + body = session.adapter.combine(body, member) + if body is None: + raise ValueError("booleanBodies produced no result bodies") + return body + + +def _pattern_instance_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str = "pattern_instance_refs", +) -> list[str]: + """Resolve CDSL pattern-instance refs to their internal body-member keys.""" + resolved: list[str] = [] + for reference in node.params.get(parameter) or (): + if not isinstance(reference, dict): + raise ValueError("pattern instance reference must be an object") + pattern_id = str(reference.get("pattern_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + instance = reference.get("instance_index") + if not pattern_id or not source_id or not isinstance(instance, int): + raise ValueError("pattern instance reference is incomplete") + pattern = session.nodes.get(pattern_id) + if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}: + raise ValueError(f"pattern instance owner is unavailable: {pattern_id}") + params = pattern.params + if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}: + raise ValueError("pattern instance source is not selected by its pattern") + count = int(params.get("pattern_count") or 0) + excluded = {int(value) for value in params.get("excluded_instance_indices") or ()} + if ( + pattern.atomic_id == "pattern_mirror" and instance != 1 + ) or ( + pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded) + ): + raise ValueError("pattern instance is outside the pattern's surviving instances") + member_id = pattern_instance_member_id(pattern_id, source_id, instance) + if member_id not in session.body_members: + raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]: + """Resolve source-qualified outputs of preceding multi-body COPY transforms.""" + resolved: list[str] = [] + for reference in node.params.get("transform_copy_refs") or (): + if not isinstance(reference, dict): + raise ValueError("transform COPY reference must be an object") + transform_id = str(reference.get("transform_feature_id") or "") + source_id = str(reference.get("source_feature_id") or "") + if not transform_id or not source_id: + raise ValueError("transform COPY reference is incomplete") + transform = session.nodes.get(transform_id) + params = transform.params if transform is not None else {} + sources = params.get("source_feature_ids") or [] + if ( + transform is None + or transform.atomic_id != "transform_bodies" + or not bool(params.get("make_copy")) + or not isinstance(sources, list) + or len(sources) < 2 + or source_id not in {str(value) for value in sources} + ): + raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}") + member_id = transform_copy_member_id(transform_id, source_id) + if member_id not in session.body_members: + raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}") + if member_id not in resolved: + resolved.append(member_id) + return resolved + + +def _member_sources( + node: FeaturePlanNode, + session: "ExecutionSession", + parameter: str, + *, + pattern_instance_parameter: str | None = None, + allow_transform_copies: bool = False, +) -> list[str]: + source_ids = [str(value) for value in node.params.get(parameter) or []] + if pattern_instance_parameter is not None: + source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter)) + if allow_transform_copies: + source_ids.extend(_transform_copy_sources(node, session)) + if not source_ids: + raise ValueError(f"{node.atomic_id} requires explicit {parameter}") + missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members] + if missing: + raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing)) + return source_ids + + +def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any: + # 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。 + path = node.params.get("path") or {} + if not isinstance(path, dict): + raise ValueError("sweep path must be an object") + plane = PlaneSpec.from_mapping(path.get("workplane") or {}) + segment = path.get("segment") or {} + if not isinstance(segment, dict): + raise ValueError("sweep path segment must be an object") + kind = str(segment.get("type") or "") + if kind == "line": + local_points = [segment.get("start"), segment.get("end")] + elif kind == "bspline": + local_points = segment.get("points") or [] + else: + raise ValueError(f"unsupported sweep path segment {kind!r}") + if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points): + raise ValueError("sweep path requires two-dimensional points") + + def point(value: list[float]) -> Vector3: + return vector_add( + plane.origin_mm, + vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))), + ) + + def tangent(value: Any) -> Vector3 | None: + if value is None: + return None + if not isinstance(value, list) or len(value) != 2: + raise ValueError("sweep path tangent must contain two coordinates") + return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))) + + return session.adapter.sweep_path( + [point(value) for value in local_points], + start_tangent=tangent(segment.get("start_tangent")), + end_tangent=tangent(segment.get("end_tangent")), + parameters=[float(value) for value in segment.get("parameters") or []] or None, + ) + + +def _register_added_solid( + session: "ExecutionSession", + node: FeaturePlanNode, + solid: Any, +) -> 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 _host_plane(resolution: SelectorResolution) -> PlaneSpec: + if resolution.record is None: + raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved") + geometry = resolution.record.geometry + return PlaneSpec.from_mapping({ + "origin_mm": geometry["center_mm"], + "x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0], + "normal": geometry["normal"], + }) + + +def _hole_starts( + spec: HoleSpec, + *, + host_plane: PlaneSpec, + positions_are_local: bool, +) -> list[Vector3]: + starts: list[Vector3] = [] + for point in spec.positions_mm: + if positions_are_local: + start = vector_add( + vector_add( + vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])), + vector_scale(host_plane.y_dir, point[1]), + ), + vector_scale(host_plane.normal, point[2]), + ) + else: + start = point + starts.append(start) + return starts + + +def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]: + resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors] + failed = next((item for item in resolved if item.status != "resolved"), None) + if failed: + raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed") + + def is_body_boundary(edge: Any) -> bool: + # 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体 + # 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边, + # 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的 + # 单边选择,所以这里只约束由 FACE 展开的候选边。 + face_count = sum( + 1 + for face in session.body.faces() + if any(candidate.is_same(edge) for candidate in face.edges()) + ) + return face_count >= 2 + + edges: list[Any] = [] + for item in resolved: + 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 diff --git a/backend/engine/cdsl_engine/executors/context.py b/backend/engine/cdsl_engine/executors/context.py new file mode 100644 index 00000000..a5f5771c --- /dev/null +++ b/backend/engine/cdsl_engine/executors/context.py @@ -0,0 +1,63 @@ +"""Reference-geometry executors (reference_plane / reference_axis). + +Context features produce no solid; they register durable topology contexts +that later features resolve through owner-qualified selectors. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec, vector_add, vector_cross, vector_dot, vector_scale, vector_unit +from ..topology import FeaturePlanNode, FeatureResult + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("reference_plane") +def _reference_plane_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + # 基准面特征(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) + + +@atomic_executor("reference_axis") +def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + # 基准轴特征(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) diff --git a/backend/engine/cdsl_engine/executors/dressup.py b/backend/engine/cdsl_engine/executors/dressup.py new file mode 100644 index 00000000..11335019 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/dressup.py @@ -0,0 +1,120 @@ +"""Dress-up executors (fillet / chamfer / shell). + +These mutate an existing body through edge/face selectors resolved from the +current B-rep snapshot. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta +from .common import _replace_shell_target, _selector_edges, _shell_target + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +def _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) + + +@atomic_executor("fillet") +def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_fillet(node, session) + + +def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。 + + # 1. 校验:倒角作用于已有主体,必须先有主体。 + if session.body is None: + raise ValueError("chamfer has no body") + # 2. 解析主距离并校验必须大于 0。 + distance = float(node.params.get("distance_mm") or 0) + if distance <= 0: + raise ValueError("chamfer distance_mm must be > 0") + # 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出 + # angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时 + # tan=1,退化为等距倒角(与历史行为一致,零回归)。 + # 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45° + # 倒角仅保证量级正确,距离所在侧可能反转。 + distance_2 = node.params.get("distance_2_mm") + angle_rad = node.params.get("angle_rad") + if distance_2 is None and angle_rad is not None: + distance_2 = distance * math.tan(float(angle_rad)) + # 4. 解析目标边(支持相切传播),执行倒角。 + edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))) + diagnostics: list[RuntimeDiagnostic] = [] + topology_delta: TopologyDelta | None = None + try: + body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges) + except ValueError as error: + # 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准 + # OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据 + # 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。 + if distance_2 is not None or not session.surface_members: + raise + try: + body = session.adapter.surface_limited_chamfer( + session.body, distance, edges, list(session.surface_members.values()), + ) + except ValueError: + raise error + diagnostics.append(RuntimeDiagnostic( + "chamfer_surface_limited", + "Chamfer was limited by an explicit coaxial surface boundary", + feature_id=node.feature_id, + detail={"distance_mm": distance, "surface_count": len(session.surface_members)}, + )) + # 5. 登记新主体并返回结果。 + session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta) + return session.result(node, diagnostics=diagnostics) + + +@atomic_executor("chamfer") +def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_chamfer(node, session) + + +def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: + # 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。 + if session.body is None: + raise ValueError("shell has no body") + thickness = float(node.params.get("thickness_mm") or 0) + if thickness <= 0: + raise ValueError("shell thickness_mm must be > 0") + target, faces = _shell_target(node, session) + result, topology_delta = session.adapter.shell_with_topology_delta( + target, faces, thickness, inward=bool(node.params.get("inward", True)), + ) + session.register_body( + node.feature_id, _replace_shell_target(session, target, result), replay_node=node, + topology_delta=topology_delta, + ) + return session.result(node) + + +@atomic_executor("shell") +def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_shell(node, session) diff --git a/backend/engine/cdsl_engine/executors/extrude.py b/backend/engine/cdsl_engine/executors/extrude.py new file mode 100644 index 00000000..e4b89829 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/extrude.py @@ -0,0 +1,42 @@ +"""Extrusion executors (blind / two-sided / cut / through / from-face).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _apply_primary_tool, _extruded_tool, _shape_from_primary + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor( + "extrude_add_blind", + "extrude_add_blind_with_hole", + "extrude_add_two_sided", + "extrude_cut_blind", + "extrude_cut_two_sided", + "extrude_cut_through", +) +def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _shape_from_primary(node, session, sketch=sketch) + + +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, + ) + + +@atomic_executor("extrude_from_face") +def _extrude_from_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_from_face(node, session) diff --git a/backend/engine/cdsl_engine/executors/holes.py b/backend/engine/cdsl_engine/executors/holes.py new file mode 100644 index 00000000..9a66b991 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/holes.py @@ -0,0 +1,78 @@ +"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import HoleSpec, PlaneSpec, vector_scale +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic +from .common import _hole_starts, _host_plane + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("hole_blind", "hole_countersink", "hole_counterbore") +def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_hole(node, session, wizard=False) + + +@atomic_executor("hole_wizard") +def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_hole(node, session, wizard=True) + + +def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult: + # 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。 + + # 1. 校验:孔是切除操作,必须先有主体。 + if session.body is None: + raise ValueError("hole feature has no body") + # 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) diff --git a/backend/engine/cdsl_engine/executors/loft_sweep.py b/backend/engine/cdsl_engine/executors/loft_sweep.py new file mode 100644 index 00000000..4cbb1ba5 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/loft_sweep.py @@ -0,0 +1,89 @@ +"""Loft and sweep executors (loft_add / loft_add_with_cap_face / sweep_add).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _sweep_path + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +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) + + +@atomic_executor("loft_add") +def _loft_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_loft_add(node, session) + + +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) + + +@atomic_executor("loft_add_with_cap_face") +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 _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) + + +@atomic_executor("sweep_add") +def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _execute_sweep_add(node, session, sketch) diff --git a/backend/engine/cdsl_engine/executors/parametric.py b/backend/engine/cdsl_engine/executors/parametric.py new file mode 100644 index 00000000..7cb74084 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/parametric.py @@ -0,0 +1,118 @@ +"""Parametric feature executors (thread / bend / gear / rack). + +Each executor parses its runtime-neutral spec from ``runtime_types`` specs, +asks the geometry adapter to build and place the local-frame solid, then +fuses it into the active body (or subtracts, for thread_cut). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import BendSpec, GearSpec, RackSpec, ThreadSpec +from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic +from .common import _register_added_solid + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +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 _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) + + +@atomic_executor("thread_add", "thread_cut") +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_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) + + +@atomic_executor("bend_add") +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) + + +@atomic_executor("gear_add") +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) + + +@atomic_executor("rack_add") +def _rack_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + # 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。 + del sketch + return _execute_rack(node, session) diff --git a/backend/engine/cdsl_engine/executors/patterns.py b/backend/engine/cdsl_engine/executors/patterns.py new file mode 100644 index 00000000..f7c4d613 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/patterns.py @@ -0,0 +1,382 @@ +"""Pattern executors (pattern_linear / pattern_mirror / pattern_circular). + +Instances replay their source features with transformed parameters rather +than copying the current body. NEW-body sources can additionally be +instanced as rigid body-graph copies, keeping each instance independently +addressable for later COPY/DELETE queries. +""" + +from __future__ import annotations + +import math +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Callable + +from ..capabilities import pattern_transform_blocker +from ..pattern_transform import ( + _box_circular_is_exact, + _mirrored_node, + _mirrored_sketch, + _normal_is_coordinate_axis, + _pattern_operation_node, + _rotated_node, + _rotated_sketch, + _translated_node, + _translated_sketch, +) +from ..registry import atomic_executor, execute_node +from ..specs import AxisSpec, PlaneSpec, Vector3, pattern_instance_member_id, vector_add, vector_dot, vector_scale, vector_subtract, vector_unit +from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + +ExecutorFunction = Callable[[FeaturePlanNode, "ExecutionSession", dict[str, Any] | None], FeatureResult] + + +def _execute_linear_pattern( + node: FeaturePlanNode, + session: "ExecutionSession", + execute: ExecutorFunction, +) -> 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) + + +@atomic_executor("pattern_linear") +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 _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) + + +@atomic_executor("pattern_mirror") +def _mirror_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_mirror_pattern(node, session) + + +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: ExecutorFunction, +) -> 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) + + +@atomic_executor("pattern_circular") +def _circular_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_circular_pattern(node, session, execute_node) diff --git a/backend/engine/cdsl_engine/executors/primitives.py b/backend/engine/cdsl_engine/executors/primitives.py new file mode 100644 index 00000000..54b9d3b1 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/primitives.py @@ -0,0 +1,97 @@ +"""Analytic primitive executors (sphere_add / box_add / cylinder_add).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec +from ..topology import FeaturePlanNode, FeatureResult +from .common import _register_added_solid + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +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) + + +@atomic_executor("sphere_add") +def _sphere_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_sphere(node, session) + + +@atomic_executor("box_add") +def _box_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_box(node, session) + + +@atomic_executor("cylinder_add") +def _cylinder_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_cylinder(node, session) diff --git a/backend/engine/cdsl_engine/executors/revolve.py b/backend/engine/cdsl_engine/executors/revolve.py new file mode 100644 index 00000000..0a11aae1 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/revolve.py @@ -0,0 +1,17 @@ +"""Revolution solid executors (revolve_add / revolve_cut).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..registry import atomic_executor +from ..topology import FeaturePlanNode, FeatureResult +from .common import _shape_from_primary + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +@atomic_executor("revolve_add", "revolve_cut") +def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + return _shape_from_primary(node, session, sketch=sketch) diff --git a/backend/engine/cdsl_engine/executors/surfaces.py b/backend/engine/cdsl_engine/executors/surfaces.py new file mode 100644 index 00000000..5ef44378 --- /dev/null +++ b/backend/engine/cdsl_engine/executors/surfaces.py @@ -0,0 +1,75 @@ +"""Surface-feature executors (extrude_surface / revolve_surface). + +Surface features register an independent shell and never touch the active +solid body's fuse/cut lifecycle. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from ..extents import _normal_from_sketch +from ..registry import atomic_executor +from ..specs import AxisSpec, PlaneSpec, vector_cross, vector_dot, vector_scale, vector_unit +from ..topology import FeaturePlanNode, FeatureResult +from .common import _revolve_axis, _validate_revolve_axis_in_sketch_plane + +if TYPE_CHECKING: # pragma: no cover - import for type checkers only + from ..session import ExecutionSession + + +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) + + +@atomic_executor("revolve_surface") +def _revolve_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_revolve_surface(node, session) + + +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) + + +@atomic_executor("extrude_surface") +def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult: + del sketch + return _execute_extrude_surface(node, session) diff --git a/backend/engine/cdsl_engine/registry.py b/backend/engine/cdsl_engine/registry.py new file mode 100644 index 00000000..129ccfdb --- /dev/null +++ b/backend/engine/cdsl_engine/registry.py @@ -0,0 +1,83 @@ +"""Atomic executor registry and dispatch for the session runtime. + +Executors register themselves with :func:`atomic_executor` from their own +modules; ``cdsl_engine.executors`` imports every executor module exactly once +to build the registry. Adding a new atomic operation therefore means adding +a decorated function in a family module -- the registry itself never changes. + +``registry`` deliberately imports no executor module, so pattern executors +can re-enter :func:`execute_node` for replay without an import cycle. +""" + +from __future__ import annotations + +from typing import Any, Callable, Protocol + +from .session import ExecutionSession +from .topology import CapabilityResult, FeaturePlanNode, FeatureResult + + +class AtomicExecutor(Protocol): + atomic_id: str + + def preflight(self, node: FeaturePlanNode, session: ExecutionSession) -> CapabilityResult: ... + def execute(self, node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: ... + + +#: The complete capability contract of this runtime. Every registered +#: executor must name an id from this set, so a mistyped registration fails +#: at import time instead of surfacing as an unknown-atomic blocker later. +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", +}) + +#: ``atomic_id -> executor``. Populated by ``cdsl_engine.executors``. +EXECUTORS: dict[str, "ExecutorFunction"] = {} + +ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult] + + +def atomic_executor(*atomic_ids: str) -> Callable[["ExecutorFunction"], "ExecutorFunction"]: + """Register one executor function for one or more atomic ids. + + Registration validates against ``ALL_ATOMIC_IDS`` and rejects duplicates, + keeping the registry consistent with the declared capability contract. + """ + unknown = [atomic_id for atomic_id in atomic_ids if atomic_id not in ALL_ATOMIC_IDS] + if unknown: + raise ValueError(f"cannot register executors for unknown atomic ids: {unknown}") + + def decorator(func: "ExecutorFunction") -> "ExecutorFunction": + for atomic_id in atomic_ids: + if atomic_id in EXECUTORS: + raise ValueError(f"duplicate executor registration for {atomic_id!r}") + EXECUTORS[atomic_id] = func + return func + + return decorator + + +def execute_node( + node: FeaturePlanNode, + session: ExecutionSession, + sketch_override: dict[str, Any] | None = None, +) -> FeatureResult: + """Dispatch one plan node through its registered executor.""" + 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 diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 5706d560..e01c20bd 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -1,26 +1,32 @@ -"""Session-based CDSL execution with atomic executor registry. +"""Session-based CDSL execution entry points. The runtime was split into focused modules (behavior-preserving move): -- ``runtime_base``: shared error types and the ``ExtentVector`` value. +- ``registry``: atomic executor registry, ``atomic_executor`` decorator, and + the ``execute_node`` dispatcher. +- ``executors/``: one module per executor family; importing the package + performs the registration and verifies registry completeness. - ``session``: ``ExecutionSession`` and the ``GeometryAdapter`` protocol. +- ``runtime_base``: shared error types and the ``ExtentVector`` value. - ``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. +This module keeps the ``analyze_cdsl`` / ``rebuild_cdsl`` entry points and +re-exports the historical ``cdsl_engine.runtime`` names, including the +private helpers referenced by the test suite, so every existing import keeps +working. """ from __future__ import annotations from copy import deepcopy -import math from pathlib import Path -from typing import Any, Callable, Protocol +from typing import Any -from .build123d_adapter import Build123dGeometryAdapter # noqa: F401 (historical re-export) +from . import executors # noqa: F401 (importing performs executor registration) from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract +# Historical private name still imported by the test suite. +from .executors.primitives import _execute_box # noqa: F401 from .extents import ( _extent_reference, _extent_vectors, @@ -46,6 +52,14 @@ from .pattern_transform import ( _translated_node, _translated_sketch, ) +from .registry import ( + ALL_ATOMIC_IDS, + EXECUTORS, + AtomicExecutor, + ExecutorFunction, + atomic_executor, + execute_node, +) from .runtime_base import ExtentVector, FeatureExecutionError, RuntimeExecutionError from .session import ExecutionSession, GeometryAdapter from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches @@ -59,1515 +73,22 @@ from .topology import ( 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, -} +__all__ = [ + "ALL_ATOMIC_IDS", + "EXECUTORS", + "ExecutionSession", + "ExecutorFunction", + "ExtentVector", + "FeatureExecutionError", + "FeaturePlanNode", + "FeatureResult", + "GeometryAdapter", + "RuntimeDiagnostic", + "RuntimeExecutionError", + "analyze_cdsl", + "execute_node", + "rebuild_cdsl", +] def analyze_cdsl(cdsl: dict[str, Any]): @@ -1609,7 +130,7 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) - break continue try: - _execute_node(node, session) + execute_node(node, session) except Exception as error: failed_resolution = next( (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,