Files
cdsl-cad/backend/tests/test_engine_selector_persistence.py
T
ganjihong 9e5aa48c3d feat(engine): 支持多实体 body 与 selector 持久化
- 多实体主体拆分为独立 body_id,支持前缀匹配解析
- 重建结果新增 solid_count 字段
- fillet/chamfer 后 selector stable_id 可解析到演化后继
2026-08-27 18:08:54 +08:00

222 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""#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
仍能 resolveB 上的二次 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 圆角竖直边 Aradius 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()