fix(evidence_v2): 将 annulus 降级为 analytic_contours 轮廓

同心圆截面改为输出外圆与内圆两条 analytic_contours 轮廓,避免旧 annulus 宏导致 CDSL 文档不符合 schema 且运行时无法执行
This commit is contained in:
2026-08-27 10:55:55 +08:00
parent 98d208ae5d
commit e7ade4aa00
2 changed files with 180 additions and 1 deletions
@@ -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()
+10 -1
View File
@@ -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