feat(cadfs): expand selector provenance coverage

This commit is contained in:
2026-09-14 11:00:14 +08:00
parent 994d06aaea
commit de21ee8a5b
45 changed files with 19837 additions and 842 deletions
+110 -4
View File
@@ -14,6 +14,7 @@ from typing import Any, Protocol
from .build123d_adapter import Build123dGeometryAdapter
from .runtime_base import FeatureExecutionError
from .sketch_solver import resolve_profile
from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3
from .topology import (
FeaturePlanNode,
@@ -38,18 +39,25 @@ class GeometryAdapter(Protocol):
def body_solids(self, body: Any) -> list[Any]: ...
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]: ...
def faces_for_sketch(self, sketch: dict[str, Any], *, support_face: Any | None = None,
external_anchor_edges: dict[str, Any] | None = None) -> list[Any]: ...
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any], *, support_face: Any | None = None,
external_anchor_edges: dict[str, Any] | None = None) -> tuple[list[Any], list[dict[str, Any]]]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
def loft_surface(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ...
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def sweep_arc_path(self, start: Vector3, end: Vector3, center: Vector3, normal: Vector3, *, radius_mm: float, clockwise: bool) -> Any: ...
def sweep_circle_path(self, center: Vector3, x_dir: Vector3, normal: Vector3, *, radius_mm: float) -> Any: ...
def face_normal(self, face: Any) -> Vector3: ...
def planar_face_workplane(self, face: Any) -> PlaneSpec: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
def extrude_faces_with_composed_topology_delta(self, faces: list[Any], direction: Vector3) -> tuple[Any, TopologyDelta] | None: ...
def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
@@ -57,6 +65,7 @@ class GeometryAdapter(Protocol):
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
def combine_surfaces(self, *surfaces: Any) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_with_topology_delta(self, face: Any, angle_deg: float, axis: AxisSpec) -> tuple[Any, TopologyDelta]: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ...
def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ...
@@ -82,6 +91,8 @@ class GeometryAdapter(Protocol):
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def target_has_forward_intersection(self, target: Any, faces: list[Any], direction: Vector3) -> bool: ...
def uniform_planar_supporting_surface_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
@@ -104,10 +115,85 @@ class ExecutionSession:
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
body_members: dict[str, Any] = field(default_factory=dict)
body_member_snapshot_ids: dict[str, str] = field(default_factory=dict)
surface_members: dict[str, Any] = field(default_factory=dict)
sketch_attachment_faces: dict[str, Any] = field(default_factory=dict)
sketch_imprint_external_edges: dict[str, dict[str, Any]] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def resolve_sketch_attachment(self, sketch_id: str, *, feature_id: str | None = None) -> None:
"""Resolve one runtime-attached sketch immediately before consumption."""
sketch = self.sketches.get(sketch_id)
if sketch is None or not isinstance(sketch.get("attachment"), dict):
return
previous_feature_id = self.active_feature_id
if feature_id is not None:
self.active_feature_id = feature_id
try:
resolution = self.resolve(sketch["attachment"])
if resolution.status != "resolved" or resolution.record is None or resolution.record.kind != "face":
detail = resolution.diagnostic.message if resolution.diagnostic else "attached face was not resolved"
raise FeatureExecutionError("sketch_attachment_unresolved", detail)
try:
plane = self.adapter.planar_face_workplane(resolution.record.value)
except ValueError as error:
raise FeatureExecutionError("sketch_attachment_nonplanar", str(error)) from error
external_edges: dict[str, Any] = {}
profile = sketch.get("profile") or {}
for entry in profile.get("external_anchors") or ():
anchor_id = entry.get("id") if isinstance(entry, dict) else None
selector = entry.get("selector") if isinstance(entry, dict) else None
if not isinstance(anchor_id, str) or not anchor_id or not isinstance(selector, dict):
raise FeatureExecutionError("imprint_external_anchor_invalid", "external anchor contract is invalid")
edge_resolution = self.resolve(selector)
if (
edge_resolution.status != "resolved"
or edge_resolution.record is None
or edge_resolution.record.kind != "edge"
):
detail = edge_resolution.diagnostic.message if edge_resolution.diagnostic else "external anchor edge was not resolved"
raise FeatureExecutionError("imprint_external_anchor_unresolved", detail)
if not any(edge.wrapped.IsSame(edge_resolution.record.value.wrapped) for edge in resolution.record.value.edges()):
raise FeatureExecutionError(
"imprint_external_anchor_not_support_boundary",
"external anchor edge is not an exact boundary of the attached support face",
)
external_edges[anchor_id] = edge_resolution.record.value
materialized = deepcopy(sketch)
materialized["workplane"] = plane.as_dict()
# IMPRINT face-side disambiguation is expressed in the source
# sketch's oriented plane. A native attached face may have the
# opposite normal, which reverses the physical side of the same
# local curve after its coordinates are mapped to that face.
# This is a session-local frame conversion, not selector or
# geometry fallback data.
source_profile = materialized.get("profile") or {}
if source_profile.get("type") == "planar_imprint":
try:
source_plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
except ValueError as error:
raise FeatureExecutionError("sketch_attachment_frame_invalid", str(error)) from error
orientation = sum(
source_plane.normal[index] * plane.normal[index]
for index in range(3)
)
if orientation < 0.0:
for selection in source_profile.get("selections") or ():
if isinstance(selection, dict) and selection.get("face_side") in {-1, 1}:
selection["face_side"] = -selection["face_side"]
# Preflight intentionally leaves attached sketches local. Resolve now
# so every world-space contour is rebuilt from the proven face frame.
materialized = resolve_profile(materialized)
self.sketches[sketch_id] = materialized
# Keep exact supports only in the live session. They are never CDSL
# geometry, serialized record IDs, or selector fallback evidence.
self.sketch_attachment_faces[sketch_id] = resolution.record.value
if external_edges:
self.sketch_imprint_external_edges[sketch_id] = external_edges
finally:
self.active_feature_id = previous_feature_id
def register_body(
self,
feature_id: str,
@@ -123,6 +209,8 @@ class ExecutionSession:
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
previous_members = dict(self.body_members)
previous_snapshot_ids = dict(self.body_member_snapshot_ids)
self.body = body
self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
@@ -135,11 +223,27 @@ class ExecutionSession:
self.topology.register(anchor)
predecessors = [*(topology_predecessors or ()), *anchors]
solids = self.adapter.body_solids(body)
member_snapshot_ids: dict[str, str] = {}
for member_key, member in self.body_members.items():
matches = [
index for index, solid in enumerate(solids)
if TopologyRegistry._same_topology_value(member, solid)
]
if len(matches) == 1:
member_snapshot_ids[member_key] = (
self.body_id if len(solids) == 1 else f"{self.body_id}:{matches[0]}"
)
member_preservations = [
(previous_snapshot_ids[key], member_snapshot_ids[key])
for key, member in previous_members.items()
if key in previous_snapshot_ids and key in member_snapshot_ids
and TopologyRegistry._same_topology_value(member, self.body_members[key])
]
if len(solids) <= 1:
self.topology.replace_body_topology(
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
topology_delta=topology_delta,
additional_predecessors=predecessors,
additional_predecessors=predecessors, member_preservations=member_preservations,
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
@@ -152,7 +256,7 @@ class ExecutionSession:
]
self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
additional_predecessors=predecessors,
additional_predecessors=predecessors, member_preservations=member_preservations,
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
@@ -160,6 +264,7 @@ class ExecutionSession:
))
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
self.body_member_snapshot_ids = member_snapshot_ids
def register_transient_prism_tool(
self,
@@ -198,6 +303,7 @@ class ExecutionSession:
self.body = None
self.body_id = None
self.body_members = {}
self.body_member_snapshot_ids = {}
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict()