fix(engine): 支持 revolve 特征的 reverse 参数

This commit is contained in:
2026-08-27 15:01:41 +08:00
parent 0acb29e96d
commit 9b4e64839b
2 changed files with 220 additions and 0 deletions
+7
View File
@@ -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 含
# reversecdsl_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
@@ -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 字段;
- 但 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()