feat(cadfs): expand selector provenance coverage

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