Files
cdsl-cad/backend/tests/test_engine_revolve_reverse.py
T

214 lines
9.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""#3 revolve.reverse:旋转方向反转必须进入 runtime 的旋转计算。
中文说明
--------
这个文件在测试什么(issue #3「revolve.reverse 未进入旋转方向计算」的回归测试):
1. 背景:三方合同错位——
- 人读契约 profile_schema.json 已声明 revolve_add / revolve_cut 的
optional_params 含 reverse
- 机器契约 cdsl_schema.json revolveParams 也已允许 reverse 字段;
- 但 runtimebackend/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π/21/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()