5ffb106f36
Phase 3 of the decoupling refactor (behavior-preserving): - registry.py: atomic_executor decorator, ALL_ATOMIC_IDS with fail-fast registration validation, execute_node dispatcher - executors/: one module per family (extrude, revolve, surfaces, loft_sweep, bodies, context, primitives, parametric, holes, dressup, patterns) + shared helpers in executors/common - executors/__init__: explicit aggregation + completeness check (registry must cover every declared atomic id at import time) - runtime.py: slimmed to entry points (analyze_cdsl/rebuild_cdsl) plus full historical re-exports incl. test-referenced privates Adding an atomic operation now touches only one executor module and its schema contract; the shared registry never changes. Verified against baseline: zero new failures.
489 lines
23 KiB
Python
489 lines
23 KiB
Python
"""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
|
||
|
||
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 _extruded_tool(
|
||
node: FeaturePlanNode,
|
||
faces: list[Any],
|
||
profile_normal: Vector3,
|
||
session: "ExecutionSession",
|
||
) -> tuple[Any, TopologyDelta | None]:
|
||
"""Build one extrude tool, retaining caps only from one exact builder result."""
|
||
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_delta: TopologyDelta | None = None
|
||
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,
|
||
)
|
||
solids.append(solid)
|
||
else:
|
||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
|
||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||
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")
|
||
return tool, topology_delta
|
||
|
||
|
||
def _apply_primary_tool(
|
||
node: FeaturePlanNode,
|
||
session: "ExecutionSession",
|
||
tool: Any,
|
||
*,
|
||
cutting: bool,
|
||
topology_delta: TopologyDelta | None = None,
|
||
) -> FeatureResult:
|
||
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||
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)
|
||
body = session.adapter.cut(session.body, tool)
|
||
topology_delta = 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
|
||
session.register_body(
|
||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||
)
|
||
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 = session.adapter.faces_for_sketch(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
|
||
# 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,
|
||
)
|
||
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 含
|
||
# reverse,cdsl_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,
|
||
)
|
||
|
||
|
||
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,
|
||
) -> 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)
|
||
return
|
||
fused = session.adapter.fuse(session.body, solid)
|
||
session.register_body(node.feature_id, fused, replay_node=node)
|
||
|
||
|
||
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:
|
||
if item.record.kind == "edge":
|
||
edges.append(item.record.value)
|
||
elif item.record.kind == "face":
|
||
edges.extend(edge for edge in item.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 = [item.record for item in resolved if item.record is not None]
|
||
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
|