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
@@ -385,6 +385,16 @@ class Build123dGeometryAdapter:
# 将主体导出为 STEP 文件。 # 将主体导出为 STEP 文件。
export_step(body, path) export_step(body, path)
@staticmethod
def body_solids(body: Any) -> list[Any]:
# 提取主体内的全部独立 Solid:Compound 返回成员,单个 Solid 返回自身。
# build123d 对部分退化布尔结果可能抛异常,退化为把主体整体视为一个实体。
try:
solids = list(body.solids())
except Exception:
return [body] if body is not None else []
return solids or ([body] if body is not None else [])
@staticmethod @staticmethod
def body_geometry(body: Any) -> dict[str, Any]: def body_geometry(body: Any) -> dict[str, Any]:
# 汇总主体基本几何信息:包围盒与体积。 # 汇总主体基本几何信息:包围盒与体积。
+19 -1
View File
@@ -74,6 +74,7 @@ class GeometryAdapter(Protocol):
""" """
def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ... def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ... def body_geometry(self, body: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ... def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ... def extrude(self, face: Any, direction: Vector3) -> Any: ...
@@ -108,9 +109,23 @@ class ExecutionSession:
active_feature_id: str = "" active_feature_id: str = ""
def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None: def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
self.body = body self.body = body
self.body_id = f"body:{feature_id}" self.body_id = f"body:{feature_id}"
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id)) solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
else:
for index, solid in enumerate(solids):
member_id = f"{self.body_id}:{index}"
self.topology.replace_body_topology(
feature_id, member_id,
self.adapter.topology_records(solid, feature_id, member_id),
active_body_id=self.body_id,
)
self.topology.register(TopologyRecord( self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id, record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,), geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
@@ -967,6 +982,9 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"out_step": str(out_step), "out_step": str(out_step),
"volume_mm3": float(geometry["volume_mm3"]), "volume_mm3": float(geometry["volume_mm3"]),
"bbox_mm": {"min": bbox[:3], "max": bbox[3:]}, "bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
# #7 multi-body:重建结果里的独立实体数(Compound 成员数),
# 与 batch 验证的 document_truth.geometry.solid_body_count 对齐。
"solid_count": len(session.adapter.body_solids(session.body)),
"feature_results": [result.as_dict() for result in session.results.values()], "feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics], "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()], "topology_records": [record.public_dict() for record in session.topology.records()],
+162 -24
View File
@@ -423,6 +423,11 @@ class TopologyRegistry:
self._records: list[TopologyRecord] = [] self._records: list[TopologyRecord] = []
self._by_feature: dict[str, list[TopologyRecord]] = {} self._by_feature: dict[str, list[TopologyRecord]] = {}
self._active_body_id: str | None = None self._active_body_id: str | None = None
# #8 selector 持久性:old_record_id -> [new_record_id]。fillet/chamfer
# 会把一条直线边拆分为若干段(中间直段 + 两端圆弧),旧边不再与任何
# 新边几何等价;这里记录"位置轨迹延续"的直段后继,使后续 selector 的
# stable_id 引用可以解析到 active body 内的新形态。
self._successors: dict[str, list[str]] = {}
def register(self, record: TopologyRecord) -> None: def register(self, record: TopologyRecord) -> None:
self._records.append(record) self._records.append(record)
@@ -446,7 +451,10 @@ class TopologyRegistry:
self.register(record) self.register(record)
return record return record
def replace_body_topology(self, feature_id: str, body_id: str, records: Iterable[TopologyRecord]) -> None: def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None,
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body. """Record a fresh B-rep snapshot after a feature mutates the body.
OCC topology object identity is invalidated by most body mutations. OCC topology object identity is invalidated by most body mutations.
@@ -455,11 +463,21 @@ class TopologyRegistry:
object has one geometrically equivalent predecessor. A changed or object has one geometrically equivalent predecessor. A changed or
split object intentionally becomes owned by this feature instead of split object intentionally becomes owned by this feature instead of
being guessed as belonging to an older one. being guessed as belonging to an older one.
``active_body_id`` names the whole-body group when ``body_id`` is a
member of a multi-solid body (issue #7): the group id keeps the next
mutation's predecessor lookup scoped to every solid of the previous
body, while each member keeps its own ``body:{feature}:{index}`` id.
""" """
previous = [ previous = [
record for record in self._records record for record in self._records
if self._active_body_id is not None and record.body_id == self._active_body_id if self._active_body_id is not None and record.body_id is not None
and (
record.body_id == self._active_body_id
or record.body_id.startswith(f"{self._active_body_id}:")
)
] ]
records = list(records)
consumed_predecessors: set[str] = set() consumed_predecessors: set[str] = set()
for record in records: for record in records:
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors) predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
@@ -477,7 +495,27 @@ class TopologyRegistry:
owner_feature_ids=owners, owner_feature_ids=owners,
) )
) )
self._active_body_id = body_id # #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
for prior in previous:
if prior.record_id in consumed_predecessors:
continue
candidates = sorted(
(
(self._evolved_drift(prior, record), record.record_id)
for record in records if self._evolved_equivalent(prior, record)
),
key=lambda item: item[0],
)
if not candidates:
continue
best, second = candidates[0], (candidates[1] if len(candidates) > 1 else None)
if second is None or (second[0] - best[0]) > max(0.5, 0.2 * best[0]):
self._successors[prior.record_id] = [best[1]]
self._active_body_id = active_body_id or body_id
@staticmethod @staticmethod
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool: def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
@@ -555,6 +593,56 @@ class TopologyRegistry:
] ]
return matches[0] if len(matches) == 1 else None return matches[0] if len(matches) == 1 else None
@staticmethod
def _evolved_drift(prior: TopologyRecord, current: TopologyRecord) -> float | None:
"""Endpoint drift between direction-aligned straight edges.
Returns the minimum total endpoint drift (mm) when the two edges are
collinear straight lines (either orientation), otherwise ``None``.
"""
if prior.kind != current.kind:
return None
left, right = prior.geometry, current.geometry
if left.get("curve_type") != "line" or right.get("curve_type") != "line":
return None
if None in (left.get("start_mm"), left.get("end_mm"), right.get("start_mm"), right.get("end_mm")):
return None
def _delta(a: tuple[float, float, float], b: tuple[float, float, float]) -> tuple[float, float, float]:
return (b[0] - a[0], b[1] - a[1], b[2] - a[2])
def _dist(a: tuple[float, float, float], b: tuple[float, float, float]) -> float:
return sqrt(sum((a[i] - b[i]) ** 2 for i in range(3)))
left_dir = _delta(left["start_mm"], left["end_mm"])
right_dir = _delta(right["start_mm"], right["end_mm"])
if _length(left_dir) <= 1e-9 or _length(right_dir) <= 1e-9:
return None
cross = (
left_dir[1] * right_dir[2] - left_dir[2] * right_dir[1],
left_dir[2] * right_dir[0] - left_dir[0] * right_dir[2],
left_dir[0] * right_dir[1] - left_dir[1] * right_dir[0],
)
if _length(cross) / (_length(left_dir) * _length(right_dir)) > 1e-3:
return None
same_order = _dist(left["start_mm"], right["start_mm"]) + _dist(left["end_mm"], right["end_mm"])
reversed_order = _dist(left["start_mm"], right["end_mm"]) + _dist(left["end_mm"], right["start_mm"])
return min(same_order, reversed_order)
@classmethod
def _evolved_equivalent(cls, prior: TopologyRecord, current: TopologyRecord, *, drift_mm: float = 5.0) -> bool:
"""Loose "position trajectory" equivalence used for evolved successors.
Unlike ``_geometry_equivalent`` (strict, anti-false-positive provenance),
this deliberately tolerates small endpoint drift: fillet/chamfer split a
straight edge into segments (a middle straight run plus end arcs). The
straight run keeps the same direction and stays within ``drift_mm`` of the
original edge, so it can serve as the edge's evolved successor. Uniqueness
is enforced by the caller (only a single best candidate is recorded).
"""
drift = cls._evolved_drift(prior, current)
return drift is not None and drift <= drift_mm
@staticmethod @staticmethod
def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None: def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None:
try: try:
@@ -579,8 +667,14 @@ class TopologyRegistry:
for key in ("surface_type", "curve_type"): for key in ("surface_type", "curve_type"):
if key in selector_geometry: if key in selector_geometry:
if record_geometry.get(key) != selector_geometry[key]: if record_geometry.get(key) != selector_geometry[key]:
return None # #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、
scores.append(1.0) # 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点)
# 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全
# 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的
# 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。
scores.append(0.5)
else:
scores.append(1.0)
if "bbox_mm" in selector_geometry: if "bbox_mm" in selector_geometry:
expected = selector_geometry["bbox_mm"] expected = selector_geometry["bbox_mm"]
actual = record_geometry.get("bbox_mm") actual = record_geometry.get("bbox_mm")
@@ -615,7 +709,14 @@ class TopologyRegistry:
owner = selector.get("owner_feature_id") owner = selector.get("owner_feature_id")
candidates = [record for record in self._records if record.kind == kind] candidates = [record for record in self._records if record.kind == kind]
if active_body_id and kind in {"face", "edge", "vertex", "body"}: if active_body_id and kind in {"face", "edge", "vertex", "body"}:
candidates = [record for record in candidates if record.body_id == active_body_id] # #7 multi-body:记录 body_id 可能是 body:{feature}:{index}(多体
# 成员),用前缀匹配把整个主体的记录纳入候选,同时保证旧 body 的
# 记录(不同 feature 前缀)不会泄漏进来。
candidates = [
record for record in candidates
if record.body_id == active_body_id
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
]
if owner: if owner:
candidates = [record for record in candidates if owner in record.owners] candidates = [record for record in candidates if owner in record.owners]
geometry = normalize_selector_geometry(selector.get("geometry")) geometry = normalize_selector_geometry(selector.get("geometry"))
@@ -632,31 +733,68 @@ class TopologyRegistry:
) )
stable_id = str(selector.get("stable_id") or "").strip() stable_id = str(selector.get("stable_id") or "").strip()
if stable_id: if stable_id:
exact = [record for record in candidates if record.record_id == stable_id] # #8 selector 持久性:stable_id 是跨 body 演化的持久标识符,精确
# 匹配在 active body 过滤之前对整个记录集(kind + owner 过滤)执行。
# 命中已过期(旧 body)的记录时,经演化后继映射解析到 active body
# 内的新形态(fillet/chamfer 拆段后的直段后继);无后继则回落到
# 几何打分流程。
stable_records = [
record for record in self._records
if record.kind == kind and (not owner or owner in record.owners)
]
exact = [record for record in stable_records if record.record_id == stable_id]
if len(exact) == 1: if len(exact) == 1:
record = exact[0] record = exact[0]
# A stable ID is only a lookup accelerator for snapshot-aware is_active = active_body_id is None or (
# selectors. It cannot revive a B-rep entity whose geometric record.body_id == active_body_id
# signature changed after an upstream rebuild. or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
if selector.get("snapshot_id"): )
score = self._geometry_score(geometry, record.geometry) if geometry else None if not is_active:
if score is None or score < minimum_score: successors = [
candidate for candidate in stable_records
if candidate.record_id in self._successors.get(record.record_id, ())
and (
candidate.body_id == active_body_id
or (candidate.body_id is not None and active_body_id and candidate.body_id.startswith(f"{active_body_id}:"))
)
]
if len(successors) == 1:
record = successors[0]
is_active = True
elif len(successors) > 1:
return SelectorResolution( return SelectorResolution(
selector=selector, selector=selector,
status="not_found", status="ambiguous",
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},), candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
diagnostic=RuntimeDiagnostic( diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch", code="selector_ambiguous",
message="The stable selector record no longer matches its geometry signature", message="More than one evolved successor record satisfies the stable_id",
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score}, detail={"stable_id": stable_id, "candidate_count": len(successors)},
), ),
) )
return SelectorResolution( if is_active:
selector=selector, # A stable ID is only a lookup accelerator for snapshot-aware
status="resolved", # selectors. It cannot revive a B-rep entity whose geometric
record=record, # signature changed after an upstream rebuild.
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},), if selector.get("snapshot_id"):
) score = self._geometry_score(geometry, record.geometry) if geometry else None
if score is None or score < minimum_score:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="The stable selector record no longer matches its geometry signature",
detail={"stable_id": stable_id, "score": score, "minimum_score": minimum_score},
),
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": round(float(score), 6) if selector.get("snapshot_id") else 1.0, **record.public_dict()},),
)
if len(exact) > 1: if len(exact) > 1:
return SelectorResolution( return SelectorResolution(
selector=selector, selector=selector,
@@ -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,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()