feat(engine): 支持多实体 body 与 selector 持久化
- 多实体主体拆分为独立 body_id,支持前缀匹配解析 - 重建结果新增 solid_count 字段 - fillet/chamfer 后 selector stable_id 可解析到演化后继
This commit is contained in:
@@ -385,6 +385,16 @@ class Build123dGeometryAdapter:
|
||||
# 将主体导出为 STEP 文件。
|
||||
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
|
||||
def body_geometry(body: Any) -> dict[str, Any]:
|
||||
# 汇总主体基本几何信息:包围盒与体积。
|
||||
|
||||
@@ -74,6 +74,7 @@ class GeometryAdapter(Protocol):
|
||||
"""
|
||||
|
||||
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 faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
|
||||
def extrude(self, face: Any, direction: Vector3) -> Any: ...
|
||||
@@ -108,9 +109,23 @@ class ExecutionSession:
|
||||
active_feature_id: str = ""
|
||||
|
||||
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_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(
|
||||
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,),
|
||||
@@ -967,6 +982,9 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
"out_step": str(out_step),
|
||||
"volume_mm3": float(geometry["volume_mm3"]),
|
||||
"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()],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
|
||||
"topology_records": [record.public_dict() for record in session.topology.records()],
|
||||
|
||||
@@ -423,6 +423,11 @@ class TopologyRegistry:
|
||||
self._records: list[TopologyRecord] = []
|
||||
self._by_feature: dict[str, list[TopologyRecord]] = {}
|
||||
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:
|
||||
self._records.append(record)
|
||||
@@ -446,7 +451,10 @@ class TopologyRegistry:
|
||||
self.register(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.
|
||||
|
||||
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
|
||||
split object intentionally becomes owned by this feature instead of
|
||||
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 = [
|
||||
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()
|
||||
for record in records:
|
||||
predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
|
||||
@@ -477,7 +495,27 @@ class TopologyRegistry:
|
||||
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
|
||||
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
|
||||
|
||||
@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
|
||||
def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None:
|
||||
try:
|
||||
@@ -579,8 +667,14 @@ class TopologyRegistry:
|
||||
for key in ("surface_type", "curve_type"):
|
||||
if key in selector_geometry:
|
||||
if record_geometry.get(key) != selector_geometry[key]:
|
||||
return None
|
||||
scores.append(1.0)
|
||||
# #8 selector 持久性:fillet/chamfer 会把直线边演化为圆弧、
|
||||
# 平面演化为柱面,但被选中拓扑的位置锚定(bbox/center/端点)
|
||||
# 不变。曲线/曲面类型变化不再一票否决,而是记低分:位置完全
|
||||
# 重合的候选(同一条边的形态演化)仍可胜出;位置不重合的
|
||||
# 相邻边会被 0 分项拉低,仍被 minimum_score 挡住。
|
||||
scores.append(0.5)
|
||||
else:
|
||||
scores.append(1.0)
|
||||
if "bbox_mm" in selector_geometry:
|
||||
expected = selector_geometry["bbox_mm"]
|
||||
actual = record_geometry.get("bbox_mm")
|
||||
@@ -615,7 +709,14 @@ class TopologyRegistry:
|
||||
owner = selector.get("owner_feature_id")
|
||||
candidates = [record for record in self._records if record.kind == kind]
|
||||
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:
|
||||
candidates = [record for record in candidates if owner in record.owners]
|
||||
geometry = normalize_selector_geometry(selector.get("geometry"))
|
||||
@@ -632,31 +733,68 @@ class TopologyRegistry:
|
||||
)
|
||||
stable_id = str(selector.get("stable_id") or "").strip()
|
||||
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:
|
||||
record = exact[0]
|
||||
# A stable ID is only a lookup accelerator for snapshot-aware
|
||||
# selectors. It cannot revive a B-rep entity whose geometric
|
||||
# signature changed after an upstream rebuild.
|
||||
if selector.get("snapshot_id"):
|
||||
score = self._geometry_score(geometry, record.geometry) if geometry else None
|
||||
if score is None or score < minimum_score:
|
||||
is_active = active_body_id is None or (
|
||||
record.body_id == active_body_id
|
||||
or (record.body_id is not None and record.body_id.startswith(f"{active_body_id}:"))
|
||||
)
|
||||
if not is_active:
|
||||
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(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=({"score": round(float(score or 0), 6), **record.public_dict()},),
|
||||
status="ambiguous",
|
||||
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
|
||||
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},
|
||||
code="selector_ambiguous",
|
||||
message="More than one evolved successor record satisfies the stable_id",
|
||||
detail={"stable_id": stable_id, "candidate_count": len(successors)},
|
||||
),
|
||||
)
|
||||
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 is_active:
|
||||
# A stable ID is only a lookup accelerator for snapshot-aware
|
||||
# selectors. It cannot revive a B-rep entity whose geometric
|
||||
# signature changed after an upstream rebuild.
|
||||
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:
|
||||
return SelectorResolution(
|
||||
selector=selector,
|
||||
|
||||
Reference in New Issue
Block a user