1462 lines
77 KiB
Python
1462 lines
77 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, BendSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec,
|
||
ThreadSpec, 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",
|
||
"extrude_cut_through",
|
||
"revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
|
||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||
"reference_plane", "reference_axis",
|
||
"hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
|
||
"pattern_circular",
|
||
"thread_add", "thread_cut",
|
||
"bend_add",
|
||
})
|
||
|
||
|
||
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
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ExtentVector:
|
||
"""Single-directional extrusion displacement for one profile face.
|
||
|
||
``trim_to`` stays ``None`` for an exact vector extrusion. When set, the
|
||
extent means "extrude until the target face, trimming any profile region
|
||
that does not reach it" (up_to_surface trim semantics, issue #5). The
|
||
piercing distance is computed inside the adapter, so ``vector`` only
|
||
supplies the direction.
|
||
"""
|
||
|
||
vector: Vector3
|
||
trim_to: Any | None = None
|
||
|
||
|
||
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_solids(self, body: Any) -> list[Any]: ...
|
||
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 extrude_trimmed(self, face: Any, target: 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 thread_solid(self, spec: ThreadSpec) -> Any: ...
|
||
def bend_solid(self, spec: BendSpec) -> 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:
|
||
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
|
||
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
||
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
|
||
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
|
||
self.body = body
|
||
self.body_id = f"body:{feature_id}"
|
||
solids = self.adapter.body_solids(body)
|
||
if len(solids) <= 1:
|
||
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
|
||
else:
|
||
for index, solid in enumerate(solids):
|
||
member_id = f"{self.body_id}:{index}"
|
||
self.topology.replace_body_topology(
|
||
feature_id, member_id,
|
||
self.adapter.topology_records(solid, feature_id, member_id),
|
||
active_body_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,
|
||
) -> ExtentVector:
|
||
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:
|
||
message = str(error)
|
||
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
|
||
if condition == "up_to_surface":
|
||
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
|
||
# 命中目标 → 悬空;或各点命中距离不一 → 斜目标面)时不再整体
|
||
# 拒绝,而是"裁剪"——只保留从 profile 到目标面之间的材料。
|
||
# extrude_trimmed 内部做穿透拉伸 + 与目标面体层布尔求交,未达
|
||
# 目标的部分被切掉(CAD "拉伸到面"标准语义)。若全部采样点都
|
||
# 未命中(profile 与目标面无交叠),extrude_trimmed 内部仍抛
|
||
# "not reached",保持显式拒绝。
|
||
# up_to_vertex/up_to_body/offset_from_surface 无 face 可构造
|
||
# 裁剪体层,仍保持显式拒绝。
|
||
return ExtentVector(vector_scale(direction, 1.0), trim_to=target)
|
||
raise FeatureExecutionError(code, message, 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 ExtentVector(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[ExtentVector]:
|
||
"""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 [ExtentVector(vector_scale(direction, distance))]
|
||
if condition == "mid_plane":
|
||
if distance <= 0:
|
||
raise ValueError("mid_plane extent requires distance_mm > 0")
|
||
return [
|
||
ExtentVector(vector_scale(direction, distance / 2)),
|
||
ExtentVector(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")
|
||
# 注意:Vector3 是 tuple,不能直接做 direction * distance(那是元组
|
||
# 重复),这里必须用 vector_scale 做数乘(顺带修复的隐藏 bug)。
|
||
return [ExtentVector(vector_scale(direction, distance))]
|
||
return [ExtentVector(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[ExtentVector]:
|
||
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 [ExtentVector(vector_scale(normal, distance))]
|
||
span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
|
||
if condition == "through_all":
|
||
return [ExtentVector(vector_scale(normal, span))]
|
||
if condition == "through_all_both":
|
||
return [ExtentVector(vector_scale(normal, span)), ExtentVector(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 [
|
||
ExtentVector(vector_scale(normal, span)),
|
||
ExtentVector(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 _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 _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_"):
|
||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
|
||
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
|
||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||
extents = _extent_vectors(node, faces, selected_sketch, session)
|
||
solids: list[Any] = []
|
||
for face in faces:
|
||
for extent in extents:
|
||
if 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))
|
||
else:
|
||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||
axis = _revolve_axis(node, session)
|
||
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
|
||
angle = float(node.params.get("angle_deg") or 0.0)
|
||
if angle <= 0:
|
||
raise ValueError("revolve requires angle_deg > 0")
|
||
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
|
||
# 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一
|
||
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
|
||
# reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime
|
||
# 侧实现,使三方合同一致。
|
||
if bool(node.params.get("reverse")):
|
||
angle = -angle
|
||
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 _execute_box(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||
# 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。
|
||
# 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。
|
||
try:
|
||
length = float(node.params.get("length_mm") or 0.0)
|
||
width = float(node.params.get("width_mm") or 0.0)
|
||
height = float(node.params.get("height_mm") or 0.0)
|
||
center = node.params.get("center_mm") or []
|
||
except (TypeError, ValueError) as error:
|
||
raise ValueError("box dimensions must be numeric") from error
|
||
if length <= 0 or width <= 0 or height <= 0 or len(center) != 3:
|
||
raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm")
|
||
# 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半
|
||
# 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。
|
||
corner = (
|
||
float(center[0]) - length / 2,
|
||
float(center[1]) - width / 2,
|
||
float(center[2]) - height / 2,
|
||
)
|
||
plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]})
|
||
solid = session.adapter.box(length, width, height, plane)
|
||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||
return session.result(node)
|
||
|
||
|
||
def _execute_cylinder(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||
# 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向;
|
||
# axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。
|
||
# 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。
|
||
try:
|
||
radius = float(node.params.get("radius_mm") or 0.0)
|
||
height = float(node.params.get("height_mm") or 0.0)
|
||
except (TypeError, ValueError) as error:
|
||
raise ValueError("cylinder dimensions must be numeric") from error
|
||
if radius <= 0 or height <= 0:
|
||
raise ValueError("cylinder_add requires positive radius_mm and height_mm")
|
||
raw_axis = node.params.get("axis")
|
||
if raw_axis is not None and not (
|
||
isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None
|
||
):
|
||
raise ValueError("cylinder_add axis must define origin_mm and direction")
|
||
axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None
|
||
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
|
||
solid = session.adapter.cylinder(radius, height, axis)
|
||
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||
return session.result(node)
|
||
|
||
|
||
def _execute_thread(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||
# 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。
|
||
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
|
||
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
|
||
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
|
||
solid = session.adapter.thread_solid(spec)
|
||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||
return session.result(node)
|
||
|
||
|
||
def _thread_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
# 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。
|
||
# thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。
|
||
del sketch
|
||
if node.atomic_id == "thread_cut":
|
||
return _execute_thread_cut(node, session)
|
||
return _execute_thread(node, session)
|
||
|
||
|
||
def _execute_thread_cut(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||
# 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置
|
||
# internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿
|
||
# spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔,
|
||
# 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。
|
||
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
|
||
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
|
||
# 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。
|
||
tool = session.adapter.thread_solid(spec)
|
||
# 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。
|
||
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||
return session.result(node)
|
||
|
||
|
||
def _execute_bend(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
|
||
# 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。
|
||
# 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。
|
||
spec = BendSpec.from_feature(node.params)
|
||
# 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。
|
||
solid = session.adapter.bend_solid(spec)
|
||
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||
return session.result(node)
|
||
|
||
|
||
def _bend_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
# 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。
|
||
del sketch
|
||
return _execute_bend(node, session)
|
||
|
||
|
||
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
|
||
if resolution.record is None:
|
||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
|
||
geometry = resolution.record.geometry
|
||
return PlaneSpec.from_mapping({
|
||
"origin_mm": geometry["center_mm"],
|
||
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
|
||
"normal": geometry["normal"],
|
||
})
|
||
|
||
|
||
def _hole_starts(
|
||
spec: HoleSpec,
|
||
*,
|
||
host_plane: PlaneSpec,
|
||
positions_are_local: bool,
|
||
) -> list[Vector3]:
|
||
starts: list[Vector3] = []
|
||
for point in spec.positions_mm:
|
||
if positions_are_local:
|
||
start = vector_add(
|
||
vector_add(
|
||
vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])),
|
||
vector_scale(host_plane.y_dir, point[1]),
|
||
),
|
||
vector_scale(host_plane.normal, point[2]),
|
||
)
|
||
else:
|
||
start = point
|
||
starts.append(start)
|
||
return starts
|
||
|
||
|
||
def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: bool = False) -> FeatureResult:
|
||
# 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。
|
||
|
||
# 1. 校验:孔是切除操作,必须先有主体。
|
||
if session.body is None:
|
||
raise ValueError("hole feature has no body")
|
||
# 2. 确定宿主面 host_face:
|
||
host_selector = node.params.get("host_face")
|
||
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
||
# 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。
|
||
host = PlaneSpec.from_mapping(host_selector["frame"])
|
||
positions_are_local = True
|
||
else:
|
||
# 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。
|
||
selectors = list(node.selectors)
|
||
if isinstance(host_selector, dict):
|
||
selectors.append(host_selector)
|
||
selector = next((item for item in selectors if item.get("kind") == "face"), None)
|
||
if selector is None:
|
||
raise ValueError("hole requires host_face selector or frame")
|
||
host = _host_plane(session.resolve(selector))
|
||
positions_are_local = False
|
||
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
|
||
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
|
||
# 4. A host-face normal is an outward B-rep orientation, so its inverse
|
||
# always enters the material. Inferring direction from the global body
|
||
# centre fails for concave or multi-leg parts: for example, the top face
|
||
# of an L bracket can sit below the whole body's centre and the old rule
|
||
# drilled outward, producing a no-op feature reported as successful.
|
||
# The selected topology face is the local, authoritative orientation.
|
||
inward = vector_scale(host.normal, -1)
|
||
# 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。
|
||
tool = session.adapter.hole_tool(
|
||
spec,
|
||
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
|
||
inward,
|
||
session.adapter.body_span(session.body, inward) + 2.0,
|
||
)
|
||
# 6. 从主体上减去工具实体,登记新主体并返回结果。
|
||
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
|
||
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
|
||
# 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。
|
||
diagnostics: list[RuntimeDiagnostic] = []
|
||
if wizard and node.params.get("thread"):
|
||
diagnostics.append(RuntimeDiagnostic(
|
||
code="thread_decoration_ignored",
|
||
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||
feature_id=node.feature_id,
|
||
))
|
||
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||
return session.result(node, diagnostics=diagnostics)
|
||
|
||
|
||
def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]:
|
||
resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors]
|
||
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||
if failed:
|
||
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
|
||
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. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
|
||
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时
|
||
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
|
||
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
|
||
# 倒角仅保证量级正确,距离所在侧可能反转。
|
||
distance_2 = node.params.get("distance_2_mm")
|
||
angle_rad = node.params.get("angle_rad")
|
||
if distance_2 is None and angle_rad is not None:
|
||
distance_2 = distance * math.tan(float(angle_rad))
|
||
# 4. 解析目标边(支持相切传播),执行倒角。
|
||
body = session.adapter.chamfer(
|
||
session.body, distance, distance_2,
|
||
_selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
|
||
)
|
||
# 5. 登记新主体并返回结果。
|
||
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 _owner_plane_frame(session: ExecutionSession, selector: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""解析 selector 的 owner 特征(reference_plane)注册的显式平面 frame。
|
||
|
||
#6 pattern 引用重解析:pattern 重放 source(pattern_mirror)时,镜像面
|
||
是 selector,其 owner 是 reference_plane 特征;该特征执行时把显式
|
||
PlaneSpec 登记为拓扑上下文,这里取出该 frame 供随实例变换使用。
|
||
"""
|
||
owner = selector.get("owner_feature_id")
|
||
if not owner:
|
||
return None
|
||
for record in session.topology.records_for_feature(str(owner)):
|
||
if record.kind == "plane" and isinstance(record.value, PlaneSpec):
|
||
return record.value.as_dict()
|
||
return None
|
||
|
||
|
||
def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3, session: ExecutionSession) -> 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)]
|
||
center = params.get("center_mm")
|
||
if center:
|
||
# box_add/sphere_add 以世界坐标几何中心定位;平移重放必须随实例移动该中心,
|
||
# 否则阵列副本会静默重合在原位置。
|
||
params["center_mm"] = [float(center[index]) + components[index] for index in range(3)]
|
||
mirror_plane = params.get("mirror_plane")
|
||
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
|
||
# #6 pattern 引用重解析:镜像面是 reference_plane 引用,随实例平移
|
||
# 到新位置后内联为显式 frame;否则重放时 resolve 到原始面,镜像
|
||
# 副本会错误地重合在源特征附近。同时源特征也必须平移后重放:镜像
|
||
# 副本 = reflect(源@t, 面@t),只平移面不平移源会落在 2P+t-x 处
|
||
# 而非正确位置 2P-x+t。
|
||
frame = _owner_plane_frame(session, mirror_plane)
|
||
if frame is None:
|
||
raise ValueError("mirror plane reference cannot be transformed for pattern replay")
|
||
cloned_selector = deepcopy(mirror_plane)
|
||
cloned_selector["frame"] = {
|
||
"origin_mm": [frame["origin_mm"][index] + components[index] for index in range(3)],
|
||
"x_dir": list(frame["x_dir"]),
|
||
"normal": list(frame["normal"]),
|
||
}
|
||
params["mirror_plane"] = cloned_selector
|
||
transformed_ids: list[str] = []
|
||
for source_id in node.params.get("source_feature_ids") or []:
|
||
source_node = session.replay_definitions.get(str(source_id))
|
||
if source_node is None:
|
||
raise ValueError(f"mirror pattern source feature {source_id} has no replay definition")
|
||
temp_id = f"{instance_id}.src.{source_id}"
|
||
shifted = _translated_node(source_node, temp_id, offset, session)
|
||
if shifted.sketch_id:
|
||
source_sketch = session.sketches.get(str(source_node.sketch_id))
|
||
if source_sketch is not None:
|
||
temp_sketch_id = f"{temp_id}.sk"
|
||
session.sketches[temp_sketch_id] = _translated_sketch(source_sketch, offset)
|
||
shifted = FeaturePlanNode(
|
||
shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on,
|
||
shifted.params, shifted.selectors, temp_sketch_id,
|
||
shifted.declared_status, shifted.source_feature,
|
||
)
|
||
# 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。
|
||
session.nodes[temp_id] = shifted
|
||
session.replay_definitions[temp_id] = shifted
|
||
transformed_ids.append(temp_id)
|
||
params["source_feature_ids"] = transformed_ids
|
||
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, session)
|
||
sketch = session.sketches.get(str(source.sketch_id))
|
||
execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None)
|
||
# 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放,
|
||
# 而非复制当前主体做近似。
|
||
# A later pattern may select this pattern feature. The definition is
|
||
# replayed recursively, never approximated by copying the current body.
|
||
session.replay_definitions[node.feature_id] = node
|
||
return session.result(node)
|
||
|
||
|
||
def _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, session: ExecutionSession) -> 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)
|
||
center = params.get("center_mm")
|
||
if isinstance(center, list) and len(center) == 3:
|
||
# box_add/sphere_add 以世界坐标几何中心定位:反射该中心即可。box_add 固定
|
||
# 世界轴对齐,跨坐标平面镜像后仍保持朝向(斜镜像面在 _execute_mirror_pattern
|
||
# 中已被显式拒绝)。
|
||
params["center_mm"] = _reflect_point(center, plane)
|
||
mirror_plane = params.get("mirror_plane")
|
||
if isinstance(mirror_plane, dict) and node.atomic_id == "pattern_mirror":
|
||
# #6 pattern 引用重解析:镜像重放 mirror source 时,其镜像面引用
|
||
# 随本实例的镜像面一起反射(内联为显式 frame),否则重放 resolve
|
||
# 到原始面,嵌套镜像会退化成与源镜像重合的错误几何。源特征同样
|
||
# 反射后重放:镜像副本 = reflect(源@P_B, reflect(面,P_B))。
|
||
frame = _owner_plane_frame(session, mirror_plane)
|
||
if frame is None:
|
||
raise ValueError("mirror plane reference cannot be transformed for pattern replay")
|
||
cloned_selector = deepcopy(mirror_plane)
|
||
cloned_selector["frame"] = {
|
||
"origin_mm": _reflect_point(frame["origin_mm"], plane),
|
||
"x_dir": _reflect_point(frame["x_dir"], plane, vector=True),
|
||
"normal": _reflect_point(frame["normal"], plane, vector=True),
|
||
}
|
||
params["mirror_plane"] = cloned_selector
|
||
transformed_ids: list[str] = []
|
||
for source_id in node.params.get("source_feature_ids") or []:
|
||
source_node = session.replay_definitions.get(str(source_id))
|
||
if source_node is None:
|
||
raise ValueError(f"mirror pattern source feature {source_id} has no replay definition")
|
||
temp_id = f"{instance_id}.src.{source_id}"
|
||
shifted = _mirrored_node(source_node, temp_id, plane, session)
|
||
if shifted.sketch_id:
|
||
source_sketch = session.sketches.get(str(source_node.sketch_id))
|
||
if source_sketch is not None:
|
||
temp_sketch_id = f"{temp_id}.sk"
|
||
session.sketches[temp_sketch_id] = _mirrored_sketch(source_sketch, plane)
|
||
shifted = FeaturePlanNode(
|
||
shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on,
|
||
shifted.params, shifted.selectors, temp_sketch_id,
|
||
shifted.declared_status, shifted.source_feature,
|
||
)
|
||
# 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。
|
||
session.nodes[temp_id] = shifted
|
||
session.replay_definitions[temp_id] = shifted
|
||
transformed_ids.append(temp_id)
|
||
params["source_feature_ids"] = transformed_ids
|
||
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
|
||
|
||
|
||
def _normal_is_coordinate_axis(normal: Any) -> bool:
|
||
# 判断单位法向是否平行于任一世界坐标轴:跨这样的平面镜像会保持轴对齐朝向。
|
||
return (
|
||
isinstance(normal, (list, tuple))
|
||
and len(normal) == 3
|
||
and any(abs(float(normal[index])) > 1 - 1e-9 for index in range(3))
|
||
)
|
||
|
||
|
||
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}")
|
||
if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal):
|
||
# box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向,
|
||
# 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面
|
||
# (法向平行于任一坐标轴)的镜像仍然精确。
|
||
raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes")
|
||
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
|
||
sketch = session.sketches.get(str(source.sketch_id))
|
||
_execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
|
||
session.replay_definitions[node.feature_id] = node
|
||
return session.result(node)
|
||
|
||
|
||
def _coordinate_axis_direction(direction: Any) -> bool:
|
||
# 判断方向是否平行于任一世界坐标轴(circular 的 box 限制用)。
|
||
# 不依赖输入已是单位向量:非零向量至多一个分量非零即为坐标轴方向
|
||
# (_box_circular_is_exact 对任意长度/含小残差的 direction 都稳健)。
|
||
if not isinstance(direction, (list, tuple)) or len(direction) != 3:
|
||
return False
|
||
return sum(1 for component in direction if abs(float(component)) > 1e-9) == 1
|
||
|
||
|
||
def _rotated_vector(value: Vector3, axis: AxisSpec, angle_rad: float) -> Vector3:
|
||
# Rodrigues 旋转公式:绕单位轴 axis.direction 旋转向量(无平移项)。
|
||
cosine = math.cos(angle_rad)
|
||
sine = math.sin(angle_rad)
|
||
axis_direction = axis.direction
|
||
cross = vector_cross(axis_direction, value)
|
||
dot = vector_dot(axis_direction, value)
|
||
return tuple( # type: ignore[return-value]
|
||
value[index] * cosine + cross[index] * sine + axis_direction[index] * dot * (1.0 - cosine)
|
||
for index in range(3)
|
||
)
|
||
|
||
|
||
def _rotated_point(point: Any, axis: AxisSpec, angle_rad: float) -> list[float]:
|
||
# 绕轴旋转三维点:先平移到轴原点、旋转向量、再平移回。
|
||
value = tuple(float(component) for component in point)
|
||
relative = vector_subtract(value, axis.origin_mm)
|
||
rotated = _rotated_vector(relative, axis, angle_rad)
|
||
return [axis.origin_mm[index] + rotated[index] for index in range(3)]
|
||
|
||
|
||
def _rotated_sketch(sketch: dict[str, Any], axis: AxisSpec, angle_rad: float) -> dict[str, Any]:
|
||
# 环形阵列实例的草图:工作平面 frame(原点为点、x/y/normal 为向量)绕轴旋转;
|
||
# 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。与
|
||
# _translated_sketch 对"世界坐标轮廓点"的处理对称,这里把 start/end/center
|
||
# 世界坐标点绕轴旋转。
|
||
output = deepcopy(sketch)
|
||
workplane = output.get("workplane") or {}
|
||
if workplane.get("origin_mm"):
|
||
workplane["origin_mm"] = _rotated_point(workplane["origin_mm"], axis, angle_rad)
|
||
for key in ("x_dir", "y_dir", "normal"):
|
||
if workplane.get(key):
|
||
workplane[key] = list(_rotated_vector(tuple(float(v) for v in workplane[key]), axis, angle_rad))
|
||
output["workplane"] = workplane
|
||
|
||
def rotate(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] = _rotated_point(value[point_key], axis, angle_rad)
|
||
for child in value.values():
|
||
rotate(child)
|
||
elif isinstance(value, list):
|
||
for child in value:
|
||
rotate(child)
|
||
|
||
for key in ("contour_edges_mm", "contour_regions_mm"):
|
||
rotate(output.get(key))
|
||
return output
|
||
|
||
|
||
def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle_rad: float, session: ExecutionSession) -> FeaturePlanNode:
|
||
# 环形阵列实例节点:把源特征的全部绝对坐标参数绕 axis 旋转(参数键布局与
|
||
# _translated_node/_mirrored_node 一致)。workplane/宿主 frame 的轴方向旋转,
|
||
# 世界坐标点旋转;局部 positions(随宿主 frame)不动。特征自带 axis(圆柱轴/
|
||
# 旋转轴/嵌套 circular 轴)与几何中心 center_mm 随实例旋转。嵌套 pattern
|
||
# source(pattern_mirror/pattern_circular)带绝对引用:镜像面 frame / 内层
|
||
# 源需连同本实例一起旋转,否则重放会退化成与源重合的错误几何。
|
||
params = deepcopy(node.params)
|
||
plane = params.get("plane")
|
||
if isinstance(plane, dict):
|
||
for key in ("origin_mm", "x_dir", "y_dir", "normal"):
|
||
if plane.get(key):
|
||
if key == "origin_mm":
|
||
plane[key] = _rotated_point(plane[key], axis, angle_rad)
|
||
else:
|
||
plane[key] = list(_rotated_vector(tuple(float(v) for v in plane[key]), axis, angle_rad))
|
||
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:
|
||
if host_frame.get("origin_mm"):
|
||
host_frame["origin_mm"] = _rotated_point(host_frame["origin_mm"], axis, angle_rad)
|
||
for key in ("x_dir", "normal"):
|
||
if host_frame.get(key):
|
||
host_frame[key] = list(_rotated_vector(tuple(float(v) for v in host_frame[key]), axis, angle_rad))
|
||
else:
|
||
for position in params.get("positions") or []:
|
||
if position.get("mm"):
|
||
position["mm"] = _rotated_point(position["mm"], axis, angle_rad)
|
||
feature_axis = params.get("axis")
|
||
if isinstance(feature_axis, dict):
|
||
if feature_axis.get("origin_mm"):
|
||
feature_axis["origin_mm"] = _rotated_point(feature_axis["origin_mm"], axis, angle_rad)
|
||
if feature_axis.get("direction"):
|
||
feature_axis["direction"] = list(_rotated_vector(tuple(float(v) for v in feature_axis["direction"]), axis, angle_rad))
|
||
center = params.get("center_mm")
|
||
if isinstance(center, list) and len(center) == 3:
|
||
params["center_mm"] = _rotated_point(center, axis, angle_rad)
|
||
if node.atomic_id in {"pattern_mirror", "pattern_circular"}:
|
||
# pattern 引用旋转重解析:镜像面 / 内层源随本实例一起旋转,否则嵌套
|
||
# pattern 作为 circular source 时重放会退化成错误几何(见 _translated_node)。
|
||
if node.atomic_id == "pattern_mirror":
|
||
mirror_plane = params.get("mirror_plane")
|
||
if not isinstance(mirror_plane, dict):
|
||
raise ValueError("mirror pattern replayed by circular pattern has no mirror plane reference")
|
||
frame = _owner_plane_frame(session, mirror_plane)
|
||
if frame is None:
|
||
raise ValueError("mirror plane reference cannot be transformed for circular pattern replay")
|
||
cloned_selector = deepcopy(mirror_plane)
|
||
cloned_selector["frame"] = {
|
||
"origin_mm": _rotated_point(frame["origin_mm"], axis, angle_rad),
|
||
"x_dir": list(_rotated_vector(tuple(frame["x_dir"]), axis, angle_rad)),
|
||
"normal": list(_rotated_vector(tuple(frame["normal"]), axis, angle_rad)),
|
||
}
|
||
params["mirror_plane"] = cloned_selector
|
||
transformed_ids: list[str] = []
|
||
for source_id in node.params.get("source_feature_ids") or []:
|
||
source_node = session.replay_definitions.get(str(source_id))
|
||
if source_node is None:
|
||
raise ValueError(f"pattern source feature {source_id} has no replay definition")
|
||
temp_id = f"{instance_id}.src.{source_id}"
|
||
shifted = _rotated_node(source_node, temp_id, axis, angle_rad, session)
|
||
if shifted.sketch_id:
|
||
source_sketch = session.sketches.get(str(source_node.sketch_id))
|
||
if source_sketch is not None:
|
||
temp_sketch_id = f"{temp_id}.sk"
|
||
session.sketches[temp_sketch_id] = _rotated_sketch(source_sketch, axis, angle_rad)
|
||
shifted = FeaturePlanNode(
|
||
shifted.feature_id, shifted.atomic_id, shifted.name, shifted.depends_on,
|
||
shifted.params, shifted.selectors, temp_sketch_id,
|
||
shifted.declared_status, shifted.source_feature,
|
||
)
|
||
# 临时 replay 定义同样进入 nodes 表(replay_sources 以此过滤)。
|
||
session.nodes[temp_id] = shifted
|
||
session.replay_definitions[temp_id] = shifted
|
||
transformed_ids.append(temp_id)
|
||
params["source_feature_ids"] = transformed_ids
|
||
return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
|
||
|
||
|
||
def _execute_circular_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
|
||
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
|
||
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
|
||
params = node.params
|
||
raw_axis = params.get("axis")
|
||
if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None):
|
||
raise ValueError("circular pattern requires an explicit axis with origin_mm and direction")
|
||
axis = AxisSpec.from_mapping(raw_axis)
|
||
count = int(params.get("pattern_count") or 1)
|
||
if count < 1:
|
||
raise ValueError("circular pattern pattern_count must be >= 1")
|
||
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0)
|
||
sources = session.replay_sources(params.get("source_feature_ids") or [])
|
||
if not sources:
|
||
raise ValueError("circular pattern source features have no replay definitions")
|
||
for instance in range(1, count):
|
||
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
|
||
angle_deg = sweep_angle_deg * instance / count
|
||
angle_rad = math.radians(angle_deg)
|
||
for source in sources:
|
||
dependency = pattern_transform_blocker(source)
|
||
if dependency:
|
||
raise ValueError(f"circular pattern source uses an unsupported {dependency}")
|
||
if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad):
|
||
raise ValueError(
|
||
"box_add circular pattern is exact only for coordinate-axis rotation "
|
||
"by multiples of 180 degrees"
|
||
)
|
||
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session)
|
||
sketch = session.sketches.get(str(source.sketch_id))
|
||
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
|
||
# 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放。
|
||
session.replay_definitions[node.feature_id] = node
|
||
return session.result(node)
|
||
|
||
|
||
def _box_circular_is_exact(axis: AxisSpec, angle_rad: float) -> bool:
|
||
# box_add 是固定世界轴对齐的原生图元:绕轴旋转任意角度会使其棱偏离坐标轴,
|
||
# 当前参数语义无法表达 → 仅坐标轴旋转且每份转角为 180° 的整数倍时精确
|
||
# (180° 翻转把轴对齐 box 映射回轴对齐 box)。与 _execute_mirror_pattern 的
|
||
# box 坐标平面限制同思路:宁可显式拒绝,也不静默产出错误几何。
|
||
if not _coordinate_axis_direction(axis.direction):
|
||
return False
|
||
half_turns = abs(math.degrees(angle_rad)) / 180.0
|
||
return abs(half_turns - round(half_turns)) < 1e-9
|
||
|
||
|
||
def _circular_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_circular_pattern(node, session, _execute_node)
|
||
|
||
|
||
def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult:
|
||
executor = EXECUTORS.get(node.atomic_id)
|
||
if executor is None:
|
||
raise ValueError(f"No executor registered for {node.atomic_id!r}")
|
||
previous_feature_id = session.active_feature_id
|
||
session.active_feature_id = node.feature_id
|
||
try:
|
||
return executor(node, session, sketch_override)
|
||
finally:
|
||
session.active_feature_id = previous_feature_id
|
||
|
||
|
||
ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]
|
||
|
||
|
||
def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
return _shape_from_primary(node, session, sketch=sketch)
|
||
|
||
|
||
def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_reference_plane(node, session)
|
||
|
||
|
||
def _reference_axis_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_reference_axis(node, session)
|
||
|
||
|
||
def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_sphere(node, session)
|
||
|
||
|
||
def _box_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_box(node, session)
|
||
|
||
|
||
def _cylinder_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_cylinder(node, session)
|
||
|
||
|
||
def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_hole(node, session)
|
||
|
||
|
||
def _hole_wizard_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_hole(node, session, wizard=True)
|
||
|
||
|
||
def _fillet_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_fillet(node, session)
|
||
|
||
|
||
def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_chamfer(node, session)
|
||
|
||
|
||
def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_linear_pattern(node, session, _execute_node)
|
||
|
||
|
||
def _mirror_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
|
||
del sketch
|
||
return _execute_mirror_pattern(node, session)
|
||
|
||
|
||
EXECUTORS: dict[str, ExecutorFunction] = {
|
||
"reference_plane": _reference_plane_executor,
|
||
"reference_axis": _reference_axis_executor,
|
||
"sphere_add": _sphere_executor,
|
||
"box_add": _box_executor,
|
||
"cylinder_add": _cylinder_executor,
|
||
"thread_add": _thread_executor,
|
||
"thread_cut": _thread_executor,
|
||
"bend_add": _bend_executor,
|
||
"extrude_add_blind": _primary_executor,
|
||
"extrude_add_two_sided": _primary_executor,
|
||
"extrude_cut_blind": _primary_executor,
|
||
"extrude_cut_through": _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,
|
||
"pattern_circular": _circular_pattern_executor,
|
||
}
|
||
|
||
|
||
def analyze_cdsl(cdsl: dict[str, Any]):
|
||
"""Resolve profiles and return the current runtime capability analysis."""
|
||
sketch_errors: dict[str, str] = {}
|
||
resolved = resolve_required_sketches(
|
||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
||
)
|
||
analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS)
|
||
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
|
||
|
||
|
||
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
|
||
"""Rebuild CDSL through session-scoped atomic executors only."""
|
||
sketch_errors: dict[str, str] = {}
|
||
resolved = resolve_required_sketches(
|
||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
||
)
|
||
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
|
||
resolved, sketch_errors=sketch_errors,
|
||
)
|
||
if strict and not analysis.runtime_eligible:
|
||
first = next((result for result in analysis.feature_results if not result.executable), None)
|
||
if first is None:
|
||
raise ValueError(analysis.document_blockers[0].code)
|
||
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
|
||
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
|
||
detail = "; ".join(blocker.code for blocker in first.blockers)
|
||
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
|
||
session = ExecutionSession(
|
||
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
|
||
nodes={node.feature_id: node for node in analysis.plan},
|
||
)
|
||
diagnostics: list[RuntimeDiagnostic] = []
|
||
for node, preflight in zip(analysis.plan, analysis.feature_results):
|
||
if not preflight.executable:
|
||
diagnostics.extend(preflight.blockers)
|
||
if strict:
|
||
break
|
||
continue
|
||
try:
|
||
_execute_node(node, session)
|
||
except Exception as error:
|
||
failed_resolution = next(
|
||
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
|
||
)
|
||
diagnostic = (
|
||
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
|
||
if isinstance(error, FeatureExecutionError)
|
||
else
|
||
RuntimeDiagnostic(
|
||
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
|
||
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
|
||
)
|
||
if failed_resolution and failed_resolution.get("diagnostic")
|
||
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
|
||
)
|
||
diagnostics.append(diagnostic)
|
||
if strict:
|
||
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
|
||
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:]},
|
||
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
|
||
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
|
||
"solid_count": len(session.adapter.body_solids(session.body)),
|
||
"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,
|
||
}
|