From e7ade4aa00aa3bf6baaee4a60610bf52f8db3666 Mon Sep 17 00:00:00 2001 From: ganjihong Date: Thu, 27 Aug 2026 10:55:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(evidence=5Fv2):=20=E5=B0=86=20annulus=20?= =?UTF-8?q?=E9=99=8D=E7=BA=A7=E4=B8=BA=20analytic=5Fcontours=20=E8=BD=AE?= =?UTF-8?q?=E5=BB=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同心圆截面改为输出外圆与内圆两条 analytic_contours 轮廓,避免旧 annulus 宏导致 CDSL 文档不符合 schema 且运行时无法执行 --- .../test_evidence_v2_annulus_lowering.py | 170 ++++++++++++++++++ json_to_cdsl/evidence_v2_to_cdsl.py | 11 +- 2 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 backend/tests/test_evidence_v2_annulus_lowering.py diff --git a/backend/tests/test_evidence_v2_annulus_lowering.py b/backend/tests/test_evidence_v2_annulus_lowering.py new file mode 100644 index 00000000..3ffa130b --- /dev/null +++ b/backend/tests/test_evidence_v2_annulus_lowering.py @@ -0,0 +1,170 @@ +"""#10 Profile chain: evidence_v2 importer must lower annulus to analytic_contours. + +The CDSL-only runtime and ``cdsl_schema.json`` accept only three generic +profiles (``circle``, ``polygon``, ``analytic_contours``). The legacy +``annulus`` macro is still emitted by ``evidence_v2_to_cdsl._analytic_profile`` +for concentric circles, which makes the resulting document schema-invalid and +runtime-ineligible. This test suite pins the lowering contract: concentric +circles must produce an ``analytic_contours`` profile (outer + inner ring). + +中文说明 +-------- +这个文件在测试什么(issue #10「10 Profile 链」的回归测试): + + 1. 背景:CDSL-only 运行时(backend/engine/cdsl_engine)与 + cdsl_schema.json 只接受三种通用 profile 类型: + circle / polygon / analytic_contours。 + 而 evidence_v2 导入器(json_to_cdsl/evidence_v2_to_cdsl.py 的 + _analytic_profile)在遇到"同心双圆"(垫圈/圆环 annulus 截面)时, + 仍会输出旧宏 {type: "annulus"},导致产出的 CDSL 文档 schema 不合法、 + 运行时不可执行(报 profile_resolution_failed / unsupported_profile)。 + + 2. 本测试套件把"降级契约"固定下来:同心双圆必须降级为 + analytic_contours 轮廓(外圆 role=outer + 内圆 role=inner), + 并保证 importer 输出能被 CDSL-only 运行时端到端重建为实体。 + + 3. sys.path 说明:把 json_to_cdsl 与 backend/engine 加入搜索路径, + 是为了让测试能直接 import 导入器内部的 _analytic_profile(灰盒测试) + 以及 CDSL-only 运行时的 rebuild_cdsl(端到端测试)。 +""" + +from __future__ import annotations + +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")) +sys.path.insert(0, str(ROOT / "json_to_cdsl")) + +try: + import build123d # noqa: F401 + _HAS_BUILD123D = True +except ImportError: + _HAS_BUILD123D = False + +from evidence_v2_to_cdsl import _analytic_profile # noqa: E402 + + +def _circle_segment(center: list[float], radius_mm: float, *, construction: bool = False) -> dict: + """One full SolidWorks sketch circle segment (start == end == center). + + 构造一条 SolidWorks 草图中的完整圆线段(start == end == center, + 即闭合圆)。radius_mm 除以 1000 转成米,与导入器内部单位保持一致。 + """ + return { + "geometry": { + "segment_type": "swSketchARC", + "construction": construction, + "start": center, + "end": center, + "center": center, + "direction": 1, + "curve": {"type": "circle", "parameters": [*center, 0, 0, 0, 1, radius_mm / 1000.0]}, + } + } + + +def _ring_cdsl(profile: dict) -> dict: + """Assemble a minimal CDSL document whose single feature extrudes a ring profile. + + 组装一份最简 CDSL 文档:一张草图(ring,携带被测试的 profile)+ + 一个拉伸特征(extrude_add_blind)。供端到端测试使用, + 验证 importer 产出的 profile 能否被运行时重建为实体。 + """ + return { + "schema": "cad.cdsl.llm.v1", + "schema_version": "1.1.0", + "kind": "part", + "part_id": "annulus-ring", + "meta": {"unit": "mm"}, + "geometry": {"sketches": [{ + "id": "ring", + "workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}, + "profile": profile, + }]}, + "features": [{ + "id": "ring_add", "atomic_id": "extrude_add_blind", "depends_on": [], + "params": {"distance_mm": 20}, "sketch_id": "ring", + }], + } + + +class EvidenceV2AnnulusLoweringTests(unittest.TestCase): + """annulus 降级契约的回归测试套件。 + + 三个测试用例分别覆盖: + 1. test_evidence_v2_emits_analytic_contours_for_annulus + 主契约:同心双圆(外 r=10mm,内 r=5mm)必须降级为 analytic_contours, + 且恰好携带 outer(10) + inner(5) 两条轮廓。 + 2. test_evidence_v2_emits_analytic_contours_for_multiple_circles + 回归护栏:两个独立(圆心不同、互不包含)的圆本来就输出 + analytic_contours,修复 annulus 分支时不得破坏该既有行为。 + 3. test_evidence_v2_annulus_output_rebuilds_to_ring_solid + 端到端:importer 输出的 analytic_contours 必须能被 CDSL-only 运行时 + rebuild_cdsl 重建为实体,且体积与圆环公式 + π * (r外² - r内²) * 高度 一致(误差 1%)。 + """ + + def test_evidence_v2_emits_analytic_contours_for_annulus(self) -> None: + """Concentric circles must lower to a generic ring (outer + inner).""" + # 主契约测试:同圆心 (0,0) 的两个圆,外径 10mm、内径 5mm + sketch = {"segments": [ + _circle_segment([0, 0], 10.0), + _circle_segment([0, 0], 5.0), + ]} + profile = _analytic_profile(sketch) + # 1) profile 类型必须是 analytic_contours(而不是旧宏 annulus) + self.assertEqual(profile["type"], "analytic_contours") + contours = profile["contours"] + # 2) 必须恰好有两条轮廓 + self.assertEqual(len(contours), 2) + # 3) 两条轮廓的角色分别是 outer(外圆)与 inner(内圆) + self.assertEqual({contour["role"] for contour in contours}, {"outer", "inner"}) + outer = next(contour for contour in contours if contour["role"] == "outer") + inner = next(contour for contour in contours if contour["role"] == "inner") + # 4) 半径正确:外 10mm / 内 5mm + self.assertEqual(outer["segments"][0]["radius_mm"], 10.0) + self.assertEqual(inner["segments"][0]["radius_mm"], 5.0) + + def test_evidence_v2_emits_analytic_contours_for_multiple_circles(self) -> None: + """Regression guard: independent circles already lower to contours.""" + # 回归护栏:两个圆心不同(相距 30mm)、互不包含的独立圆, + # 修复 annulus 分支前后都必须保持输出 analytic_contours(各一条 outer 轮廓) + sketch = {"segments": [ + _circle_segment([0, 0], 10.0), + _circle_segment([30, 0], 5.0), + ]} + profile = _analytic_profile(sketch) + self.assertEqual(profile["type"], "analytic_contours") + self.assertEqual(len(profile["contours"]), 2) + + @unittest.skipUnless(_HAS_BUILD123D, "build123d is not available") + def test_evidence_v2_annulus_output_rebuilds_to_ring_solid(self) -> None: + """Importer output must be consumed end-to-end by the CDSL-only runtime.""" + # 端到端:直接取 _analytic_profile 的输出组装成 CDSL 文档, + # 交给 CDSL-only 运行时 rebuild_cdsl 重建实体,再断言体积符合圆环公式。 + # 此测试验证修复后运行时不再报 profile_resolution_failed / unsupported_profile。 + from cdsl_engine.runtime import rebuild_cdsl + + sketch = {"segments": [ + _circle_segment([0, 0], 10.0), + _circle_segment([0, 0], 5.0), + ]} + profile = _analytic_profile(sketch) + cdsl = _ring_cdsl(profile) + with tempfile.TemporaryDirectory() as directory: + out_step = Path(directory) / "ring.step" + result = rebuild_cdsl(cdsl, out_step) + # 圆环体积 = π * (r外² - r内²) * 高度 = π * (100 - 25) * 20 + expected = math.pi * (10.0 ** 2 - 5.0 ** 2) * 20.0 + self.assertAlmostEqual(result["volume_mm3"], expected, delta=expected * 0.01) + + +if __name__ == "__main__": + unittest.main() diff --git a/json_to_cdsl/evidence_v2_to_cdsl.py b/json_to_cdsl/evidence_v2_to_cdsl.py index 4efed0c8..717ab613 100644 --- a/json_to_cdsl/evidence_v2_to_cdsl.py +++ b/json_to_cdsl/evidence_v2_to_cdsl.py @@ -412,7 +412,16 @@ def _analytic_profile(sketch: dict[str, Any]) -> dict[str, Any]: left, right = drawable if math.dist(left["center"], right["center"]) <= EPSILON_MM: smaller, larger = sorted(drawable, key=lambda item: item["radius_mm"]) - return {"type": "annulus", "center": larger["center"], "inner_radius_mm": smaller["radius_mm"], "outer_radius_mm": larger["radius_mm"]} + # Annulus is emitted as analytic_contours (outer + inner) so the + # generic runtime profile contract accepts the record; ring_revolve + # can then revolve the two concentric circles into a hollow solid. + return { + "type": "analytic_contours", + "contours": [ + {"role": "outer", "closed": True, "segments": [larger]}, + {"role": "inner", "closed": True, "segments": [smaller]}, + ], + } if drawable and all(item["type"] == "circle" for item in drawable): # Multiple independent circles share the "extrude every closed loop" intent; # emit them as analytic_contours (one outer contour per circle) so the