feat(cadfs): expand selector provenance coverage
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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(
|
||||
|
||||
@@ -95,12 +95,23 @@
|
||||
"properties": {
|
||||
"type": {"type": "string", "minLength": 1},
|
||||
"solidworks_code": {"type": "integer"},
|
||||
"reference": {"$ref": "#/$defs/selectorRef"},
|
||||
"reference": {"oneOf": [{"$ref": "#/$defs/selectorRef"}, {"$ref": "#/$defs/sourceVertexDatumRef"}]},
|
||||
"offset_mm": {"type": "number", "minimum": 0}
|
||||
},
|
||||
"required": ["type", "solidworks_code"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sourceVertexDatumRef": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {"const": "source_vertex"},
|
||||
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"point_mm": {"$ref": "#/$defs/point3"}
|
||||
},
|
||||
"required": ["kind", "source_sketch_id", "source_entity_id", "point_mm"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorOrUnresolved": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/selectorRef"},
|
||||
@@ -140,6 +151,14 @@
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
},
|
||||
"initial_output_roles": {"type": "boolean"},
|
||||
"cap_output_profile_sources": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
}
|
||||
},
|
||||
"required": ["profile_sketch_ids"],
|
||||
@@ -160,20 +179,92 @@
|
||||
"additionalProperties": false
|
||||
},
|
||||
"sweepPath": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workplane": {"$ref": "#/$defs/workplane"},
|
||||
"segment": {"$ref": "#/$defs/analyticSegment"}
|
||||
},
|
||||
"required": ["workplane", "segment"],
|
||||
"additionalProperties": false
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"workplane": {"$ref": "#/$defs/workplane"},
|
||||
"segment": {"$ref": "#/$defs/analyticSegment"},
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {"$ref": "#/$defs/analyticSegment"}
|
||||
}
|
||||
},
|
||||
"required": ["workplane"],
|
||||
"oneOf": [
|
||||
{"required": ["segment"], "not": {"required": ["segments"]}},
|
||||
{"required": ["segments"], "not": {"required": ["segment"]}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {"$ref": "#/$defs/spatialSweepSegment"}
|
||||
}
|
||||
},
|
||||
"required": ["segments"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"sweepParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"$ref": "#/$defs/sweepPath"},
|
||||
"is_frenet": {"type": "boolean"},
|
||||
"result_mode": {"enum": ["fuse", "new_body"]}
|
||||
"result_mode": {"enum": ["fuse", "new_body"]},
|
||||
"initial_output_roles": {"type": "boolean"},
|
||||
"cap_output_contract": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"profile_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_reversed": {"type": "boolean"}
|
||||
},
|
||||
"required": ["profile_source", "profile_entity", "path_source", "path_entity", "path_reversed"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"swept_face_contract": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"profile_entities": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"minItems": 1,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_reversed": {"type": "boolean"}
|
||||
},
|
||||
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"swept_edge_contract": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"profile_entities": {
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"minItems": 2,
|
||||
"uniqueItems": true
|
||||
},
|
||||
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"path_reversed": {"type": "boolean"}
|
||||
},
|
||||
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
@@ -405,14 +496,17 @@
|
||||
"operation": {"enum": ["union", "subtract", "intersect"]},
|
||||
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"target_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"keep_tools": {"type": "boolean"}
|
||||
"tool_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"keep_tools": {"type": "boolean"},
|
||||
"targetless_body_set": {"type": "boolean"}
|
||||
},
|
||||
"required": ["operation"],
|
||||
"allOf": [
|
||||
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]},
|
||||
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]}
|
||||
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}, {"required": ["target_transform_copy_refs"]}]},
|
||||
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}, {"required": ["tool_transform_copy_refs"]}]}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
@@ -453,12 +547,22 @@
|
||||
"required": ["transform_feature_id", "source_feature_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"transformSourceMemberAlias": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"active_member_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
|
||||
},
|
||||
"required": ["source_feature_id", "active_member_feature_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"transformBodiesParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
|
||||
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
|
||||
"source_member_aliases": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformSourceMemberAlias"}},
|
||||
"transform": {"$ref": "#/$defs/bodyTransform"},
|
||||
"make_copy": {"type": "boolean"}
|
||||
},
|
||||
@@ -533,6 +637,22 @@
|
||||
"required": ["axis"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"referencePointParams": {
|
||||
"type": "object",
|
||||
"properties": {"point_mm": {"$ref": "#/$defs/point3"}},
|
||||
"required": ["point_mm"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"assignVariableParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "minLength": 1, "maxLength": 256},
|
||||
"value": {"type": "number"},
|
||||
"value_kind": {"enum": ["any", "length"]}
|
||||
},
|
||||
"required": ["name", "value", "value_kind"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"holeWizardParams": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -588,6 +708,132 @@
|
||||
"required": ["ast"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntentQueryExprNode": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "set"},
|
||||
"operator": {"enum": ["union", "intersection", "subtraction"]},
|
||||
"operands": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "operator", "operands"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "filter"},
|
||||
"filter": {"enum": ["adjacent", "owner_body", "body_type", "construction"]},
|
||||
"input": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "filter", "input", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "topology_query"},
|
||||
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"topology_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "owner", "topology_type", "entity_type", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "created_by"},
|
||||
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "owner", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "source_entity"},
|
||||
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"entity": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "sketch", "entity_type", "entity", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "sketch_region"},
|
||||
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "sketch", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "opaque_call"},
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "name", "arguments"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "list"},
|
||||
"items": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
|
||||
},
|
||||
"required": ["node", "items"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "map"},
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {"type": "string"},
|
||||
"value": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
|
||||
},
|
||||
"required": ["key", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["node", "entries"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"node": {"const": "literal"},
|
||||
"value": {}
|
||||
},
|
||||
"required": ["node", "value"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectorIntentQueryExpr": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {"const": "1.0"},
|
||||
"root": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
|
||||
},
|
||||
"required": ["version", "root"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntent": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -595,6 +841,7 @@
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"},
|
||||
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
|
||||
"query_expr": {"$ref": "#/$defs/selectorIntentQueryExpr"},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -606,7 +853,7 @@
|
||||
},
|
||||
"source_entities": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -642,9 +889,81 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"blend_sources": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edge": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"const": "CAP_EDGE"},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"face": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"output_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["edge", "face"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"blend_face_source": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"const": "CAP_EDGE"},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||
"consumer_contract": {"enum": ["direct_prism_cap_face_workplane", "primary_add_shell_union_continuation", "primary_add_up_to_surface_union_continuation", "primary_add_dressup_union_continuation", "symmetric_direct_prism_two_sided_up_to_surface_cap_pair", "symmetric_direct_prism_shell_swept_face_up_to_surface_pair", "immediate_retained_source_prism_swept_face_up_to_surface", "shell_retained_direct_prism_cap_offset_face_profile", "direct_prism_shell_offset_edge_tdd", "direct_prism_shell_offset_edge_vertex"]},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"body_member_contract": {"enum": ["direct_new_body"]},
|
||||
"query_set_contract": {"enum": ["proven_operand_union", "proven_operand_intersection", "proven_operand_subtraction"]},
|
||||
"owner_body_contract": {"enum": ["exact_input_owner"]},
|
||||
"copy_contract": {"enum": ["primary_cut_cap_edge", "primary_cut_cap_face_workplane", "primary_cut_swept_face_workplane"]},
|
||||
"set_kind": {"enum": ["face", "edge"]},
|
||||
"body_scope": {"enum": ["active_member"]},
|
||||
"empty_policy": {"enum": ["reject"]},
|
||||
"multiple_policy": {"enum": ["all", "one"]},
|
||||
"derivation_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -674,6 +993,8 @@
|
||||
"snapshot_id": {"type": "string", "minLength": 1},
|
||||
"binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"match_mode": {"enum": ["unique", "all"]},
|
||||
"query_input": {"$ref": "#/$defs/selectorRef"},
|
||||
"query_operands": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
|
||||
"selector_intent_version": {"const": "1.0"},
|
||||
@@ -715,6 +1036,33 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"spatialSweepSegment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"enum": ["line", "arc", "bspline"]},
|
||||
"start_mm": {"$ref": "#/$defs/point3"},
|
||||
"end_mm": {"$ref": "#/$defs/point3"},
|
||||
"center_mm": {"$ref": "#/$defs/point3"},
|
||||
"normal": {"$ref": "#/$defs/point3"},
|
||||
"radius_mm": {"$ref": "#/$defs/positive"},
|
||||
"points_mm": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point3"}},
|
||||
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
|
||||
"periodic": {"type": "boolean"},
|
||||
"clockwise": {"type": "boolean"},
|
||||
"start_tangent_mm": {"$ref": "#/$defs/point3"},
|
||||
"end_tangent_mm": {"$ref": "#/$defs/point3"},
|
||||
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
|
||||
},
|
||||
"required": ["type", "source_sketch_id", "source_entity_id"],
|
||||
"allOf": [
|
||||
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start_mm", "end_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start_mm", "end_mm", "center_mm", "normal", "radius_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["points_mm"]}},
|
||||
{"if": {"properties": {"type": {"const": "bspline"}, "points_mm": {"maxItems": 2}}}, "then": {"required": ["start_tangent_mm", "end_tangent_mm", "parameters"], "properties": {"periodic": {"const": false}}}}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"constructionBsplineSegment": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -738,7 +1086,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"role": {"enum": ["outer", "inner", "open", "unknown"]},
|
||||
"closed": {"type": "boolean"},
|
||||
"closed": {"type": "boolean"},
|
||||
"surface_wire": {"type": "boolean"},
|
||||
"segments": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}}
|
||||
},
|
||||
"required": ["role", "closed", "segments"],
|
||||
@@ -767,10 +1116,24 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"external_anchor_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"side": {"enum": [-1, 1]},
|
||||
"intersection_index": {"type": "integer", "minimum": 0}
|
||||
},
|
||||
"required": ["anchor_entity_id", "side"],
|
||||
"required": ["side"],
|
||||
"oneOf": [
|
||||
{"required": ["anchor_entity_id"]},
|
||||
{"required": ["external_anchor_id"]}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imprintExternalAnchor": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"selector": {"$ref": "#/$defs/selectorRef"}
|
||||
},
|
||||
"required": ["id", "selector"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"imprintSelection": {
|
||||
@@ -787,13 +1150,37 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "planar_imprint"},
|
||||
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
|
||||
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}
|
||||
"source_entities": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
|
||||
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}},
|
||||
"external_anchors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintExternalAnchor"}}
|
||||
},
|
||||
"required": ["type", "source_entities", "selections"],
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"not": {"required": ["external_anchors"]}},
|
||||
"then": {"properties": {"source_entities": {"minItems": 2}}}
|
||||
}
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
|
||||
"multiSourceRegionsProfile": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {"const": "multi_source_regions"},
|
||||
"source_sketch_ids": {"type": "array", "minItems": 2, "items": {"type": "string", "minLength": 1, "maxLength": 160}, "uniqueItems": true},
|
||||
"profiles": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/directProfile"}}
|
||||
},
|
||||
"required": ["type", "source_sketch_ids", "profiles"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"directProfile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"}
|
||||
]
|
||||
},
|
||||
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "reference_point", "assign_variable", "hole_wizard"]},
|
||||
"feature": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -819,7 +1206,9 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "loft_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "sweep_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}},
|
||||
@@ -846,15 +1235,18 @@
|
||||
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "reference_point"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePointParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "assign_variable"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/assignVariableParams"}}}},
|
||||
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
|
||||
]
|
||||
},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"]},
|
||||
"profile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"},
|
||||
{"$ref": "#/$defs/multiSourceRegionsProfile"},
|
||||
{"$ref": "#/$defs/planarImprintProfile"}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -24,9 +24,11 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
|
||||
params = node.params
|
||||
target_ids = _member_sources(
|
||||
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
|
||||
allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs",
|
||||
)
|
||||
tool_ids = _member_sources(
|
||||
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
|
||||
allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_refs",
|
||||
)
|
||||
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
|
||||
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
|
||||
@@ -49,6 +51,12 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
|
||||
}
|
||||
members[node.feature_id] = result
|
||||
if bool(params.get("keep_tools")):
|
||||
# In a targetless FeatureScript body-set operation every selected
|
||||
# member is semantically a tool. Lowering chooses the first source
|
||||
# member only to satisfy CDSL's binary executor shape, so retain that
|
||||
# left operand too when the explicit targetless contract requests it.
|
||||
if bool(params.get("targetless_body_set")):
|
||||
members.update(targets)
|
||||
members.update(tools)
|
||||
session.register_body(
|
||||
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||
|
||||
@@ -6,13 +6,14 @@ used by exactly one family lives in that family's module instead.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
|
||||
from ..runtime_base import ExtentVector, FeatureExecutionError
|
||||
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord
|
||||
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
@@ -57,20 +58,41 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
|
||||
)
|
||||
|
||||
|
||||
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
|
||||
def _cut_explicit_body_members(
|
||||
session: "ExecutionSession", tool: Any,
|
||||
) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]:
|
||||
"""Apply a cut to each independently owned body without erasing ownership.
|
||||
|
||||
A CADFS NEW body stays independently addressable even when a later REMOVE
|
||||
feature affects several active bodies. Cutting the aggregate first loses
|
||||
that identity, so this path uses the equivalent per-member set difference
|
||||
and drops only members that the tool removes completely.
|
||||
and drops only members that the tool removes completely. Each non-empty
|
||||
member result retains its own OCC builder history; callers may compose
|
||||
those disjoint exact relations into one operation-wide delta.
|
||||
"""
|
||||
members: dict[str, Any] = {}
|
||||
deltas: list[TopologyDelta] = []
|
||||
for feature_id, body in session.body_members.items():
|
||||
result = session.adapter.cut(body, tool)
|
||||
if abs(float(result.volume)) > 1e-12:
|
||||
result, delta = session.adapter.cut_with_topology_delta(body, tool)
|
||||
if delta is not None:
|
||||
deltas.append(delta)
|
||||
if result is not None and abs(float(result.volume)) > 1e-12:
|
||||
members[feature_id] = result
|
||||
return members
|
||||
return members, tuple(deltas)
|
||||
|
||||
|
||||
def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None:
|
||||
"""Combine exact independent member-cut histories without inventing links."""
|
||||
if not deltas or any(delta.history_status != "proven" for delta in deltas):
|
||||
return None
|
||||
return TopologyDelta(
|
||||
operation="subtract",
|
||||
relations=tuple(relation for delta in deltas for relation in delta.relations),
|
||||
section_values=tuple(value for delta in deltas for value in delta.section_values),
|
||||
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
|
||||
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
|
||||
history_reason="per_member_exact_cut_history",
|
||||
)
|
||||
|
||||
|
||||
def _can_register_primary_cut_tool_history(
|
||||
@@ -82,12 +104,14 @@ def _can_register_primary_cut_tool_history(
|
||||
"""Return whether a primary REMOVE can retain a transient tool snapshot.
|
||||
|
||||
The implicit CADFS primary boolean normally has no independently active
|
||||
tool body. It may contribute selector provenance only when the target,
|
||||
tool, profile anchors, and prism builder history are all singular and
|
||||
exact. This gate applies only to the tool-side transient snapshot used by
|
||||
source-qualified section queries. The cut builder can independently
|
||||
prove a target-side continuation even when a trimmed or fallback tool has
|
||||
no direct-prism history.
|
||||
tool body. It may retain its direct-prism input snapshot whenever the
|
||||
tool, profile anchors, and prism builder history are singular and exact.
|
||||
The target may contain several independent members: that changes the cut
|
||||
result cardinality but not the identity of the one transient tool input.
|
||||
This gate does not make any multi-member selector executable; a later
|
||||
resolver must still prove its complete operation-wide relation and active
|
||||
member. The cut builder can independently prove a target-side continuation
|
||||
even when a trimmed or fallback tool has no direct-prism history.
|
||||
"""
|
||||
if (
|
||||
session.body is None
|
||||
@@ -96,8 +120,6 @@ def _can_register_primary_cut_tool_history(
|
||||
or topology_delta.history_status != "proven"
|
||||
or not topology_delta.relations
|
||||
or not topology_anchors
|
||||
or len(session.body_members) != 1
|
||||
or len(session.adapter.body_solids(session.body)) != 1
|
||||
or len(session.adapter.body_solids(tool)) != 1
|
||||
):
|
||||
return False
|
||||
@@ -115,6 +137,7 @@ def _extruded_tool(
|
||||
session: "ExecutionSession",
|
||||
*,
|
||||
record_multiface_prism_history: bool = False,
|
||||
use_operation_wide_prism_history: bool = False,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Build an extrude tool, retaining complete direct builder history."""
|
||||
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||
@@ -124,21 +147,38 @@ def _extruded_tool(
|
||||
taper_deg = float(draft["angle_deg"])
|
||||
if not bool(draft["pull_direction"]):
|
||||
taper_deg = -taper_deg
|
||||
topology_deltas: list[TopologyDelta] = []
|
||||
if (
|
||||
use_operation_wide_prism_history
|
||||
and draft is None
|
||||
and len(faces) > 1
|
||||
and len(extents) == 1
|
||||
and extents[0].trim_to is None
|
||||
):
|
||||
composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector)
|
||||
if composed is not None:
|
||||
return composed
|
||||
topology_deltas: list[tuple[int, TopologyDelta]] = []
|
||||
solids: list[Any] = []
|
||||
exact_two_sided_prism = (
|
||||
node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}
|
||||
and draft is None
|
||||
and len(faces) == 1
|
||||
and len(extents) == 2
|
||||
and all(extent.trim_to is None for extent in extents)
|
||||
)
|
||||
for face in faces:
|
||||
for extent in extents:
|
||||
for extent_index, extent in enumerate(extents):
|
||||
if draft is not None:
|
||||
if len(faces) == 1 and len(extents) == 1:
|
||||
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
|
||||
face, extent.vector, taper_deg,
|
||||
)
|
||||
if topology_delta is not None:
|
||||
topology_deltas.append(topology_delta)
|
||||
topology_deltas.append((extent_index, topology_delta))
|
||||
solids.append(solid)
|
||||
else:
|
||||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||||
elif extent.trim_to is None and len(extents) == 1 and (
|
||||
elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and (
|
||||
len(faces) == 1 or record_multiface_prism_history
|
||||
):
|
||||
# Each independently constructed profile face has its own OCC
|
||||
@@ -147,8 +187,19 @@ def _extruded_tool(
|
||||
# multi-face profiles keep the established general-extrude
|
||||
# path; forcing them through MakePrism can make a previously
|
||||
# executable profile invalid without adding usable evidence.
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
topology_deltas.append(topology_delta)
|
||||
try:
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
except ValueError:
|
||||
# Keep the established executable profile result when a
|
||||
# selected IMPRINT region is valid as a face but cannot be
|
||||
# a standalone valid prism. Its later fuse may still be
|
||||
# valid. There is no complete builder witness in this
|
||||
# case, so the entire multi-region topology delta is
|
||||
# withheld below instead of mixing proven and guessed
|
||||
# source anchors.
|
||||
solid = session.adapter.extrude(face, extent.vector)
|
||||
else:
|
||||
topology_deltas.append((extent_index, topology_delta))
|
||||
solids.append(solid)
|
||||
elif extent.trim_to is None:
|
||||
solids.append(session.adapter.extrude(face, extent.vector))
|
||||
@@ -161,9 +212,21 @@ def _extruded_tool(
|
||||
raise ValueError("extrude produced no solid")
|
||||
if len(topology_deltas) != len(solids):
|
||||
return tool, None
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for extent_index, topology_delta in topology_deltas:
|
||||
for relation in topology_delta.relations:
|
||||
# Both prism builders start on the source plane. Its two source
|
||||
# caps are internal to the fused two-sided result. The reverse
|
||||
# extent's far ``LastShape`` is the FeatureScript start cap; map
|
||||
# that exact builder handle before the registry checks final
|
||||
# membership. No source-plane or geometry-derived relation is
|
||||
# promoted to a CAP role.
|
||||
if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end":
|
||||
relation = replace(relation, output_role="extrude.start")
|
||||
relations.append(relation)
|
||||
return tool, TopologyDelta(
|
||||
operation="extrude",
|
||||
relations=tuple(relation for delta in topology_deltas for relation in delta.relations),
|
||||
relations=tuple(relations),
|
||||
)
|
||||
|
||||
|
||||
@@ -181,7 +244,7 @@ def _apply_primary_tool(
|
||||
if cutting:
|
||||
if session.body is None:
|
||||
raise ValueError("cut feature has no body")
|
||||
members = _cut_explicit_body_members(session, tool)
|
||||
members, member_cut_deltas = _cut_explicit_body_members(session, tool)
|
||||
if not members:
|
||||
session.clear_body()
|
||||
return session.result(node)
|
||||
@@ -190,12 +253,10 @@ def _apply_primary_tool(
|
||||
retain_transient_tool = _can_register_primary_cut_tool_history(
|
||||
session, tool, tool_delta, topology_anchors,
|
||||
)
|
||||
if (
|
||||
len(session.body_members) == 1
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
and len(session.adapter.body_solids(tool)) == 1
|
||||
):
|
||||
body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool)
|
||||
if len(session.body_members) == 1:
|
||||
member_id = next(iter(session.body_members))
|
||||
body = members[member_id]
|
||||
cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None
|
||||
# The target-side boolean history is independent of the source
|
||||
# tool's construction history. A trimmed tool cannot support a
|
||||
# source-qualified section query, but its exact BRepAlgoAPI_Cut
|
||||
@@ -219,23 +280,64 @@ def _apply_primary_tool(
|
||||
topology_delta = cut_delta
|
||||
else:
|
||||
topology_delta = None
|
||||
member_id = next(iter(session.body_members))
|
||||
members = {member_id: body}
|
||||
else:
|
||||
body = session.adapter.cut(session.body, tool)
|
||||
topology_delta = None
|
||||
if retain_transient_tool:
|
||||
try:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=tool_delta,
|
||||
topology_anchors=tool_anchors,
|
||||
)
|
||||
except ValueError:
|
||||
# A transient tool is optional evidence. Keep the exact
|
||||
# per-member target history when its tool snapshot cannot
|
||||
# be registered completely.
|
||||
topology_predecessors = None
|
||||
body = session.adapter.combine(None, next(iter(members.values())))
|
||||
for member in list(members.values())[1:]:
|
||||
body = session.adapter.combine(body, member)
|
||||
topology_delta = _compose_member_cut_deltas(member_cut_deltas)
|
||||
topology_anchors = None
|
||||
elif node.params.get("result_mode") == "new_body":
|
||||
body = session.adapter.combine(session.body, tool)
|
||||
members = {**session.body_members, node.feature_id: tool}
|
||||
else:
|
||||
body = session.adapter.fuse(session.body, tool)
|
||||
# An ADD can replace both the prior active solid and its direct prism
|
||||
# tool. Retain a selector relation only when both snapshots are
|
||||
# singular and OCC supplies the exact union history. The direct-prism
|
||||
# snapshot remains transient: its source anchors cannot be selected
|
||||
# until the union proves a complete successor in the active result.
|
||||
can_trace_add = (
|
||||
session.body is not None
|
||||
and topology_delta is not None
|
||||
and topology_delta.operation == "extrude"
|
||||
and topology_delta.history_status == "proven"
|
||||
and bool(topology_delta.relations)
|
||||
and bool(topology_anchors)
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
and len(session.adapter.body_solids(tool)) == 1
|
||||
)
|
||||
if can_trace_add:
|
||||
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool)
|
||||
if fuse_delta is not None:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=topology_delta,
|
||||
topology_anchors=list(topology_anchors or ()),
|
||||
)
|
||||
body = fused
|
||||
topology_delta = fuse_delta
|
||||
topology_anchors = None
|
||||
else:
|
||||
body = fused
|
||||
topology_delta = None
|
||||
topology_anchors = None
|
||||
else:
|
||||
body = session.adapter.fuse(session.body, tool)
|
||||
members = {node.feature_id: body}
|
||||
# A fuse rebuilds subshape identity. Builder evidence belongs only to
|
||||
# an unchanged standalone/new-body prism snapshot.
|
||||
if session.body is not None:
|
||||
topology_delta = None
|
||||
topology_anchors = None
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||||
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
|
||||
@@ -252,7 +354,11 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
if selected_sketch is None:
|
||||
raise ValueError("primary feature has no resolved sketch")
|
||||
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch)
|
||||
support_face = session.sketch_attachment_faces.get(str(node.sketch_id))
|
||||
external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id))
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(
|
||||
selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
|
||||
)
|
||||
if not faces:
|
||||
raise ValueError("sketch does not create a closed profile region")
|
||||
if node.atomic_id == "extrude_add_blind_with_hole":
|
||||
@@ -274,6 +380,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||||
for contour in contours
|
||||
)
|
||||
planar_imprint_profile = profile.get("type") == "planar_imprint"
|
||||
# 3. 按特征类型生成子实体:
|
||||
if node.atomic_id.startswith("extrude_"):
|
||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||
@@ -282,7 +389,15 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||
tool, topology_delta = _extruded_tool(
|
||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||
record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces),
|
||||
# A multi-region prism gets per-region builder history only when
|
||||
# every profile region has at least one exact source boundary.
|
||||
# This covers direct circles and the adapter's bounded IMPRINT
|
||||
# splitter path, without turning arbitrary multi-face profiles
|
||||
# into a different construction algorithm.
|
||||
record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile)
|
||||
and len(source_anchor_specs) >= len(faces),
|
||||
use_operation_wide_prism_history=planar_imprint_profile
|
||||
and len(source_anchor_specs) >= len(faces),
|
||||
)
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
@@ -318,10 +433,44 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
if bool(node.params.get("reverse")):
|
||||
angle = -angle
|
||||
tool = None
|
||||
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
can_record_revolve_history = (
|
||||
node.atomic_id == "revolve_add"
|
||||
and node.params.get("result_mode") == "new_body"
|
||||
and len(faces) == 1
|
||||
)
|
||||
for face in faces:
|
||||
if can_record_revolve_history:
|
||||
try:
|
||||
solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis)
|
||||
except ValueError:
|
||||
# Retain the established executable revolve when OCC
|
||||
# cannot expose a complete builder-history witness.
|
||||
solid = session.adapter.revolve(face, angle, axis)
|
||||
topology_delta = None
|
||||
else:
|
||||
solid = session.adapter.revolve(face, angle, axis)
|
||||
tool = solid if tool is None else session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("revolve produced no solid")
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
value = spec.get("value")
|
||||
if kind not in {"edge", "vertex"} or value is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if source_entity is None and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=value,
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
@@ -374,10 +523,14 @@ def _pattern_instance_sources(
|
||||
return resolved
|
||||
|
||||
|
||||
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
|
||||
def _transform_copy_sources(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
parameter: str = "transform_copy_refs",
|
||||
) -> list[str]:
|
||||
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
|
||||
resolved: list[str] = []
|
||||
for reference in node.params.get("transform_copy_refs") or ():
|
||||
for reference in node.params.get(parameter) or ():
|
||||
if not isinstance(reference, dict):
|
||||
raise ValueError("transform COPY reference must be an object")
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
@@ -411,12 +564,13 @@ def _member_sources(
|
||||
*,
|
||||
pattern_instance_parameter: str | None = None,
|
||||
allow_transform_copies: bool = False,
|
||||
transform_copy_parameter: str = "transform_copy_refs",
|
||||
) -> list[str]:
|
||||
source_ids = [str(value) for value in node.params.get(parameter) or []]
|
||||
if pattern_instance_parameter is not None:
|
||||
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
|
||||
if allow_transform_copies:
|
||||
source_ids.extend(_transform_copy_sources(node, session))
|
||||
source_ids.extend(_transform_copy_sources(node, session, transform_copy_parameter))
|
||||
if not source_ids:
|
||||
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
|
||||
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
|
||||
@@ -430,37 +584,119 @@ def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
|
||||
path = node.params.get("path") or {}
|
||||
if not isinstance(path, dict):
|
||||
raise ValueError("sweep path must be an object")
|
||||
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
|
||||
spatial = path.get("workplane") is None
|
||||
plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {})
|
||||
segment = path.get("segment") or {}
|
||||
if not isinstance(segment, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(segment.get("type") or "")
|
||||
if kind == "line":
|
||||
local_points = [segment.get("start"), segment.get("end")]
|
||||
elif kind == "bspline":
|
||||
local_points = segment.get("points") or []
|
||||
else:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
segments = path.get("segments")
|
||||
|
||||
def point(value: list[float]) -> Vector3:
|
||||
def local_point(value: Any) -> Vector3:
|
||||
if plane is None:
|
||||
raise ValueError("planar sweep path requires a workplane")
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
return vector_add(
|
||||
plane.origin_mm,
|
||||
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
|
||||
)
|
||||
|
||||
def tangent(value: Any) -> Vector3 | None:
|
||||
if value is None:
|
||||
return None
|
||||
def local_vector(value: Any) -> Vector3:
|
||||
if plane is None:
|
||||
raise ValueError("planar sweep path requires a workplane")
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
raise ValueError("sweep path tangent must contain two coordinates")
|
||||
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
|
||||
|
||||
if segments is not None:
|
||||
if segment:
|
||||
raise ValueError("sweep path cannot mix segment and segments")
|
||||
if not isinstance(segments, list) or len(segments) < 2:
|
||||
raise ValueError("sweep segmented path requires at least two segments")
|
||||
materialized: list[dict[str, Any]] = []
|
||||
for index, source in enumerate(segments):
|
||||
if not isinstance(source, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(source.get("type") or "")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
target: dict[str, Any] = {"type": kind}
|
||||
if spatial:
|
||||
if kind in {"line", "arc"}:
|
||||
target["start_mm"] = source.get("start_mm")
|
||||
target["end_mm"] = source.get("end_mm")
|
||||
if kind == "arc":
|
||||
target["center_mm"] = source.get("center_mm")
|
||||
target["normal"] = source.get("normal")
|
||||
target["radius_mm"] = source.get("radius_mm")
|
||||
target["clockwise"] = bool(source.get("clockwise", False))
|
||||
if kind == "bspline":
|
||||
target["points_mm"] = source.get("points_mm")
|
||||
if source.get("start_tangent_mm") is not None:
|
||||
target["start_tangent_mm"] = source.get("start_tangent_mm")
|
||||
if source.get("end_tangent_mm") is not None:
|
||||
target["end_tangent_mm"] = source.get("end_tangent_mm")
|
||||
if source.get("parameters") is not None:
|
||||
target["parameters"] = [float(value) for value in source.get("parameters") or []]
|
||||
if source.get("periodic") is not None:
|
||||
target["periodic"] = bool(source.get("periodic"))
|
||||
materialized.append(target)
|
||||
continue
|
||||
if kind in {"line", "arc"}:
|
||||
target["start_mm"] = local_point(source.get("start"))
|
||||
target["end_mm"] = local_point(source.get("end"))
|
||||
if kind == "arc":
|
||||
target["center_mm"] = local_point(source.get("center"))
|
||||
target["normal"] = plane.normal
|
||||
target["clockwise"] = bool(source.get("clockwise", False))
|
||||
if kind == "bspline":
|
||||
points = source.get("points")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
raise ValueError("sweep B-spline path requires at least two interpolation points")
|
||||
target["points_mm"] = [local_point(value) for value in points]
|
||||
if source.get("start_tangent") is not None:
|
||||
target["start_tangent_mm"] = local_vector(source.get("start_tangent"))
|
||||
if source.get("end_tangent") is not None:
|
||||
target["end_tangent_mm"] = local_vector(source.get("end_tangent"))
|
||||
if source.get("parameters") is not None:
|
||||
target["parameters"] = [float(value) for value in source.get("parameters") or []]
|
||||
if source.get("periodic") is not None:
|
||||
target["periodic"] = bool(source.get("periodic"))
|
||||
materialized.append(target)
|
||||
return session.adapter.sweep_path_segments(materialized)
|
||||
|
||||
if spatial:
|
||||
raise ValueError("spatial sweep path requires captured segments")
|
||||
if not isinstance(segment, dict):
|
||||
raise ValueError("sweep path segment must be an object")
|
||||
kind = str(segment.get("type") or "")
|
||||
if kind == "line":
|
||||
local_points = [segment.get("start"), segment.get("end")]
|
||||
elif kind == "circle":
|
||||
return session.adapter.sweep_circle_path(
|
||||
local_point(segment.get("center")),
|
||||
plane.x_dir,
|
||||
plane.normal,
|
||||
radius_mm=float(segment["radius_mm"]),
|
||||
)
|
||||
elif kind == "arc":
|
||||
return session.adapter.sweep_arc_path(
|
||||
local_point(segment.get("start")),
|
||||
local_point(segment.get("end")),
|
||||
local_point(segment.get("center")),
|
||||
plane.normal,
|
||||
radius_mm=float(segment["radius_mm"]),
|
||||
clockwise=bool(segment["clockwise"]),
|
||||
)
|
||||
elif kind == "bspline":
|
||||
local_points = segment.get("points") or []
|
||||
else:
|
||||
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||
if len(local_points) < 2:
|
||||
raise ValueError("sweep path requires two-dimensional points")
|
||||
|
||||
return session.adapter.sweep_path(
|
||||
[point(value) for value in local_points],
|
||||
start_tangent=tangent(segment.get("start_tangent")),
|
||||
end_tangent=tangent(segment.get("end_tangent")),
|
||||
[local_point(value) for value in local_points],
|
||||
start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None,
|
||||
end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None,
|
||||
parameters=[float(value) for value in segment.get("parameters") or []] or None,
|
||||
)
|
||||
|
||||
@@ -536,15 +772,10 @@ def _direct_output_role_records(
|
||||
return result
|
||||
|
||||
|
||||
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
|
||||
def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec:
|
||||
if resolution.record is None:
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
|
||||
geometry = resolution.record.geometry
|
||||
return PlaneSpec.from_mapping({
|
||||
"origin_mm": geometry["center_mm"],
|
||||
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
|
||||
"normal": geometry["normal"],
|
||||
})
|
||||
return adapter.planar_face_workplane(resolution.record.value)
|
||||
|
||||
|
||||
def _hole_starts(
|
||||
|
||||
@@ -6,6 +6,7 @@ that later features resolve through owner-qualified selectors.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
@@ -61,3 +62,28 @@ def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession",
|
||||
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
|
||||
session.topology.register_context(node.feature_id, axis)
|
||||
return session.result(node, context=axis)
|
||||
|
||||
|
||||
@atomic_executor("reference_point")
|
||||
def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
"""Execute an explicit source-datum point without changing model topology."""
|
||||
del sketch
|
||||
point = node.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)
|
||||
):
|
||||
raise ValueError("reference point requires one finite three-dimensional point")
|
||||
return session.result(node, include_body=False)
|
||||
|
||||
|
||||
@atomic_executor("assign_variable")
|
||||
def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
"""Replay a source variable declaration after lowering has consumed it."""
|
||||
del sketch
|
||||
name = node.params.get("name")
|
||||
value = node.params.get("value")
|
||||
if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
|
||||
raise ValueError("assign_variable requires one finite named value")
|
||||
return session.result(node, include_body=False)
|
||||
|
||||
@@ -17,6 +17,60 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
|
||||
|
||||
def _single_member_dressup_members(
|
||||
node: FeaturePlanNode,
|
||||
session: "ExecutionSession",
|
||||
body: Any,
|
||||
selected_edges: list[Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Preserve unchanged body members after one exact-member dress-up.
|
||||
|
||||
The adapter only reports Compound history when every selected edge maps to
|
||||
one source solid. Keep the same proof at the body-graph layer: all other
|
||||
members must appear unchanged in the result and exactly one result solid
|
||||
must remain for the changed member. Otherwise aggregate replay remains
|
||||
valid, but no member-lifecycle transfer is asserted.
|
||||
"""
|
||||
if not selected_edges or len(session.body_members) < 2:
|
||||
return None
|
||||
source_members: dict[str, Any] = {}
|
||||
for member_id, member in session.body_members.items():
|
||||
solids = session.adapter.body_solids(member)
|
||||
if len(solids) != 1:
|
||||
return None
|
||||
source_members[member_id] = solids[0]
|
||||
selected_members = {
|
||||
member_id
|
||||
for edge in selected_edges
|
||||
for member_id, member in source_members.items()
|
||||
if any(edge.is_same(candidate) for candidate in member.edges())
|
||||
}
|
||||
if len(selected_members) != 1:
|
||||
return None
|
||||
changed_member_id = next(iter(selected_members))
|
||||
result_solids = session.adapter.body_solids(body)
|
||||
unchanged: dict[str, Any] = {}
|
||||
matched_result_indexes: set[int] = set()
|
||||
for member_id, member in source_members.items():
|
||||
if member_id == changed_member_id:
|
||||
continue
|
||||
matches = [
|
||||
index for index, result in enumerate(result_solids)
|
||||
if session.topology._same_topology_value(member, result)
|
||||
]
|
||||
if len(matches) != 1 or matches[0] in matched_result_indexes:
|
||||
return None
|
||||
matched_result_indexes.add(matches[0])
|
||||
unchanged[member_id] = result_solids[matches[0]]
|
||||
changed = [
|
||||
result for index, result in enumerate(result_solids)
|
||||
if index not in matched_result_indexes
|
||||
]
|
||||
if len(changed) != 1:
|
||||
return None
|
||||
return {**unchanged, node.feature_id: changed[0]}
|
||||
|
||||
|
||||
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
|
||||
|
||||
@@ -28,11 +82,15 @@ def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> Featu
|
||||
if radius <= 0:
|
||||
raise ValueError("fillet radius_mm must be > 0")
|
||||
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
|
||||
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
|
||||
body, topology_delta = session.adapter.fillet_with_topology_delta(
|
||||
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
|
||||
session.body, radius, edges,
|
||||
)
|
||||
# 4. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
body_members=_single_member_dressup_members(node, session, body, edges),
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@@ -86,7 +144,10 @@ def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> Feat
|
||||
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
|
||||
))
|
||||
# 5. 登记新主体并返回结果。
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
body_members=_single_member_dressup_members(node, session, body, edges),
|
||||
)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
|
||||
|
||||
@@ -32,22 +32,16 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
if session.body is None:
|
||||
raise ValueError("hole feature has no body")
|
||||
scope_feature_id = node.params.get("scope_feature_id")
|
||||
scoped_body = None
|
||||
if scope_feature_id is not None:
|
||||
if not isinstance(scope_feature_id, str) or not scope_feature_id:
|
||||
raise ValueError("hole scope_feature_id is invalid")
|
||||
if len(session.body_members) != 1:
|
||||
raise ValueError("hole scope body is no longer the sole active member")
|
||||
scoped_body = session.body_members.get(scope_feature_id)
|
||||
if scoped_body is None:
|
||||
raise ValueError("hole scope body is no longer an independently selectable member")
|
||||
scoped_solids = session.adapter.body_solids(scoped_body)
|
||||
active_solids = session.adapter.body_solids(session.body)
|
||||
if (
|
||||
len(scoped_solids) != 1
|
||||
or len(active_solids) != 1
|
||||
or not scoped_solids[0].is_same(active_solids[0])
|
||||
):
|
||||
raise ValueError("hole scope body does not match the active body")
|
||||
if not scoped_solids:
|
||||
raise ValueError("hole scope body has no active solid")
|
||||
# 2. 确定宿主面 host_face:
|
||||
host_selector = node.params.get("host_face")
|
||||
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
||||
@@ -62,8 +56,15 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
selector = next((item for item in selectors if item.get("kind") == "face"), None)
|
||||
if selector is None:
|
||||
raise ValueError("hole requires host_face selector or frame")
|
||||
host = _host_plane(session.resolve(selector))
|
||||
positions_are_local = False
|
||||
host = _host_plane(session.resolve(selector), session.adapter)
|
||||
intent = selector.get("selector_intent") if isinstance(selector, dict) else None
|
||||
# Existing selector-hosted holes store world positions. Only the new
|
||||
# runtime-attached COPY(CAP_FACE) sketch preserves local coordinates
|
||||
# until its exact host relation has materialized.
|
||||
positions_are_local = (
|
||||
isinstance(intent, dict)
|
||||
and intent.get("copy_contract") == "primary_cut_cap_face_workplane"
|
||||
)
|
||||
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
|
||||
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
|
||||
# 4. A host-face normal is an outward B-rep orientation, so its inverse
|
||||
@@ -78,7 +79,7 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
spec,
|
||||
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
|
||||
inward,
|
||||
session.adapter.body_span(session.body, inward) + 2.0,
|
||||
session.adapter.body_span(scoped_body or session.body, inward) + 2.0,
|
||||
)
|
||||
# 6. 从主体上减去工具实体,登记新主体并返回结果。
|
||||
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
|
||||
@@ -91,8 +92,21 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||||
feature_id=node.feature_id,
|
||||
))
|
||||
result_body = session.adapter.cut(session.body, tool)
|
||||
members = {scope_feature_id: result_body} if scope_feature_id is not None else None
|
||||
if scope_feature_id is None:
|
||||
result_body = session.adapter.cut(session.body, tool)
|
||||
members = None
|
||||
else:
|
||||
# CADFS ``scope`` identifies the target body member. Never apply the
|
||||
# cutter to the aggregate merely because unrelated live members share
|
||||
# the exported part; unchanged members remain exact body-graph nodes.
|
||||
result_body = session.adapter.cut(scoped_body, tool)
|
||||
members = {**session.body_members, scope_feature_id: result_body}
|
||||
body = None
|
||||
for member in members.values():
|
||||
body = session.adapter.combine(body, member)
|
||||
if body is None:
|
||||
raise ValueError("hole scope cut produced no active body")
|
||||
result_body = body
|
||||
session.register_body(
|
||||
node.feature_id, result_body, replay_node=node, body_members=members,
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..registry import atomic_executor
|
||||
from ..topology import FeaturePlanNode, FeatureResult
|
||||
from .common import _sweep_path
|
||||
from ..topology import FeaturePlanNode, FeatureResult, TopologyRecord
|
||||
from .common import _apply_primary_tool, _sweep_path
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||
from ..session import ExecutionSession
|
||||
@@ -66,13 +66,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
|
||||
profile = sketch or session.sketches.get(str(node.sketch_id))
|
||||
if profile is None:
|
||||
raise ValueError("sweep has no resolved profile sketch")
|
||||
faces = session.adapter.faces_for_sketch(profile)
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(profile)
|
||||
if len(faces) != 1:
|
||||
raise ValueError("sweep requires exactly one closed profile region")
|
||||
solid, topology_delta = session.adapter.sweep_with_topology_delta(
|
||||
faces[0], _sweep_path(node, session),
|
||||
is_frenet=bool(node.params.get("is_frenet", False)),
|
||||
)
|
||||
if node.atomic_id == "sweep_cut":
|
||||
# The PipeShell is a transient cutting tool. Its own builder history
|
||||
# cannot become selector provenance after the BRepAlgoAPI_Cut; only
|
||||
# the cut's exact target-side delta may survive registration.
|
||||
return _apply_primary_tool(node, session, solid, cutting=True)
|
||||
is_new_body = node.params.get("result_mode") == "new_body"
|
||||
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
|
||||
# A union rebuilds topology, so the pipe-shell builder cannot prove the
|
||||
@@ -80,10 +85,37 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
|
||||
# subshape identity and may expose evidence for the new member.
|
||||
if session.body is not None and not is_new_body:
|
||||
topology_delta = None
|
||||
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||
topology_anchors: list[TopologyRecord] = []
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
if kind not in {"edge", "vertex"} or spec.get("value") is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if not isinstance(source_entity, tuple) and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=spec["value"],
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
|
||||
@atomic_executor("sweep_add")
|
||||
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
return _execute_sweep_add(node, session, sketch)
|
||||
|
||||
|
||||
@atomic_executor("sweep_cut")
|
||||
def _sweep_cut_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
return _execute_sweep_add(node, session, sketch)
|
||||
|
||||
@@ -73,3 +73,21 @@ def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession")
|
||||
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_extrude_surface(node, session)
|
||||
|
||||
|
||||
def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||
profiles: list[dict[str, Any]] = []
|
||||
for sketch_id in profile_ids:
|
||||
profile = session.sketches.get(str(sketch_id))
|
||||
if profile is None:
|
||||
raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved")
|
||||
profiles.append(profile)
|
||||
surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles))
|
||||
return session.result(node, include_body=False, surface_id=surface_id)
|
||||
|
||||
|
||||
@atomic_executor("loft_surface")
|
||||
def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||
del sketch
|
||||
return _execute_loft_surface(node, session)
|
||||
|
||||
@@ -8,6 +8,7 @@ selector resolution, never on executors.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .runtime_base import ExtentVector, FeatureExecutionError
|
||||
@@ -44,12 +45,29 @@ def _targeted_extent_vector(
|
||||
end_condition: dict[str, Any] | None = None,
|
||||
offset_mm: float | None = None,
|
||||
) -> ExtentVector:
|
||||
if session.body is None:
|
||||
reference = _extent_reference(node, end_condition) if condition != "through_next" else None
|
||||
source_vertex_point: Vector3 | None = None
|
||||
if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex":
|
||||
raw_point = reference.get("point_mm")
|
||||
if not (
|
||||
isinstance(raw_point, list)
|
||||
and len(raw_point) == 3
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point)
|
||||
):
|
||||
raise FeatureExecutionError(
|
||||
"invalid_source_vertex_extent",
|
||||
"The source-vertex extent datum must contain one finite 3D point",
|
||||
extent=condition,
|
||||
)
|
||||
source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2]))
|
||||
elif session.body is None:
|
||||
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
|
||||
if condition == "through_next":
|
||||
target = session.body
|
||||
elif source_vertex_point is not None:
|
||||
target = None
|
||||
else:
|
||||
reference = _extent_reference(node, end_condition)
|
||||
assert isinstance(reference, dict)
|
||||
resolution = session.resolve(reference)
|
||||
if resolution.status != "resolved" or resolution.record is None:
|
||||
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
|
||||
@@ -62,7 +80,7 @@ def _targeted_extent_vector(
|
||||
)
|
||||
target = resolution.record.value
|
||||
if condition == "up_to_vertex":
|
||||
target_point = session.adapter.vertex_coordinates(target)
|
||||
target_point = source_vertex_point or session.adapter.vertex_coordinates(target)
|
||||
projections = [
|
||||
vector_dot(vector_subtract(target_point, point), direction)
|
||||
for face in faces
|
||||
@@ -89,6 +107,19 @@ def _targeted_extent_vector(
|
||||
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
|
||||
except ValueError as error:
|
||||
message = str(error)
|
||||
if condition == "up_to_surface" and message == "extent target is not reached by every profile ray":
|
||||
# Do not reinterpret a partially hit finite face: that path
|
||||
# has explicit trimmed-solid semantics below. Only a wholly
|
||||
# unreachable planar face may terminate on its supporting
|
||||
# plane, and the adapter proves one positive, uniform
|
||||
# profile-to-plane distance before returning it.
|
||||
if not session.adapter.target_has_forward_intersection(target, faces, direction):
|
||||
try:
|
||||
distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return ExtentVector(vector_scale(direction, distance))
|
||||
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
|
||||
if condition in {"up_to_surface", "through_next"}:
|
||||
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
|
||||
|
||||
@@ -409,6 +409,28 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
|
||||
for key in ("x_dir", "y_dir", "normal"):
|
||||
if path_plane.get(key):
|
||||
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad))
|
||||
elif isinstance(path, dict) and isinstance(path.get("segments"), list):
|
||||
# A source-only spatial sweep path has no common workplane. Its
|
||||
# captured points and directions are absolute, so replayed circular
|
||||
# pattern instances must rotate each geometric field independently.
|
||||
for segment in path["segments"]:
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
for key in ("start_mm", "end_mm", "center_mm"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
segment[key] = _rotated_point(value, axis, angle_rad)
|
||||
points = segment.get("points_mm")
|
||||
if isinstance(points, list):
|
||||
segment["points_mm"] = [
|
||||
_rotated_point(point, axis, angle_rad)
|
||||
for point in points
|
||||
if isinstance(point, list) and len(point) == 3
|
||||
]
|
||||
for key in ("normal", "start_tangent_mm", "end_tangent_mm"):
|
||||
value = segment.get(key)
|
||||
if isinstance(value, list) and len(value) == 3:
|
||||
segment[key] = list(_rotated_vector(tuple(float(v) for v in value), axis, angle_rad))
|
||||
host = params.get("host_face")
|
||||
host_frame = host.get("frame") if isinstance(host, dict) else None
|
||||
positions_are_local = isinstance(host_frame, dict) and all(
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
"cdsl_json_schema_file": "cdsl_schema.json",
|
||||
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
|
||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"],
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "multi_source_regions", "planar_imprint"],
|
||||
"operation_contracts": {
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.1","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_surface": {"atomic_id":"loft_surface","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":2,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"sweep_cut": {"atomic_id":"sweep_cut","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true},"params.reverse_end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_or_primary_add_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
@@ -33,6 +35,8 @@
|
||||
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false}},"required":["axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"reference_point": {"atomic_id":"reference_point","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"point_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["point_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["reference_point_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"assign_variable": {"atomic_id":"assign_variable","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":256},"value":{"type":"number"},"value_kind":{"enum":["any","length"]}},"required":["name","value","value_kind"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["assign_variable_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"hole_wizard": {
|
||||
"runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false},
|
||||
"atomic_id": "hole_wizard",
|
||||
@@ -67,11 +71,11 @@
|
||||
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.2","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"},"targetless_body_set":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
"transform_bodies": {
|
||||
"runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false},
|
||||
"atomic_id": "transform_bodies",
|
||||
"contract_version": "3.3",
|
||||
"contract_version": "3.4",
|
||||
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"},
|
||||
"author_params_schema": {
|
||||
"type": "object",
|
||||
@@ -79,6 +83,7 @@
|
||||
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
|
||||
"pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
|
||||
"source_member_aliases": {"type": "array", "items": {"type": "object", "properties": {"source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "active_member_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["source_feature_id", "active_member_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
|
||||
"transform": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -129,6 +134,9 @@
|
||||
"analytic_contours": {
|
||||
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
|
||||
},
|
||||
"multi_source_regions": {
|
||||
"summary": "A direct qUnion of two or more closed qSketchRegion sources on exactly one shared workplane. Every child profile is solved as an independent region set before union, so cross-source nested contours are never reclassified as holes. This is profile geometry only and carries no single-source topology anchor."
|
||||
},
|
||||
"planar_imprint": {
|
||||
"summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour."
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ class AtomicExecutor(Protocol):
|
||||
#: at import time instead of surfacing as an unknown-atomic blocker later.
|
||||
ALL_ATOMIC_IDS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
|
||||
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add",
|
||||
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
"reference_plane", "reference_axis",
|
||||
"reference_plane", "reference_axis", "reference_point", "assign_variable",
|
||||
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
|
||||
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
|
||||
"thread_add", "thread_cut",
|
||||
|
||||
@@ -169,6 +169,8 @@ class IncrementalCdslExecution:
|
||||
raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}")
|
||||
return None
|
||||
try:
|
||||
if node.sketch_id is not None:
|
||||
self.session.resolve_sketch_attachment(str(node.sketch_id), feature_id=node.feature_id)
|
||||
return execute_node(node, self.session)
|
||||
except Exception as error:
|
||||
diagnostic = _execution_diagnostic(error, node, self.session)
|
||||
|
||||
@@ -43,6 +43,7 @@ from .topology import (
|
||||
SelectorResolution,
|
||||
TopologyDelta,
|
||||
TopologyDeltaRelation,
|
||||
TopologyBlendRelation,
|
||||
TopologySectionRelation,
|
||||
TopologyLineage,
|
||||
TopologyRecord,
|
||||
@@ -65,6 +66,7 @@ __all__ = [
|
||||
"ThreadSpec",
|
||||
"TopologyDelta",
|
||||
"TopologyDeltaRelation",
|
||||
"TopologyBlendRelation",
|
||||
"TopologySectionRelation",
|
||||
"TopologyLineage",
|
||||
"TopologyRecord",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ from typing import Any, Protocol
|
||||
|
||||
from .build123d_adapter import Build123dGeometryAdapter
|
||||
from .runtime_base import FeatureExecutionError
|
||||
from .sketch_solver import resolve_profile
|
||||
from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3
|
||||
from .topology import (
|
||||
FeaturePlanNode,
|
||||
@@ -38,18 +39,25 @@ class GeometryAdapter(Protocol):
|
||||
def body_solids(self, body: Any) -> list[Any]: ...
|
||||
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
||||
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
|
||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
|
||||
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]: ...
|
||||
def faces_for_sketch(self, sketch: dict[str, Any], *, support_face: Any | None = None,
|
||||
external_anchor_edges: dict[str, Any] | None = None) -> list[Any]: ...
|
||||
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any], *, support_face: Any | None = None,
|
||||
external_anchor_edges: dict[str, Any] | None = None) -> tuple[list[Any], list[dict[str, Any]]]: ...
|
||||
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
|
||||
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
|
||||
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
|
||||
def loft_surface(self, sketches: list[dict[str, Any]]) -> Any: ...
|
||||
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
|
||||
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
|
||||
def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ...
|
||||
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
|
||||
def sweep_arc_path(self, start: Vector3, end: Vector3, center: Vector3, normal: Vector3, *, radius_mm: float, clockwise: bool) -> Any: ...
|
||||
def sweep_circle_path(self, center: Vector3, x_dir: Vector3, normal: Vector3, *, radius_mm: float) -> Any: ...
|
||||
def face_normal(self, face: Any) -> Vector3: ...
|
||||
def planar_face_workplane(self, face: Any) -> PlaneSpec: ...
|
||||
def extrude(self, face: Any, direction: Vector3) -> Any: ...
|
||||
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
|
||||
def extrude_faces_with_composed_topology_delta(self, faces: list[Any], direction: Vector3) -> tuple[Any, TopologyDelta] | None: ...
|
||||
def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ...
|
||||
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
|
||||
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
|
||||
@@ -57,6 +65,7 @@ class GeometryAdapter(Protocol):
|
||||
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
|
||||
def combine_surfaces(self, *surfaces: Any) -> Any: ...
|
||||
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
|
||||
def revolve_with_topology_delta(self, face: Any, angle_deg: float, axis: AxisSpec) -> tuple[Any, TopologyDelta]: ...
|
||||
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
|
||||
def intersect(self, left: Any, right: Any) -> Any: ...
|
||||
def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ...
|
||||
@@ -82,6 +91,8 @@ class GeometryAdapter(Protocol):
|
||||
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
|
||||
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
|
||||
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
|
||||
def target_has_forward_intersection(self, target: Any, faces: list[Any], direction: Vector3) -> bool: ...
|
||||
def uniform_planar_supporting_surface_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
|
||||
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
|
||||
def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ...
|
||||
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
|
||||
@@ -104,10 +115,85 @@ class ExecutionSession:
|
||||
results: dict[str, FeatureResult] = field(default_factory=dict)
|
||||
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
|
||||
body_members: dict[str, Any] = field(default_factory=dict)
|
||||
body_member_snapshot_ids: dict[str, str] = field(default_factory=dict)
|
||||
surface_members: dict[str, Any] = field(default_factory=dict)
|
||||
sketch_attachment_faces: dict[str, Any] = field(default_factory=dict)
|
||||
sketch_imprint_external_edges: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
|
||||
active_feature_id: str = ""
|
||||
|
||||
def resolve_sketch_attachment(self, sketch_id: str, *, feature_id: str | None = None) -> None:
|
||||
"""Resolve one runtime-attached sketch immediately before consumption."""
|
||||
sketch = self.sketches.get(sketch_id)
|
||||
if sketch is None or not isinstance(sketch.get("attachment"), dict):
|
||||
return
|
||||
previous_feature_id = self.active_feature_id
|
||||
if feature_id is not None:
|
||||
self.active_feature_id = feature_id
|
||||
try:
|
||||
resolution = self.resolve(sketch["attachment"])
|
||||
if resolution.status != "resolved" or resolution.record is None or resolution.record.kind != "face":
|
||||
detail = resolution.diagnostic.message if resolution.diagnostic else "attached face was not resolved"
|
||||
raise FeatureExecutionError("sketch_attachment_unresolved", detail)
|
||||
try:
|
||||
plane = self.adapter.planar_face_workplane(resolution.record.value)
|
||||
except ValueError as error:
|
||||
raise FeatureExecutionError("sketch_attachment_nonplanar", str(error)) from error
|
||||
external_edges: dict[str, Any] = {}
|
||||
profile = sketch.get("profile") or {}
|
||||
for entry in profile.get("external_anchors") or ():
|
||||
anchor_id = entry.get("id") if isinstance(entry, dict) else None
|
||||
selector = entry.get("selector") if isinstance(entry, dict) else None
|
||||
if not isinstance(anchor_id, str) or not anchor_id or not isinstance(selector, dict):
|
||||
raise FeatureExecutionError("imprint_external_anchor_invalid", "external anchor contract is invalid")
|
||||
edge_resolution = self.resolve(selector)
|
||||
if (
|
||||
edge_resolution.status != "resolved"
|
||||
or edge_resolution.record is None
|
||||
or edge_resolution.record.kind != "edge"
|
||||
):
|
||||
detail = edge_resolution.diagnostic.message if edge_resolution.diagnostic else "external anchor edge was not resolved"
|
||||
raise FeatureExecutionError("imprint_external_anchor_unresolved", detail)
|
||||
if not any(edge.wrapped.IsSame(edge_resolution.record.value.wrapped) for edge in resolution.record.value.edges()):
|
||||
raise FeatureExecutionError(
|
||||
"imprint_external_anchor_not_support_boundary",
|
||||
"external anchor edge is not an exact boundary of the attached support face",
|
||||
)
|
||||
external_edges[anchor_id] = edge_resolution.record.value
|
||||
materialized = deepcopy(sketch)
|
||||
materialized["workplane"] = plane.as_dict()
|
||||
# IMPRINT face-side disambiguation is expressed in the source
|
||||
# sketch's oriented plane. A native attached face may have the
|
||||
# opposite normal, which reverses the physical side of the same
|
||||
# local curve after its coordinates are mapped to that face.
|
||||
# This is a session-local frame conversion, not selector or
|
||||
# geometry fallback data.
|
||||
source_profile = materialized.get("profile") or {}
|
||||
if source_profile.get("type") == "planar_imprint":
|
||||
try:
|
||||
source_plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
except ValueError as error:
|
||||
raise FeatureExecutionError("sketch_attachment_frame_invalid", str(error)) from error
|
||||
orientation = sum(
|
||||
source_plane.normal[index] * plane.normal[index]
|
||||
for index in range(3)
|
||||
)
|
||||
if orientation < 0.0:
|
||||
for selection in source_profile.get("selections") or ():
|
||||
if isinstance(selection, dict) and selection.get("face_side") in {-1, 1}:
|
||||
selection["face_side"] = -selection["face_side"]
|
||||
# Preflight intentionally leaves attached sketches local. Resolve now
|
||||
# so every world-space contour is rebuilt from the proven face frame.
|
||||
materialized = resolve_profile(materialized)
|
||||
self.sketches[sketch_id] = materialized
|
||||
# Keep exact supports only in the live session. They are never CDSL
|
||||
# geometry, serialized record IDs, or selector fallback evidence.
|
||||
self.sketch_attachment_faces[sketch_id] = resolution.record.value
|
||||
if external_edges:
|
||||
self.sketch_imprint_external_edges[sketch_id] = external_edges
|
||||
finally:
|
||||
self.active_feature_id = previous_feature_id
|
||||
|
||||
def register_body(
|
||||
self,
|
||||
feature_id: str,
|
||||
@@ -123,6 +209,8 @@ class ExecutionSession:
|
||||
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
||||
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
|
||||
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
|
||||
previous_members = dict(self.body_members)
|
||||
previous_snapshot_ids = dict(self.body_member_snapshot_ids)
|
||||
self.body = body
|
||||
self.body_id = f"body:{feature_id}"
|
||||
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
|
||||
@@ -135,11 +223,27 @@ class ExecutionSession:
|
||||
self.topology.register(anchor)
|
||||
predecessors = [*(topology_predecessors or ()), *anchors]
|
||||
solids = self.adapter.body_solids(body)
|
||||
member_snapshot_ids: dict[str, str] = {}
|
||||
for member_key, member in self.body_members.items():
|
||||
matches = [
|
||||
index for index, solid in enumerate(solids)
|
||||
if TopologyRegistry._same_topology_value(member, solid)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
member_snapshot_ids[member_key] = (
|
||||
self.body_id if len(solids) == 1 else f"{self.body_id}:{matches[0]}"
|
||||
)
|
||||
member_preservations = [
|
||||
(previous_snapshot_ids[key], member_snapshot_ids[key])
|
||||
for key, member in previous_members.items()
|
||||
if key in previous_snapshot_ids and key in member_snapshot_ids
|
||||
and TopologyRegistry._same_topology_value(member, self.body_members[key])
|
||||
]
|
||||
if len(solids) <= 1:
|
||||
self.topology.replace_body_topology(
|
||||
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
|
||||
topology_delta=topology_delta,
|
||||
additional_predecessors=predecessors,
|
||||
additional_predecessors=predecessors, member_preservations=member_preservations,
|
||||
)
|
||||
else:
|
||||
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
|
||||
@@ -152,7 +256,7 @@ class ExecutionSession:
|
||||
]
|
||||
self.topology.replace_body_topologies(
|
||||
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
|
||||
additional_predecessors=predecessors,
|
||||
additional_predecessors=predecessors, member_preservations=member_preservations,
|
||||
)
|
||||
self.topology.register(TopologyRecord(
|
||||
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
|
||||
@@ -160,6 +264,7 @@ class ExecutionSession:
|
||||
))
|
||||
if replay_node is not None:
|
||||
self.replay_definitions[feature_id] = replay_node
|
||||
self.body_member_snapshot_ids = member_snapshot_ids
|
||||
|
||||
def register_transient_prism_tool(
|
||||
self,
|
||||
@@ -198,6 +303,7 @@ class ExecutionSession:
|
||||
self.body = None
|
||||
self.body_id = None
|
||||
self.body_members = {}
|
||||
self.body_member_snapshot_ids = {}
|
||||
|
||||
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
|
||||
evidence = resolution.as_dict()
|
||||
|
||||
@@ -489,6 +489,49 @@ def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bo
|
||||
return inside
|
||||
|
||||
|
||||
def _point_on_loop_boundary(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
|
||||
"""Return whether a sampled contour point lies on one sampled boundary.
|
||||
|
||||
Region nesting is topology, not a ray-casting tie-break. A contour that
|
||||
shares a vertex or an edge with another contour cannot be a hole of it.
|
||||
The ordinary parity test intentionally leaves that boundary case
|
||||
unspecified, so keep it out of containment classification explicitly.
|
||||
"""
|
||||
if len(loop) < 2:
|
||||
return False
|
||||
px, py = point
|
||||
previous = loop[-1]
|
||||
for current in loop:
|
||||
dx, dy = current[0] - previous[0], current[1] - previous[1]
|
||||
length_squared = dx * dx + dy * dy
|
||||
if length_squared <= _TOLERANCE_MM * _TOLERANCE_MM:
|
||||
if math.dist(point, previous) <= _TOLERANCE_MM:
|
||||
return True
|
||||
else:
|
||||
projection = ((px - previous[0]) * dx + (py - previous[1]) * dy) / length_squared
|
||||
if -_TOLERANCE_MM <= projection <= 1.0 + _TOLERANCE_MM:
|
||||
nearest = (previous[0] + projection * dx, previous[1] + projection * dy)
|
||||
if math.dist(point, nearest) <= _TOLERANCE_MM:
|
||||
return True
|
||||
previous = current
|
||||
return False
|
||||
|
||||
|
||||
def _strictly_contains_loop(outer: list[tuple[float, float]], inner: list[tuple[float, float]]) -> bool:
|
||||
"""Require every sampled inner boundary point to be strictly inside outer.
|
||||
|
||||
This conservative predicate rejects touching and intersecting contours
|
||||
rather than manufacturing an invalid face with a self-identical or shared
|
||||
hole. Curved contours are already sampled by ``_sample_loop`` before
|
||||
this stage, so the decision uses the same region representation as the
|
||||
existing parity classifier.
|
||||
"""
|
||||
return bool(inner) and all(
|
||||
not _point_on_loop_boundary(point, outer) and _contains(point, outer)
|
||||
for point in inner
|
||||
)
|
||||
|
||||
|
||||
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
loops: list[_Ctx] = []
|
||||
entities: list[_Ctx] = []
|
||||
@@ -516,6 +559,16 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
))
|
||||
raw_edges.extend(_segment_edges(segment))
|
||||
if raw_edges:
|
||||
# A pure surface extrusion consumes its source wire directly.
|
||||
# Do not invent the closing edge used by the solid-cut open
|
||||
# profile contract: it would turn one requested ruled face into
|
||||
# a different shell. This marker is emitted only by the CADFS
|
||||
# ToolBodyType.SURFACE lowering contract.
|
||||
if bool(contour.get("surface_wire")):
|
||||
if not contour_open or contour.get("role") != "open":
|
||||
raise ValueError(f"analytic_contours: surface wire {index} must be an open contour")
|
||||
meta.setdefault("_surface_wires", []).append(_join(raw_edges, allow_open=True))
|
||||
continue
|
||||
edges = _join(raw_edges, allow_open=contour_open)
|
||||
contour_opened = False
|
||||
if contour_open:
|
||||
@@ -537,11 +590,17 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
if not loops:
|
||||
return entities, []
|
||||
for loop in loops:
|
||||
loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer"
|
||||
loop["role"] = "inner" if sum(
|
||||
_strictly_contains_loop(other["points"], loop["points"])
|
||||
for other in loops if other is not loop
|
||||
) % 2 else "outer"
|
||||
outers = [loop for loop in loops if loop["role"] == "outer"]
|
||||
regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers]
|
||||
for inner in (loop for loop in loops if loop["role"] == "inner"):
|
||||
containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])]
|
||||
containing = [
|
||||
outer for outer in outers
|
||||
if _strictly_contains_loop(outer["points"], inner["points"])
|
||||
]
|
||||
if not containing:
|
||||
raise ValueError("analytic_contours: inner contour has no containing outer contour")
|
||||
if any(outer.get("open") for outer in containing):
|
||||
@@ -552,6 +611,58 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
return entities, []
|
||||
|
||||
|
||||
def _multi_source_child_as_analytic(profile: _Ctx) -> _Ctx:
|
||||
"""Use the established region classifier once per source profile."""
|
||||
kind = profile.get("type")
|
||||
if kind == "analytic_contours":
|
||||
return deepcopy(profile)
|
||||
if kind == "circle":
|
||||
return {
|
||||
"type": "analytic_contours",
|
||||
"contours": [{"role": "outer", "closed": True, "segments": [{
|
||||
"type": "circle", "center": deepcopy(profile.get("center") or [0.0, 0.0]),
|
||||
"radius_mm": profile.get("radius_mm"),
|
||||
}]}],
|
||||
}
|
||||
if kind == "polygon":
|
||||
vertices = profile.get("vertices") or []
|
||||
if len(vertices) < 3:
|
||||
raise ValueError("multi_source_regions: polygon needs at least three vertices")
|
||||
return {
|
||||
"type": "analytic_contours",
|
||||
"contours": [{"role": "outer", "closed": True, "segments": [
|
||||
{"type": "line", "start": deepcopy(vertices[index]), "end": deepcopy(vertices[(index + 1) % len(vertices)])}
|
||||
for index in range(len(vertices))
|
||||
]}],
|
||||
}
|
||||
raise ValueError(f"multi_source_regions: unsupported child profile type {kind!r}")
|
||||
|
||||
|
||||
def _gen_multi_source_regions(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
"""Resolve each qSketchRegion source independently before taking its union.
|
||||
|
||||
Flattening all contours would reclassify a nested region from a separate
|
||||
sketch as a hole. FeatureScript qUnion selects each source region, so its
|
||||
operands must retain independent outer/hole classification here.
|
||||
"""
|
||||
sources, profiles = profile.get("source_sketch_ids") or [], profile.get("profiles") or []
|
||||
if len(sources) < 2 or len(sources) != len(profiles) or len(set(sources)) != len(sources):
|
||||
raise ValueError("multi_source_regions: source sketch ids must be unique and match profiles")
|
||||
entities: list[_Ctx] = []
|
||||
regions: list[_Ctx] = []
|
||||
for child in profiles:
|
||||
if not isinstance(child, dict):
|
||||
raise ValueError("multi_source_regions: child profile must be an object")
|
||||
child_meta: _Ctx = {"_regions": [], "_has_open_contour": False, "_surface_wires": []}
|
||||
child_entities, _ = _gen_analytic_contours(_multi_source_child_as_analytic(child), child_meta)
|
||||
if child_meta["_has_open_contour"] or child_meta["_surface_wires"] or not child_meta["_regions"]:
|
||||
raise ValueError("multi_source_regions: every child must resolve to closed regions")
|
||||
entities.extend(child_entities)
|
||||
regions.extend(child_meta["_regions"])
|
||||
meta["_regions"] = regions
|
||||
return entities, []
|
||||
|
||||
|
||||
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
"""Prepare source curves for an exact OCC planar-arrangement split.
|
||||
|
||||
@@ -571,8 +682,23 @@ def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ct
|
||||
if not edges:
|
||||
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
|
||||
source_entities.append({"id": source_id, "edges": edges})
|
||||
if len(source_entities) < 2:
|
||||
raise ValueError("planar_imprint: at least two source entities are required")
|
||||
external_anchors = profile.get("external_anchors") or []
|
||||
external_ids = {
|
||||
str(entry.get("id") or "")
|
||||
for entry in external_anchors
|
||||
if isinstance(entry, dict)
|
||||
}
|
||||
single_external_fragment = (
|
||||
len(source_entities) == 1
|
||||
and bool(external_ids)
|
||||
and all(
|
||||
isinstance(selection, dict)
|
||||
and str((selection.get("fragment") or {}).get("external_anchor_id") or "") in external_ids
|
||||
for selection in profile.get("selections") or ()
|
||||
)
|
||||
)
|
||||
if len(source_entities) < 2 and not single_external_fragment:
|
||||
raise ValueError("planar_imprint: at least two source entities or one external anchor are required")
|
||||
meta["_imprint_entities"] = source_entities
|
||||
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
|
||||
return [], []
|
||||
@@ -582,6 +708,7 @@ CORE_SHAPE_GENERATORS: dict[str, Any] = {
|
||||
"circle": _gen_circle,
|
||||
"polygon": _gen_polygon,
|
||||
"analytic_contours": _gen_analytic_contours,
|
||||
"multi_source_regions": _gen_multi_source_regions,
|
||||
"planar_imprint": _gen_planar_imprint,
|
||||
}
|
||||
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
|
||||
@@ -589,6 +716,7 @@ SHAPE_CAPABILITIES: dict[str, _Ctx] = {
|
||||
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
|
||||
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
|
||||
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
|
||||
"multi_source_regions": {"detectable": True, "arity": "multi_source", "description": "independent direct sketch regions on one shared frame"},
|
||||
}
|
||||
|
||||
|
||||
@@ -609,7 +737,7 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
|
||||
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
|
||||
meta: _Ctx = {
|
||||
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
|
||||
"_imprint_entities": [], "_imprint_selections": [],
|
||||
"_imprint_entities": [], "_imprint_selections": [], "_surface_wires": [],
|
||||
}
|
||||
entities, contour = generator(profile, meta)
|
||||
output = deepcopy(sketch)
|
||||
@@ -627,6 +755,11 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
|
||||
]
|
||||
if meta["_has_open_contour"]:
|
||||
output["_open_contour"] = True
|
||||
if meta["_surface_wires"]:
|
||||
output["surface_wires_mm"] = [
|
||||
_transform_contours(wire, workplane) if workplane else wire
|
||||
for wire in meta["_surface_wires"]
|
||||
]
|
||||
if meta["_imprint_entities"]:
|
||||
output["imprint_entities_mm"] = [
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,11 @@ from __future__ import annotations
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopoDS import TopoDS
|
||||
|
||||
from .specs import canonical_plane_signature
|
||||
from .topology import TopologyRecord
|
||||
|
||||
@@ -48,6 +53,40 @@ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyR
|
||||
edges = list(body.edges())
|
||||
vertices = list(body.vertices())
|
||||
|
||||
# build123d may wrap a vertex extracted from a solid in a distinct OCC
|
||||
# handle. Builder FirstShape/LastShape vertex history is only comparable
|
||||
# with the final result's native explorer handles, so retain those exact
|
||||
# handles for selector records. Faces and edges keep their established
|
||||
# build123d export path.
|
||||
result_vertices: list[Any] = []
|
||||
explorer = TopExp_Explorer(body.wrapped, TopAbs_VERTEX)
|
||||
while explorer.More():
|
||||
candidate = explorer.Current()
|
||||
if not any(candidate.IsSame(existing) for existing in result_vertices):
|
||||
result_vertices.append(candidate)
|
||||
explorer.Next()
|
||||
result_edges: list[Any] = []
|
||||
explorer = TopExp_Explorer(body.wrapped, TopAbs_EDGE)
|
||||
while explorer.More():
|
||||
candidate = explorer.Current()
|
||||
if not any(candidate.IsSame(existing) for existing in result_edges):
|
||||
result_edges.append(candidate)
|
||||
explorer.Next()
|
||||
|
||||
def exact_incident_edge_count(vertex: Any) -> int:
|
||||
count = 0
|
||||
for edge in result_edges:
|
||||
edge_vertices: list[Any] = []
|
||||
edge_explorer = TopExp_Explorer(edge, TopAbs_VERTEX)
|
||||
while edge_explorer.More():
|
||||
candidate = edge_explorer.Current()
|
||||
if not any(candidate.IsSame(existing) for existing in edge_vertices):
|
||||
edge_vertices.append(candidate)
|
||||
edge_explorer.Next()
|
||||
if any(vertex.IsSame(candidate) for candidate in edge_vertices):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def index_for(shape: Any, candidates: list[Any]) -> int | None:
|
||||
"""Map a subshape returned by a face/edge back to body topology."""
|
||||
# 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。
|
||||
@@ -302,10 +341,11 @@ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyR
|
||||
geometry=geometry,
|
||||
))
|
||||
# 5. 导出顶点记录:含坐标与关联边数。
|
||||
for index, vertex in enumerate(vertices):
|
||||
point = [vertex.X, vertex.Y, vertex.Z]
|
||||
for index, vertex in enumerate(result_vertices):
|
||||
point_value = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex))
|
||||
point = [point_value.X(), point_value.Y(), point_value.Z()]
|
||||
records.append(TopologyRecord(
|
||||
record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex,
|
||||
geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])},
|
||||
geometry={"center_mm": point, "incident_edge_count": exact_incident_edge_count(vertex)},
|
||||
))
|
||||
return records
|
||||
|
||||
Reference in New Issue
Block a user