Files
cdsl-cad/backend/engine/cdsl_engine/runtime.py
T
2026-08-25 17:41:24 +08:00

829 lines
40 KiB
Python

"""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)
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)
self.selector_resolutions.append(resolution.as_dict())
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:
selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
if selected_sketch is None:
raise ValueError("primary feature has no resolved sketch")
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.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")
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
tool = None
for solid in solids:
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("primary feature produced no solid")
if "cut" in node.atomic_id:
if session.body is None:
raise ValueError("cut feature has no body")
body = session.adapter.cut(session.body, tool)
else:
body = session.adapter.fuse(session.body, tool)
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
plane = PlaneSpec.from_mapping(node.params.get("plane") or {})
session.topology.register_context(node.feature_id, plane)
return session.result(node, context=plane)
def _execute_reference_axis(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
params = node.params.get("axis") or {}
if params.get("origin_mm") and params.get("direction"):
axis = AxisSpec.from_mapping(params)
else:
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)]
if len(resolved) < 2:
raise ValueError("reference axis requires two uniquely resolved planes")
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")
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)
axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis"))
session.topology.register_context(node.feature_id, axis)
return session.result(node, context=axis)
def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
radius = float(node.params.get("radius_mm") or 0.0)
center = node.params.get("center_mm") or []
if radius <= 0 or len(center) != 3:
raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm")
solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
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:
if session.body is None:
raise ValueError("hole feature has no body")
host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
host = PlaneSpec.from_mapping(host_selector["frame"])
positions_are_local = True
else:
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
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
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)
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,
)
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:
if session.body is None:
raise ValueError("fillet has no body")
radius = float(node.params.get("radius_mm") or 0)
if radius <= 0:
raise ValueError("fillet radius_mm must be > 0")
body = session.adapter.fillet(
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
)
session.register_body(node.feature_id, body, replay_node=node)
return session.result(node)
def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
if session.body is None:
raise ValueError("chamfer has no body")
distance = float(node.params.get("distance_mm") or 0)
if distance <= 0:
raise ValueError("chamfer distance_mm must be > 0")
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"))),
)
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:
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")
count_1 = int(params.get("pattern_count_1") or 1)
count_2 = int(params.get("pattern_count_2") or 1)
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))
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)
# 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", "y_dir", "normal"):
if host_frame.get(key):
host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
# 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}")
return executor(node, session, sketch_override)
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,
}