Files
cdsl-cad/backend/engine/cdsl_engine/executors/common.py
T
ganjihong 5ffb106f36 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.
2026-09-09 13:33:06 +08:00

489 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 含
# reversecdsl_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