fix(engine): 支持 revolve 特征的 reverse 参数
This commit is contained in:
@@ -346,6 +346,13 @@ def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, ske
|
|||||||
angle = float(node.params.get("angle_deg") or 0.0)
|
angle = float(node.params.get("angle_deg") or 0.0)
|
||||||
if angle <= 0:
|
if angle <= 0:
|
||||||
raise ValueError("revolve requires angle_deg > 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]
|
solids = [session.adapter.revolve(face, angle, axis) for face in faces]
|
||||||
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
|
# 4. 将所有子实体做布尔并(fuse)合并为一个工具体(tool)。
|
||||||
tool = None
|
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 字段;
|
||||||
|
- 但 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()
|
||||||
Reference in New Issue
Block a user