feat(cadfs): expand reconstruction capability coverage

This commit is contained in:
2026-09-09 10:11:46 +08:00
parent 5bd1011335
commit 2f89d7930c
21 changed files with 8373 additions and 418 deletions
+480 -17
View File
@@ -12,17 +12,20 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable
from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
from .runtime_types import (
CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic,
pattern_instance_member_id, transform_copy_member_id,
)
from .operation_contracts import materialized_feature_contracts
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_PRIMARY_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface",
"extrude_cut_through",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
@@ -36,11 +39,18 @@ _ACTIVE_BODY_REQUIRED = frozenset({
"thread_cut",
})
_BODY_MUTATING_ATOMICS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell", "boolean_bodies",
})
# ``ExecutionSession.body_members`` only contains independently selectable
# body outputs. A normal additive/cut/dress-up feature replaces the active
# aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two
# contracts that preserve a previous member. Circular patterns over those
# members can additionally expose a proven COPY instance; replayed/fused
# patterns remain ineligible because no exact instance ownership exists.
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
# A pattern may replay a previous pattern as well as a direct body mutation.
# Context-only features have no geometry definition to instance. thread_add,
# thread_cut and bend_add are excluded: pattern translation does not yet move
@@ -61,6 +71,167 @@ _EXTENT_TARGET_KINDS = {
}
def _mappings(value: Any):
"""Yield nested feature mappings for capability-only contract checks."""
if isinstance(value, dict):
yield value
for child in value.values():
yield from _mappings(child)
elif isinstance(value, list):
for child in value:
yield from _mappings(child)
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
"""Return internal body-member keys named by a transform contract."""
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
source_ids.update(
pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
int(reference.get("instance_index") or 0),
)
for reference in params.get("pattern_instance_refs") or ()
if isinstance(reference, dict)
)
source_ids.update(
transform_copy_member_id(
str(reference.get("transform_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
)
for reference in params.get("transform_copy_refs") or ()
if isinstance(reference, dict)
)
return source_ids
def _pattern_instance_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
"""Project structured pattern refs to internal body-member keys."""
return {
pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
int(reference.get("instance_index") or 0),
)
for reference in params.get(parameter) or ()
if isinstance(reference, dict)
}
def _next_body_graph(
node: FeaturePlanNode,
members: set[str],
has_active_body: bool,
nodes_by_id: dict[str, FeaturePlanNode],
) -> tuple[set[str], bool]:
"""Project the explicit runtime body-member lifecycle without geometry.
The capability phase cannot know whether two B-reps intersect, but it can
mirror the ownership contract used by ``ExecutionSession``. This keeps a
historical feature's successful execution separate from its continued
availability as an independently selectable body. In particular, this
must never turn an absorbed feature or pattern replay into ``session.body``.
"""
atomic_id = node.atomic_id
feature_id = node.feature_id
if atomic_id == "boolean_bodies":
target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs"))
tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()}
tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs"))
next_members = members - target_ids - tool_ids
next_members.add(feature_id)
if bool(node.params.get("keep_tools")):
next_members.update(tool_ids)
return next_members, True
if atomic_id == "transform_bodies":
source_ids = _transform_member_sources(node.params)
next_members = set(members)
if not bool(node.params.get("make_copy")):
next_members.difference_update(source_ids)
next_members.add(feature_id)
elif len(node.params.get("source_feature_ids") or ()) > 1:
# Multi-source COPY outputs have no aggregate body-member owner.
# Keep each transformed source addressable by its exact origin.
next_members.update(
transform_copy_member_id(feature_id, source_id)
for source_id in source_ids
)
else:
next_members.add(feature_id)
return next_members, True
if atomic_id == "delete_bodies":
source_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
next_members = members - source_ids
return next_members, bool(next_members)
if atomic_id in _PATTERN_ATOMICS:
source_ids = {str(value) for value in node.params.get("source_feature_ids") or ()}
if (
atomic_id == "pattern_mirror"
and source_ids
and source_ids <= members
and all(
(source := nodes_by_id.get(source_id)) is not None
and source.params.get("result_mode") == "new_body"
for source_id in source_ids
)
):
# A mirror produces an independently addressable COPY only when
# every source is an explicit NEW body. A hole, dress-up, or
# ordinary additive feature may be the current aggregate's
# successor, not a standalone body: its mirror must remain a
# feature replay and cannot be exposed as a body member.
next_members = set(members)
next_members.update(
pattern_instance_member_id(node.feature_id, source_id, 1)
for source_id in source_ids
)
return next_members, True
if (
atomic_id == "pattern_circular"
and str(node.params.get("operation_mode") or "add") == "add"
and source_ids
and source_ids <= members
):
count = int(node.params.get("pattern_count") or 0)
excluded = {int(value) for value in node.params.get("excluded_instance_indices") or ()}
next_members = set(members)
for instance in range(1, count):
if instance in excluded:
continue
next_members.update(
pattern_instance_member_id(node.feature_id, source_id, instance)
for source_id in source_ids
)
return next_members, True
# Replay/fused pattern output has no member-level contract. It creates
# active geometry, but cannot prove which replay instance a later body
# query names.
return set(), has_active_body
if atomic_id not in _BODY_MUTATING_ATOMICS:
return members, has_active_body
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
# Primary cuts execute per explicit member in the runtime so a later
# body query still addresses the same CADFS NEW/COPY lifecycle node.
return set(members), True
if atomic_id in _PRIMARY_ATOMICS and "cut" not in atomic_id and node.params.get("operation") != "cut" and node.params.get("result_mode") == "new_body":
return members | {feature_id}, True
# All remaining body-mutating executors register their output as the sole
# explicit member. This includes fused additive features, cuts and
# dress-ups, whose source-member topology no longer has an identity.
return {feature_id}, True
def _has_explicit_axis(axis: Any) -> bool:
return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None
@@ -171,6 +342,13 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
def _has_closed_region(sketch: dict[str, Any]) -> bool:
"""Mirror the adapter's input contract without importing the geometry kernel."""
profile = sketch.get("profile") or {}
if profile.get("type") == "planar_imprint":
# The exact bounded-region proof happens in the OCC splitter. At this
# stage the typed contract proves only that region selection work is
# possible; an unbounded or ambiguous runtime result remains a stable
# feature execution diagnostic rather than a guessed sketch contour.
return bool(sketch.get("imprint_entities_mm") and sketch.get("imprint_selections"))
regions = sketch.get("contour_regions_mm") or []
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
return True
@@ -254,6 +432,7 @@ class CapabilityAnalyzer:
results: list[CapabilityResult] = []
completed: set[str] = set()
body_available = False
body_members: set[str] = set()
for node in plan:
blockers: list[RuntimeDiagnostic] = []
contract = self.contracts.get(node.atomic_id)
@@ -267,7 +446,9 @@ class CapabilityAnalyzer:
for dependency in node.depends_on:
if dependency not in completed:
blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency))
if node.atomic_id in _ACTIVE_BODY_REQUIRED:
if node.atomic_id in _ACTIVE_BODY_REQUIRED or (
node.atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
required.append("active_body")
if not body_available:
blockers.append(self._blocker(
@@ -362,6 +543,74 @@ class CapabilityAnalyzer:
"Loft currently requires exactly one outer profile without holes",
sketch_id=sketch_id,
))
for selector in _mappings(params):
if selector.get("output_role") is not None:
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector_context",
"Feature output role selectors are only supported in feature.selectors",
))
for selector_index, selector in enumerate(node.selectors):
if selector.get("output_role") is None:
continue
required.append("selector:feature_output_role")
if contract is None or contract.get("selector_slot") != "feature.selectors" or contract.get("selector_token_kind") != "face":
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector",
"This feature contract cannot consume a feature output role selector",
selector_index=selector_index,
))
if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector requires kind face and owner_feature_id",
selector_index=selector_index,
))
if selector.get("source") != "runtime_snapshot":
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector requires runtime_snapshot evidence",
selector_index=selector_index,
))
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
blockers.append(self._blocker(
node.feature_id,
"invalid_output_role_selector",
"A feature output role selector cannot mix stable or geometry evidence",
selector_index=selector_index,
))
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
source = nodes_by_id.get(str(source_owner or ""))
source_params = (source.params if source is not None else {}) or {}
if not isinstance(source_owner, str) or not isinstance(source_role, str):
blockers.append(self._blocker(
node.feature_id, "invalid_output_role_source",
"An output role source requires owner_feature_id and output_role",
selector_index=selector_index,
))
elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell":
blockers.append(self._blocker(
node.feature_id, "unsupported_output_role_source",
"Output role sources are currently supported only for shell.offset_face",
selector_index=selector_index,
))
elif source_role not in {"extrude.start", "extrude.end"} or (
source is None
or source.atomic_id != "extrude_add_blind"
or source_params.get("result_mode") != "new_body"
or (source_params.get("end_condition") or {}).get("type") != "blind"
):
blockers.append(self._blocker(
node.feature_id, "unsupported_output_role_source",
"shell.offset_face requires a direct new_body blind extrusion cap source",
selector_index=selector_index,
))
if node.atomic_id == "sweep_add":
path = params.get("path")
segment = path.get("segment") if isinstance(path, dict) else None
@@ -389,18 +638,29 @@ class CapabilityAnalyzer:
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
target_instance_refs = params.get("target_pattern_instance_refs")
tool_ids = params.get("tool_feature_ids")
tool_instance_refs = params.get("tool_pattern_instance_refs")
operation = params.get("operation")
if operation not in {"union", "subtract", "intersect"}:
blockers.append(self._blocker(
node.feature_id, "unsupported_boolean_operation",
"booleanBodies requires union, subtract or intersect",
))
for parameter, feature_ids in (("target_feature_ids", target_ids), ("tool_feature_ids", tool_ids)):
if not isinstance(feature_ids, list) or not feature_ids:
target_members: set[str] = set()
tool_members: set[str] = set()
for parameter, instance_parameter, feature_ids, instance_refs, selected_members in (
("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members),
("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members),
):
if feature_ids is None:
feature_ids = []
if instance_refs is None:
instance_refs = []
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs):
blockers.append(self._blocker(
node.feature_id, "missing_boolean_bodies",
"booleanBodies requires explicit target and tool feature ids", parameter=parameter,
"booleanBodies requires explicit target and tool body references", parameter=parameter,
))
continue
for feature_id in feature_ids:
@@ -408,18 +668,222 @@ class CapabilityAnalyzer:
if source is None:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature does not exist", feature_id=feature_id,
"booleanBodies source feature does not exist", source_feature_id=feature_id,
))
elif source.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"booleanBodies source feature did not become executable", feature_id=feature_id,
"booleanBodies source feature did not become executable", source_feature_id=feature_id,
))
if isinstance(target_ids, list) and isinstance(tool_ids, list) and set(target_ids) & set(tool_ids):
elif source.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "boolean_body_unavailable",
"Selected source no longer has an independently selectable body output",
source_feature_id=feature_id,
))
else:
selected_members.add(str(feature_id))
for index, reference in enumerate(instance_refs):
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference must be an object", parameter=instance_parameter, index=index,
))
continue
instance = reference.get("instance_index")
if not isinstance(instance, int):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference requires an integer instance_index",
parameter=instance_parameter, index=index,
))
continue
member_id = pattern_instance_member_id(
str(reference.get("pattern_feature_id") or ""),
str(reference.get("source_feature_id") or ""),
instance,
)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance has no independently selectable body output",
pattern_feature_id=reference.get("pattern_feature_id"),
source_feature_id=reference.get("source_feature_id"),
instance_index=reference.get("instance_index"),
))
continue
selected_members.add(member_id)
if target_members & tool_members:
blockers.append(self._blocker(
node.feature_id, "boolean_body_overlap",
"booleanBodies targets and tools must be disjoint",
))
if node.atomic_id == "shell" and params.get("target_feature_id") is not None:
target_feature_id = params.get("target_feature_id")
required.append("shell:explicit_target_body")
target = nodes_by_id.get(str(target_feature_id or ""))
if not isinstance(target_feature_id, str) or not target_feature_id:
blockers.append(self._blocker(
node.feature_id, "invalid_shell_target_body",
"shell target_feature_id must name one preceding body member",
))
elif target is None:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target body feature does not exist",
target_feature_id=target_feature_id,
))
elif target.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target body feature did not become executable",
target_feature_id=target_feature_id,
))
elif target.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "shell_target_body_unavailable",
"shell target no longer has an independently selectable body output",
target_feature_id=target_feature_id,
))
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
source_ids = params.get(parameter)
pattern_instances = params.get("pattern_instance_refs") if node.atomic_id == "transform_bodies" else []
transform_copies = params.get("transform_copy_refs") if node.atomic_id == "transform_bodies" else []
if source_ids is None:
source_ids = []
if pattern_instances is None:
pattern_instances = []
if transform_copies is None:
transform_copies = []
if (
not isinstance(source_ids, list)
or not isinstance(pattern_instances, list)
or not isinstance(transform_copies, list)
or not (source_ids or pattern_instances or transform_copies)
):
blockers.append(self._blocker(
node.feature_id, "missing_body_sources",
f"{node.atomic_id} requires explicit source body feature ids", parameter=parameter,
))
else:
for source_id in source_ids:
source = nodes_by_id.get(str(source_id))
if source is None:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected body source feature does not exist", source_feature_id=source_id,
))
elif source.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected body source did not become executable", source_feature_id=source_id,
))
elif source.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "body_source_unavailable",
"Selected source no longer has an independently selectable body output",
source_feature_id=source_id,
))
for reference in pattern_instances:
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_pattern_instance_ref",
"Pattern instance body reference must be an object",
))
continue
pattern_id = str(reference.get("pattern_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
instance = reference.get("instance_index")
pattern = nodes_by_id.get(pattern_id)
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance owner is not a preceding circular or mirror pattern",
pattern_feature_id=pattern_id,
))
continue
if pattern.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance owner did not become executable",
pattern_feature_id=pattern_id,
))
continue
count = int(pattern.params.get("pattern_count") or 0)
excluded = {int(value) for value in pattern.params.get("excluded_instance_indices") or ()}
surviving_instance = (
isinstance(instance, int)
and (
(pattern.atomic_id == "pattern_mirror" and instance == 1)
or (
pattern.atomic_id == "pattern_circular"
and 1 <= instance < count
and instance not in excluded
)
)
)
if (
not surviving_instance
or source_id not in {str(value) for value in pattern.params.get("source_feature_ids") or ()}
):
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance reference is not a surviving source copy",
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
))
continue
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "pattern_instance_unavailable",
"Pattern instance has no independently selectable body output",
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
))
for reference in transform_copies:
if not isinstance(reference, dict):
blockers.append(self._blocker(
node.feature_id, "invalid_transform_copy_ref",
"Transform COPY body reference must be an object",
))
continue
transform_id = str(reference.get("transform_feature_id") or "")
source_id = str(reference.get("source_feature_id") or "")
transform = nodes_by_id.get(transform_id)
if transform is None or transform.atomic_id != "transform_bodies":
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY owner is not a preceding body transform",
transform_feature_id=transform_id,
))
continue
if transform.feature_id not in completed:
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY owner did not become executable",
transform_feature_id=transform_id,
))
continue
transform_sources = transform.params.get("source_feature_ids") or []
if (
not bool(transform.params.get("make_copy"))
or not isinstance(transform_sources, list)
or len(transform_sources) < 2
or source_id not in {str(value) for value in transform_sources}
):
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY reference is not a source-qualified multi-body copy",
transform_feature_id=transform_id, source_feature_id=source_id,
))
continue
member_id = transform_copy_member_id(transform_id, source_id)
if member_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "transform_copy_unavailable",
"Transform COPY source has no independently selectable body output",
transform_feature_id=transform_id, source_feature_id=source_id,
))
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
end_condition = params.get("end_condition") or {"type": "blind"}
end_type = end_condition.get("type")
@@ -429,7 +893,7 @@ class CapabilityAnalyzer:
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
if (
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind"}
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind", "extrude_from_face"}
or end_type != "blind"
):
blockers.append(self._blocker(
@@ -469,7 +933,7 @@ class CapabilityAnalyzer:
node.feature_id, "missing_offset_distance",
"Offset-from-surface requires a non-zero captured offset distance",
))
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}:
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")):
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
reverse_type = reverse_condition.get("type")
required.append(f"extent:reverse:{reverse_type}")
@@ -501,7 +965,7 @@ class CapabilityAnalyzer:
))
if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
if node.atomic_id in {"extrude_add_blind_with_hole", "loft_add_with_cap_face"}:
if node.atomic_id in {"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face"}:
face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"]
if len(face_selectors) != 1 or len(node.selectors) != 1:
blockers.append(self._blocker(
@@ -604,10 +1068,9 @@ class CapabilityAnalyzer:
results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
if status == "executable":
completed.add(node.feature_id)
if node.atomic_id in _BODY_MUTATING_ATOMICS:
body_available = True
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
body_producers = {
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided",
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "bend_add",