Files
cdsl-cad/backend/engine/cdsl_engine/runtime.py
T

921 lines
46 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.
"""Session-based CDSL execution with atomic executor registry."""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
import math
from pathlib import Path
from typing import Any, Callable, Protocol
from .build123d_adapter import Build123dGeometryAdapter
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
from .runtime_types import (
AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, Vector3,
RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
)
from .sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "reference_plane", "reference_axis",
"hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
})
class RuntimeExecutionError(RuntimeError):
"""A feature execution failure with serializable runtime evidence."""
def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None:
super().__init__(diagnostic.message)
self.diagnostic = diagnostic
self.selector_resolutions = selector_resolutions
class FeatureExecutionError(RuntimeError):
"""An expected feature-level execution rejection with a stable code."""
def __init__(self, code: str, message: str, **detail: Any) -> None:
super().__init__(message)
self.code = code
self.detail = detail
class AtomicExecutor(Protocol):
atomic_id: str
def preflight(self, node: FeaturePlanNode, session: "ExecutionSession") -> CapabilityResult: ...
def execute(self, node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: ...
class GeometryAdapter(Protocol):
"""Kernel boundary consumed by the session runtime.
Geometry values remain opaque here. A future adapter may use a different
B-rep kernel as long as it preserves these construction/query contracts.
"""
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ...
def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ...
def vertex_coordinates(self, vertex: Any) -> Vector3: ...
def profile_sample_points(self, face: Any) -> list[Any]: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ...
def export(self, body: Any, path: str) -> None: ...
@dataclass
class ExecutionSession:
sketches: dict[str, dict[str, Any]]
nodes: dict[str, FeaturePlanNode]
adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter)
topology: TopologyRegistry = field(default_factory=TopologyRegistry)
body: Any | None = None
body_id: str | None = None
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
self.body = body
self.body_id = f"body:{feature_id}"
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
))
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
resolution = self.topology.resolve(selector, active_body_id=self.body_id)
evidence = resolution.as_dict()
evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence)
return resolution
def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult:
result = FeatureResult(
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", body_id=self.body_id,
context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
diagnostics=diagnostics or [],
)
self.results[node.feature_id] = result
return result
def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]:
"""Return selected source features in their original history order.
A pattern's exported selection order is not an execution order. In
particular, a boolean cut may appear before its parent boss in the
raw selection array. The CDSL feature list is dependency-ordered by
semantic validation, so it is the stable order for replay.
"""
requested = {str(feature_id) for feature_id in source_feature_ids}
sources = [
feature
for feature_id, feature in self.nodes.items()
if feature_id in requested and feature_id in self.replay_definitions
]
if len(sources) != len(requested):
missing = sorted(requested - {source.feature_id for source in sources})
raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}")
return sources
def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3:
return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal
def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]:
condition = condition or node.params.get("end_condition") or {}
reference = condition.get("reference")
if not isinstance(reference, dict):
raise FeatureExecutionError(
"missing_extent_reference",
"This end condition requires a captured target selector",
extent=condition.get("type"),
)
return reference
def _targeted_extent_vector(
node: FeaturePlanNode,
faces: list[Any],
direction: Vector3,
session: ExecutionSession,
condition: str,
*,
end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None,
) -> Vector3:
if session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next":
target = session.body
else:
reference = _extent_reference(node, end_condition)
resolution = session.resolve(reference)
if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face")
if resolution.record.kind != expected_kind:
raise FeatureExecutionError(
"unsupported_extent_target",
"The resolved target kind is incompatible with this end condition",
extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind,
)
target = resolution.record.value
if condition == "up_to_vertex":
target_point = session.adapter.vertex_coordinates(target)
projections = [
vector_dot(vector_subtract(target_point, point), direction)
for face in faces
for point in session.adapter.profile_sample_points(face)
]
if not projections or min(projections) <= 1e-6:
raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition)
if max(projections) - min(projections) > 1e-5:
raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
distance = sum(projections) / len(projections)
else:
try:
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached"
raise FeatureExecutionError(code, str(error), extent=condition) from error
if condition == "offset_from_surface":
offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
distance -= offset
if distance <= 1e-6:
raise FeatureExecutionError(
"invalid_extent_offset",
"Offset distance reaches or passes the target surface",
extent=condition, offset_mm=offset,
)
return vector_scale(direction, distance)
def _side_extent_vectors(
node: FeaturePlanNode,
faces: list[Any],
direction: Vector3,
session: ExecutionSession,
*,
end_condition: dict[str, Any],
distance_mm: float,
) -> list[Vector3]:
"""Resolve one directional extent without borrowing the opposite side.
``extrude_add_two_sided`` calls this once for each independently captured
termination. The regular one-sided executor also uses it for all simple
termination modes, keeping the geometry adapter interface uniform.
"""
condition = str(end_condition.get("type") or "blind")
distance = abs(float(distance_mm or 0.0))
if condition == "blind":
if distance <= 0:
raise ValueError("blind extent requires distance_mm > 0")
return [vector_scale(direction, distance)]
if condition == "mid_plane":
if distance <= 0:
raise ValueError("mid_plane extent requires distance_mm > 0")
return [vector_scale(direction, distance / 2), vector_scale(direction, -distance / 2)]
if condition == "through_all":
if session.body is None:
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [direction * distance]
return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)]
if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
return [
_targeted_extent_vector(
node, faces, direction, session, condition,
end_condition=end_condition, offset_mm=distance,
)
]
raise ValueError(f"unsupported directional extent {condition!r}")
def _extent_vectors(
node: FeaturePlanNode,
faces: list[Any],
sketch: dict[str, Any],
session: ExecutionSession,
) -> list[Vector3]:
params = node.params
normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
if bool(params.get("reverse")):
normal = vector_scale(normal, -1)
end_condition = params.get("end_condition") or {"type": "blind"}
condition = end_condition.get("type", "blind")
distance = abs(float(params.get("distance_mm") or 0.0))
if node.atomic_id == "extrude_add_two_sided":
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
if reverse_distance <= 0:
raise ValueError("two-sided extrusion requires reverse_distance_mm > 0")
return [
*_side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
),
*_side_extent_vectors(
node, faces, vector_scale(normal, -1), session,
end_condition=reverse_condition, distance_mm=reverse_distance,
),
]
if condition in {"through_all", "through_all_both", "through_all_and_blind"}:
if session.body is None:
# A first feature with through-all has no body to terminate
# against. The source must provide a usable blind component.
if distance <= 0:
raise ValueError("through_all on an initial feature has no body and no fallback distance")
return [vector_scale(normal, distance)]
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
if condition == "through_all":
return [vector_scale(normal, span)]
if condition == "through_all_both":
return [vector_scale(normal, span), vector_scale(normal, -span)]
# Through-all-and-blind is represented by a through direction plus
# its captured opposite blind direction when available.
reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
return [vector_scale(normal, span), vector_scale(normal, -(reverse_distance or span))]
return _side_extent_vectors(
node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
)
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 _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")
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
# 再对每个面沿每个向量做拉伸,得到实体列表。
vectors = _extent_vectors(node, faces, selected_sketch, session)
solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors]
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
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
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
tool = None
for solid in solids:
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("primary feature produced no solid")
# 5. 与当前主体做布尔操作:
if "cut" in node.atomic_id:
# 切除类特征:要求已有主体,从主体上减去工具体(cut)。
if session.body is None:
raise ValueError("cut feature has no body")
body = session.adapter.cut(session.body, tool)
else:
# 添加类特征:将工具体并到当前主体上(fuse),首个特征时 body 为 None 也能直接成立。
body = session.adapter.fuse(session.body, tool)
# 6. 登记新主体(更新拓扑、记录重放定义),并返回该特征的结果对象。
session.register_body(node.feature_id, body, replay_node=node)
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 _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)后登记为新主体,并返回该特征的结果对象。
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return session.result(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 _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. 确定孔轴向:默认沿宿主面法向,但需保证指向主体内部(按主体中心与面原点的相对位置取反)。
normal = host.normal
inward = normal if vector_dot(vector_subtract(session.adapter.body_center(session.body), host.origin_mm), normal) >= 0 else vector_scale(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. 从主体上减去工具实体,登记新主体并返回结果。
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
return session.result(node)
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")
edges: list[Any] = []
for item in resolved:
if item.record.kind == "edge":
edges.append(item.record.value)
elif item.record.kind == "face":
edges.extend(item.record.value.edges())
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 _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 = session.adapter.fillet(
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)
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. 解析目标边(支持相切传播),执行倒角;distance_2_mm 提供时产生非对称倒角。
body = session.adapter.chamfer(
session.body, distance, node.params.get("distance_2_mm"),
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
# 4. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]:
output = deepcopy(sketch)
components = offset
workplane = output.get("workplane") or {}
origin = workplane.get("origin_mm") or [0, 0, 0]
workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)]
output["workplane"] = workplane
for key in ("contour_edges_mm", "contour_regions_mm"):
def translate(value: Any) -> None:
if isinstance(value, dict):
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)]
for child in value.values():
translate(child)
elif isinstance(value, list):
for child in value:
translate(child)
translate(output.get(key))
return output
def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3) -> FeaturePlanNode:
params = deepcopy(node.params)
components = offset
if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"):
params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)]
host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all(
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
)
if positions_are_local and host_frame.get("origin_mm"):
host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)]
if not positions_are_local:
for position in params.get("positions") or []:
if position.get("mm"):
position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)]
axis = params.get("axis") or {}
if axis.get("origin_mm"):
axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)]
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
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)
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 _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]:
value = tuple(float(component) for component in point)
offset = value if vector else vector_subtract(value, plane.origin_mm)
mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal)))
return list(mirrored)
def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]:
output = deepcopy(sketch)
workplane = output.get("workplane") or {}
if workplane.get("origin_mm"):
workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane)
for key in ("x_dir", "y_dir", "normal"):
if workplane.get(key):
workplane[key] = _reflect_point(workplane[key], plane, vector=True)
output["workplane"] = workplane
# A reflection reverses handedness. ``PlaneSpec`` reconstructs its local
# y direction as normal x x, so keeping the reflected normal means that
# local y is the inverse of the reflected source y. Profiles represented
# as local circles (rather than already-transformed contour edges) must
# therefore invert v to remain at their actual reflected world position.
def mirror_local_coordinates(value: Any) -> None:
if isinstance(value, dict):
for point_key in ("center", "start", "end"):
point = value.get(point_key)
if isinstance(point, list) and len(point) == 2:
value[point_key] = [float(point[0]), -float(point[1])]
for child in value.values():
mirror_local_coordinates(child)
elif isinstance(value, list):
for child in value:
mirror_local_coordinates(child)
mirror_local_coordinates(output.get("entities"))
# This is not consumed after sketch resolution, but retaining the same
# local semantics makes an overridden sketch safe to inspect or replay.
mirror_local_coordinates(output.get("profile"))
def mirror(value: Any) -> None:
if isinstance(value, dict):
for point_key in ("start_mm", "end_mm", "center_mm"):
if point_key in value:
value[point_key] = _reflect_point(value[point_key], plane)
if value.get("normal"):
value["normal"] = _reflect_point(value["normal"], plane, vector=True)
for child in value.values():
mirror(child)
elif isinstance(value, list):
for child in value:
mirror(child)
mirror(output.get("contour_edges_mm"))
mirror(output.get("contour_regions_mm"))
return output
def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) -> FeaturePlanNode:
params = deepcopy(node.params)
if isinstance(params.get("plane"), dict):
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
if params["plane"].get(key):
params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm")
host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all(
host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
)
if positions_are_local:
for key in ("origin_mm", "x_dir", "normal"):
if host_frame.get(key):
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
# #1 y_dir 保留:PlaneSpec 现在会尊重显式正交 y_dir。镜像后 frame 的
# canonical y 轴必须是 n×x(x 已反射 → y 反转),否则反射后的 frame
# 会保留反射前的 y_dir,与下方"局部坐标 v 取反"双重翻转。
x_reflected = host_frame.get("x_dir")
n_reflected = host_frame.get("normal")
if x_reflected is not None and n_reflected is not None:
host_frame["y_dir"] = [
n_reflected[1] * x_reflected[2] - n_reflected[2] * x_reflected[1],
n_reflected[2] * x_reflected[0] - n_reflected[0] * x_reflected[2],
n_reflected[0] * x_reflected[1] - n_reflected[1] * x_reflected[0],
]
# See _mirrored_sketch: the canonical reflected plane reverses local
# y, so local hole coordinates must do the same.
for position in params.get("positions") or []:
point = position.get("mm")
if isinstance(point, list) and len(point) == 3:
position["mm"] = [float(point[0]), -float(point[1]), float(point[2])]
else:
for position in params.get("positions") or []:
if position.get("mm"):
position["mm"] = _reflect_point(position["mm"], plane)
axis = params.get("axis") or {}
if axis.get("origin_mm"):
axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane)
if axis.get("direction"):
axis["direction"] = _reflect_point(axis["direction"], plane, vector=True)
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
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")
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}")
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value)
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 _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 _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 _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 _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,
"extrude_add_blind": _primary_executor,
"extrude_add_two_sided": _primary_executor,
"extrude_cut_blind": _primary_executor,
"revolve_add": _primary_executor,
"revolve_cut": _primary_executor,
"hole_blind": _hole_executor,
"hole_countersink": _hole_executor,
"hole_counterbore": _hole_executor,
"hole_wizard": _hole_wizard_executor,
"fillet": _fillet_executor,
"chamfer": _chamfer_executor,
"pattern_linear": _linear_pattern_executor,
"pattern_mirror": _mirror_pattern_executor,
}
def analyze_cdsl(cdsl: dict[str, Any]):
"""Resolve profiles and return the current runtime capability analysis."""
sketch_errors: dict[str, str] = {}
resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
)
analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS)
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
"""Rebuild CDSL through session-scoped atomic executors only."""
sketch_errors: dict[str, str] = {}
resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
)
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
resolved, sketch_errors=sketch_errors,
)
if strict and not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
session = ExecutionSession(
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
nodes={node.feature_id: node for node in analysis.plan},
)
diagnostics: list[RuntimeDiagnostic] = []
for node, preflight in zip(analysis.plan, analysis.feature_results):
if not preflight.executable:
diagnostics.extend(preflight.blockers)
if strict:
break
continue
try:
_execute_node(node, session)
except Exception as error:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
diagnostic = (
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if isinstance(error, FeatureExecutionError)
else
RuntimeDiagnostic(
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
)
if failed_resolution and failed_resolution.get("diagnostic")
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
)
diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
if session.body is None:
raise ValueError("CDSL execution produced no body")
out_step.parent.mkdir(parents=True, exist_ok=True)
session.adapter.export(session.body, str(out_step))
geometry = session.adapter.body_geometry(session.body)
bbox = geometry["bbox_mm"]
return {
"engine": "cdsl_session_runtime",
"out_step": str(out_step),
"volume_mm3": float(geometry["volume_mm3"]),
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],
"selector_resolution": session.selector_resolutions,
}