merge
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
"""#5 高级终止条件:up_to_surface 非均匀 profile 必须裁剪而非拒绝。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #5「高级终止条件要求整张 profile 同一距离」的回归测试):
|
||||
|
||||
1. 背景:up_to_surface(拉伸到面)等终止条件用"profile 采样点射线求交"
|
||||
判定终止距离。修复前,只要 profile 与目标面**非均匀相交**——一部分
|
||||
采样点到达目标、一部分悬空(profile 超出目标面范围),或目标面相对
|
||||
profile 倾斜——runtime 就抛 non_uniform_extent_target 拒绝整个特征,
|
||||
零件无法重建。而 CAD 的标准语义是**裁剪**:保留"从 profile 到目标面"
|
||||
的可达材料,切掉悬空部分。
|
||||
修复后:adapter 新增 extrude_trimmed(穿透拉伸 + 目标面体层布尔求交),
|
||||
runtime 的 _targeted_extent_vector 在 up_to_surface 非均匀时返回
|
||||
带 trim_to 的 ExtentVector,_shape_from_primary 改用裁剪拉伸。
|
||||
up_to_vertex / up_to_body / offset_from_surface 没有可构造裁剪体层
|
||||
的 face,仍保持显式拒绝(语义上无法裁剪)。
|
||||
|
||||
2. 本测试套件把"非均匀 up_to_surface 裁剪"合同固定下来:
|
||||
- 单元几何契约(adapter):斜目标面 + 水平 profile 裁剪出楔形,
|
||||
体积 = ∫斜顶 dxdy;
|
||||
- 集成裁剪契约(rebuild):profile 悬空超出目标面时不再拒绝,
|
||||
悬空部分被切掉,只保留与目标面之间的材料;
|
||||
- 集成回归护栏(rebuild):profile 完全落在目标面内仍走精确
|
||||
均匀拉伸路径,体积不变(防止裁剪回退把均匀路径也改坏)。
|
||||
|
||||
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine
|
||||
包做端到端测试(与既有测试风格一致)。
|
||||
|
||||
函数功能一览
|
||||
------------
|
||||
_workplane(origin, normal) 构造指定原点与法向的草图工作平面。
|
||||
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||
_base_block() 构造 10×10×10 拉伸主体(体积 1000,
|
||||
顶面 z=10、范围 x/y ∈ [-5, 5])。
|
||||
_top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z)
|
||||
的几何快照,作为 up_to_surface 的 reference。
|
||||
ExtentTrimContractTests 见各测试方法 docstring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
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"))
|
||||
|
||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter # noqa: E402
|
||||
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
|
||||
|
||||
|
||||
try:
|
||||
from build123d import Face, Plane, Vector # noqa: F401
|
||||
_HAS_BUILD123D = True
|
||||
except ImportError:
|
||||
_HAS_BUILD123D = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _workplane(*, origin: list[float], normal: list[float]) -> dict:
|
||||
"""构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。"""
|
||||
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
def _base_block() -> dict:
|
||||
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10、范围 x/y ∈ [-5, 5]。"""
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "extent-trim-contract", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "base",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _top_face_selector(baseline: dict) -> dict:
|
||||
"""从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。
|
||||
|
||||
这个快照被 TopologyRegistry 用来做几何等价匹配,从而把 up_to_surface
|
||||
的 reference 解析到重建主体上的真实 Face。
|
||||
"""
|
||||
return next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "face"
|
||||
and item["geometry"]["surface_type"] == "plane"
|
||||
and item["geometry"]["normal"][2] > 0.9
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试套件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExtentTrimContractTests(unittest.TestCase):
|
||||
"""up_to_surface 非均匀相交「裁剪几何-行为-回归」三方合同测试。"""
|
||||
|
||||
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||
def test_extrude_trimmed_slanted_target_produces_wedge(self) -> None:
|
||||
"""单元几何契约:斜目标面 + 水平 profile 裁剪出楔形。
|
||||
|
||||
profile 是 z=0 的 10×10 矩形;目标面是斜面 z = 0.2x + 5
|
||||
(z_dir=(0.2, 0, 0.98) 的平面)。采样点沿 +z 到斜面的距离从
|
||||
4(x=-5)到 6(x=+5)变化 → uniform_intersection_distance 必然
|
||||
判定非均匀。裁剪结果应是顶面贴斜面的楔形,体积 =
|
||||
∫∫ (0.2x + 5) dxdy = 5 × 100 = 500。修复前该场景直接抛
|
||||
non_uniform_extent_target(无实体可断言),本测试锁死裁剪几何。
|
||||
"""
|
||||
profile = Face.make_rect(10, 10) # 中心在原点,x/y ∈ [-5, 5],z=0
|
||||
slanted = Face.make_rect(20, 20, Plane(origin=(0, 0, 5), z_dir=(0.2, 0, 0.98)))
|
||||
|
||||
trimmed = Build123dGeometryAdapter.extrude_trimmed(profile, slanted, (0, 0, 1))
|
||||
|
||||
self.assertAlmostEqual(trimmed.volume, 500.0, places=5)
|
||||
|
||||
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||
def test_up_to_surface_hanging_profile_is_trimmed_not_rejected(self) -> None:
|
||||
"""集成裁剪契约:profile 悬空超出目标面时不再拒绝,悬空部分被切掉。
|
||||
|
||||
baseline 是 10×10×10 主体(顶面 z=10、范围 [-5, 5]²)。第二个 add
|
||||
特征在 z=12 平面、法向 -z,profile 为 16×16 矩形(x/y ∈ [-8, 8],
|
||||
大部分悬空在顶面范围之外)。up_to_surface 目标 = 顶面:
|
||||
- 中心采样点沿 -z 命中顶面(距离 2);
|
||||
- 角落采样点(x/y = ±8)在顶面范围外,射线不命中 → 非均匀。
|
||||
修复前抛 non_uniform_extent_target;修复后裁剪出
|
||||
x/y ∈ [-5, 5]、z ∈ [10, 12] 的 10×10×2 体块,总体积 = 1000 + 200。
|
||||
"""
|
||||
base = _base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = _top_face_selector(baseline)
|
||||
|
||||
with_cap = deepcopy(base)
|
||||
with_cap["geometry"]["sketches"].append({
|
||||
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
|
||||
"profile": _rectangle([-8, -8], [8, 8]),
|
||||
})
|
||||
with_cap["features"].append({
|
||||
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||
"sketch_id": "cap",
|
||||
"params": {
|
||||
"distance_mm": 0,
|
||||
"end_condition": {"type": "up_to_surface", "reference": top_face},
|
||||
},
|
||||
})
|
||||
rebuilt = rebuild_cdsl(with_cap, root / "trimmed.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 200, places=5)
|
||||
|
||||
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||
def test_up_to_surface_uniform_profile_keeps_exact_distance(self) -> None:
|
||||
"""集成回归护栏:profile 完全落在目标面内仍走精确均匀拉伸。
|
||||
|
||||
与上一个测试同构,但 profile 缩到 6×6(x/y ∈ [-3, 3]),完全落在
|
||||
顶面 [-5, 5]² 范围内 → 所有采样点沿 -z 都命中且距离一致(2)→
|
||||
保持原精确拉伸路径(不触发裁剪),体积 = 1000 + 36×2 = 1072。
|
||||
防止裁剪回退把均匀路径也改坏。
|
||||
"""
|
||||
base = _base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = _top_face_selector(baseline)
|
||||
|
||||
with_cap = deepcopy(base)
|
||||
with_cap["geometry"]["sketches"].append({
|
||||
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
|
||||
"profile": _rectangle([-3, -3], [3, 3]),
|
||||
})
|
||||
with_cap["features"].append({
|
||||
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||
"sketch_id": "cap",
|
||||
"params": {
|
||||
"distance_mm": 0,
|
||||
"end_condition": {"type": "up_to_surface", "reference": top_face},
|
||||
},
|
||||
})
|
||||
rebuilt = rebuild_cdsl(with_cap, root / "exact.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 36 * 2, places=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,226 @@
|
||||
"""#2 draft 假接受陷阱:extrudeParams.draft 必须被显式拒绝,而不是静默忽略。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #2「draft 被 schema 接受但 runtime 未执行」的回归测试):
|
||||
|
||||
1. 背景:三方合同错位——
|
||||
- 机器契约 cdsl_schema.json(extrudeParams,约 101 行)允许
|
||||
"draft": {"type": "object"},且是空 object(无任何子字段约束),
|
||||
即"文档格式假接受";
|
||||
- 人读契约 profile_schema.json 的 extrude_add_blind /
|
||||
extrude_add_two_sided / extrude_cut_blind 均**未**声明 draft
|
||||
为 optional_params;
|
||||
- runtime(build123d_adapter.extrude 仅调 Solid.extrude,无锥形
|
||||
拉伸)也完全没有 draft 实现,遇到 draft 就静默忽略。
|
||||
- 隐患:importer(translator.py 已解析 SolidWorks 的
|
||||
draft_angle_rad / reverse_draft_angle_rad)一旦把拔模角写进
|
||||
CDSL params,runtime 会静默产出**无拔模角的直壁实体**——
|
||||
注塑件/压铸件丢失脱模斜度,脱模卡死、分型面配合错误,且全程
|
||||
无警告(与 #1 y_dir / #3 revolve.reverse 同族的静默错误)。
|
||||
|
||||
2. 修复策略:因为 build123d 内核没有锥形拉伸能力、且人读契约未声明
|
||||
draft,正确的合同是"显式拒绝"而不是"实现拔模"——
|
||||
capabilities.py 对携带 draft 的 extrude 特征报 unsupported_draft
|
||||
blocker(与 unsupported_extent 同模式)。schema 字段保留(文档格式
|
||||
契约,importer 未来可能产出),能力层明确划界。
|
||||
|
||||
3. 本测试套件把"draft 必须显式拒绝"固定下来:
|
||||
- 主契约:带 draft 的 extrude 特征 → analyze 报 unsupported_draft
|
||||
blocker,runtime_eligible=False;
|
||||
- 回归护栏:不带 draft 的 extrude 特征 → 仍 runtime_eligible;
|
||||
- 文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json(拒绝
|
||||
发生在能力层,不是 schema 层);
|
||||
- 覆盖:extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
|
||||
三种原子都报同一 blocker(同一检查全类生效);
|
||||
- 端到端:rebuild_cdsl(strict)带 draft → 抛 ValueError(大声失败),
|
||||
analyze_document → runtime_eligible=False 且不 built(批量重建
|
||||
不被静默污染)。
|
||||
|
||||
4. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import
|
||||
cdsl_engine 包做端到端测试(与 test_engine_revolve_reverse.py 风格
|
||||
一致)。
|
||||
|
||||
函数功能一览
|
||||
------------
|
||||
_rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||
_extrude_cdsl() 构造最小 extrude 文档;draft 参数决定
|
||||
是否携带 draft 字段、atomic_id 可切换
|
||||
三种 extrude 原子。
|
||||
_rebuild() 在临时目录内调用 rebuild_cdsl(strict),
|
||||
带 draft 时预期抛 ValueError。
|
||||
_validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。
|
||||
RevolveReverseContractTests (见各测试方法 docstring)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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.batch_rebuild import analyze_document # noqa: E402
|
||||
from cdsl_engine.runtime import analyze_cdsl, 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"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。
|
||||
|
||||
用于拉伸特征的最小闭合轮廓:x∈[minimum[0], maximum[0]]、
|
||||
y∈[minimum[1], maximum[1]]。
|
||||
"""
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
def _extrude_cdsl(*, atomic_id: str = "extrude_add_blind", with_draft: bool = True) -> dict:
|
||||
"""构造最小 extrude 文档。
|
||||
|
||||
- with_draft=True 时 params 携带 draft 对象(任意非空 object 即可,
|
||||
因为 cdsl_schema.json 对 draft 没有子字段约束);
|
||||
- with_draft=False 时完全不写 draft 字段(回归护栏用)。
|
||||
- atomic_id 可切换 extrude_add_blind / extrude_add_two_sided /
|
||||
extrude_cut_blind 三种原子,验证同一 blocker 检查对全类生效。
|
||||
"""
|
||||
params: dict = {"distance_mm": 10.0}
|
||||
if with_draft:
|
||||
params["draft"] = {"angle_deg": 5.0, "direction": "toward_sketch"}
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "draft-contract", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base",
|
||||
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": atomic_id, "depends_on": [], "sketch_id": "base",
|
||||
"params": params,
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _validate_against_cdsl_schema(doc: dict) -> None:
|
||||
"""对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。"""
|
||||
jsonschema.validate(instance=doc, schema=_SCHEMA)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试套件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ExtrudeDraftContractTests(unittest.TestCase):
|
||||
"""draft 假接受陷阱「文档格式-能力边界-运行时」三方合同的回归测试。"""
|
||||
|
||||
def test_draft_extrude_is_explicitly_blocked(self) -> None:
|
||||
"""主契约:带 draft 的 extrude 特征必须被显式拒绝,而不是静默通过。
|
||||
|
||||
修复前(当前):capabilities 对 extrude 只检查 end_condition,
|
||||
draft 字段完全无人过问 → analyze 报 runtime_eligible=True,
|
||||
rebuild 静默产出直壁实体 → 本测试红灯。
|
||||
修复后:capabilities 报 unsupported_draft blocker →
|
||||
runtime_eligible=False → 绿灯。
|
||||
"""
|
||||
analysis = analyze_cdsl(_extrude_cdsl(with_draft=True))
|
||||
|
||||
self.assertFalse(analysis.runtime_eligible)
|
||||
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
|
||||
self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers])
|
||||
|
||||
def test_draft_free_extrude_stays_eligible(self) -> None:
|
||||
"""回归护栏:不带 draft 的 extrude 特征仍必须 runtime_eligible。
|
||||
|
||||
修复前/修复后均应通过。这条测试防止我们把检查加过头——
|
||||
一旦把"携带 draft"错写成"所有 extrude 都拒绝",护栏会变红。
|
||||
"""
|
||||
analysis = analyze_cdsl(_extrude_cdsl(with_draft=False))
|
||||
|
||||
self.assertTrue(analysis.runtime_eligible)
|
||||
|
||||
def test_draft_passes_machine_schema(self) -> None:
|
||||
"""文档格式契约:带 draft 的文档仍能通过 cdsl_schema.json 校验。
|
||||
|
||||
cdsl_schema.json(extrudeParams)保留 draft 字段,拒绝发生在
|
||||
能力层(capabilities),不是 schema 层。这条测试锁死"schema 允许
|
||||
+ 能力拒绝"的分层职责,防止未来把 schema 改过头(删掉字段后
|
||||
importer 未来产出 draft 会直接被 schema 打回,失去可诊断性)。
|
||||
"""
|
||||
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_blind", with_draft=True))
|
||||
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_add_two_sided", with_draft=True))
|
||||
_validate_against_cdsl_schema(_extrude_cdsl(atomic_id="extrude_cut_blind", with_draft=True))
|
||||
|
||||
def test_draft_blocks_every_extrude_atomic(self) -> None:
|
||||
"""覆盖:三种 extrude 原子都报同一个 unsupported_draft blocker。
|
||||
|
||||
draft 检查挂在 _SKETCH_ATOM_PREFIXES(extrude_/revolve_)公共入口,
|
||||
必须对 extrude_add_blind / extrude_add_two_sided / extrude_cut_blind
|
||||
同时生效,而不是只修了某一个。
|
||||
"""
|
||||
for atomic_id in ("extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind"):
|
||||
with self.subTest(atomic_id=atomic_id):
|
||||
analysis = analyze_cdsl(_extrude_cdsl(atomic_id=atomic_id, with_draft=True))
|
||||
result = next(item for item in analysis.feature_results if item.feature_id == "base_add")
|
||||
self.assertIn("unsupported_draft", [blocker.code for blocker in result.blockers])
|
||||
|
||||
def test_draft_rebuild_fails_loudly_not_silently(self) -> None:
|
||||
"""端到端:draft 文档的重建必须大声失败,而不是静默产出直壁实体。
|
||||
|
||||
修复前:rebuild_cdsl(strict)对 draft 视而不见 → 正常返回实体,
|
||||
volume > 0,但几何是**没有拔模角的直壁**——静默错误。
|
||||
修复后:rebuild_cdsl(strict)因 runtime_eligible=False 抛
|
||||
ValueError(feature is not runtime eligible: unsupported_draft);
|
||||
analyze_document 报告 runtime_eligible=False 且不 built——
|
||||
批量重建不会被静默污染。
|
||||
"""
|
||||
# 1) strict 重建直接抛错(大声失败)。
|
||||
with self.assertRaisesRegex(ValueError, "unsupported_draft"):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuild_cdsl(_extrude_cdsl(with_draft=True), Path(directory) / "part.step")
|
||||
|
||||
# 2) 批量层 analyze_document 报告不可执行且不产出 STEP。
|
||||
cdsl = _extrude_cdsl(with_draft=True)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
out_step = Path(directory) / "part.step"
|
||||
report = _analyze_inline(cdsl, out_step)
|
||||
self.assertFalse(report["runtime_eligible"])
|
||||
self.assertFalse(report.get("built", False))
|
||||
|
||||
|
||||
def _analyze_inline(cdsl: dict, out_step: Path) -> dict:
|
||||
"""把 cdsl 写入临时 json 后走 analyze_document(与批量层同一入口)。
|
||||
|
||||
analyze_document 接受文件路径,这里把内存中的文档落地成临时文件,
|
||||
保证测试走的路径与 batch_rebuild 完全一致。
|
||||
"""
|
||||
import tempfile as _tf
|
||||
|
||||
with _tf.TemporaryDirectory() as directory:
|
||||
source = Path(directory) / "draft.cdsl.json"
|
||||
source.write_text(json.dumps(cdsl), encoding="utf-8")
|
||||
return analyze_document(source, out_step=out_step)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,263 @@
|
||||
"""#9 孔型:hole_wizard.thread(装饰螺纹)必须可执行并降级为光滑孔。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #9「HoleSpec 仅支持简单圆柱、沉头、沉孔」的回归测试):
|
||||
|
||||
1. 背景:SolidWorks 螺纹孔(hole_wizard + thread,如"M5 螺纹孔"、
|
||||
"底部螺纹孔")在真实语料里大量存在,修复前 capabilities 把 thread
|
||||
误判为"当前 CDSL runtime 无法表示的几何"而报 unsupported_hole_subtype
|
||||
blocker,≈712 个带 thread 的孔特征因此整体被拒(runtime_eligible=False),
|
||||
零件无法重建。
|
||||
实际上 thread 只是装饰信息:
|
||||
- 数据形态只有 {diameter_mm, depth_mm, class},没有螺距;
|
||||
- SolidWorks/STEP 的螺纹孔实体几何就是光滑圆柱孔(装饰螺纹不进
|
||||
实体、不进 STEP);
|
||||
- HoleSpec.from_feature 只读直径/深度/位置/沉头/沉孔,thread 天然
|
||||
不参与几何计算。
|
||||
因此正确合同是"接受 thread、按光滑孔执行",并在 runtime 记录
|
||||
info 级诊断(thread_decoration_ignored)便于批量报告追溯降级数量。
|
||||
|
||||
2. 本测试套件把"thread 孔必须可执行且降级为光滑孔"固定下来:
|
||||
- 主契约:带 thread 的 hole_wizard → runtime_eligible=True
|
||||
(不再被拒绝);
|
||||
- 回归护栏:不带 thread 的 hole_wizard → 仍可执行(防止把检查
|
||||
加过头,所有孔都被拒);
|
||||
- 文档契约:hole_wizard + thread 通过 cdsl_schema.json 校验
|
||||
(字段本就在 holeWizardParams 里);
|
||||
- 几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模,
|
||||
与 SolidWorks/STEP 语义一致);
|
||||
- 可追溯:rebuild 结果里带 thread_decoration_ignored 信息诊断
|
||||
(降级不是静默发生的)。
|
||||
|
||||
3. sys.path 说明:把 backend/engine 加入搜索路径,是为了直接 import
|
||||
cdsl_engine 包做端到端测试(与 test_engine_hole_thread_contract 等
|
||||
既有测试风格一致)。
|
||||
|
||||
函数功能一览
|
||||
------------
|
||||
_workplane() 构造默认草图工作平面(XY 平面)。
|
||||
_rectangle() 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||
_base_block() 构造 10×10×10 拉伸主体文档(体积 1000)。
|
||||
_thread_wizard_feature() 构造 hole_wizard 特征(with_thread 决定
|
||||
是否携带 thread 装饰字段)。
|
||||
_validate_against_cdsl_schema() 对整张文档跑 cdsl_schema.json 校验。
|
||||
HoleThreadContractTests 见各测试方法 docstring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
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 analyze_cdsl, 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _workplane() -> dict:
|
||||
"""默认草图工作平面:原点在 (0,0,0)、x 轴沿 +X、法向沿 +Z。"""
|
||||
return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
def _base_block() -> dict:
|
||||
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10。"""
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "hole-thread-contract", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": _workplane(),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "base",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _thread_wizard_feature(*, with_thread: bool = True) -> dict:
|
||||
"""构造 hole_wizard 特征(仿真实数据"M5 螺纹孔")。
|
||||
|
||||
- with_thread=True:携带 thread 装饰字段
|
||||
{"diameter_mm", "depth_mm", "class"}(真实语料形态,无螺距);
|
||||
- with_thread=False:不写 thread(回归护栏用)。
|
||||
"""
|
||||
params: dict = {
|
||||
"hole_type": "底部螺纹孔", "diameter_mm": 5.0, "depth_mm": 10.0,
|
||||
"end_condition": {"type": "blind", "solidworks_code": 0},
|
||||
"positions": [{"mm": [0.0, 0.0, 0.0]}],
|
||||
# 合法的 selectorRef(cdsl_schema.json hostFace oneOf 分支),
|
||||
# 满足机器 schema 的 required: [kind, stable_id, source, confidence]。
|
||||
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1},
|
||||
}
|
||||
if with_thread:
|
||||
params["thread"] = {"diameter_mm": 5.0, "depth_mm": 10.0, "class": "1B"}
|
||||
return {"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"], "params": params}
|
||||
|
||||
|
||||
def _validate_against_cdsl_schema(doc: dict) -> None:
|
||||
"""对整张 CDSL 文档跑 cdsl_schema.json 校验;任何字段不通过都会抛 ValidationError。"""
|
||||
jsonschema.validate(instance=doc, schema=_SCHEMA)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试套件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HoleThreadContractTests(unittest.TestCase):
|
||||
"""thread 装饰螺纹「文档格式-能力边界-运行时-几何」四方合同的回归测试。"""
|
||||
|
||||
def test_thread_hole_wizard_is_executable(self) -> None:
|
||||
"""主契约:带 thread 的 hole_wizard 必须 runtime_eligible。
|
||||
|
||||
修复前(当前):capabilities 报 unsupported_hole_subtype →
|
||||
runtime_eligible=False → 零件无法重建 → 本测试红灯。
|
||||
修复后:capabilities 不再拒绝 thread → runtime_eligible=True,
|
||||
hole 特征无任何 blocker → 绿灯。
|
||||
"""
|
||||
cdsl = _base_block()
|
||||
cdsl["features"].append(_thread_wizard_feature(with_thread=True))
|
||||
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
hole = next(item for item in analysis.feature_results if item.feature_id == "hole")
|
||||
|
||||
self.assertTrue(analysis.runtime_eligible)
|
||||
self.assertTrue(hole.executable)
|
||||
self.assertNotIn("unsupported_hole_subtype", [blocker.code for blocker in hole.blockers])
|
||||
|
||||
def test_threadless_hole_wizard_stays_eligible(self) -> None:
|
||||
"""回归护栏:不带 thread 的 hole_wizard 仍必须 runtime_eligible。
|
||||
|
||||
修复前/修复后均应通过。这条测试防止我们把修复做成"所有孔都被拒"
|
||||
(例如误删 hole 检查整段)。
|
||||
"""
|
||||
cdsl = _base_block()
|
||||
cdsl["features"].append(_thread_wizard_feature(with_thread=False))
|
||||
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
self.assertTrue(analysis.runtime_eligible)
|
||||
|
||||
def test_thread_hole_passes_machine_schema(self) -> None:
|
||||
"""文档契约:hole_wizard + thread 必须通过 cdsl_schema.json 校验。
|
||||
|
||||
thread 字段本就在 holeWizardParams.properties 里(允许携带),
|
||||
修复策略是"能力层接受并降级",不是"schema 层拒绝"——这条测试锁死
|
||||
文档格式对 thread 的认可,防止未来把 schema 改过头。
|
||||
"""
|
||||
cdsl = _base_block()
|
||||
cdsl["features"].append(_thread_wizard_feature(with_thread=True))
|
||||
_validate_against_cdsl_schema(cdsl)
|
||||
|
||||
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||
def test_thread_hole_cuts_plain_cylindrical_bore(self) -> None:
|
||||
"""几何契约:thread 孔切出的体积 = 光滑圆柱孔体积(thread 不建模)。
|
||||
|
||||
10×10×10 主体,在顶面(z=10)中心打一个 d=2、深 5 的 thread 盲孔:
|
||||
体积 = 1000 - π·1²·5。若 thread 参与几何(或孔整体被拒),体积断言
|
||||
都会失败。这条测试锁死"降级为光滑孔"的几何语义——与 SolidWorks/
|
||||
STEP 的螺纹孔表示一致。
|
||||
"""
|
||||
base = _base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "face"
|
||||
and item["geometry"]["surface_type"] == "plane"
|
||||
and item["geometry"]["normal"][2] > 0.9
|
||||
)
|
||||
feature = _thread_wizard_feature(with_thread=True)
|
||||
feature["params"]["diameter_mm"] = 2.0
|
||||
feature["params"]["depth_mm"] = 5.0
|
||||
feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}]
|
||||
feature["params"]["host_face"] = {
|
||||
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"],
|
||||
}
|
||||
feature["selectors"] = [
|
||||
{"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"]},
|
||||
]
|
||||
with_hole = deepcopy(base)
|
||||
with_hole["features"].append(feature)
|
||||
holed = rebuild_cdsl(with_hole, root / "thread-hole.step")
|
||||
|
||||
self.assertAlmostEqual(holed["volume_mm3"], 1000 - 5 * math.pi, places=5)
|
||||
|
||||
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
|
||||
def test_thread_fallback_reports_info_diagnostic(self) -> None:
|
||||
"""可追溯:thread 降级必须留下 thread_decoration_ignored 信息诊断。
|
||||
|
||||
降级不是静默发生的:runtime 在 wizard 模式且携带 thread 时记录
|
||||
info 级诊断,批量报告(summary-by-diagnostic 或 per-part report)
|
||||
可以统计降级数量。这条测试锁死"降级可观测"。
|
||||
"""
|
||||
base = _base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "face"
|
||||
and item["geometry"]["surface_type"] == "plane"
|
||||
and item["geometry"]["normal"][2] > 0.9
|
||||
)
|
||||
feature = _thread_wizard_feature(with_thread=True)
|
||||
feature["params"]["positions"] = [{"mm": [0.0, 0.0, 10.0]}]
|
||||
feature["params"]["host_face"] = {
|
||||
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"],
|
||||
}
|
||||
feature["selectors"] = [
|
||||
{"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"]},
|
||||
]
|
||||
with_hole = deepcopy(base)
|
||||
with_hole["features"].append(feature)
|
||||
holed = rebuild_cdsl(with_hole, root / "thread-hole.step")
|
||||
|
||||
hole_result = next(item for item in holed["feature_results"] if item["feature_id"] == "hole")
|
||||
codes = [diagnostic["code"] for diagnostic in hole_result["diagnostics"]]
|
||||
self.assertIn("thread_decoration_ignored", codes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,304 @@
|
||||
"""#7 ExecutionSession 单 active body → 多实体 body_id 回归测试。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #7「ExecutionSession 单 active body」的回归测试):
|
||||
|
||||
1. 背景:ExecutionSession 只跟踪一个 active body(session.body /
|
||||
session.body_id = body:{feature_id})。多实体零件(例如两个不相交
|
||||
的拉伸 add,布尔并后 build123d 返回 Compound,含 ≥2 个独立 Solid)
|
||||
被当成一个 body 处理:
|
||||
- 拓扑记录把 Compound 的所有面/边/顶点统一登记为一个 body_id;
|
||||
- body_id 实际上变成"最后执行的特征的 id",不反映真实实体数;
|
||||
- 后续特征无法精确匹配"某个实体"上的拓扑,多体信息在重建报告里
|
||||
完全丢失。
|
||||
修复后(#7):register_body 通过 adapter.body_solids 拆出独立实体,
|
||||
每个 Solid 一个 body:{feature}:{index},selector resolve 与拓扑继承
|
||||
用前缀匹配整个主体;单体路径保持 body:{feature} 完全不变。
|
||||
|
||||
2. 本测试套件把"多体可识别、单体不受影响"固定下来:
|
||||
- 多体契约:两个不相交 add → 最后主体含 ≥2 个独立 body_id,
|
||||
rebuild 输出 solid_count=2;
|
||||
- 多体 + 后续操作契约:在多体主体上打孔/切除仍 resolve 到正确实体;
|
||||
- 单体护栏:单个 add → 只有 1 个实体、solid_count=1,body_id 语义
|
||||
与修复前一致。
|
||||
|
||||
3. sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import
|
||||
cdsl_engine 包做端到端测试(与既有测试风格一致)。
|
||||
|
||||
函数功能一览
|
||||
------------
|
||||
_workplane(origin) 构造指定原点的 XY 平面草图工作平面。
|
||||
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||
_single_boss_doc() 10×10×10 单体拉伸文档(体积 1000)。
|
||||
_two_boss_doc() 两个不相交 10×10×10 拉伸(x 相距 20),
|
||||
布尔并后为 2 个独立 Solid(体积 2000)。
|
||||
MultiBodyContractTests 见各测试方法 docstring。
|
||||
"""
|
||||
|
||||
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"))
|
||||
|
||||
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _workplane(*, origin: list[float]) -> dict:
|
||||
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
def _single_boss_doc() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "multi-body-single", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _two_boss_doc() -> dict:
|
||||
"""两个不相交 10×10×10 拉伸:主体 x∈[-5,5],第二个 x∈[15,25]。"""
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "multi-body-two", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s2"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试套件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MultiBodyContractTests(unittest.TestCase):
|
||||
"""多体识别「实体数-拓扑归属-单体护栏」三方合同测试。"""
|
||||
|
||||
def test_two_disjoint_bosses_report_two_independent_body_ids(self) -> None:
|
||||
"""多体契约:两个不相交 add → 最后主体含 ≥2 个独立 body_id。
|
||||
|
||||
修复前 body_id 是"每特征一 id"(body:add_1 / body:add_2),Compound
|
||||
的所有面都登记为 body:add_2,无法区分独立实体。修复后 register_body
|
||||
为每个 Solid 分配 body:{feature}:{index},最后主体应同时包含
|
||||
body:add_2:0 与 body:add_2:1 的拓扑记录。
|
||||
"""
|
||||
cdsl = _two_boss_doc()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "two.step")
|
||||
body_ids = sorted({r["body_id"] for r in rebuilt["topology_records"] if r.get("body_id")})
|
||||
self.assertIn("body:add_2:0", body_ids)
|
||||
self.assertIn("body:add_2:1", body_ids)
|
||||
# 两个成员都要有可 resolve 的面记录(而不是只有整体 body 记录)。
|
||||
member_faces = {
|
||||
r["body_id"] for r in rebuilt["topology_records"]
|
||||
if r["kind"] == "face" and r.get("body_id", "").startswith("body:add_2:")
|
||||
}
|
||||
self.assertEqual(member_faces, {"body:add_2:0", "body:add_2:1"})
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 2000.0, places=5)
|
||||
|
||||
def test_rebuild_reports_solid_count(self) -> None:
|
||||
"""多体契约:rebuild 输出 solid_count 反映独立实体数。"""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
two = rebuild_cdsl(_two_boss_doc(), Path(directory) / "two.step")
|
||||
one = rebuild_cdsl(_single_boss_doc(), Path(directory) / "one.step")
|
||||
self.assertEqual(two["solid_count"], 2)
|
||||
self.assertEqual(one["solid_count"], 1)
|
||||
|
||||
def test_single_body_keeps_legacy_body_id(self) -> None:
|
||||
"""单体护栏:单个 add → 1 个实体,body_id 语义与修复前一致。
|
||||
|
||||
防止把多体拆分做成"所有主体都拆":单体主体必须保持
|
||||
body:{feature}(无 :index 后缀),后续 selector 行为不变。
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuilt = rebuild_cdsl(_single_boss_doc(), Path(directory) / "one.step")
|
||||
body_ids = sorted({r["body_id"] for r in rebuilt["topology_records"] if r.get("body_id")})
|
||||
self.assertEqual(body_ids, ["body:add_1"])
|
||||
face_ids = {
|
||||
r["body_id"] for r in rebuilt["topology_records"]
|
||||
if r["kind"] == "face" and r.get("body_id")
|
||||
}
|
||||
self.assertEqual(face_ids, {"body:add_1"})
|
||||
|
||||
def test_cut_on_multi_body_mutates_only_the_intersected_solid(self) -> None:
|
||||
"""多体 + 后续操作契约:多体主体上切除仍 resolve 到正确实体。
|
||||
|
||||
两个不相交 box(各 1000)。在第一个 box(x∈[-5,5])顶面打贯穿孔
|
||||
(r=1、深 10):只有第一个 box 被切,第二个 box(x∈[15,25])不受
|
||||
影响。期望体积 = 2000 − π×1²×10,且 STEP 仍含 2 个 Solid。
|
||||
"""
|
||||
base = _two_boss_doc()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "face"
|
||||
and item["geometry"]["surface_type"] == "plane"
|
||||
and item["geometry"]["normal"][2] > 0.9
|
||||
and abs(item["geometry"]["center_mm"][0]) < 1.0
|
||||
)
|
||||
with_cut = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "multi-body-cut", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "cut", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1}},
|
||||
]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s2"},
|
||||
{"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["add_2"],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "cut"},
|
||||
],
|
||||
}
|
||||
rebuilt = rebuild_cdsl(with_cut, root / "cut.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 2000 - math.pi * 10, places=5)
|
||||
self.assertEqual(rebuilt["solid_count"], 2)
|
||||
# 最后主体(cut 后)仍是 2 个独立实体,每个都有拓扑记录。
|
||||
member_ids = {
|
||||
r["body_id"] for r in rebuilt["topology_records"]
|
||||
if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_1:")
|
||||
}
|
||||
self.assertEqual(member_ids, {"body:cut_1:0", "body:cut_1:1"})
|
||||
|
||||
def test_face_selector_resolves_on_multi_body_via_prefix_matching(self) -> None:
|
||||
"""多体 + selector resolve 契约:face selector 经前缀匹配命中正确实体。
|
||||
|
||||
这是 resolve 前缀匹配的哨兵测试:两个不相交 box 合并为 Compound 后,
|
||||
拓扑记录属于 body:add_2:0 / body:add_2:1。若 resolve 仍用精确 body_id
|
||||
过滤(active_body_id=body:add_2 不匹配任何记录),host face 会解析失败;
|
||||
只有前缀匹配才能让 hole 的宿主面命中第一个 box 的顶面。体积 = 2000 − 10π
|
||||
且 box2(x∈[15,25])不受影响,证明解析到的确实是正确实体。
|
||||
"""
|
||||
base = _two_boss_doc()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
# 第一个 box 的顶面:center ≈ (0, 0, 10)。
|
||||
top_face = next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "face"
|
||||
and item["geometry"]["surface_type"] == "plane"
|
||||
and item["geometry"]["normal"][2] > 0.9
|
||||
and abs(item["geometry"]["center_mm"][0]) < 1.0
|
||||
)
|
||||
with_hole = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "multi-body-hole", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s2"},
|
||||
{"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["add_2"],
|
||||
"params": {
|
||||
"hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0,
|
||||
"end_condition": {"type": "blind"},
|
||||
"positions": [{"mm": [0.0, 0.0, 10.0]}],
|
||||
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"]},
|
||||
}},
|
||||
],
|
||||
}
|
||||
rebuilt = rebuild_cdsl(with_hole, root / "hole.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 2000 - math.pi * 10, places=5)
|
||||
self.assertEqual(rebuilt["solid_count"], 2)
|
||||
|
||||
def test_multi_body_collapse_reverts_to_flat_body_id(self) -> None:
|
||||
"""转换护栏:多体塌缩回单体后 body_id 恢复单体制(无 :index 后缀)。
|
||||
|
||||
两个 box 合并为多体后,用大切除把第二个 box 整体切掉 → 主体恢复为
|
||||
单个 Solid → register_body 应回到 body:{feature}(flat),后续
|
||||
selector 行为与普通单体零件完全一致。
|
||||
"""
|
||||
base = _two_boss_doc()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with_cut = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "multi-body-collapse", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "s2", "workplane": _workplane(origin=[20, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5])},
|
||||
{"id": "cut", "workplane": _workplane(origin=[14.5, 0, 0]),
|
||||
"profile": _rectangle([0, -5.5], [11, 5.5])},
|
||||
]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "add_2", "atomic_id": "extrude_add_blind", "depends_on": ["add_1"],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s2"},
|
||||
{"id": "cut_2", "atomic_id": "extrude_cut_blind", "depends_on": ["add_2"],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "cut"},
|
||||
],
|
||||
}
|
||||
rebuilt = rebuild_cdsl(with_cut, root / "collapse.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000.0, places=5)
|
||||
self.assertEqual(rebuilt["solid_count"], 1)
|
||||
# 塌缩回单体:最后主体(body:cut_2)的面全部属于 flat 的 body_id
|
||||
# (无 :index 成员后缀)——而不是像多体时那样带 body:cut_2:0/:1。
|
||||
# (registry 会保留历史快照供语义继承,因此只断言最后主体的记录。)
|
||||
cut_faces = {
|
||||
r["body_id"] for r in rebuilt["topology_records"]
|
||||
if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_2")
|
||||
}
|
||||
self.assertEqual(cut_faces, {"body:cut_2"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,310 @@
|
||||
"""#6 Pattern selector 变换:固定终止面/宿主面的 pattern source 必须放行。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #6「Pattern 严禁 source selector/host selector/
|
||||
extent selector」的回归测试):
|
||||
|
||||
1. 背景:pattern_transform_blocker 原本只放行"带显式 host frame"的孔
|
||||
与"带显式 axis 坐标"的旋转轴。真实语料里绝大多数 pattern source
|
||||
的依赖是**主体上的固定面**,而非随实例移动的几何:
|
||||
- hole_wizard 的宿主面是 face selector(带几何快照、无 frame),
|
||||
孔位置由 positions 平移决定(_translated_node),宿主面本身
|
||||
resolve 原面即可正确打孔;
|
||||
- up_to_surface 拉伸的终止面是 face reference,终止面不随实例
|
||||
平移(CAD 阵列语义:每个实例拉伸到同一终止面),且 #5 修复后
|
||||
非均匀相交可裁剪。
|
||||
修复前这些 source 一律报 unsupported_pattern_selector_transform,
|
||||
669 个 pattern 文档里 121 个 source 被整体拒绝。
|
||||
修复后:face selector(孔宿主面)与 face reference(终止面)放行,
|
||||
由运行时 resolve 原面执行;edge/vertex selector(需逐实例变换但
|
||||
无法平移)与 mirror-as-source(镜像面需逐实例平移,架构不支持)
|
||||
仍保持显式阻塞,避免产出错误几何。
|
||||
|
||||
2. 本测试套件把"固定面依赖放行、逐实例拓扑依赖仍阻塞"固定下来:
|
||||
- analyze 契约:无 frame 孔(face selector)→ executable;
|
||||
- analyze 契约:up_to_surface 拉伸(face reference)→ executable;
|
||||
- 回归护栏:mirror-as-source(plane selector)→ 仍阻塞
|
||||
(unsupported_pattern_selector_transform 保留);
|
||||
- 几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱;
|
||||
- 几何契约:up_to_surface 拉伸 pattern → 源块 + 源拉伸 + 实例
|
||||
拉伸(实例部分悬空被 #5 裁剪)。
|
||||
|
||||
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import cdsl_engine
|
||||
包做端到端测试(与既有测试风格一致)。
|
||||
|
||||
函数功能一览
|
||||
------------
|
||||
_workplane(origin, normal) 构造指定原点与法向的草图工作平面。
|
||||
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||
_base_block() 构造 10×10×10 拉伸主体(体积 1000,
|
||||
顶面 z=10、范围 x/y ∈ [-5, 5])。
|
||||
_top_face_selector(baseline) 从 baseline 拓扑记录里挑出顶面(法向 +z)
|
||||
的几何快照,作为宿主面 / 终止面 reference。
|
||||
PatternTransformContractTests 见各测试方法 docstring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
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"))
|
||||
|
||||
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _workplane(*, origin: list[float], normal: list[float]) -> dict:
|
||||
"""构造草图工作平面:显式指定原点与法向(x_dir 固定 +X)。"""
|
||||
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": normal}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
"""XY 平面内的矩形轮廓(2D 多边形),顶点逆时针。"""
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
def _base_block() -> dict:
|
||||
"""10×10×10 拉伸主体:体积 1000,顶面位于 z=10、范围 x/y ∈ [-5, 5]。"""
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "pattern-transform-contract", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "base", "workplane": _workplane(origin=[0, 0, 0], normal=[0, 0, 1]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "base",
|
||||
}],
|
||||
}
|
||||
|
||||
|
||||
def _top_face_selector(baseline: dict) -> dict:
|
||||
"""从 baseline 拓扑记录里取顶面(法向 +z 的平面 face)的几何快照。
|
||||
|
||||
这个快照被 TopologyRegistry 用来做几何等价匹配,从而把宿主面 / 终止面
|
||||
reference 解析到重建主体上的真实 Face。
|
||||
"""
|
||||
return next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "face"
|
||||
and item["geometry"]["surface_type"] == "plane"
|
||||
and item["geometry"]["normal"][2] > 0.9
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试套件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PatternTransformContractTests(unittest.TestCase):
|
||||
"""pattern source 固定面依赖「能力边界-几何-护栏」三方合同测试。"""
|
||||
|
||||
def test_pattern_replays_face_selector_hole_is_eligible(self) -> None:
|
||||
"""analyze 契约:无 frame 孔(face selector 宿主面)→ executable。
|
||||
|
||||
宿主面是主体上的固定面:实例孔位置由 positions 平移决定,宿主面
|
||||
resolve 原面即可。修复前 capabilities 把它当成"无法变换的 feature
|
||||
selector"报 unsupported_pattern_selector_transform → 整个零件
|
||||
runtime_eligible=False → 本测试红灯。
|
||||
"""
|
||||
base = _base_block()
|
||||
base["features"].extend([
|
||||
{
|
||||
"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
|
||||
"params": {
|
||||
"hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0,
|
||||
"end_condition": {"type": "blind"},
|
||||
"positions": [{"mm": [0.0, 0.0, 10.0]}],
|
||||
"host_face": {
|
||||
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": {"bbox_mm": [-5, -5, 10, 5, 5, 10],
|
||||
"center_mm": [0, 0, 10],
|
||||
"normal": [0, 0, 1],
|
||||
"surface_type": "plane"},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"],
|
||||
"params": {"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0],
|
||||
"spacing_1_mm": 4, "pattern_count_1": 2},
|
||||
},
|
||||
])
|
||||
analysis = analyze_cdsl(base)
|
||||
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
|
||||
self.assertTrue(pattern.executable)
|
||||
self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
|
||||
|
||||
def test_pattern_replays_up_to_surface_extrusion_is_eligible(self) -> None:
|
||||
"""analyze 契约:up_to_surface 拉伸(face reference 终止面)→ executable。
|
||||
|
||||
终止面是主体上的固定面,不随实例平移(CAD 阵列语义);#5 修复后
|
||||
非均匀相交走裁剪。修复前 reference 一律返回 extent target selector
|
||||
阻塞。本测试锁死该依赖被放行。
|
||||
"""
|
||||
base = _base_block()
|
||||
base["geometry"]["sketches"].append({
|
||||
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
|
||||
"profile": _rectangle([-3, -3], [3, 3]),
|
||||
})
|
||||
base["features"].extend([
|
||||
{
|
||||
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||
"sketch_id": "cap",
|
||||
"params": {
|
||||
"distance_mm": 0,
|
||||
"end_condition": {"type": "up_to_surface", "reference": {
|
||||
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": {"bbox_mm": [-5, -5, 10, 5, 5, 10],
|
||||
"center_mm": [0, 0, 10],
|
||||
"normal": [0, 0, 1],
|
||||
"surface_type": "plane"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cap_add"],
|
||||
"params": {"source_feature_ids": ["cap_add"], "direction_1": [1, 0, 0],
|
||||
"spacing_1_mm": 6, "pattern_count_1": 2},
|
||||
},
|
||||
])
|
||||
analysis = analyze_cdsl(base)
|
||||
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
|
||||
self.assertTrue(pattern.executable)
|
||||
self.assertNotIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
|
||||
|
||||
def test_mirror_source_stays_blocked(self) -> None:
|
||||
"""回归护栏:mirror-as-source(plane selector)仍阻塞。
|
||||
|
||||
pattern_mirror 的镜像面是 reference_plane 引用;线性阵列重放镜像
|
||||
特征需要把镜像面逐实例平移,当前执行器没有实例变换通道,放行会
|
||||
产出"所有实例重合"的错误几何。因此 mirror 特征作为 pattern source
|
||||
必须继续保持 unsupported_pattern_selector_transform。
|
||||
"""
|
||||
base = _base_block()
|
||||
base["features"].extend([
|
||||
{
|
||||
"id": "plane_ctx", "atomic_id": "reference_plane", "depends_on": [],
|
||||
"params": {"plane": _workplane(origin=[0, 0, 0], normal=[0, 1, 0])},
|
||||
},
|
||||
{
|
||||
"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["base_add", "plane_ctx"],
|
||||
"params": {"source_feature_ids": ["base_add"], "mirror_plane": {
|
||||
"kind": "plane", "stable_id": "plane-1", "source": "solidworks",
|
||||
"confidence": 1, "owner_feature_id": "plane_ctx",
|
||||
}},
|
||||
"selectors": [{
|
||||
"kind": "plane", "stable_id": "plane-1", "source": "solidworks",
|
||||
"confidence": 1, "owner_feature_id": "plane_ctx",
|
||||
}],
|
||||
},
|
||||
{
|
||||
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["mirror"],
|
||||
"params": {"source_feature_ids": ["mirror"], "direction_1": [1, 0, 0],
|
||||
"spacing_1_mm": 10, "pattern_count_1": 2},
|
||||
},
|
||||
])
|
||||
analysis = analyze_cdsl(base)
|
||||
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
|
||||
self.assertFalse(pattern.executable)
|
||||
self.assertIn("unsupported_pattern_selector_transform", [blocker.code for blocker in pattern.blockers])
|
||||
|
||||
def test_pattern_replays_face_selector_hole_geometry(self) -> None:
|
||||
"""几何契约:无 frame 孔 pattern → 源孔 + 实例孔各切一个圆柱。
|
||||
|
||||
baseline 10×10×10(顶面 z=10)。hole_wizard 宿主面是 face selector
|
||||
(无 frame),positions=[(0,0,10)] 直径 2 深 10(贯穿)。pattern 沿
|
||||
+x 间距 4 → 实例孔在 (4,0,10),与源孔不重叠。期望体积 =
|
||||
1000 − 2×π×1²×10。修复前 capabilities 拒绝整个零件,本测试红灯。
|
||||
"""
|
||||
base = _base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = _top_face_selector(baseline)
|
||||
|
||||
with_holes = deepcopy(base)
|
||||
with_holes["features"].extend([
|
||||
{
|
||||
"id": "hole_1", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
|
||||
"params": {
|
||||
"hole_type": "简单直孔", "diameter_mm": 2.0, "depth_mm": 10.0,
|
||||
"end_condition": {"type": "blind"},
|
||||
"positions": [{"mm": [0.0, 0.0, 10.0]}],
|
||||
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"]},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"],
|
||||
"params": {"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0],
|
||||
"spacing_1_mm": 4, "pattern_count_1": 2},
|
||||
},
|
||||
])
|
||||
rebuilt = rebuild_cdsl(with_holes, root / "patterned-holes.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 - 2 * math.pi * 10, places=5)
|
||||
|
||||
def test_pattern_replays_up_to_surface_extrusion_geometry(self) -> None:
|
||||
"""几何契约:up_to_surface 拉伸 pattern → 源 + 实例(实例悬空被裁剪)。
|
||||
|
||||
baseline 10×10×10(顶面 z=10,范围 [-5,5]²)。源 cap:z=12 平面、
|
||||
法向 -z,profile 6×6(完全在顶面内)→ 均匀拉伸到顶面,体积 6²×2=72。
|
||||
pattern 沿 +x 间距 6 → 实例 profile 落在 x ∈ [3,9],与顶面交集为
|
||||
x ∈ [3,5]、y ∈ [-3,3] → 部分悬空 → #5 裁剪,体积 2×6×2=24。
|
||||
期望总体积 = 1000 + 72 + 24 = 1096。
|
||||
"""
|
||||
base = _base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
top_face = _top_face_selector(baseline)
|
||||
|
||||
with_cap = deepcopy(base)
|
||||
with_cap["geometry"]["sketches"].append({
|
||||
"id": "cap", "workplane": _workplane(origin=[0, 0, 12], normal=[0, 0, -1]),
|
||||
"profile": _rectangle([-3, -3], [3, 3]),
|
||||
})
|
||||
with_cap["features"].extend([
|
||||
{
|
||||
"id": "cap_add", "atomic_id": "extrude_add_blind", "depends_on": ["base_add"],
|
||||
"sketch_id": "cap",
|
||||
"params": {
|
||||
"distance_mm": 0,
|
||||
"end_condition": {"type": "up_to_surface", "reference": {
|
||||
"kind": "face", "stable_id": "top", "source": "inferred_from_step",
|
||||
"confidence": 1, "geometry": top_face["geometry"],
|
||||
}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cap_add"],
|
||||
"params": {"source_feature_ids": ["cap_add"], "direction_1": [1, 0, 0],
|
||||
"spacing_1_mm": 6, "pattern_count_1": 2},
|
||||
},
|
||||
])
|
||||
rebuilt = rebuild_cdsl(with_cap, root / "patterned-caps.step")
|
||||
|
||||
self.assertAlmostEqual(rebuilt["volume_mm3"], 1000 + 72 + 24, places=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,119 @@
|
||||
"""#1 y_dir 静默丢弃:PlaneSpec.from_mapping 与 sketch_solver 必须尊重输入的 y_dir。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #1「y_dir 静默丢弃」的回归测试):
|
||||
|
||||
1. 背景:SolidWorks 导出草图常带冗余或非正交的 y_dir。
|
||||
runtime 的 PlaneSpec.from_mapping(runtime_types.py:172-180)与
|
||||
sketch_solver._to_3d(sketch_solver.py:43-52)都**无视输入的
|
||||
y_dir 字段**,永远用 normal × x_dir 叉积补全。后果:
|
||||
a) 文档作者显式给出的**正交** y_dir 被悄悄替换成叉积结果
|
||||
(当 x_dir 与 normal 不正交时,重建的 y_dir 与真实几何不符);
|
||||
b) **偏斜** y_dir 被静默丢弃,没有任何提示,用户不知道
|
||||
坐标系被正交化过了。
|
||||
|
||||
2. 本测试把 y_dir 处理契约固定下来:
|
||||
- 输入 y_dir 存在且与 x_dir / normal 正交 → **保留**输入值;
|
||||
- 输入 y_dir 存在但偏斜(非正交)→ 正交化 + **显式 UserWarning**;
|
||||
- 输入 y_dir 缺失 → 用 normal × x_dir 补全(回归护栏);
|
||||
- sketch_solver 的轮廓点变换(_transform_contours → _to_3d)
|
||||
同样尊重正交的输入 y_dir。
|
||||
|
||||
3. sys.path 说明:把 backend/engine 加入搜索路径,直接 import
|
||||
cdsl_engine 包内模块(与前面几个回归测试风格一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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"))
|
||||
|
||||
from cdsl_engine.runtime_types import PlaneSpec # noqa: E402
|
||||
from cdsl_engine.sketch_solver import _transform_contours # noqa: E402
|
||||
|
||||
|
||||
def _assert_vector_close(testcase: unittest.TestCase, actual: tuple | list, expected: tuple | list, *, tol: float = 1e-6) -> None:
|
||||
testcase.assertEqual(len(actual), len(expected))
|
||||
for left, right in zip(actual, expected):
|
||||
testcase.assertAlmostEqual(float(left), float(right), delta=tol)
|
||||
|
||||
|
||||
class PlaneYDirPreservationTests(unittest.TestCase):
|
||||
"""y_dir 处理契约:正交保留 / 偏斜警告 / 缺失补全。"""
|
||||
|
||||
def test_orthogonal_input_y_dir_is_preserved(self) -> None:
|
||||
"""主契约:输入的正交 y_dir 必须被保留,而不是被叉积结果覆盖。
|
||||
|
||||
构造一个 x_dir 与 normal 不垂直的 frame(这是 SolidWorks 导出里
|
||||
最常见的情形):x_dir=(1,0,0)、normal=(0,1,0)。此时
|
||||
normal × x_dir = (0,0,-1),但文档显式给出 y_dir=(0,0,1)。
|
||||
修复前:PlaneSpec.y_dir == (0,0,-1)(静默丢弃,红灯)。
|
||||
修复后:PlaneSpec.y_dir == (0,0,1)(保留输入,绿灯)。
|
||||
"""
|
||||
plane = PlaneSpec.from_mapping({
|
||||
"origin_mm": [0.0, 0.0, 0.0],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 0.0, 1.0],
|
||||
"normal": [0.0, 1.0, 0.0],
|
||||
})
|
||||
_assert_vector_close(self, plane.y_dir, (0.0, 0.0, 1.0))
|
||||
|
||||
def test_skewed_input_y_dir_orthogonalizes_and_warns(self) -> None:
|
||||
"""主契约:偏斜 y_dir 必须被正交化,并且**显式**发出 UserWarning。
|
||||
|
||||
输入 y_dir=(0, 1, 1) 与 normal=(0,0,1) 的夹角不是 90°,无法直接
|
||||
作为右手系 y 轴。修复前:静默替换为 normal × x_dir(无警告,红灯)。
|
||||
修复后:正交化为 normal × x_dir,同时 raise UserWarning(绿灯)。
|
||||
"""
|
||||
with self.assertWarns(UserWarning):
|
||||
plane = PlaneSpec.from_mapping({
|
||||
"origin_mm": [0.0, 0.0, 0.0],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 1.0, 1.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
})
|
||||
# normal × x_dir = (0,0,1) × (1,0,0) = (0,1,0),且为右手系补全
|
||||
_assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0))
|
||||
|
||||
def test_missing_y_dir_gets_orthonormal_default(self) -> None:
|
||||
"""回归护栏:y_dir 字段缺失时,仍用 normal × x_dir 补全。
|
||||
|
||||
修复前后都应通过,这条测试防止我们把默认补全路径改坏。
|
||||
"""
|
||||
plane = PlaneSpec.from_mapping({
|
||||
"origin_mm": [0.0, 0.0, 0.0],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"normal": [0.0, 0.0, 1.0],
|
||||
})
|
||||
_assert_vector_close(self, plane.y_dir, (0.0, 1.0, 0.0))
|
||||
|
||||
def test_contour_transform_respects_input_y_dir(self) -> None:
|
||||
"""端到端:sketch_solver 的轮廓点变换必须尊重正交的输入 y_dir。
|
||||
|
||||
workplane 与 test_orthogonal_input_y_dir_is_preserved 相同
|
||||
(x=(1,0,0), n=(0,1,0), 输入 y=(0,0,1))。一条从 (0,0) 到 (0,1)
|
||||
的轮廓直线,修复前 _to_3d 用默认 y_dir=(0,0,-1) 变换,终点落到
|
||||
(0,0,-1);修复后用输入 y_dir=(0,0,1),终点落到 (0,0,1)。
|
||||
"""
|
||||
workplane = {
|
||||
"origin_mm": [0.0, 0.0, 0.0],
|
||||
"x_dir": [1.0, 0.0, 0.0],
|
||||
"y_dir": [0.0, 0.0, 1.0],
|
||||
"normal": [0.0, 1.0, 0.0],
|
||||
}
|
||||
contours = _transform_contours([
|
||||
{"type": "line", "start_mm": [0.0, 0.0], "end_mm": [0.0, 1.0]},
|
||||
], workplane)
|
||||
_assert_vector_close(self, contours[0]["start_mm"], (0.0, 0.0, 0.0))
|
||||
_assert_vector_close(self, contours[0]["end_mm"], (0.0, 0.0, 1.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
@@ -658,6 +658,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertLess(chamfer_result["volume_mm3"], baseline["volume_mm3"])
|
||||
self.assertAlmostEqual(pattern_result["volume_mm3"], 1000 - 3 * 10 * 3.141592653589793, places=5)
|
||||
|
||||
def test_chamfer_consumes_angle_rad_instead_of_silent_45_degree_fallback(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
import math
|
||||
|
||||
base = self._base_block()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(base, root / "baseline.step")
|
||||
edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge")
|
||||
selector = {
|
||||
"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1,
|
||||
"owner_feature_id": "base_add", "geometry": edge["geometry"],
|
||||
}
|
||||
|
||||
equal = deepcopy(base)
|
||||
equal["features"].append({
|
||||
"id": "chamfer_45", "atomic_id": "chamfer", "depends_on": ["base_add"],
|
||||
"params": {"distance_mm": 1, "angle_rad": math.pi / 4}, "selectors": [deepcopy(selector)],
|
||||
})
|
||||
equal_result = rebuild_cdsl(equal, root / "chamfer-45.step")
|
||||
# 45° Distance-Angle 等价于等距倒角:tan(45°)=1,切掉 0.5*1*1*10=5 mm³。
|
||||
self.assertAlmostEqual(equal_result["volume_mm3"], 1000 - 5, places=5)
|
||||
|
||||
slanted = deepcopy(base)
|
||||
slanted["features"].append({
|
||||
"id": "chamfer_30", "atomic_id": "chamfer", "depends_on": ["base_add"],
|
||||
"params": {"distance_mm": 1, "angle_rad": math.pi / 6}, "selectors": [deepcopy(selector)],
|
||||
})
|
||||
slanted_result = rebuild_cdsl(slanted, root / "chamfer-30.step")
|
||||
# 30°:第二距离 = 1*tan(30°)≈0.577,切掉 0.5*1*0.577*10≈2.887 mm³,
|
||||
# 体积明显大于 45° 等距倒角(995),验证 angle_rad 被消费而非静默 45°。
|
||||
self.assertAlmostEqual(slanted_result["volume_mm3"], 1000 - 0.5 * math.tan(math.pi / 6) * 10, places=5)
|
||||
|
||||
def test_linear_pattern_replays_hole_with_explicit_host_frame(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""#8 selector 持久性:上游 fillet 消费边之后的 selector 解析回归测试。
|
||||
|
||||
中文说明
|
||||
--------
|
||||
这个文件在测试什么(issue #8「Selector 持久性」的回归测试):
|
||||
|
||||
1. 背景:selector 的 stable_id 与几何签名来自特征执行前的 B-rep。上游
|
||||
fillet/chamfer 会重建被选中边附近的拓扑:
|
||||
- 被 fillet 直接选中的边会被双端圆角、拆分成"两条等距直段 + 两段
|
||||
圆弧",几何上不存在唯一"同一条边"的后继(本质歧义,CAD 中该边
|
||||
也被视为已消费);
|
||||
- 与被圆角边共享端点的相邻边则只被"单端缩短",方向不变、另一端
|
||||
端点重合——它有一个明确的演化后继。
|
||||
修复前:相邻边的 selector 在 fillet 之后 resolve 失败(not_found),
|
||||
因为记录已迁移到新 body 且几何签名变了。
|
||||
修复后(#8):registry 记录"位置轨迹延续"的演化后继映射
|
||||
(old_record_id -> 漂移显著最小的唯一后继),resolve 的 stable_id
|
||||
精确匹配在 active body 过滤之前执行,命中过期记录时经演化后继解析
|
||||
到 active body 内的新形态。
|
||||
|
||||
2. 本测试套件把"被波及边可解析、被消费边保持保守"固定下来:
|
||||
- 核心契约:fillet 圆角竖直边 A 后,引用相邻底面边 B 的 selector
|
||||
仍能 resolve,B 上的二次 fillet 重建成功、体积减少;
|
||||
- 护栏:引用被完整消费的边 A 的 selector 保持 not_found(不把相邻
|
||||
直段误匹配为 A 的延续,确定性优先);
|
||||
- 护栏:引用未被 fillet 波及的边的 selector 行为不变。
|
||||
|
||||
3. sys.path 说明:把 backend 与 backend/engine 加入搜索路径,直接 import
|
||||
cdsl_engine 包做端到端测试(与既有测试风格一致)。
|
||||
|
||||
函数功能一览
|
||||
------------
|
||||
_workplane(origin) 构造指定原点的 XY 平面草图工作平面。
|
||||
_rectangle(minimum, maximum) 构造 XY 平面内的矩形轮廓(2D 多边形)。
|
||||
_boss_doc() 10×10×10 单体拉伸文档(体积 1000)。
|
||||
_baseline_edges(...) 从基线重建里挑出 A/B/C 三条边并构造 selector。
|
||||
SelectorPersistenceTests 见各测试方法 docstring。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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"))
|
||||
|
||||
from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _workplane(*, origin: list[float]) -> dict:
|
||||
return {"origin_mm": origin, "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
|
||||
|
||||
|
||||
def _rectangle(minimum: list[float], maximum: list[float]) -> dict:
|
||||
return {"type": "polygon", "vertices": [
|
||||
[minimum[0], minimum[1]], [maximum[0], minimum[1]],
|
||||
[maximum[0], maximum[1]], [minimum[0], maximum[1]],
|
||||
]}
|
||||
|
||||
|
||||
def _boss_doc() -> dict:
|
||||
return {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "selector-persist", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _baseline_edges():
|
||||
"""从基线重建里挑出测试用的三条边。
|
||||
|
||||
A = 竖直边 [-5,-5,0]→[-5,-5,10](被 fillet 直接选中,将被完整消费);
|
||||
B = 底面边 [-5,-5,0]→[5,-5,0](与 A 共享端点 [-5,-5,0],被单端缩短);
|
||||
C = 竖直边 [5,5,0]→[5,5,10](远离 A/B,完全不受 fillet 影响)。
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
baseline = rebuild_cdsl(_boss_doc(), Path(directory) / "base.step")
|
||||
|
||||
def _edge(match) -> dict:
|
||||
return next(
|
||||
item for item in baseline["topology_records"]
|
||||
if item["kind"] == "edge" and match(item["geometry"])
|
||||
)
|
||||
|
||||
def _selector(record: dict) -> dict:
|
||||
return {"kind": "edge", "stable_id": record["record_id"],
|
||||
"source": "inferred_from_step", "confidence": 1,
|
||||
"geometry": record["geometry"]}
|
||||
|
||||
edge_a = _edge(lambda g: g.get("curve_type") == "line"
|
||||
and g.get("start_mm") == [-5.0, -5.0, 0.0]
|
||||
and g.get("end_mm") == [-5.0, -5.0, 10.0])
|
||||
edge_b = _edge(lambda g: g.get("curve_type") == "line"
|
||||
and g.get("start_mm") == [-5.0, -5.0, 0.0]
|
||||
and g.get("end_mm") == [5.0, -5.0, 0.0])
|
||||
edge_c = _edge(lambda g: g.get("curve_type") == "line"
|
||||
and g.get("start_mm") == [5.0, 5.0, 0.0]
|
||||
and g.get("end_mm") == [5.0, 5.0, 10.0])
|
||||
return _selector(edge_a), _selector(edge_b), _selector(edge_c)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试套件
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SelectorPersistenceTests(unittest.TestCase):
|
||||
"""#8 selector 持久性契约:被波及边可解析、被消费边保持保守。"""
|
||||
|
||||
def test_consumed_adjacent_edge_selector_resolves_after_fillet(self) -> None:
|
||||
"""核心契约:fillet 圆角 A 后,引用相邻边 B 的 selector 仍可 resolve。
|
||||
|
||||
fillet_1 圆角竖直边 A(radius 2),把与其共享端点 [-5,-5,0] 的底面
|
||||
边 B 单端缩短为 [-3,-5,0]→[5,-5,0]。fillet_2 用修复前捕获的 B
|
||||
selector 再圆角一次(radius 1):
|
||||
- 修复前:B 记录已迁移到 body:fillet_1 且几何签名变化 → not_found,
|
||||
重建抛 RuntimeExecutionError;
|
||||
- 修复后:演化后继解析到 B 的缩短形态 → 重建成功且体积减少。
|
||||
"""
|
||||
selector_a, selector_b, _ = _baseline_edges()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
baseline = rebuild_cdsl(_boss_doc(), root / "base.step")
|
||||
with_fillets = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "selector-persist", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"],
|
||||
"params": {"radius_mm": 2}, "selectors": [selector_a]},
|
||||
{"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"],
|
||||
"params": {"radius_mm": 1}, "selectors": [selector_b]},
|
||||
],
|
||||
}
|
||||
rebuilt = rebuild_cdsl(with_fillets, root / "two-fillets.step")
|
||||
self.assertLess(rebuilt["volume_mm3"], baseline["volume_mm3"])
|
||||
self.assertGreater(rebuilt["volume_mm3"], 900.0)
|
||||
|
||||
def test_fully_consumed_edge_selector_stays_conservative(self) -> None:
|
||||
"""护栏:被完整消费的边 A 的 selector 保持 not_found(确定性优先)。
|
||||
|
||||
fillet_1 圆角 A 后,A 被拆分成两条等距直段 + 两段圆弧,几何上没有
|
||||
唯一后继(两条直段到原边的漂移并列)。此场景下 registry 不登记演化
|
||||
映射,二次引用 A 应明确失败,而不是把相邻直段误匹配成 A 的延续。
|
||||
"""
|
||||
selector_a, _, _ = _baseline_edges()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with_second = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "selector-persist", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"],
|
||||
"params": {"radius_mm": 2}, "selectors": [selector_a]},
|
||||
{"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"],
|
||||
"params": {"radius_mm": 1}, "selectors": [selector_a]},
|
||||
],
|
||||
}
|
||||
with self.assertRaises(Exception) as caught:
|
||||
rebuild_cdsl(with_second, root / "second.step")
|
||||
message = str(caught.exception)
|
||||
self.assertIn("No runtime topology record satisfies the selector", message)
|
||||
|
||||
def test_unaffected_edge_selector_resolves_after_fillet(self) -> None:
|
||||
"""护栏:远离 fillet 的边 C 的 selector 行为不受影响。
|
||||
|
||||
fillet_1 圆角 A 后,引用 C([5,5,0]→[5,5,10],几何完全不变)的
|
||||
selector 通过几何打分照常 resolve,二次 fillet 重建成功。
|
||||
"""
|
||||
selector_a, _, selector_c = _baseline_edges()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
with_fillets = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
|
||||
"part_id": "selector-persist", "meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "s1", "workplane": _workplane(origin=[0, 0, 0]),
|
||||
"profile": _rectangle([-5, -5], [5, 5]),
|
||||
}]},
|
||||
"features": [
|
||||
{"id": "add_1", "atomic_id": "extrude_add_blind", "depends_on": [],
|
||||
"params": {"distance_mm": 10}, "sketch_id": "s1"},
|
||||
{"id": "fillet_1", "atomic_id": "fillet", "depends_on": ["add_1"],
|
||||
"params": {"radius_mm": 2}, "selectors": [selector_a]},
|
||||
{"id": "fillet_2", "atomic_id": "fillet", "depends_on": ["fillet_1"],
|
||||
"params": {"radius_mm": 1}, "selectors": [selector_c]},
|
||||
],
|
||||
}
|
||||
rebuilt = rebuild_cdsl(with_fillets, root / "with-c.step")
|
||||
self.assertLess(rebuilt["volume_mm3"], 1000.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user