"""#3 revolve.reverse:旋转方向反转必须进入 runtime 的旋转计算。 中文说明 -------- 这个文件在测试什么(issue #3「revolve.reverse 未进入旋转方向计算」的回归测试): 1. 背景:三方合同错位—— - 人读契约 profile_schema.json 已声明 revolve_add / revolve_cut 的 optional_params 含 reverse; - 机器契约 cdsl_schema.json revolveParams 也已允许 reverse 字段; - 但 runtime(backend/engine/cdsl_engine/runtime.py:343-349 的 _shape_from_primary revolve 分支)只读取 angle_deg,完全忽略 reverse,导致 reverse=true 的旋转特征被静默当作正向旋转, 实体生成在轴的错误一侧(与 #1 y_dir 同类:合法字段被静默丢弃)。 - 真实数据(如 json_to_cdsl/output/013003.cdsl.json)里 revolve 特征大量携带 reverse: true,一旦 revolve 的 selector 捕获问题 (deferred)解禁,成批旋转特征将几何方向错误。 2. 本测试套件把"reverse=true 必须绕轴反向扫掠"的契约固定下来: - 主契约:reverse=true 与 reverse=false 的实体 bbox 落在轴的两侧 (z 范围符号相反),方向确实反转; - 回归护栏:不带 reverse 字段时行为与修复前完全一致(bbox / 体积 不变),不会破坏现有正向旋转; - 等价性:reverse=true 与"轴方向取反"在几何上恒等(负旋转角 ≡ 反向轴),锁死 reverse 的精确语义; - 机器契约:带 reverse 的 revolve_add / revolve_cut 必须通过 cdsl_schema.json 校验。 3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import cdsl_engine 包做端到端测试(与 test_engine_host_face_contract.py 风格一致)。 旋转方向约定(已在修复前实测确认) ------------------------------------ profile 矩形 [2,1]-[4,2] 位于 XY 平面(z=0),绕 X 轴(direction=(1,0,0), 与 face 平面共面)旋转 90°: - 不 reverse:实体落在 y/z 第一象限,bbox z∈[0,2]; - reverse :实体落在 y/z 第四象限,bbox z∈[-2,0]。 若旋转轴垂直于 profile 平面(如绕 Z 轴),build123d 会产出退化薄片 (volume=0),因此夹具必须让轴与 profile 平面共面(与 test_engine_runtime_foundation.test_revolve_can_resolve_an_owner_qualified_reference_axis 一致)。 """ from __future__ import annotations import json import math import sys import tempfile 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")) import jsonschema # noqa: E402 import cdsl_engine # noqa: E402 from cdsl_engine.runtime import rebuild_cdsl # noqa: E402 # cdsl_schema.json 路径:随 cdsl_engine 包部署。 _SCHEMA_PATH = Path(cdsl_engine.__file__).parent / "cdsl_schema.json" _SCHEMA = json.loads(_SCHEMA_PATH.read_text(encoding="utf-8")) try: import build123d # noqa: F401 _HAS_BUILD123D = True except ImportError: _HAS_BUILD123D = False # --------------------------------------------------------------------------- # 测试夹具:构造最小 revolve CDSL 文档 # --------------------------------------------------------------------------- def _rectangle(minimum: list[float], maximum: list[float]) -> dict: """XY 平面内的矩形轮廓:顶点按逆时针顺序排列。""" return {"type": "polygon", "vertices": [ [minimum[0], minimum[1]], [maximum[0], minimum[1]], [maximum[0], maximum[1]], [minimum[0], maximum[1]], ]} def _revolve_cdsl(*, atomic_id: str = "revolve_add", reverse: bool | None = None, axis_direction: list[float] | None = None) -> dict: """最小 revolve 文档。 矩形 [2,1]-[4,2] 位于 XY 平面(z=0),绕 X 轴旋转 90°。轴默认 (1,0,0),可通过 axis_direction 覆盖(用于等价性测试)。reverse 为 None 时完全不写该字段(回归护栏:缺省正向)。 """ params: dict = { "angle_deg": 90.0, "axis": {"origin_mm": [0, 0, 0], "direction": axis_direction or [1, 0, 0]}, } if reverse is not None: params["reverse"] = reverse return { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "revolve-reverse-contract", "meta": {"unit": "mm"}, "geometry": {"sketches": [{ "id": "profile", "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, "profile": _rectangle([2, 1], [4, 2]), }]}, "features": [{ "id": "turn", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "profile", "params": params, }], } def _rebuild(cdsl: dict) -> dict: """临时目录内重建文档并返回 runtime 结果。""" with tempfile.TemporaryDirectory() as directory: return rebuild_cdsl(cdsl, Path(directory) / "part.step") def _validate_against_cdsl_schema(doc: dict) -> None: """对整张 CDSL 文档跑 cdsl_schema.json 校验。""" jsonschema.validate(instance=doc, schema=_SCHEMA) # --------------------------------------------------------------------------- # 测试套件 # --------------------------------------------------------------------------- class RevolveReverseContractTests(unittest.TestCase): """revolve.reverse「人读契约-机器契约-运行时」三方一致性的回归测试。""" @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") def test_revolve_reverse_true_flips_rotation_direction(self) -> None: """主契约:reverse=true 必须让旋转体落在轴的相反侧。 修复前(当前):runtime 忽略 reverse,正反旋转结果 bbox 完全相同 (都在 z∈[0,2] 第一象限)→ 本测试红灯。 修复后:reverse=true 取负旋转角,实体落在 z∈[-2,0] 第四象限 → 绿灯。z 范围符号相反即方向确实反转。 """ forward = _rebuild(_revolve_cdsl(reverse=False)) flipped = _rebuild(_revolve_cdsl(reverse=True)) forward_min_z = forward["bbox_mm"]["min"][2] forward_max_z = forward["bbox_mm"]["max"][2] flipped_min_z = flipped["bbox_mm"]["min"][2] flipped_max_z = flipped["bbox_mm"]["max"][2] # 正向:z 落在 [0, 2];反向:z 落在 [-2, 0]。 self.assertAlmostEqual(forward_min_z, 0.0, places=5) self.assertAlmostEqual(forward_max_z, 2.0, places=5) self.assertAlmostEqual(flipped_min_z, -2.0, places=5) self.assertAlmostEqual(flipped_max_z, 0.0, places=5) # 语义锁死:方向相反意味着 z 范围严格位于轴的两侧,互不重叠。 self.assertGreater(forward_max_z, flipped_max_z) @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") def test_revolve_without_reverse_keeps_forward_direction(self) -> None: """回归护栏:不带 reverse 字段时行为与修复前完全一致。 修复前 runtime 本来就把 revolve 当正向旋转处理;修复后缺省路径 必须保持不动(不 reverse 就绝不能取负角)。bbox 与体积都要和 修复前一致:z∈[0,2]、volume = 3π/2(1/4 圆柱壳,内半径 1、 外半径 2、轴向长 2)。 """ result = _rebuild(_revolve_cdsl(reverse=None)) bbox = result["bbox_mm"] self.assertAlmostEqual(bbox["min"][0], 2.0, places=5) self.assertAlmostEqual(bbox["max"][0], 4.0, places=5) self.assertAlmostEqual(bbox["min"][2], 0.0, places=5) self.assertAlmostEqual(bbox["max"][2], 2.0, places=5) self.assertAlmostEqual(result["volume_mm3"], 1.5 * math.pi, places=5) @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") def test_revolve_reverse_matches_axis_inversion(self) -> None: """等价性:reverse=true ≡ 轴方向取反(负旋转角与反向轴几何恒等)。 这条测试锁死 reverse 的精确语义——它是"绕轴反向扫掠",等价于把 轴方向反转后再正向扫掠。两者 bbox 与体积必须逐分量一致。 """ flipped = _rebuild(_revolve_cdsl(reverse=True)) axis_inverted = _rebuild(_revolve_cdsl(reverse=False, axis_direction=[-1, 0, 0])) for axis_name in ("min", "max"): for component in range(3): self.assertAlmostEqual( flipped["bbox_mm"][axis_name][component], axis_inverted["bbox_mm"][axis_name][component], places=5, msg=f"bbox {axis_name}[{component}] must match axis inversion", ) self.assertAlmostEqual(flipped["volume_mm3"], axis_inverted["volume_mm3"], places=5) def test_revolve_params_reverse_passes_machine_schema(self) -> None: """机器契约:带 reverse 的 revolve_add / revolve_cut 必须通过 schema。 修复前/修复后均应通过(cdsl_schema.json revolveParams 早已允许 reverse)。这条测试是三方合同的一部分:保证机器契约确实承认这个 字段,runtime 侧修复才名正言顺。 """ _validate_against_cdsl_schema(_revolve_cdsl(atomic_id="revolve_add", reverse=True)) _validate_against_cdsl_schema(_revolve_cdsl(atomic_id="revolve_cut", reverse=True)) if __name__ == "__main__": unittest.main()