Files
cdsl-cad/backend/tests/test_engine_multi_body_contract.py
T

314 lines
16 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.
"""#7 ExecutionSession 单 active body → 多实体 body_id 回归测试。
中文说明
--------
这个文件在测试什么(issue #7「ExecutionSession 单 active body」的回归测试):
1. 背景:ExecutionSession 只跟踪一个 active bodysession.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=1body_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)。在第一个 boxx∈[-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"})
cut_delta = next(item for item in rebuilt["topology_deltas"] if item["feature_id"] == "cut_1")
self.assertEqual(cut_delta["operation"], "subtract")
self.assertEqual(cut_delta["history_status"], "proven")
self.assertEqual(cut_delta["history_reason"], "per_member_exact_cut_history")
self.assertEqual(
{record_id.split(":face:", 1)[0] for relation in cut_delta["relations"]
for record_id in relation["source_record_ids"] if ":face:" in record_id},
{"body:add_2:0", "body:add_2: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", "solidworks_code": 0},
"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()