871070c440
Phase 2 of the decoupling refactor (behavior-preserving move): - runtime_base.py: RuntimeExecutionError, FeatureExecutionError, ExtentVector - session.py: GeometryAdapter protocol + ExecutionSession - extents.py: end-condition planning (_extent_vectors family) - pattern_transform.py: translate/mirror/rotate replay parameter algebra - runtime.py: keeps executors + registry + entry points; re-exports all moved names (incl. test-referenced privates) for import stability No behavior change; verified against baseline (zero new failures).
510 lines
27 KiB
Python
510 lines
27 KiB
Python
"""Parametric transforms for pattern replay (translate / mirror / rotate).
|
||
|
||
A pattern instance re-executes its source feature with every absolute
|
||
coordinate parameter transformed (sketch, host frame, axis, positions,
|
||
center, nested mirror-plane references). These helpers own that parameter
|
||
algebra; the pattern executors only decide which transform to apply.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import math
|
||
from copy import deepcopy
|
||
from typing import TYPE_CHECKING, Any, Callable
|
||
|
||
from .specs import AxisSpec, PlaneSpec, Vector3, vector_cross, vector_dot, vector_scale, vector_subtract
|
||
from .topology import FeaturePlanNode
|
||
|
||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||
from .session import ExecutionSession
|
||
|
||
|
||
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)]
|
||
if "points_mm" in value:
|
||
value["points_mm"] = [
|
||
[float(point[index]) + components[index] for index in range(3)]
|
||
for point in value["points_mm"]
|
||
]
|
||
for child in value.values():
|
||
translate(child)
|
||
elif isinstance(value, list):
|
||
for child in value:
|
||
translate(child)
|
||
translate(output.get(key))
|
||
return output
|
||
|
||
|
||
def _transformed_loft_profiles(
|
||
node: FeaturePlanNode,
|
||
params: dict[str, Any],
|
||
instance_id: str,
|
||
session: "ExecutionSession",
|
||
transform: Callable[[dict[str, Any]], dict[str, Any]],
|
||
) -> None:
|
||
"""为 pattern replay 创建放样截面的变换副本。"""
|
||
if node.atomic_id != "loft_add":
|
||
return
|
||
profile_ids = params.get("profile_sketch_ids") or []
|
||
transformed_ids: list[str] = []
|
||
for index, sketch_id in enumerate(profile_ids):
|
||
source = session.sketches.get(str(sketch_id))
|
||
if source is None:
|
||
raise ValueError(f"loft profile sketch {sketch_id!r} has no replay definition")
|
||
transformed_id = f"{instance_id}.profile.{index}"
|
||
# 不复用原 profile:pattern 中的每个截面都必须与 source feature
|
||
# 使用相同的平移、镜像或旋转,才能保持放样的真实空间位置。
|
||
session.sketches[transformed_id] = transform(source)
|
||
transformed_ids.append(transformed_id)
|
||
params["profile_sketch_ids"] = transformed_ids
|
||
|
||
|
||
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)]
|
||
_transformed_loft_profiles(
|
||
node, params, instance_id, session,
|
||
lambda sketch: _translated_sketch(sketch, offset),
|
||
)
|
||
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 _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])]
|
||
if isinstance(value.get("points"), list):
|
||
value["points"] = [
|
||
[float(point[0]), -float(point[1])]
|
||
for point in value["points"]
|
||
if isinstance(point, list) and len(point) == 2
|
||
]
|
||
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 "points_mm" in value:
|
||
value["points_mm"] = [_reflect_point(point, plane) for point in value["points_mm"]]
|
||
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)
|
||
_transformed_loft_profiles(
|
||
node, params, instance_id, session,
|
||
lambda sketch: _mirrored_sketch(sketch, 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 _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)
|
||
if "points_mm" in value:
|
||
value["points_mm"] = [_rotated_point(point, axis, angle_rad) for point in value["points_mm"]]
|
||
if "normal" in value:
|
||
value["normal"] = list(_rotated_vector(tuple(float(v) for v in value["normal"]), 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))
|
||
path = params.get("path")
|
||
path_plane = path.get("workplane") if isinstance(path, dict) else None
|
||
if isinstance(path_plane, dict):
|
||
if path_plane.get("origin_mm"):
|
||
path_plane["origin_mm"] = _rotated_point(path_plane["origin_mm"], axis, angle_rad)
|
||
for key in ("x_dir", "y_dir", "normal"):
|
||
if path_plane.get(key):
|
||
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_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)
|
||
_transformed_loft_profiles(
|
||
node, params, instance_id, session,
|
||
lambda sketch: _rotated_sketch(sketch, 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 _pattern_operation_node(node: FeaturePlanNode, operation_mode: str) -> FeaturePlanNode:
|
||
# REMOVE pattern 的实例必须沿用 source 的 profile/extent,但以 cut 而不是
|
||
# add 写入当前主体。lowering 已将初始 source 同步改写,运行时保留此处以
|
||
# 支持完整的 CDSL replay contract。
|
||
if operation_mode != "remove": return node
|
||
atomic_id = {
|
||
"extrude_add_blind": "extrude_cut_blind",
|
||
"extrude_add_two_sided": "extrude_cut_two_sided",
|
||
"revolve_add": "revolve_cut",
|
||
}.get(node.atomic_id, node.atomic_id)
|
||
if atomic_id == node.atomic_id and "cut" not in atomic_id:
|
||
raise ValueError("REMOVE pattern source is not a replayable cutting feature")
|
||
params = {key: value for key, value in node.params.items() if key != "result_mode"}
|
||
return FeaturePlanNode(
|
||
node.feature_id, atomic_id, node.name, node.depends_on, params,
|
||
node.selectors, node.sketch_id, node.declared_status, node.source_feature,
|
||
)
|
||
|
||
|
||
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
|