305 lines
15 KiB
Python
305 lines
15 KiB
Python
"""#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", "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()
|