From 9b4e64839b16676ca29de22f42391dec3fdc8f2b Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 15:01:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(engine):=20=E6=94=AF=E6=8C=81=20revolve=20?= =?UTF-8?q?=E7=89=B9=E5=BE=81=E7=9A=84=20reverse=20=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/engine/cdsl_engine/runtime.py | 7 + backend/tests/test_engine_revolve_reverse.py | 213 +++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 backend/tests/test_engine_revolve_reverse.py diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py index 80c1d1ae..e4ea5f53 100644 --- a/backend/engine/cdsl_engine/runtime.py +++ b/backend/engine/cdsl_engine/runtime.py @@ -346,6 +346,13 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske 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 diff --git a/backend/tests/test_engine_revolve_reverse.py b/backend/tests/test_engine_revolve_reverse.py new file mode 100644 index 00000000..79a9dbfa --- /dev/null +++ b/backend/tests/test_engine_revolve_reverse.py @@ -0,0 +1,213 @@ +"""#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()