feat(engine): 支持多实体 body 与 selector 持久化

- 多实体主体拆分为独立 body_id,支持前缀匹配解析
- 重建结果新增 solid_count 字段
- fillet/chamfer 后 selector stable_id 可解析到演化后继
This commit is contained in:
2026-08-27 18:08:54 +08:00
parent e5c71d07fa
commit 9e5aa48c3d
5 changed files with 716 additions and 25 deletions
@@ -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
仍能 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()