Files
cdsl-cad/backend/engine/cdsl_engine/executors/common.py
T
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00

669 lines
31 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.
"""Helpers shared by executor family modules.
Every function here is imported by two or more executor modules. Anything
used by exactly one family lives in that family's module instead.
"""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any, Callable
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
from ..runtime_base import ExtentVector, FeatureExecutionError
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec:
raw_axis = node.params.get("axis") or {}
if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None:
return AxisSpec.from_mapping(raw_axis)
selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None
if not isinstance(selector, dict):
selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
if not isinstance(selector, dict):
raise FeatureExecutionError(
"missing_revolve_axis",
"Revolve requires an explicit axis or an owner-qualified reference-axis selector",
)
resolution = session.resolve(selector)
if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved")
if not isinstance(resolution.record.value, AxisSpec):
raise FeatureExecutionError(
"unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind,
)
return resolution.record.value
def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None:
"""Defend direct CDSL execution from an out-of-plane revolve axis."""
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
direction_normal_dot = abs(vector_dot(axis.direction, plane.normal))
if direction_normal_dot > 1e-7:
raise ValueError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to "
f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
)
origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal))
if origin_plane_offset > 1e-6:
raise ValueError(
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in "
f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}"
)
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
"""Apply a cut to each independently owned body without erasing ownership.
A CADFS NEW body stays independently addressable even when a later REMOVE
feature affects several active bodies. Cutting the aggregate first loses
that identity, so this path uses the equivalent per-member set difference
and drops only members that the tool removes completely.
"""
members: dict[str, Any] = {}
for feature_id, body in session.body_members.items():
result = session.adapter.cut(body, tool)
if abs(float(result.volume)) > 1e-12:
members[feature_id] = result
return members
def _can_register_primary_cut_tool_history(
session: "ExecutionSession",
tool: Any,
topology_delta: TopologyDelta | None,
topology_anchors: list[TopologyRecord] | None,
) -> bool:
"""Return whether a primary REMOVE can retain a transient tool snapshot.
The implicit CADFS primary boolean normally has no independently active
tool body. It may contribute selector provenance only when the target,
tool, profile anchors, and prism builder history are all singular and
exact. This gate applies only to the tool-side transient snapshot used by
source-qualified section queries. The cut builder can independently
prove a target-side continuation even when a trimmed or fallback tool has
no direct-prism history.
"""
if (
session.body is None
or topology_delta is None
or topology_delta.operation != "extrude"
or topology_delta.history_status != "proven"
or not topology_delta.relations
or not topology_anchors
or len(session.body_members) != 1
or len(session.adapter.body_solids(session.body)) != 1
or len(session.adapter.body_solids(tool)) != 1
):
return False
return all(
anchor.kind in {"edge", "vertex"}
and (anchor.source_entity is not None or anchor.source_entities)
for anchor in topology_anchors
)
def _extruded_tool(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
*,
record_multiface_prism_history: bool = False,
) -> tuple[Any, TopologyDelta | None]:
"""Build an extrude tool, retaining complete direct builder history."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
draft = node.params.get("draft")
taper_deg = 0.0
if isinstance(draft, dict):
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_deltas: list[TopologyDelta] = []
solids: list[Any] = []
for face in faces:
for extent in extents:
if draft is not None:
if len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg,
)
if topology_delta is not None:
topology_deltas.append(topology_delta)
solids.append(solid)
else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(extents) == 1 and (
len(faces) == 1 or record_multiface_prism_history
):
# Each independently constructed profile face has its own OCC
# prism history only when the adapter retained direct source
# anchors for every participating profile path. Other complex
# multi-face profiles keep the established general-extrude
# path; forcing them through MakePrism can make a previously
# executable profile invalid without adding usable evidence.
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
topology_deltas.append(topology_delta)
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
else:
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
tool = None
for solid in solids:
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("extrude produced no solid")
if len(topology_deltas) != len(solids):
return tool, None
return tool, TopologyDelta(
operation="extrude",
relations=tuple(relation for delta in topology_deltas for relation in delta.relations),
)
def _apply_primary_tool(
node: FeaturePlanNode,
session: "ExecutionSession",
tool: Any,
*,
cutting: bool,
topology_delta: TopologyDelta | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> FeatureResult:
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
topology_predecessors: list[TopologyRecord] | None = None
if cutting:
if session.body is None:
raise ValueError("cut feature has no body")
members = _cut_explicit_body_members(session, tool)
if not members:
session.clear_body()
return session.result(node)
tool_delta = topology_delta
tool_anchors = list(topology_anchors or ())
retain_transient_tool = _can_register_primary_cut_tool_history(
session, tool, tool_delta, topology_anchors,
)
if (
len(session.body_members) == 1
and len(session.adapter.body_solids(session.body)) == 1
and len(session.adapter.body_solids(tool)) == 1
):
body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool)
# The target-side boolean history is independent of the source
# tool's construction history. A trimmed tool cannot support a
# source-qualified section query, but its exact BRepAlgoAPI_Cut
# Modified/Preserved facts can still prove a unique continuation
# of the active target face or edge.
topology_anchors = None
if cut_delta is not None:
if retain_transient_tool:
try:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=tool_delta,
topology_anchors=tool_anchors,
)
except ValueError:
# Retain cut history as partial diagnostic evidence. The
# absent transient source records prevent it from proving
# a source-qualified section edge.
topology_predecessors = None
topology_delta = cut_delta
else:
topology_delta = None
member_id = next(iter(session.body_members))
members = {member_id: body}
else:
body = session.adapter.cut(session.body, tool)
topology_delta = None
topology_anchors = None
elif node.params.get("result_mode") == "new_body":
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
else:
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# A fuse rebuilds subshape identity. Builder evidence belongs only to
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = None
topology_anchors = None
session.register_body(
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
)
return session.result(node)
def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult:
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
# 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景),
# 否则按 sketch_id 从会话草图表中取原始草图。
selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
if selected_sketch is None:
raise ValueError("primary feature has no resolved sketch")
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved")
if len(faces) != 1:
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
topology_delta: TopologyDelta | None = None
topology_anchors: list[TopologyRecord] = []
profile = selected_sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
)
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session,
record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces),
)
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
value = spec.get("value")
if kind not in {"edge", "vertex"} or value is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if source_entity is None and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=value,
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
angle = float(node.params.get("angle_deg") or 0.0)
if angle <= 0:
raise ValueError("revolve requires angle_deg > 0")
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
# 旋转角,与 extrude 的 reverse_extent_vectors 反转拉伸方向)同一
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
# reversecdsl_schema.json revolveParams 也已允许,这里补齐 runtime
# 侧实现,使三方合同一致。
if bool(node.params.get("reverse")):
angle = -angle
tool = None
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("revolve produced no solid")
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any:
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("booleanBodies produced no result bodies")
return body
def _pattern_instance_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str = "pattern_instance_refs",
) -> list[str]:
"""Resolve CDSL pattern-instance refs to their internal body-member keys."""
resolved: list[str] = []
for reference in node.params.get(parameter) or ():
if not isinstance(reference, dict):
raise ValueError("pattern instance reference must be an object")
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
instance = reference.get("instance_index")
if not pattern_id or not source_id or not isinstance(instance, int):
raise ValueError("pattern instance reference is incomplete")
pattern = session.nodes.get(pattern_id)
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
raise ValueError(f"pattern instance owner is unavailable: {pattern_id}")
params = pattern.params
if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}:
raise ValueError("pattern instance source is not selected by its pattern")
count = int(params.get("pattern_count") or 0)
excluded = {int(value) for value in params.get("excluded_instance_indices") or ()}
if (
pattern.atomic_id == "pattern_mirror" and instance != 1
) or (
pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
):
raise ValueError("pattern instance is outside the pattern's surviving instances")
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
if member_id not in session.body_members:
raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}")
if member_id not in resolved:
resolved.append(member_id)
return resolved
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
resolved: list[str] = []
for reference in node.params.get("transform_copy_refs") or ():
if not isinstance(reference, dict):
raise ValueError("transform COPY reference must be an object")
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
if not transform_id or not source_id:
raise ValueError("transform COPY reference is incomplete")
transform = session.nodes.get(transform_id)
params = transform.params if transform is not None else {}
sources = params.get("source_feature_ids") or []
if (
transform is None
or transform.atomic_id != "transform_bodies"
or not bool(params.get("make_copy"))
or not isinstance(sources, list)
or len(sources) < 2
or source_id not in {str(value) for value in sources}
):
raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}")
member_id = transform_copy_member_id(transform_id, source_id)
if member_id not in session.body_members:
raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}")
if member_id not in resolved:
resolved.append(member_id)
return resolved
def _member_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str,
*,
pattern_instance_parameter: str | None = None,
allow_transform_copies: bool = False,
) -> list[str]:
source_ids = [str(value) for value in node.params.get(parameter) or []]
if pattern_instance_parameter is not None:
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
if allow_transform_copies:
source_ids.extend(_transform_copy_sources(node, session))
if not source_ids:
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
if missing:
raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing))
return source_ids
def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
# 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。
path = node.params.get("path") or {}
if not isinstance(path, dict):
raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {}
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
def point(value: list[float]) -> Vector3:
return vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
)
def tangent(value: Any) -> Vector3 | None:
if value is None:
return None
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
return session.adapter.sweep_path(
[point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")),
end_tangent=tangent(segment.get("end_tangent")),
parameters=[float(value) for value in segment.get("parameters") or []] or None,
)
def _register_added_solid(
session: "ExecutionSession",
node: FeaturePlanNode,
solid: Any,
*,
topology_delta: TopologyDelta | None = None,
) -> None:
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
When ``node.params['result_mode'] == "new_body"`` the primitive is kept as
an independent body member so that downstream ``boolean_bodies`` can
reference it without pulling in the accumulated fuse history. The current
body is replaced by a Compound that preserves both, matching the
``extrude_add_blind`` ``new_body`` semantics. Any other value (including
missing) falls back to the legacy fuse-into-body behavior.
"""
if node.params.get("result_mode") == "new_body":
combined = session.adapter.combine(session.body, solid)
members = {**session.body_members, node.feature_id: solid}
session.register_body(
node.feature_id, combined, replay_node=node, body_members=members,
topology_delta=topology_delta,
)
return
if session.body is None:
session.register_body(node.feature_id, solid, replay_node=node, topology_delta=topology_delta)
return
if topology_delta is None:
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
return
# Preserve primitive output roles only through the exact OCC fuse history.
# The transient records provide source handles, not selectable snapshots.
role_records = _direct_output_role_records(session, node, solid, topology_delta)
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, solid)
session.register_body(
node.feature_id, fused, replay_node=node, topology_delta=fuse_delta,
topology_predecessors=role_records,
)
def _direct_output_role_records(
session: "ExecutionSession",
node: FeaturePlanNode,
solid: Any,
topology_delta: TopologyDelta,
) -> list[TopologyRecord]:
"""Attach only builder-proven output roles to a transient primitive snapshot."""
records = session.adapter.topology_records(solid, node.feature_id, f"transient:{node.feature_id}")
result: list[TopologyRecord] = []
for record in records:
roles = {
relation.output_role
for relation in topology_delta.relations
if relation.output_role is not None
and relation.kind == record.kind
and any(session.topology._same_topology_value(record.value, value) for value in relation.result_values)
}
if roles:
result.append(TopologyRecord(
record_id=record.record_id,
kind=record.kind,
feature_id=record.feature_id,
body_id=record.body_id,
geometry=record.geometry,
value=record.value,
owner_feature_ids=(node.feature_id,),
output_roles=tuple(sorted(roles)),
))
return result
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
geometry = resolution.record.geometry
return PlaneSpec.from_mapping({
"origin_mm": geometry["center_mm"],
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
"normal": geometry["normal"],
})
def _hole_starts(
spec: HoleSpec,
*,
host_plane: PlaneSpec,
positions_are_local: bool,
) -> list[Vector3]:
starts: list[Vector3] = []
for point in spec.positions_mm:
if positions_are_local:
start = vector_add(
vector_add(
vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])),
vector_scale(host_plane.y_dir, point[1]),
),
vector_scale(host_plane.normal, point[2]),
)
else:
start = point
starts.append(start)
return starts
def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]:
resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
def is_body_boundary(edge: Any) -> bool:
# 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体
# 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边,
# 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的
# 单边选择,所以这里只约束由 FACE 展开的候选边。
face_count = sum(
1
for face in session.body.faces()
if any(candidate.is_same(edge) for candidate in face.edges())
)
return face_count >= 2
edges: list[Any] = []
for item in resolved:
records = item.records or ((item.record,) if item.record is not None else ())
for record in records:
if record.kind == "edge":
edges.append(record.value)
elif record.kind == "face":
edges.extend(edge for edge in record.value.edges() if is_body_boundary(edge))
if not edges:
raise ValueError("selectors did not resolve any edges")
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]:
# shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个
# Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测
# 应抽壳的成员。
resolved = [session.resolve(selector) for selector in node.selectors]
failed = next((item for item in resolved if item.status != "resolved"), None)
if failed:
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
records = [
record
for item in resolved
for record in (item.records or ((item.record,) if item.record is not None else ()))
]
if not records or any(record.kind != "face" for record in records):
raise ValueError("shell selectors must resolve to faces")
target_ids = {record.body_id for record in records}
if len(target_ids) != 1:
raise ValueError("shell faces must belong to one target body")
target_id = next(iter(target_ids))
members = session.adapter.body_solids(session.body)
if len(members) == 1:
target = members[0]
else:
if target_id is None or session.body_id is None:
raise ValueError("shell target body is unresolved")
prefix = f"{session.body_id}:"
if not target_id.startswith(prefix):
raise ValueError("shell target body is outside the active body set")
try:
member_index = int(target_id[len(prefix):])
except ValueError as error:
raise ValueError("shell target body has an invalid member id") from error
if member_index < 0 or member_index >= len(members):
raise ValueError("shell target body member is unavailable")
target = members[member_index]
target_feature_id = node.params.get("target_feature_id")
if target_feature_id is not None:
if not isinstance(target_feature_id, str) or not target_feature_id:
raise ValueError("shell target_feature_id is invalid")
declared = session.body_members.get(target_feature_id)
if declared is None:
raise ValueError("shell target body is no longer an independently selectable member")
declared_solids = session.adapter.body_solids(declared)
if len(declared_solids) != 1:
raise ValueError("shell target body must resolve to exactly one active solid")
if not declared_solids[0].is_same(target):
raise ValueError("shell target body does not match the resolved face member")
return target, [record.value for record in records]
def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any:
# 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。
members = session.adapter.body_solids(session.body)
if len(members) == 1:
return replacement
replaced = False
result = None
for member in members:
if member.is_same(target):
result = session.adapter.combine(result, replacement)
replaced = True
else:
result = session.adapter.combine(result, member)
if not replaced or result is None:
raise ValueError("shell target solid is no longer part of the active body")
return result