Files
cdsl-cad/backend/engine/cdsl_engine/session.py
T

427 lines
25 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.
"""Execution session state and the geometry adapter boundary.
``ExecutionSession`` owns the active body, body-member graph, replay
definitions, and selector-resolution evidence. ``GeometryAdapter`` is the
kernel-facing protocol the session consumes; geometry values stay opaque so a
different B-rep backend can replace build123d without touching the runtime.
"""
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
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,
FeatureResult,
RuntimeDiagnostic,
SelectorResolution,
TopologyDelta,
TopologyRecord,
TopologyRegistry,
validate_selector_provenance_intent,
)
class GeometryAdapter(Protocol):
"""Kernel boundary consumed by the session runtime.
Geometry values remain opaque here. A future adapter may use a different
B-rep kernel as long as it preserves these construction/query contracts.
"""
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 surface_geometry(self, surface: Any) -> 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: ...
def surface_wires_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
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]: ...
def transform(self, body: Any, transform: dict[str, Any]) -> Any: ...
def transform_with_topology_delta(self, body: Any, transform: dict[str, Any]) -> tuple[Any, TopologyDelta]: ...
def fuse(self, body: Any | None, solid: Any) -> Any: ...
def fuse_with_topology_delta(self, body: Any | None, solid: Any) -> tuple[Any, TopologyDelta | None]: ...
def combine(self, body: Any | None, solid: Any) -> Any: ...
def cut(self, body: Any, tool: Any) -> Any: ...
def cut_with_topology_delta(self, body: Any, tool: Any) -> tuple[Any, TopologyDelta | None]: ...
def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
def cylinder_with_topology_delta(self, radius_mm: float, height_mm: float, axis: AxisSpec | None = None) -> tuple[Any, TopologyDelta]: ...
def thread_solid(self, spec: ThreadSpec) -> Any: ...
def bend_solid(self, spec: BendSpec) -> Any: ...
def gear_solid(self, spec: GearSpec) -> Any: ...
def rack_solid(self, spec: RackSpec) -> Any: ...
def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ...
def body_center(self, body: Any) -> Vector3: ...
def body_span(self, body: Any, direction: Vector3) -> float: ...
def vertex_coordinates(self, vertex: Any) -> Vector3: ...
def intersection_vertex(self, body: Any, face_sets: list[list[Any]]) -> Any: ...
def profile_sample_points(self, face: Any) -> list[Any]: ...
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]: ...
def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ...
def chamfer_with_topology_delta(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> tuple[Any, TopologyDelta | None]: ...
def surface_limited_chamfer(self, body: Any, distance_mm: float, edges: list[Any], surfaces: list[Any]) -> Any: ...
def shell(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> Any: ...
def shell_with_topology_delta(self, body: Any, faces: list[Any], thickness_mm: float, *, inward: bool = True) -> tuple[Any, TopologyDelta]: ...
def export(self, body: Any, path: str) -> None: ...
@dataclass
class ExecutionSession:
sketches: dict[str, dict[str, Any]]
nodes: dict[str, FeaturePlanNode]
adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter)
topology: TopologyRegistry = field(default_factory=TopologyRegistry)
body: Any | None = None
body_id: str | None = None
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,
body: Any,
*,
replay_node: FeaturePlanNode | None = None,
body_members: dict[str, Any] | None = None,
topology_delta: TopologyDelta | None = None,
topology_predecessors: list[TopologyRecord] | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。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}
# Source-profile anchors are transient construction facts, but unlike
# generic role predecessors they must remain addressable by a later
# selector intent. They never receive a body id, so active selector
# scans cannot mistake them for current model topology.
anchors = list(topology_anchors or ())
for anchor in anchors:
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, member_preservations=member_preservations,
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
# 已登记的本轮成员成为下一个成员的 predecessor,进而把 pattern copy
# 的 owner 错误转移到相邻实例。必须原子替换整个多 body 拓扑快照。
members = [
(member_id, self.adapter.topology_records(solid, feature_id, member_id))
for index, solid in enumerate(solids)
for member_id in [f"{self.body_id}:{index}"]
]
self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
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,
geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
))
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,
feature_id: str,
tool: Any,
*,
topology_delta: TopologyDelta,
topology_anchors: list[TopologyRecord],
) -> list[TopologyRecord]:
"""Keep one direct-prism primary tool as boolean-input evidence only."""
snapshot_id = f"transient:{feature_id}"
records = self.adapter.topology_records(tool, feature_id, snapshot_id)
return list(self.topology.register_transient_snapshot(
feature_id,
snapshot_id,
records,
topology_delta=topology_delta,
anchors=topology_anchors,
))
def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
surface_id = f"surface:{feature_id}"
self.surface_members[feature_id] = surface
for record in self.adapter.topology_records(surface, feature_id, surface_id):
self.topology.register(record)
self.topology.register(TopologyRecord(
record_id=surface_id, kind="surface", feature_id=feature_id, body_id=surface_id,
geometry=self.adapter.surface_geometry(surface), value=surface, owner_feature_ids=(feature_id,),
))
return surface_id
def clear_body(self) -> None:
"""Clear the active solid after an explicit deleteBodies result."""
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()
evidence["feature_id"] = self.active_feature_id
self.selector_resolutions.append(evidence)
return resolution
def _intersection_component_records(self, selector: dict[str, Any]) -> list[TopologyRecord]:
matched = selector.get("matched_selectors") if selector.get("match_mode") == "all" else None
if matched is not None:
if not isinstance(matched, list) or not matched:
raise FeatureExecutionError("intersection_selector_unbound", "Intersection selector has no bound face matches")
resolved = [self._record_selector_resolution(self.topology.resolve(item, active_body_id=self.body_id)) for item in matched]
else:
binding_feature_id = selector.get("binding_feature_id")
active_body_id = None if binding_feature_id and self.body_id != f"body:{binding_feature_id}" else self.body_id
resolved = [self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))]
failures = [item for item in resolved if item.status != "resolved" or (item.record is None and not item.records)]
if failures:
detail = failures[0].diagnostic.message if failures[0].diagnostic else "intersection selector component was not resolved"
raise FeatureExecutionError("intersection_selector_component_unresolved", detail)
return [
record
for item in resolved
for record in (item.records or ((item.record,) if item.record is not None else ()))
]
def _resolve_intersection_vertex(self, selector: dict[str, Any]) -> SelectorResolution:
components = selector.get("intersection_of")
if self.body is None:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("missing_extent_body", "Intersection selector requires an existing body"),
)
if not isinstance(components, list) or len(components) < 2:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_selector_incomplete", "Intersection selector requires at least two face components"),
)
try:
face_sets = [self._intersection_component_records(component) for component in components]
if any(record.kind != "face" for records in face_sets for record in records):
raise FeatureExecutionError("intersection_selector_kind", "Intersection selector components must resolve to faces")
vertex = self.adapter.intersection_vertex(self.body, [[record.value for record in records] for records in face_sets])
except FeatureExecutionError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic(error.code, str(error), detail=error.detail),
)
except ValueError as error:
return SelectorResolution(
selector=selector, status="not_found", candidates=(),
diagnostic=RuntimeDiagnostic("intersection_vertex_unresolved", str(error)),
)
point = self.adapter.vertex_coordinates(vertex)
record = TopologyRecord(
record_id=str(selector.get("stable_id") or f"intersection:{id(vertex)}"),
kind="vertex", feature_id=self.active_feature_id, body_id=self.body_id,
geometry={"center_mm": list(point)}, value=vertex,
owner_feature_ids=tuple(filter(None, [str(selector.get("owner_feature_id") or "")])),
)
return SelectorResolution(
selector=selector, status="resolved", record=record,
candidates=({"score": 1.0, **record.public_dict()},),
)
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None:
# The session computes vertex intersections directly from resolved
# face components, so enforce the same outer provenance gate that
# TopologyRegistry.resolve applies before any geometry operation.
validation_error = validate_selector_provenance_intent(selector)
if validation_error is not None:
return self._record_selector_resolution(SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=validation_error,
))
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
return self._record_selector_resolution(self.topology.resolve(selector, active_body_id=active_body_id))
def result(
self,
node: FeaturePlanNode,
*,
context: PlaneSpec | AxisSpec | None = None,
diagnostics: list[RuntimeDiagnostic] | None = None,
include_body: bool = True,
surface_id: str | None = None,
) -> FeatureResult:
result = FeatureResult(
feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed",
body_id=self.body_id if include_body else None, surface_id=surface_id,
context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
diagnostics=diagnostics or [],
)
self.results[node.feature_id] = result
return result
def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]:
"""Return selected source features in their original history order.
A pattern's exported selection order is not an execution order. In
particular, a boolean cut may appear before its parent boss in the
raw selection array. The CDSL feature list is dependency-ordered by
semantic validation, so it is the stable order for replay.
"""
requested = {str(feature_id) for feature_id in source_feature_ids}
sources = [
feature
for feature_id, feature in self.nodes.items()
if feature_id in requested and feature_id in self.replay_definitions
]
if len(sources) != len(requested):
missing = sorted(requested - {source.feature_id for source in sources})
raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}")
return sources