feat(cadfs): expand selector provenance coverage
This commit is contained in:
@@ -8,6 +8,7 @@ state from registered atomic executors, profile support, and complete inputs.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
@@ -17,7 +18,23 @@ from .runtime_types import (
|
||||
pattern_instance_member_id, transform_copy_member_id,
|
||||
)
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
|
||||
from .selector_capabilities import (
|
||||
is_direct_blind_extrude_cap_output_role,
|
||||
is_immediate_retained_source_prism_swept_face_extent,
|
||||
is_direct_prism_shell_offset_edge_tdd,
|
||||
is_direct_prism_shell_offset_edge_vertex,
|
||||
is_initial_direct_loft_cap_output_role,
|
||||
is_initial_direct_sweep_cap_edge,
|
||||
is_initial_direct_sweep_cap_output_role,
|
||||
is_initial_direct_sweep_swept_edge,
|
||||
is_initial_direct_sweep_swept_face,
|
||||
is_initial_two_sided_circle_shell_cap_output_role,
|
||||
is_planar_imprint_extrude_cap_output_role,
|
||||
is_primary_add_dressup_cap_output_role,
|
||||
is_primary_add_shell_cap_output_role,
|
||||
is_primary_add_up_to_surface_cap_output_role,
|
||||
is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair,
|
||||
)
|
||||
|
||||
|
||||
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
|
||||
@@ -61,7 +78,7 @@ _OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if
|
||||
_PRIMARY_ATOMICS = frozenset({
|
||||
"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",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "sweep_cut", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
})
|
||||
_ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"])
|
||||
@@ -74,6 +91,148 @@ _BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() i
|
||||
_REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"])
|
||||
|
||||
|
||||
def _sweep_segmented_path_error(segments: list[Any]) -> tuple[str, str] | None:
|
||||
"""Validate the self-contained, ordered open-wire CDSL path contract."""
|
||||
if len(segments) < 2 or not all(isinstance(item, dict) for item in segments):
|
||||
return "invalid_sweep_path", "Sweep segmented path requires at least two captured curve segments"
|
||||
|
||||
def point(value: Any) -> tuple[float, float] | None:
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
try:
|
||||
result = (float(value[0]), float(value[1]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
def same(left: tuple[float, float], right: tuple[float, float]) -> bool:
|
||||
return math.dist(left, right) <= 1e-8
|
||||
|
||||
endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
for item in segments:
|
||||
kind = item.get("type")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
return "unsupported_sweep_path", "Sweep segmented path supports only line, arc, or B-spline segments"
|
||||
if kind == "bspline":
|
||||
points = item.get("points")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
return "invalid_sweep_path", "Sweep B-spline path requires at least two interpolation points"
|
||||
converted = [point(value) for value in points]
|
||||
if any(value is None for value in converted):
|
||||
return "invalid_sweep_path", "Sweep B-spline path requires two-dimensional interpolation points"
|
||||
if item.get("periodic"):
|
||||
return "invalid_sweep_path", "Sweep segmented path requires open, non-periodic B-spline segments"
|
||||
start, end = converted[0], converted[-1]
|
||||
has_start_tangent = item.get("start_tangent") is not None
|
||||
has_end_tangent = item.get("end_tangent") is not None
|
||||
if has_start_tangent != has_end_tangent:
|
||||
return "invalid_sweep_path", "Sweep B-spline path requires both endpoint tangents when either is provided"
|
||||
if has_start_tangent and (point(item.get("start_tangent")) is None or point(item.get("end_tangent")) is None):
|
||||
return "invalid_sweep_path", "Sweep B-spline path tangents require two finite coordinates"
|
||||
if len(converted) == 2 and not has_start_tangent:
|
||||
return "invalid_sweep_path", "A two-point B-spline sweep path requires both endpoint tangents"
|
||||
else:
|
||||
start, end = point(item.get("start")), point(item.get("end"))
|
||||
if start is None or end is None:
|
||||
return "invalid_sweep_path", "Sweep segmented line and arc paths require two-dimensional start and end points"
|
||||
if kind == "arc" and (
|
||||
point(item.get("center")) is None
|
||||
or not isinstance(item.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(item["radius_mm"]))
|
||||
or float(item["radius_mm"]) <= 0
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep arc path requires a finite center and positive source radius"
|
||||
if start is None or end is None or same(start, end):
|
||||
return "invalid_sweep_path", "Sweep segmented path contains a degenerate curve segment"
|
||||
endpoints.append((start, end))
|
||||
|
||||
if any(not same(left[1], right[0]) for left, right in zip(endpoints, endpoints[1:])):
|
||||
return "invalid_sweep_path", "Sweep segmented path must be source-ordered and connected"
|
||||
vertices = [endpoints[0][0], *(end for _start, end in endpoints)]
|
||||
if any(
|
||||
later > index + 1 and same(left, right)
|
||||
for index, left in enumerate(vertices)
|
||||
for later, right in enumerate(vertices[index + 1:], start=index + 1)
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep segmented path must be an open non-branching wire"
|
||||
return None
|
||||
|
||||
|
||||
def _spatial_sweep_segmented_path_error(segments: list[Any]) -> tuple[str, str] | None:
|
||||
"""Validate ordered global sweep curves from multiple source sketch frames."""
|
||||
if len(segments) < 2 or not all(isinstance(item, dict) for item in segments):
|
||||
return "invalid_sweep_path", "Sweep spatial path requires at least two captured curve segments"
|
||||
|
||||
def point(value: Any) -> tuple[float, float, float] | None:
|
||||
if not isinstance(value, list) or len(value) != 3:
|
||||
return None
|
||||
try:
|
||||
result = tuple(float(component) for component in value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
def same(left: tuple[float, float, float], right: tuple[float, float, float]) -> bool:
|
||||
return math.dist(left, right) <= 1e-8
|
||||
|
||||
endpoints: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
source_entities: set[tuple[str, str]] = set()
|
||||
for item in segments:
|
||||
if not isinstance(item.get("source_sketch_id"), str) or not item["source_sketch_id"] or not isinstance(item.get("source_entity_id"), str) or not item["source_entity_id"]:
|
||||
return "invalid_sweep_path", "Sweep spatial path requires explicit source sketch and entity identities"
|
||||
source_entity = (item["source_sketch_id"], item["source_entity_id"])
|
||||
if source_entity in source_entities:
|
||||
return "invalid_sweep_path", "Sweep spatial path must not repeat one source sketch entity"
|
||||
source_entities.add(source_entity)
|
||||
kind = item.get("type")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
return "unsupported_sweep_path", "Sweep spatial path supports only line, arc, or B-spline segments"
|
||||
if kind == "bspline":
|
||||
points = item.get("points_mm")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path requires at least two interpolation points"
|
||||
converted = [point(value) for value in points]
|
||||
if any(value is None for value in converted):
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path requires three-dimensional interpolation points"
|
||||
if item.get("periodic"):
|
||||
return "invalid_sweep_path", "Sweep spatial path requires open, non-periodic B-spline segments"
|
||||
start, end = converted[0], converted[-1]
|
||||
has_start_tangent = item.get("start_tangent_mm") is not None
|
||||
has_end_tangent = item.get("end_tangent_mm") is not None
|
||||
if has_start_tangent != has_end_tangent:
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path requires both endpoint tangents when either is provided"
|
||||
if has_start_tangent and (point(item.get("start_tangent_mm")) is None or point(item.get("end_tangent_mm")) is None):
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path tangents require three finite coordinates"
|
||||
if len(converted) == 2 and not has_start_tangent:
|
||||
return "invalid_sweep_path", "A two-point spatial B-spline sweep path requires both endpoint tangents"
|
||||
else:
|
||||
start, end = point(item.get("start_mm")), point(item.get("end_mm"))
|
||||
if start is None or end is None:
|
||||
return "invalid_sweep_path", "Sweep spatial line and arc paths require three-dimensional start and end points"
|
||||
if kind == "arc" and (
|
||||
point(item.get("center_mm")) is None
|
||||
or point(item.get("normal")) is None
|
||||
or not isinstance(item.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(item["radius_mm"]))
|
||||
or float(item["radius_mm"]) <= 0
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep spatial arc path requires a finite center, normal, and positive source radius"
|
||||
if start is None or end is None or same(start, end):
|
||||
return "invalid_sweep_path", "Sweep spatial path contains a degenerate curve segment"
|
||||
endpoints.append((start, end))
|
||||
|
||||
if any(not same(left[1], right[0]) for left, right in zip(endpoints, endpoints[1:])):
|
||||
return "invalid_sweep_path", "Sweep spatial path must be source-ordered and connected"
|
||||
vertices = [endpoints[0][0], *(end for _start, end in endpoints)]
|
||||
if any(
|
||||
later > index + 1 and same(left, right)
|
||||
for index, left in enumerate(vertices)
|
||||
for later, right in enumerate(vertices[index + 1:], start=index + 1)
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep spatial path must be an open non-branching wire"
|
||||
return None
|
||||
|
||||
|
||||
def _mappings(value: Any):
|
||||
"""Yield nested feature mappings for capability-only contract checks."""
|
||||
if isinstance(value, dict):
|
||||
@@ -85,6 +244,17 @@ def _mappings(value: Any):
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
def _query_selector_leaves(selector: dict[str, Any]):
|
||||
"""Yield only executable leaves of a recursive QUERY_SET selector."""
|
||||
operands = selector.get("query_operands")
|
||||
if not isinstance(operands, list) or not operands:
|
||||
yield selector
|
||||
return
|
||||
for operand in operands:
|
||||
if isinstance(operand, dict):
|
||||
yield from _query_selector_leaves(operand)
|
||||
|
||||
|
||||
def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Read only the selector slot declared by the operation contract."""
|
||||
slot = str((contract or {}).get("selector_slot") or "")
|
||||
@@ -105,7 +275,7 @@ def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[s
|
||||
not isinstance(policy, dict)
|
||||
or policy.get("end_condition_type") != "up_to_surface"
|
||||
or policy.get("token_kind") != "face"
|
||||
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
|
||||
or policy.get("output_role_contract") != "direct_or_primary_add_blind_extrude_cap"
|
||||
or policy.get("requires_immediate_owner") is not True
|
||||
):
|
||||
return None
|
||||
@@ -116,6 +286,63 @@ def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[s
|
||||
return None
|
||||
|
||||
|
||||
def _retained_source_prism_swept_face_extent_reference(node: FeaturePlanNode) -> dict[str, Any] | None:
|
||||
"""Return the one non-output-role wall selector admitted for an extent."""
|
||||
end_condition = node.params.get("end_condition") or {}
|
||||
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
|
||||
intent = reference.get("selector_intent") if isinstance(reference, dict) else None
|
||||
if (
|
||||
end_condition.get("type") == "up_to_surface"
|
||||
and isinstance(intent, dict)
|
||||
and intent.get("consumer_contract") == "immediate_retained_source_prism_swept_face_up_to_surface"
|
||||
):
|
||||
return reference
|
||||
return None
|
||||
|
||||
|
||||
def _two_sided_up_to_surface_cap_pair_references(node: FeaturePlanNode, contract: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
"""Return the complete forward/reverse pair for the symmetric CAP contract."""
|
||||
policies = ((contract or {}).get("nested_selector_policies") or {})
|
||||
required = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair"
|
||||
forward_policy = policies.get("params.end_condition.reference")
|
||||
reverse_policy = policies.get("params.reverse_end_condition.reference")
|
||||
if not (
|
||||
isinstance(forward_policy, dict)
|
||||
and isinstance(reverse_policy, dict)
|
||||
and forward_policy.get("output_role_contract") == required
|
||||
and reverse_policy.get("output_role_contract") == required
|
||||
and forward_policy.get("requires_immediate_owner") is True
|
||||
and reverse_policy.get("requires_immediate_owner") is True
|
||||
):
|
||||
return None
|
||||
forward = node.params.get("end_condition") or {}
|
||||
reverse = node.params.get("reverse_end_condition") or {}
|
||||
forward_reference = forward.get("reference") if isinstance(forward, dict) else None
|
||||
reverse_reference = reverse.get("reference") if isinstance(reverse, dict) else None
|
||||
if (
|
||||
forward.get("type") == "up_to_surface"
|
||||
and reverse.get("type") == "up_to_surface"
|
||||
and isinstance(forward_reference, dict)
|
||||
and isinstance(reverse_reference, dict)
|
||||
and forward_reference.get("output_role") is not None
|
||||
and reverse_reference.get("output_role") is not None
|
||||
):
|
||||
return forward_reference, reverse_reference
|
||||
return None
|
||||
|
||||
|
||||
def _transform_copy_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
|
||||
"""Project source-qualified multi-body COPY refs to body-member keys."""
|
||||
return {
|
||||
transform_copy_member_id(
|
||||
str(reference.get("transform_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
)
|
||||
for reference in params.get(parameter) or ()
|
||||
if isinstance(reference, dict)
|
||||
}
|
||||
|
||||
|
||||
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 ()}
|
||||
@@ -128,14 +355,7 @@ def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
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)
|
||||
)
|
||||
source_ids.update(_transform_copy_member_sources(params, "transform_copy_refs"))
|
||||
return source_ids
|
||||
|
||||
|
||||
@@ -172,8 +392,10 @@ def _next_body_graph(
|
||||
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"))
|
||||
target_ids.update(_transform_copy_member_sources(node.params, "target_transform_copy_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"))
|
||||
tool_ids.update(_transform_copy_member_sources(node.params, "tool_transform_copy_refs"))
|
||||
next_members = members - target_ids - tool_ids
|
||||
next_members.add(feature_id)
|
||||
if bool(node.params.get("keep_tools")):
|
||||
@@ -370,7 +592,7 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
|
||||
required.add(str(feature["sketch_id"]))
|
||||
if atomic_id in {"loft_add", "loft_add_with_cap_face"}:
|
||||
if atomic_id in {"loft_add", "loft_add_with_cap_face", "loft_surface"}:
|
||||
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
|
||||
required.add(str(sketch_id))
|
||||
return frozenset(required)
|
||||
@@ -514,7 +736,14 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
if profile_type not in self.profile_types:
|
||||
blockers.append(self._blocker(node.feature_id, "unsupported_profile", "The current runtime cannot resolve the sketch profile", profile_type=profile_type))
|
||||
elif not resolution_error and not _has_closed_region(sketches[node.sketch_id]):
|
||||
elif (
|
||||
not resolution_error
|
||||
and not _has_closed_region(sketches[node.sketch_id])
|
||||
and not (
|
||||
node.atomic_id == "extrude_surface"
|
||||
and bool(sketches[node.sketch_id].get("surface_wires_mm"))
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "profile_no_closed_region",
|
||||
"The resolved sketch contains no closed profile region",
|
||||
@@ -531,7 +760,7 @@ class CapabilityAnalyzer:
|
||||
sketch_id=node.sketch_id,
|
||||
atomic_id=node.atomic_id,
|
||||
))
|
||||
if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}:
|
||||
if node.atomic_id in {"loft_add", "loft_add_with_cap_face", "loft_surface"}:
|
||||
profile_ids = params.get("profile_sketch_ids")
|
||||
minimum_profiles = 1 if node.atomic_id == "loft_add_with_cap_face" else 2
|
||||
if not isinstance(profile_ids, list) or len(profile_ids) < minimum_profiles:
|
||||
@@ -582,10 +811,27 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
contract_selectors = _contract_selectors(node, contract)
|
||||
extent_selector = _up_to_surface_output_role_reference(node, contract)
|
||||
two_sided_extent_pair = _two_sided_up_to_surface_cap_pair_references(node, contract)
|
||||
output_role_selectors = [
|
||||
*contract_selectors,
|
||||
*([extent_selector] if extent_selector is not None else []),
|
||||
*(list(two_sided_extent_pair) if two_sided_extent_pair is not None else []),
|
||||
]
|
||||
retained_source_swept_face_extent = _retained_source_prism_swept_face_extent_reference(node)
|
||||
if retained_source_swept_face_extent is not None:
|
||||
required.append("selector:retained_source_prism_swept_face_extent")
|
||||
if (
|
||||
previous_node is None
|
||||
or retained_source_swept_face_extent.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_immediate_retained_source_prism_swept_face_extent(
|
||||
retained_source_swept_face_extent, previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_retained_source_prism_swept_face_extent",
|
||||
"up_to_surface SWEPT_FACE requires the immediately preceding direct new_body prism with one exact retained source edge",
|
||||
))
|
||||
contract_selector_ids = {id(selector) for selector in output_role_selectors}
|
||||
selector_intent_ids = {
|
||||
id(selector.get("selector_intent"))
|
||||
@@ -609,16 +855,10 @@ class CapabilityAnalyzer:
|
||||
continue
|
||||
required.append("selector:feature_output_role")
|
||||
is_extent_reference = selector is extent_selector
|
||||
if (
|
||||
not is_extent_reference
|
||||
and (contract is None or not contract.get("selector_slot") 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,
|
||||
))
|
||||
is_two_sided_extent_reference = (
|
||||
two_sided_extent_pair is not None
|
||||
and any(selector is reference for reference in two_sided_extent_pair)
|
||||
)
|
||||
if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
@@ -642,30 +882,93 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
role_source = selector.get("output_role_source")
|
||||
selector_intent = selector.get("selector_intent")
|
||||
is_retained_shell_cap_offset_profile = (
|
||||
node.atomic_id == "extrude_from_face"
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_FACE"
|
||||
and selector_intent.get("consumer_contract")
|
||||
== "shell_retained_direct_prism_cap_offset_face_profile"
|
||||
and selector.get("output_role") == "shell.offset_face"
|
||||
and previous_node is not None
|
||||
and selector.get("owner_feature_id") == previous_node.feature_id
|
||||
)
|
||||
is_shell_cap_face_output_role = (
|
||||
node.atomic_id == "shell"
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
and selector.get("output_role") in {
|
||||
"extrude.start", "extrude.end", "loft.start", "loft.end", "sweep.start", "sweep.end",
|
||||
}
|
||||
)
|
||||
if is_extent_reference and (
|
||||
is_imprint_dressup_cap_output_role = (
|
||||
node.atomic_id in {"fillet", "chamfer"}
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
and selector_intent.get("consumer_contract") != "primary_add_dressup_union_continuation"
|
||||
)
|
||||
is_primary_add_dressup_cap_role = (
|
||||
node.atomic_id in {"fillet", "chamfer"}
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
and is_primary_add_dressup_cap_output_role(
|
||||
selector, previous_node.source_feature if previous_node is not None else None, sketches,
|
||||
)
|
||||
)
|
||||
if (
|
||||
not is_extent_reference
|
||||
and not is_two_sided_extent_reference
|
||||
and not is_imprint_dressup_cap_output_role
|
||||
and not is_primary_add_dressup_cap_role
|
||||
and (contract is None or not contract.get("selector_slot") 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 is_two_sided_extent_reference and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
or not is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair(
|
||||
two_sided_extent_pair[0], two_sided_extent_pair[1], previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_two_sided_extent_output_role_pair",
|
||||
"two-sided up_to_surface CAP roles require the immediately preceding direct symmetric new_body prism pair",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_extent_reference and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not (
|
||||
is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
or is_primary_add_up_to_surface_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_extent_output_role_selector",
|
||||
"up_to_surface output roles require the immediately preceding direct new_body blind extrusion cap",
|
||||
"up_to_surface output roles require the immediately preceding direct new_body or primary ADD blind extrusion cap",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_shell_cap_face_output_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
or not (
|
||||
is_direct_blind_extrude_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_primary_add_shell_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_initial_direct_loft_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_initial_direct_sweep_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_initial_two_sided_circle_shell_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
@@ -674,6 +977,139 @@ class CapabilityAnalyzer:
|
||||
"CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_primary_add_dressup_cap_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_cap_face_output_role_selector",
|
||||
"primary ADD CAP_FACE output roles require the immediately preceding blind extrusion",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_imprint_dressup_cap_output_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_planar_imprint_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_cap_face_output_role_selector",
|
||||
"IMPRINT CAP_FACE output roles require the immediately preceding complete new_body blind prism",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_primary_add_dressup_cap_role or is_imprint_dressup_cap_output_role:
|
||||
# The normal fillet/chamfer contract consumes edges, but a
|
||||
# complete CAP_FACE set is intentionally expanded into its
|
||||
# physical boundary edges by the shared dress-up executor.
|
||||
pass
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_tdd(
|
||||
selector,
|
||||
previous_node.source_feature,
|
||||
nodes_by_id.get(str(selector.get("owner_feature_id") or "")).source_feature
|
||||
if nodes_by_id.get(str(selector.get("owner_feature_id") or "")) is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_tdd_selector",
|
||||
"OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_vertex(
|
||||
selector,
|
||||
previous_node.source_feature,
|
||||
nodes_by_id.get(str(selector.get("owner_feature_id") or "")).source_feature
|
||||
if nodes_by_id.get(str(selector.get("owner_feature_id") or "")) is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_vertex_selector",
|
||||
"OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_EDGE"
|
||||
and selector_intent.get("lineage_role") in {"sweep.start", "sweep.end"}
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_initial_direct_sweep_cap_edge(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_sweep_cap_edge_selector",
|
||||
"CAP_EDGE sweep roles require the immediately preceding direct new_body one-edge sweep",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "SWEPT_FACE"
|
||||
and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_path"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_initial_direct_sweep_swept_face(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_sweep_swept_face_selector",
|
||||
"SWEPT_FACE sweep relations require the immediately preceding direct new_body analytic-profile sweep",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "SWEPT_EDGE"
|
||||
and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_vertex_path"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_initial_direct_sweep_swept_edge(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_sweep_swept_edge_selector",
|
||||
"SWEPT_EDGE sweep relations require the immediately preceding direct new_body analytic-profile sweep",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
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
|
||||
@@ -685,10 +1121,13 @@ class CapabilityAnalyzer:
|
||||
"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":
|
||||
elif (
|
||||
selector.get("output_role") != "shell.offset_face"
|
||||
or not (node.atomic_id == "shell" or is_retained_shell_cap_offset_profile)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_output_role_source",
|
||||
"Output role sources are currently supported only for shell.offset_face",
|
||||
"Output role sources are currently supported only for a shell offset-face contract",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif source_role not in {"extrude.start", "extrude.end"} or (
|
||||
@@ -702,14 +1141,110 @@ class CapabilityAnalyzer:
|
||||
"shell.offset_face requires a direct new_body blind extrusion cap source",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if node.atomic_id == "sweep_add":
|
||||
elif is_retained_shell_cap_offset_profile:
|
||||
shell = previous_node.source_feature or {}
|
||||
shell_selectors = shell.get("selectors") or []
|
||||
shell_params = shell.get("params") or {}
|
||||
removed = shell_selectors[0] if len(shell_selectors) == 1 else {}
|
||||
removed_intent = removed.get("selector_intent") if isinstance(removed, dict) else {}
|
||||
disambiguation = selector_intent.get("disambiguation") or {}
|
||||
source_ids = disambiguation.get("source_profile_entity_ids") if isinstance(disambiguation, dict) else None
|
||||
expected_retained = "extrude.end" if removed.get("output_role") == "extrude.start" else "extrude.start"
|
||||
if (
|
||||
shell.get("atomic_id") != "shell"
|
||||
or shell.get("depends_on") != [source_owner]
|
||||
or not isinstance(removed, dict)
|
||||
or removed.get("owner_feature_id") != source_owner
|
||||
or removed.get("output_role") not in {"extrude.start", "extrude.end"}
|
||||
or not isinstance(removed_intent, dict)
|
||||
or removed_intent.get("query_family") != "CAP_FACE"
|
||||
or source_role != expected_retained
|
||||
or not isinstance(source_ids, list)
|
||||
or not source_ids
|
||||
or len(set(source_ids)) != len(source_ids)
|
||||
or disambiguation.get("removed_cap_role") != removed.get("output_role")
|
||||
or params.get("operation") != "add"
|
||||
or params.get("result_mode") != "new_body"
|
||||
or (params.get("end_condition") or {}).get("type") != "blind"
|
||||
or params.get("two_sided")
|
||||
or params.get("draft") is not None
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_retained_shell_cap_offset_profile",
|
||||
"Retained shell cap profiles require one immediate opposite direct-prism cap removal",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
# Query-set parents carry no producer themselves. Their leaves
|
||||
# still need the same lifecycle proof as a direct selector before
|
||||
# runtime can attempt any recursive set evaluation.
|
||||
for selector_index, selector in enumerate(output_role_selectors):
|
||||
for leaf in _query_selector_leaves(selector):
|
||||
if leaf is selector:
|
||||
continue
|
||||
selector_intent = leaf.get("selector_intent")
|
||||
producer_node = nodes_by_id.get(str(leaf.get("owner_feature_id") or ""))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_tdd(
|
||||
leaf, previous_node.source_feature,
|
||||
producer_node.source_feature if producer_node is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_tdd_selector",
|
||||
"OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_vertex(
|
||||
leaf, previous_node.source_feature,
|
||||
producer_node.source_feature if producer_node is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_vertex_selector",
|
||||
"OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if node.atomic_id in {"sweep_add", "sweep_cut"}:
|
||||
path = params.get("path")
|
||||
segment = path.get("segment") if isinstance(path, dict) else None
|
||||
segments = path.get("segments") if isinstance(path, dict) else None
|
||||
kind = segment.get("type") if isinstance(segment, dict) else None
|
||||
if kind not in {"line", "bspline"}:
|
||||
if isinstance(segments, list):
|
||||
spatial = not isinstance(path.get("workplane"), dict) if isinstance(path, dict) else False
|
||||
error = _spatial_sweep_segmented_path_error(segments) if spatial else _sweep_segmented_path_error(segments)
|
||||
if error is not None:
|
||||
code, message = error
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, code, message,
|
||||
))
|
||||
elif kind not in {"line", "arc", "circle", "bspline"}:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_sweep_path",
|
||||
"Sweep requires one captured line or B-spline path",
|
||||
"Sweep requires one captured line, arc, circle, or B-spline path",
|
||||
))
|
||||
elif kind == "line" and not all(
|
||||
isinstance(segment.get(key), list) and len(segment[key]) == 2
|
||||
@@ -719,6 +1254,34 @@ class CapabilityAnalyzer:
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep line path requires two-dimensional start and end points",
|
||||
))
|
||||
elif kind == "arc" and (
|
||||
not all(
|
||||
isinstance(segment.get(key), list)
|
||||
and len(segment[key]) == 2
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in segment[key])
|
||||
for key in ("start", "end", "center")
|
||||
)
|
||||
or not isinstance(segment.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(segment["radius_mm"]))
|
||||
or float(segment["radius_mm"]) <= 0
|
||||
or not isinstance(segment.get("clockwise"), bool)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep arc path requires finite start, end, center, positive radius, and direction",
|
||||
))
|
||||
elif kind == "circle" and (
|
||||
not isinstance(segment.get("center"), list)
|
||||
or len(segment["center"]) != 2
|
||||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in segment["center"])
|
||||
or not isinstance(segment.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(segment["radius_mm"]))
|
||||
or float(segment["radius_mm"]) <= 0
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep circle path requires a finite center and positive radius",
|
||||
))
|
||||
elif kind == "bspline" and (
|
||||
not isinstance(segment.get("points"), list)
|
||||
or len(segment.get("points") or []) < 2
|
||||
@@ -744,8 +1307,10 @@ class CapabilityAnalyzer:
|
||||
if node.atomic_id == "boolean_bodies":
|
||||
target_ids = params.get("target_feature_ids")
|
||||
target_instance_refs = params.get("target_pattern_instance_refs")
|
||||
target_transform_refs = params.get("target_transform_copy_refs")
|
||||
tool_ids = params.get("tool_feature_ids")
|
||||
tool_instance_refs = params.get("tool_pattern_instance_refs")
|
||||
tool_transform_refs = params.get("tool_transform_copy_refs")
|
||||
operation = params.get("operation")
|
||||
if operation not in {"union", "subtract", "intersect"}:
|
||||
blockers.append(self._blocker(
|
||||
@@ -754,15 +1319,17 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
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),
|
||||
for parameter, instance_parameter, transform_parameter, feature_ids, instance_refs, transform_refs, selected_members in (
|
||||
("target_feature_ids", "target_pattern_instance_refs", "target_transform_copy_refs", target_ids, target_instance_refs, target_transform_refs, target_members),
|
||||
("tool_feature_ids", "tool_pattern_instance_refs", "tool_transform_copy_refs", tool_ids, tool_instance_refs, tool_transform_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):
|
||||
if transform_refs is None:
|
||||
transform_refs = []
|
||||
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not isinstance(transform_refs, list) or not (feature_ids or instance_refs or transform_refs):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_boolean_bodies",
|
||||
"booleanBodies requires explicit target and tool body references", parameter=parameter,
|
||||
@@ -818,6 +1385,45 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
continue
|
||||
selected_members.add(member_id)
|
||||
for index, reference in enumerate(transform_refs):
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_transform_copy_ref",
|
||||
"Transform COPY body reference must be an object", parameter=transform_parameter, index=index,
|
||||
))
|
||||
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" or transform.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY owner is not an executable preceding body transform",
|
||||
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,
|
||||
))
|
||||
continue
|
||||
selected_members.add(member_id)
|
||||
if target_members & tool_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "boolean_body_overlap",
|
||||
@@ -865,12 +1471,6 @@ class CapabilityAnalyzer:
|
||||
"hole scope body is no longer an independently selectable body output",
|
||||
scope_feature_id=scope_feature_id,
|
||||
))
|
||||
elif len(body_members) != 1:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "hole_scope_body_ambiguous",
|
||||
"hole scope body must be the sole active member",
|
||||
scope_feature_id=scope_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)
|
||||
@@ -1041,19 +1641,23 @@ class CapabilityAnalyzer:
|
||||
blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type))
|
||||
target_kind = _EXTENT_TARGET_KINDS.get(end_type or "")
|
||||
if target_kind:
|
||||
required.append(f"selector:extent_target:{target_kind}")
|
||||
reference = end_condition.get("reference")
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_extent_reference",
|
||||
"This end condition requires a captured target selector", extent=end_type,
|
||||
))
|
||||
elif end_type == "up_to_vertex" and reference.get("kind") == "source_vertex":
|
||||
required.append("source_vertex_extent")
|
||||
elif reference.get("kind") != target_kind:
|
||||
required.append(f"selector:extent_target:{target_kind}")
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_extent_target",
|
||||
"The captured target kind is incompatible with this end condition",
|
||||
extent=end_type, expected_kind=target_kind, actual_kind=reference.get("kind"),
|
||||
))
|
||||
else:
|
||||
required.append(f"selector:extent_target:{target_kind}")
|
||||
if end_type == "offset_from_surface" and abs(float(params.get("distance_mm") or 0.0)) <= 1e-12:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_offset_distance",
|
||||
@@ -1070,20 +1674,24 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
reverse_target_kind = _EXTENT_TARGET_KINDS.get(reverse_type or "")
|
||||
if reverse_target_kind:
|
||||
required.append(f"selector:reverse_extent_target:{reverse_target_kind}")
|
||||
reverse_reference = reverse_condition.get("reference")
|
||||
if not isinstance(reverse_reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_reverse_extent_reference",
|
||||
"This reverse end condition requires a captured target selector", extent=reverse_type,
|
||||
))
|
||||
elif reverse_type == "up_to_vertex" and reverse_reference.get("kind") == "source_vertex":
|
||||
required.append("source_vertex_reverse_extent")
|
||||
elif reverse_reference.get("kind") != reverse_target_kind:
|
||||
required.append(f"selector:reverse_extent_target:{reverse_target_kind}")
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_reverse_extent_target",
|
||||
"The reverse target kind is incompatible with this end condition",
|
||||
extent=reverse_type, expected_kind=reverse_target_kind,
|
||||
actual_kind=reverse_reference.get("kind"),
|
||||
))
|
||||
else:
|
||||
required.append(f"selector:reverse_extent_target:{reverse_target_kind}")
|
||||
if reverse_type == "offset_from_surface" and abs(float(params.get("reverse_distance_mm") or 0.0)) <= 1e-12:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_reverse_offset_distance",
|
||||
@@ -1129,6 +1737,33 @@ class CapabilityAnalyzer:
|
||||
blockers.append(self._blocker(node.feature_id, "missing_reference_orientation", "Reference plane requires an explicit plane frame"))
|
||||
if node.atomic_id == "reference_plane" and isinstance(params.get("plane"), dict) and params["plane"].get("unresolved"):
|
||||
blockers.append(self._blocker(node.feature_id, "missing_reference_orientation", "Reference plane orientation was not captured"))
|
||||
if node.atomic_id == "reference_point":
|
||||
point = params.get("point_mm")
|
||||
if (
|
||||
not isinstance(point, list)
|
||||
or len(point) != 3
|
||||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_reference_point",
|
||||
"Reference point requires one finite three-dimensional coordinate",
|
||||
))
|
||||
if node.atomic_id == "assign_variable":
|
||||
value = params.get("value")
|
||||
if (
|
||||
not isinstance(params.get("name"), str)
|
||||
or not params["name"]
|
||||
or not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
or params.get("value_kind") not in {"any", "length"}
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_assign_variable",
|
||||
"Source variable requires one finite named scalar value",
|
||||
))
|
||||
if node.atomic_id == "reference_axis":
|
||||
axis = params.get("axis") or {}
|
||||
if not (axis.get("origin_mm") and axis.get("direction")):
|
||||
@@ -1195,17 +1830,21 @@ class CapabilityAnalyzer:
|
||||
if status == "executable":
|
||||
completed.add(node.feature_id)
|
||||
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
||||
previous_node = node
|
||||
# Source variables affect lowering expressions only. They are not
|
||||
# topology producers and must not break an immediate selector's
|
||||
# producer/lifecycle relationship.
|
||||
if node.atomic_id != "assign_variable":
|
||||
previous_node = node
|
||||
body_producers = {
|
||||
"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",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "sweep_cut", "boolean_bodies",
|
||||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||||
"thread_add", "bend_add", "gear_add", "rack_add",
|
||||
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
|
||||
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
|
||||
"thread_cut",
|
||||
}
|
||||
surface_producers = {"extrude_surface", "revolve_surface"}
|
||||
surface_producers = {"extrude_surface", "revolve_surface", "loft_surface"}
|
||||
document_blockers: list[RuntimeDiagnostic] = []
|
||||
if not any(node.atomic_id in body_producers | surface_producers for node in plan):
|
||||
document_blockers.append(RuntimeDiagnostic(
|
||||
|
||||
Reference in New Issue
Block a user