214 lines
9.5 KiB
Python
214 lines
9.5 KiB
Python
"""#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()
|