feat(cadfs): expand reconstruction capability coverage
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user