Files
cdsl-cad/backend/engine/cdsl_engine/topology.py
T
ganjihong 3fb08423da refactor(cdsl_engine): split runtime_types into specs + topology with shim
Phase 1 of the decoupling refactor (behavior-preserving move):
- specs.py: vector math, plane/axis helpers, parametric feature specs
- topology.py: diagnostics, planning contracts, TopologyRegistry
- runtime_types.py: compatibility shim re-exporting all public names

No behavior change; all historical import paths keep working.
2026-09-09 12:58:58 +08:00

1011 lines
48 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.
"""Runtime-neutral CDSL diagnostics, planning, and topology contracts.
This module deliberately has no build123d dependency. The planner and
selector resolver can therefore be used by validation, batch reporting, and
any geometry adapter without importing OCC objects.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from math import sqrt
from typing import Any, Iterable
from .specs import AxisSpec, PlaneSpec, Vector3, _length, _vector3, normalize_selector_geometry
@dataclass(frozen=True)
class RuntimeDiagnostic:
code: str
message: str
feature_id: str | None = None
detail: dict[str, Any] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
output: dict[str, Any] = {"code": self.code, "message": self.message}
if self.feature_id is not None:
output["feature_id"] = self.feature_id
if self.detail:
output["detail"] = self.detail
return output
@dataclass(frozen=True)
class CapabilityResult:
feature_id: str
atomic_id: str
resolved_status: str
required_capabilities: tuple[str, ...] = ()
blockers: tuple[RuntimeDiagnostic, ...] = ()
@property
def executable(self) -> bool:
return self.resolved_status == "executable"
def as_dict(self) -> dict[str, Any]:
return {
"feature_id": self.feature_id,
"atomic_id": self.atomic_id,
"resolved_status": self.resolved_status,
"required_capabilities": list(self.required_capabilities),
"blockers": [blocker.as_dict() for blocker in self.blockers],
}
@dataclass(frozen=True)
class FeaturePlanNode:
feature_id: str
atomic_id: str
name: str | None
depends_on: tuple[str, ...]
params: dict[str, Any]
selectors: tuple[dict[str, Any], ...]
sketch_id: str | None
declared_status: str | None
source_feature: dict[str, Any]
@dataclass
class FeatureResult:
feature_id: str
atomic_id: str
status: str
body_id: str | None = None
surface_id: str | None = None
context: PlaneSpec | AxisSpec | None = None
replay_definition: dict[str, Any] | None = None
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
def as_dict(self) -> dict[str, Any]:
output: dict[str, Any] = {
"feature_id": self.feature_id,
"atomic_id": self.atomic_id,
"status": self.status,
"diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics],
}
if self.body_id is not None:
output["body_id"] = self.body_id
if self.surface_id is not None:
output["surface_id"] = self.surface_id
if self.context is not None:
output["context"] = self.context.as_dict()
if self.replay_definition is not None:
output["replay_definition"] = self.replay_definition
return output
@dataclass(frozen=True)
class TopologyRecord:
"""Runtime-side signature of a topology item or context object.
``feature_id`` identifies the feature that produced this *snapshot*.
``owner_feature_ids`` is durable semantic provenance for an unchanged
current B-rep item. Boolean and dress-up operations replace OCC objects,
so keeping these concepts separate prevents a later mutation from making
every surviving face appear to be owned by that mutation.
"""
record_id: str
kind: str
feature_id: str
body_id: str | None = None
geometry: dict[str, Any] = field(default_factory=dict)
value: Any = None
owner_feature_ids: tuple[str, ...] = ()
# Builder-produced roles describe a particular result subshape. They are
# deliberately separate from the geometric signature: equal geometry does
# not prove that two faces have the same feature-output meaning.
output_roles: tuple[str, ...] = ()
# Generated roles may carry the direct feature-output role that the kernel
# operation transformed. This is semantic provenance, not a stable-id
# shortcut: the resolver still requires the exact active result snapshot.
output_role_sources: tuple[tuple[str, str, str], ...] = ()
@property
def owners(self) -> tuple[str, ...]:
"""Return durable provenance, retaining compatibility for contexts."""
return self.owner_feature_ids or (self.feature_id,)
def public_dict(self) -> dict[str, Any]:
output: dict[str, Any] = {
"record_id": self.record_id,
"kind": self.kind,
"feature_id": self.feature_id,
"geometry": self.geometry,
}
if self.body_id is not None:
output["body_id"] = self.body_id
if self.owner_feature_ids:
output["owner_feature_ids"] = list(self.owner_feature_ids)
if self.output_roles:
output["output_roles"] = list(self.output_roles)
if self.output_role_sources:
output["output_role_sources"] = [
{"output_role": role, "owner_feature_id": owner, "source_output_role": source_role}
for role, owner, source_role in self.output_role_sources
]
return output
@dataclass(frozen=True)
class TopologyDeltaRelation:
"""One opaque kernel-history relationship for a topology subshape.
Geometry adapters retain ownership of the values in this structure. They
are intentionally opaque to the runtime: a build123d/OCC adapter may use
``TopoDS_Shape`` values while another adapter can use its native handles.
The registry only asks whether a handle is exactly the same topology item;
it never uses this contract to score nearby geometry.
"""
event: str
kind: str
source_value: Any
result_values: tuple[Any, ...] = ()
output_role: str | None = None
def __post_init__(self) -> None:
if self.event not in {"preserved", "modified", "generated", "deleted"}:
raise ValueError(f"unsupported topology delta event {self.event!r}")
if self.kind not in {"face", "edge", "vertex"}:
raise ValueError(f"unsupported topology delta kind {self.kind!r}")
if self.event == "deleted" and (self.result_values or self.output_role is not None):
raise ValueError("deleted topology delta relations cannot have result values or an output role")
if self.output_role is not None and (not isinstance(self.output_role, str) or not self.output_role):
raise ValueError("topology delta output_role must be a non-empty string when provided")
@dataclass(frozen=True)
class TopologyDelta:
"""Kernel-backed topology history for one adapter operation.
``operation`` is evidence only. The runtime transfers durable provenance
solely from a unique exact relationship, never from an operation name or a
geometric resemblance.
"""
operation: str
relations: tuple[TopologyDeltaRelation, ...] = ()
@dataclass(frozen=True)
class SelectorResolution:
selector: dict[str, Any]
status: str
record: TopologyRecord | None = None
candidates: tuple[dict[str, Any], ...] = ()
diagnostic: RuntimeDiagnostic | None = None
def as_dict(self) -> dict[str, Any]:
output = {
"selector": self.selector,
"status": self.status,
"candidates": list(self.candidates),
}
if self.record is not None:
output["record"] = self.record.public_dict()
output["selected"] = self.record.public_dict()
score = next(
(candidate.get("score") for candidate in self.candidates if candidate.get("record_id") == self.record.record_id),
None,
)
if score is not None:
output["score"] = score
if self.diagnostic is not None:
output["diagnostic"] = self.diagnostic.as_dict()
return output
class TopologyRegistry:
"""Feature-scoped context/topology registry with explainable matching."""
def __init__(self) -> None:
self._records: list[TopologyRecord] = []
self._by_feature: dict[str, list[TopologyRecord]] = {}
self._active_body_id: str | None = None
self._topology_deltas: list[dict[str, Any]] = []
# #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)
self._by_feature.setdefault(record.feature_id, []).append(record)
def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]:
return tuple(self._by_feature.get(feature_id, ()))
def records(self) -> tuple[TopologyRecord, ...]:
return tuple(self._records)
def topology_deltas(self) -> tuple[dict[str, Any], ...]:
"""Return serializable evidence derived from exact adapter history."""
return tuple(self._topology_deltas)
def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord:
kind = "plane" if isinstance(context, PlaneSpec) else "axis"
record = TopologyRecord(
record_id=f"{feature_id}:{kind}",
kind=kind,
feature_id=feature_id,
geometry=context.as_dict(),
value=context,
)
self.register(record)
return record
def replace_body_topology(
self, feature_id: str, body_id: str, records: Iterable[TopologyRecord],
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
additional_predecessors: Iterable[TopologyRecord] = (),
) -> None:
self.replace_body_topologies(
feature_id, [(body_id, records)], active_body_id=active_body_id, topology_delta=topology_delta,
additional_predecessors=additional_predecessors,
)
def replace_body_topologies(
self, feature_id: str, bodies: Iterable[tuple[str, Iterable[TopologyRecord]]],
*, active_body_id: str | None = None, topology_delta: TopologyDelta | None = None,
additional_predecessors: Iterable[TopologyRecord] = (),
) -> None:
"""Record a fresh B-rep snapshot after a feature mutates the body.
OCC topology object identity is invalidated by most body mutations.
We therefore keep old objects out of active selector resolution but
carry their semantic owners forward when, and only when, one current
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.
"""
active_previous = [
record for record in self._records
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}:")
)
]
# A pattern COPY can carry a chain of exact transform/boolean builder
# histories before its final aggregate snapshot is registered. These
# temporary records are valid predecessors only for that documented
# kernel-history bridge. They are deliberately excluded from geometric
# fallback matching: an equal-looking final face never proves that it
# belongs to one particular copy instance.
transient_previous = list(additional_predecessors)
previous = [*active_previous, *transient_previous]
# 同一 source feature 的 pattern copy 可以产生完全相同的几何面。它们
# 必须保留为多个实例,不能在跨 body 的全局 predecessor 匹配中互相消费。
# pattern 的 Compound 成员顺序是稳定的:已有实例以同一 member index
# 延续,新增实例只会出现在末尾。按该 index 限定后继匹配。
current = [(body_id, list(records)) for body_id, records in bodies]
current_records = [record for _body_id, records in current for record in records]
(
exact_predecessors,
exact_successors,
kernel_covered_predecessors,
exact_output_roles,
exact_output_role_sources,
delta_evidence,
) = self._exact_delta_links(
topology_delta, previous, current_records,
)
previous_member_ids = {
suffix for record in active_previous
for suffix in [str(record.body_id).rsplit(":", 1)[-1]]
if suffix.isdigit()
}
use_member_indexes = len(current) > 1 and previous_member_ids
consumed_predecessors: set[str] = set()
registered: list[TopologyRecord] = []
for body_id, records in current:
member_id = str(body_id).rsplit(":", 1)[-1]
local_predecessors = [
record for record in active_previous
if not use_member_indexes or str(record.body_id).rsplit(":", 1)[-1] == member_id
]
for record in records:
exact_predecessor_id = exact_predecessors.get(record.record_id)
# Member order is a useful isolation boundary for geometric
# fallback matching, especially for coincident pattern
# copies. It is not a provenance boundary when a kernel
# builder explicitly relates one source subshape to one
# result subshape: boolean/delete lifecycle can remove an
# earlier member and shift a surviving source to another
# member index. A unique OCC continuation remains exact
# evidence across that index change.
predecessor = next(
(prior for prior in previous if prior.record_id == exact_predecessor_id),
None,
)
if predecessor is None and record.record_id not in exact_predecessors:
predecessor = self._unique_equivalent_predecessor(record, local_predecessors, consumed_predecessors)
owners = predecessor.owners if predecessor is not None else (feature_id,)
output_roles = set(record.output_roles)
output_roles.update(exact_output_roles.get(record.record_id, ()))
output_role_sources = set(record.output_role_sources)
output_role_sources.update(exact_output_role_sources.get(record.record_id, ()))
# A feature-output role can survive a later operation only
# through the same unique kernel continuation used for owner
# provenance. Geometry equivalence alone never carries it.
if predecessor is not None and record.record_id in exact_predecessors:
output_roles.update(predecessor.output_roles)
if predecessor is not None and record.record_id not in exact_predecessors:
consumed_predecessors.add(predecessor.record_id)
registered.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=feature_id,
body_id=body_id,
geometry=dict(record.geometry),
value=record.value,
owner_feature_ids=owners,
output_roles=tuple(sorted(output_roles)),
output_role_sources=tuple(sorted(output_role_sources)),
))
for record in registered:
self.register(record)
for predecessor_id, successor_ids in exact_successors.items():
known = self._successors.setdefault(predecessor_id, [])
for successor_id in successor_ids:
if successor_id not in known:
known.append(successor_id)
if delta_evidence is not None:
self._topology_deltas.append({
"feature_id": feature_id,
"operation": topology_delta.operation,
"relations": delta_evidence,
})
# #8 selector 持久性:被消费(拆分成段)的旧边记录演化后继,供后续
# selector 的 stable_id 引用解析到 active body 内的新形态。多条演化
# 候选时只登记"漂移显著最小"的那条(例如底面边圆角后既有缩短的直段
# 也有圆角过渡带的新边,前者的端点与原边重合、漂移更小);漂移并列
# (如竖直边被完整消费成两条等距直段)属于本质歧义,保守不登记。
for prior in previous:
if (
prior.record_id in consumed_predecessors
or prior.record_id in exact_successors
or prior.record_id in kernel_covered_predecessors
):
continue
candidates = sorted(
(
(self._evolved_drift(prior, record), record.record_id)
for record in registered 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 _same_topology_value(left: Any, right: Any) -> bool:
"""Compare adapter handles only through their exact topology identity."""
left_value = getattr(left, "wrapped", left)
right_value = getattr(right, "wrapped", right)
if left_value is right_value:
return True
for candidate, other in ((left_value, right_value), (right_value, left_value)):
for method_name in ("IsSame", "is_same"):
method = getattr(candidate, method_name, None)
if callable(method):
try:
return bool(method(other))
except (AttributeError, TypeError, ValueError):
continue
return False
@classmethod
def _exact_delta_links(
cls,
topology_delta: TopologyDelta | None,
previous: list[TopologyRecord],
current: list[TopologyRecord],
) -> tuple[
dict[str, str],
dict[str, list[str]],
set[str],
dict[str, tuple[str, ...]],
dict[str, tuple[tuple[str, str, str], ...]],
list[dict[str, Any]] | None,
]:
"""Bind opaque kernel history to snapshots without geometric guessing.
Ownership transfer is intentionally limited to a single source item and
a single output item. Split/merge history remains useful evidence, but
has no unique owner continuation until a later operation-specific
contract can express it.
"""
if topology_delta is None:
return {}, {}, set(), {}, {}, None
candidate_sources: dict[str, set[str]] = {}
kernel_covered_predecessors: set[str] = set()
output_roles: dict[str, set[str]] = {}
output_role_sources: dict[str, set[tuple[str, str, str]]] = {}
relation_links: list[tuple[str, str] | None] = []
evidence: list[dict[str, Any]] = []
for relation in topology_delta.relations:
sources = [
record for record in previous
if record.kind == relation.kind and cls._same_topology_value(record.value, relation.source_value)
]
outputs = [
record for record in current
if record.kind == relation.kind
and any(cls._same_topology_value(record.value, value) for value in relation.result_values)
]
item = {
"event": relation.event,
"kind": relation.kind,
"source_record_ids": [record.record_id for record in sources],
"result_record_ids": [record.record_id for record in outputs],
"proof": "kernel_history",
}
if relation.output_role is not None:
item["output_role"] = relation.output_role
role_is_unique = len(relation.result_values) == 1 and len(outputs) == 1
item["output_role_status"] = (
"unique_result_snapshot" if role_is_unique else "non_unique_or_missing_result_snapshot"
)
if role_is_unique:
output_roles.setdefault(outputs[0].record_id, set()).add(relation.output_role)
for source in sources:
for source_role in source.output_roles:
for source_owner in source.owners:
output_role_sources.setdefault(outputs[0].record_id, set()).add(
(relation.output_role, source_owner, source_role)
)
if len(sources) == 1:
kernel_covered_predecessors.add(sources[0].record_id)
can_transfer = (
relation.event in {"preserved", "modified"}
and len(relation.result_values) == 1
and len(sources) == 1
and len(outputs) == 1
)
if can_transfer:
source_id, result_id = sources[0].record_id, outputs[0].record_id
candidate_sources.setdefault(result_id, set()).add(source_id)
relation_links.append((source_id, result_id))
else:
relation_links.append(None)
evidence.append(item)
predecessors = {
result_id: next(iter(source_ids))
for result_id, source_ids in candidate_sources.items()
if len(source_ids) == 1
}
successors: dict[str, list[str]] = {}
for result_id, source_id in predecessors.items():
successors.setdefault(source_id, []).append(result_id)
for item, relation, link in zip(evidence, topology_delta.relations, relation_links):
if link is not None:
_source_id, result_id = link
item["status"] = (
"unique_exact_continuation"
if len(candidate_sources[result_id]) == 1 else "ambiguous_exact_continuation"
)
elif relation.event in {"preserved", "modified"}:
item["status"] = "non_unique_or_incomplete"
else:
item["status"] = "recorded_without_owner_transfer"
return (
predecessors,
successors,
kernel_covered_predecessors,
{record_id: tuple(sorted(roles)) for record_id, roles in output_roles.items()},
{record_id: tuple(sorted(sources)) for record_id, sources in output_role_sources.items()},
evidence,
)
@staticmethod
def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
try:
return abs(float(left) - float(right)) <= tolerance
except (TypeError, ValueError):
return False
@classmethod
def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
try:
first = _vector3(left, field_name="prior topology geometry")
second = _vector3(right, field_name="current topology geometry")
except ValueError:
return False
return all(abs(a - b) <= tolerance for a, b in zip(first, second))
@classmethod
def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool:
"""Check a complete, orientation-aware snapshot signature.
This is intentionally much stricter than selector scoring. Selector
scoring may compare partial source evidence; provenance transfer must
never manufacture ownership from a merely similar candidate.
"""
if prior.kind != current.kind:
return False
left, right = prior.geometry, current.geometry
for key in ("surface_type", "curve_type"):
if left.get(key) != right.get(key):
return False
for key in ("bbox_mm", "center_mm", "normal", "plane_normal"):
if key in left or key in right:
if key not in left or key not in right:
return False
left_value, right_value = left[key], right[key]
if key == "bbox_mm":
if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)):
return False
if len(left_value) != 6 or len(right_value) != 6:
return False
if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)):
return False
elif not cls._vectors_equal(left_value, right_value):
return False
for key in ("area_mm2", "length_mm", "plane_offset_mm"):
if key in left or key in right:
if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]):
return False
for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"):
if key in left or key in right:
if key not in left or key not in right or left[key] != right[key]:
return False
left_start, left_end = left.get("start_mm"), left.get("end_mm")
right_start, right_end = right.get("start_mm"), right.get("end_mm")
if any(value is not None for value in (left_start, left_end, right_start, right_end)):
if None in (left_start, left_end, right_start, right_end):
return False
same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end)
reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start)
if not same_direction and not reverse_direction:
return False
return True
@classmethod
def _unique_equivalent_predecessor(
cls,
current: TopologyRecord,
predecessors: Iterable[TopologyRecord],
consumed_predecessors: set[str],
) -> TopologyRecord | None:
matches = [
record for record in predecessors
if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current)
]
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:
left = _vector3(expected, field_name="selector geometry")
right = _vector3(actual, field_name="record geometry")
except ValueError:
return None
error = _length(tuple(a - b for a, b in zip(left, right)))
return max(0.0, 1.0 - error / tolerance)
@classmethod
def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None:
if not selector_geometry:
return 0.0
scores: list[float] = []
for key in ("center_mm", "circle_center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"):
if key in selector_geometry:
score = cls._vector_score(selector_geometry[key], record_geometry.get(key))
if score is None:
return None
scores.append(score)
for key in ("surface_type", "curve_type"):
if key in selector_geometry:
if record_geometry.get(key) != selector_geometry[key]:
# #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")
if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual):
return None
delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual))
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "plane_offset_mm" in selector_geometry:
try:
delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm")))
except (TypeError, ValueError):
return None
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "radius_mm" in selector_geometry:
try:
delta = abs(float(selector_geometry["radius_mm"]) - float(record_geometry.get("radius_mm")))
except (TypeError, ValueError):
return None
scores.append(max(0.0, 1.0 - delta / 1e-4))
if "area_mm2" in selector_geometry:
try:
expected_area = float(selector_geometry["area_mm2"])
actual_area = float(record_geometry.get("area_mm2"))
except (TypeError, ValueError):
return None
relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9)
scores.append(max(0.0, 1.0 - relative_delta / 1e-4))
return sum(scores) / len(scores) if scores else 0.0
def resolve(
self,
selector: dict[str, Any],
*,
minimum_score: float = 0.8,
active_body_id: str | None = None,
) -> SelectorResolution:
kind = selector.get("kind")
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"}:
# #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]
if kind == "plane" and selector.get("frame") is not None:
# #6 pattern 引用重解析:pattern 重放 sourcepattern_mirror)时,
# mirror_plane 的 plane 引用由运行时随实例变换后内联为显式 frame
# _translated_node / _mirrored_node),这里直接构造 PlaneSpec
# 不再走 stable_id / 几何匹配,避免解析到未随实例变换的原始面。
try:
plane = PlaneSpec.from_mapping(selector.get("frame") or {})
except (TypeError, ValueError):
plane = None
if plane is not None:
record = TopologyRecord(
record_id=f"inline:{id(plane)}",
kind="plane",
feature_id=str(owner or "inline"),
geometry=plane.as_dict(),
value=plane,
)
return SelectorResolution(
selector=selector,
status="resolved",
record=record,
candidates=({"score": 1.0, **record.public_dict()},),
)
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_frame_incomplete",
message="An inline plane frame requires origin_mm, x_dir and normal",
detail={"frame": selector.get("frame")},
),
)
geometry = normalize_selector_geometry(selector.get("geometry"))
if selector.get("snapshot_id") and not owner:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_owner_required",
message="A snapshot selector requires owner_feature_id",
detail={"minimum_score": minimum_score},
),
)
output_role = str(selector.get("output_role") or "").strip()
if output_role:
if not owner:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_owner_required",
message="A feature output role selector requires owner_feature_id",
detail={"output_role": output_role},
),
)
if active_body_id is None:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_active_body_required",
message="A feature output role selector requires an active body snapshot",
detail={"output_role": output_role},
),
)
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_mixed_evidence",
message="A feature output role selector cannot mix stable or geometry evidence",
detail={"output_role": output_role},
),
)
role_candidates = [record for record in candidates if output_role in record.output_roles]
role_source = selector.get("output_role_source")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
if not isinstance(source_owner, str) or not isinstance(source_role, str):
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_source_invalid",
message="An output role selector source requires owner_feature_id and output_role",
),
)
if output_role != "shell.offset_face" or source_role not in {"extrude.start", "extrude.end"}:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_source_unsupported",
message="Output role sources are currently supported only for shell.offset_face from an extrusion cap",
),
)
role_candidates = [
record for record in role_candidates
if (output_role, source_owner, source_role) in record.output_role_sources
]
public_candidates = tuple(
{"score": 1.0, **record.public_dict()} for record in role_candidates
)
if len(role_candidates) == 1:
return SelectorResolution(
selector=selector,
status="resolved",
record=role_candidates[0],
candidates=public_candidates,
)
if len(role_candidates) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_output_role_ambiguous",
message="More than one active topology record has the requested output role",
detail={"output_role": output_role, "candidate_count": len(role_candidates)},
),
)
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_output_role_not_found",
message="No active topology record has the requested output role",
detail={"output_role": output_role, "candidate_count": 0},
),
)
stable_id = str(selector.get("stable_id") or "").strip()
if 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]
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="ambiguous",
candidates=tuple({"score": 1.0, **candidate.public_dict()} for candidate in successors),
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one evolved successor record satisfies the stable_id",
detail={"stable_id": stable_id, "candidate_count": len(successors)},
),
)
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 not geometry:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_stable_id_inactive",
message="The stable selector record is not active and has no geometry signature for rebinding",
detail={"stable_id": stable_id},
),
)
if len(exact) > 1:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=tuple({"score": 1.0, **record.public_dict()} for record in exact),
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one runtime topology record has the requested stable_id",
detail={"stable_id": stable_id, "candidate_count": len(exact)},
),
)
if selector.get("snapshot_id") and not geometry:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=RuntimeDiagnostic(
code="selector_geometry_mismatch",
message="A snapshot selector requires a geometry signature",
detail={"minimum_score": minimum_score},
),
)
scored: list[tuple[float, TopologyRecord]] = []
for candidate in candidates:
# An owner-qualified context selector is deterministic when it has
# a single runtime candidate even if its source stable_id cannot
# survive the SolidWorks -> OCC boundary.
score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry)
if score is not None:
scored.append((score, candidate))
scored.sort(key=lambda item: (-item[0], item[1].record_id))
public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored)
if not scored or scored[0][0] < minimum_score:
return SelectorResolution(
selector=selector,
status="not_found",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_not_found",
message="No runtime topology record satisfies the selector",
detail={"candidate_count": len(scored), "minimum_score": minimum_score},
),
)
best_score, best_record = scored[0]
if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9:
return SelectorResolution(
selector=selector,
status="ambiguous",
candidates=public_candidates,
diagnostic=RuntimeDiagnostic(
code="selector_ambiguous",
message="More than one runtime topology record has the best selector score",
detail={"best_score": best_score, "candidate_count": len(scored)},
),
)
return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates)