feat(cadfs): expand selector provenance coverage

This commit is contained in:
2026-09-14 11:00:14 +08:00
parent 994d06aaea
commit de21ee8a5b
45 changed files with 19837 additions and 842 deletions
+690 -51
View File
@@ -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(