"""N2 pattern_circular —— 旋转几何契约层测试。 中文说明 -------- 统计报告把 N2(直线/环形阵列)归为 A 类;runtime 的 pattern 采用源特征 重放范式(pattern_linear/_translated_node、pattern_mirror/_mirrored_node), 本次为 pattern_circular 补齐了缺失的"环形重放实现入口":旋转重放节点 (_rotated_node)与 Rodrigues 旋转辅助(_rotated_vector/_rotated_point)。 本文件是三个测试角度中的**几何单元层**:只验证旋转数学与草图/参数变换 函数的正确性,不驱动 executor、不跑布尔运算。旋转是环形阵列几何正确性 的全部基础——若绕轴旋转的坐标变换有误,任何 count/包角组合都会产出错误 实例位置。runtime 端到端层见 test_engine_circular_pattern_runtime.py。 契约要点(与 runtime 层的分工): 1. Rodrigues 旋转是等距变换:轴向分量守恒、长度/到轴距离不变、 非轴向按 cos/sin 旋转 → 用 90°/180° 与随机向量精确断言坐标。 2. 方向约定:绕轴 direction 正角度为右手逆时针(+Z 轴把 +X 转到 +Y)。 3. _rotated_sketch 只旋转世界坐标(workplane frame 与 start/end/center_mm), 2D 局部实体坐标不动(frame 旋转后由草图求解器映射到新世界位置)。 4. _box_circular_is_exact:box_add 是固定世界轴对齐图元,仅坐标轴旋转 且每份转角为 180° 整数倍时才精确(对齐 _execute_mirror_pattern 的 box 坐标平面限制思路)。 sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import cdsl_engine 包内模块直测 adapter(与既有测试风格一致)。 """ from __future__ import annotations import math import sys import unittest from pathlib import Path ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(ROOT / "backend")) sys.path.insert(0, str(ROOT / "backend" / "engine")) from cdsl_engine.runtime import ( # noqa: E402 _box_circular_is_exact, _coordinate_axis_direction, _rotated_point, _rotated_sketch, _rotated_vector, ) from cdsl_engine.runtime_types import AxisSpec # noqa: E402 X_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(1.0, 0.0, 0.0)) Y_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(0.0, 1.0, 0.0)) Z_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(0.0, 0.0, 1.0)) # Rodrigues 要求单位轴(运行时 AxisSpec.from_mapping 会单位化);测试直接 # 构造 dataclass,必须显式传入单位方向。 _UNIT_DIAGONAL = math.sqrt(1.0 / 3.0) SKEW_AXIS = AxisSpec(origin_mm=(0.0, 0.0, 0.0), direction=(_UNIT_DIAGONAL, _UNIT_DIAGONAL, _UNIT_DIAGONAL)) def _assert_vector_close(test: unittest.TestCase, actual, expected, *, places: int = 9) -> None: for actual_component, expected_component in zip(actual, expected): test.assertAlmostEqual(float(actual_component), float(expected_component), places=places) class CircularPatternGeometryTests(unittest.TestCase): def test_rotated_point_around_z_axis_cardinal_angles(self) -> None: # +Z 轴右手逆时针:90° 把 (10, 0, 0) 转到 (0, 10, 0),180° 到 (-10, 0, 0), # 270° 到 (0, -10, 0)。z 坐标不变。 point = (10.0, 0.0, 4.0) for angle_deg, expected in [ (90.0, (0.0, 10.0, 4.0)), (180.0, (-10.0, 0.0, 4.0)), (270.0, (0.0, -10.0, 4.0)), (360.0, (10.0, 0.0, 4.0)), ]: with self.subTest(angle=angle_deg): rotated = _rotated_point(point, Z_AXIS, math.radians(angle_deg)) _assert_vector_close(self, rotated, expected) def test_rotated_point_negative_angle_rotates_clockwise(self) -> None: # -90° 顺时针:+X 转到 -Y。 rotated = _rotated_point((10.0, 0.0, 0.0), Z_AXIS, math.radians(-90.0)) _assert_vector_close(self, rotated, (0.0, -10.0, 0.0)) def test_rotated_point_about_arbitrary_axis_keeps_axis_distance(self) -> None: # 绕空间对角轴:轴向分量守恒、到轴距离不变(等距变换)。 point = (3.0, -2.0, 7.0) rotated = _rotated_point(point, SKEW_AXIS, math.radians(40.0)) axial = sum(component * value for component, value in zip(point, SKEW_AXIS.direction)) rotated_axial = sum(component * value for component, value in zip(rotated, SKEW_AXIS.direction)) self.assertAlmostEqual(rotated_axial, axial, places=9) distance_sq = sum(c * c for c in point) - axial * axial rotated_distance_sq = sum(c * c for c in rotated) - rotated_axial * rotated_axial self.assertAlmostEqual(rotated_distance_sq, distance_sq, places=9) def test_rotated_point_around_offset_axis(self) -> None: # 轴不过原点:先平移到轴、旋转、再平移回。绕 (10, 0, 0) 竖直轴转 90°, # 点 (10, 5, 0) 的相对矢量 (0, 5, 0) 转到 (-5, 0, 0) → (5, 0, 0)。 offset_axis = AxisSpec(origin_mm=(10.0, 0.0, 0.0), direction=(0.0, 0.0, 1.0)) rotated = _rotated_point((10.0, 5.0, 0.0), offset_axis, math.radians(90.0)) _assert_vector_close(self, rotated, (5.0, 0.0, 0.0)) def test_rotated_vector_preserves_length_and_normalizes_axis(self) -> None: # 向量旋转不含平移项(原点到向量尾所在轴上的投影分量守恒)。 value = (2.0, 0.0, 0.0) rotated = _rotated_vector(value, Z_AXIS, math.radians(90.0)) _assert_vector_close(self, rotated, (0.0, 2.0, 0.0)) for axis, expected in [ (X_AXIS, (2.0, 0.0, 0.0)), # 绕自身轴旋转不变 (Y_AXIS, (0.0, 0.0, -2.0)), # 绕 +Y 转 90°(右手)把 +X 转到 -Z ]: with self.subTest(axis=axis.direction): _assert_vector_close(self, _rotated_vector(value, axis, math.radians(90.0)), expected) def test_rotated_vector_arbitrary_angle_is_length_preserving(self) -> None: value = (1.0, 2.0, 3.0) rotated = _rotated_vector(value, SKEW_AXIS, math.radians(71.0)) self.assertAlmostEqual( sum(c * c for c in rotated), sum(c * c for c in value), places=9) def test_rotated_sketch_rotates_workplane_and_world_contours_only(self) -> None: # workplane frame(原点 + 三向量)与 contour 世界坐标点绕 +Z 转 90°; # 2D 局部实体坐标保持原样(frame 旋转负责映射到新世界位置)。 sketch = { "workplane": {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": [0.0, 0.0, 1.0]}, "entities": [{"type": "circle", "center": [0.0, 1.0], "radius_mm": 2.0}], "contour_edges_mm": [{"start_mm": [1.0, 2.0, 0.0], "end_mm": [3.0, 4.0, 5.0], "center_mm": [2.0, 3.0, 0.0]}], "contour_regions_mm": [{"outer": [{"start_mm": [0.0, 1.0, 0.0], "end_mm": [1.0, 0.0, 0.0]}]}], } rotated = _rotated_sketch(sketch, Z_AXIS, math.radians(90.0)) _assert_vector_close(self, rotated["workplane"]["origin_mm"], (0.0, 0.0, 0.0)) _assert_vector_close(self, rotated["workplane"]["x_dir"], (0.0, 1.0, 0.0)) _assert_vector_close(self, rotated["workplane"]["y_dir"], (-1.0, 0.0, 0.0)) _assert_vector_close(self, rotated["workplane"]["normal"], (0.0, 0.0, 1.0)) self.assertEqual(rotated["entities"], sketch["entities"]) edge = rotated["contour_edges_mm"][0] _assert_vector_close(self, edge["start_mm"], (-2.0, 1.0, 0.0)) _assert_vector_close(self, edge["end_mm"], (-4.0, 3.0, 5.0)) _assert_vector_close(self, edge["center_mm"], (-3.0, 2.0, 0.0)) outer = rotated["contour_regions_mm"][0]["outer"][0] _assert_vector_close(self, outer["start_mm"], (-1.0, 0.0, 0.0)) _assert_vector_close(self, outer["end_mm"], (0.0, 1.0, 0.0)) def test_box_circular_exactness_boundary(self) -> None: # box_add 固定世界轴对齐:仅坐标轴旋转且每份转角为 180° 整数倍时精确。 self.assertTrue(_box_circular_is_exact(Z_AXIS, math.radians(180.0))) self.assertTrue(_box_circular_is_exact(Z_AXIS, math.radians(360.0))) self.assertFalse(_box_circular_is_exact(Z_AXIS, math.radians(90.0))) self.assertFalse(_box_circular_is_exact(Z_AXIS, math.radians(45.0))) # 绕 x/y 的非 180° 转角同样不可精确表达;空间对角轴任何转角都不行。 self.assertFalse(_box_circular_is_exact(X_AXIS, math.radians(90.0))) self.assertFalse(_box_circular_is_exact(SKEW_AXIS, math.radians(180.0))) def test_coordinate_axis_direction_detection(self) -> None: self.assertTrue(_coordinate_axis_direction([0.0, 0.0, 1.0])) self.assertTrue(_coordinate_axis_direction((0.0, -1.0, 0.0))) self.assertFalse(_coordinate_axis_direction([1.0, 1.0, 1.0])) self.assertFalse(_coordinate_axis_direction([1.0, 0.0, 0.5])) if __name__ == "__main__": unittest.main()