1105 lines
63 KiB
Python
1105 lines
63 KiB
Python
"""Capability analysis and feature planning for semantic CDSL.
|
||
|
||
The analyzer is intentionally independent of the geometry kernel. It treats
|
||
the CDSL ``execution_status`` as provenance, then derives current executable
|
||
state from registered atomic executors, profile support, and complete inputs.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Any, Iterable
|
||
|
||
from .runtime_types import (
|
||
CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic,
|
||
pattern_instance_member_id, transform_copy_member_id,
|
||
)
|
||
from .operation_contracts import materialized_feature_contracts
|
||
|
||
|
||
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
|
||
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
|
||
# ``ExecutionSession.body_members`` only contains independently selectable
|
||
# body outputs. A normal additive/cut/dress-up feature replaces the active
|
||
# aggregate, while ``result_mode: new_body`` and ``keep_tools`` are the two
|
||
# contracts that preserve a previous member. Circular patterns over those
|
||
# members can additionally expose a proven COPY instance; replayed/fused
|
||
# patterns remain ineligible because no exact instance ownership exists.
|
||
_PATTERN_ATOMICS = frozenset({"pattern_linear", "pattern_mirror", "pattern_circular"})
|
||
_SUPPORTED_EXTENTS = frozenset({
|
||
"blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
|
||
"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body",
|
||
})
|
||
_EXTENT_TARGET_KINDS = {
|
||
"up_to_surface": "face",
|
||
"offset_from_surface": "face",
|
||
"up_to_vertex": "vertex",
|
||
"up_to_body": "body",
|
||
}
|
||
|
||
|
||
def _runtime_capabilities() -> dict[str, dict[str, bool]]:
|
||
"""Return the per-atomic runtime capability flags from the schema registry.
|
||
|
||
``profile_schema.json.operation_contracts[*].runtime_capability`` is the
|
||
single source of truth for these classifications. Adding an atomic
|
||
operation updates one JSON contract instead of editing code-level sets.
|
||
"""
|
||
path = Path(__file__).with_name("profile_schema.json")
|
||
contracts = materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
|
||
return {atomic_id: contract["runtime_capability"] for atomic_id, contract in contracts.items()}
|
||
|
||
|
||
_RUNTIME_CAPABILITIES = _runtime_capabilities()
|
||
_SELECTOR_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_selector"])
|
||
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
|
||
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
|
||
_OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["open_profile_ok"])
|
||
_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",
|
||
"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"])
|
||
_BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["body_mutating"])
|
||
# A pattern may replay a previous pattern as well as a direct body mutation.
|
||
# Context-only features have no geometry definition to instance. thread_add,
|
||
# thread_cut, bend_add, gear_add and rack_add are excluded: pattern
|
||
# translation does not yet move their parametric axis/frame, so a replayed
|
||
# instance would silently re-run at the original location.
|
||
_REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"])
|
||
|
||
|
||
def _mappings(value: Any):
|
||
"""Yield nested feature mappings for capability-only contract checks."""
|
||
if isinstance(value, dict):
|
||
yield value
|
||
for child in value.values():
|
||
yield from _mappings(child)
|
||
elif isinstance(value, list):
|
||
for child in value:
|
||
yield from _mappings(child)
|
||
|
||
|
||
def _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 "")
|
||
if slot == "feature.selectors":
|
||
values = node.selectors
|
||
elif slot.startswith("params.") and slot.count(".") == 1:
|
||
value = node.params.get(slot.removeprefix("params."))
|
||
values = value if isinstance(value, list) else [value]
|
||
else:
|
||
values = []
|
||
return [value for value in values if isinstance(value, 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 ()}
|
||
source_ids.update(
|
||
pattern_instance_member_id(
|
||
str(reference.get("pattern_feature_id") or ""),
|
||
str(reference.get("source_feature_id") or ""),
|
||
int(reference.get("instance_index") or 0),
|
||
)
|
||
for reference in params.get("pattern_instance_refs") or ()
|
||
if isinstance(reference, dict)
|
||
)
|
||
source_ids.update(
|
||
transform_copy_member_id(
|
||
str(reference.get("transform_feature_id") or ""),
|
||
str(reference.get("source_feature_id") or ""),
|
||
)
|
||
for reference in params.get("transform_copy_refs") or ()
|
||
if isinstance(reference, dict)
|
||
)
|
||
return source_ids
|
||
|
||
|
||
def _pattern_instance_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
|
||
"""Project structured pattern refs to internal body-member keys."""
|
||
return {
|
||
pattern_instance_member_id(
|
||
str(reference.get("pattern_feature_id") or ""),
|
||
str(reference.get("source_feature_id") or ""),
|
||
int(reference.get("instance_index") or 0),
|
||
)
|
||
for reference in params.get(parameter) or ()
|
||
if isinstance(reference, dict)
|
||
}
|
||
|
||
|
||
def _next_body_graph(
|
||
node: FeaturePlanNode,
|
||
members: set[str],
|
||
has_active_body: bool,
|
||
nodes_by_id: dict[str, FeaturePlanNode],
|
||
) -> tuple[set[str], bool]:
|
||
"""Project the explicit runtime body-member lifecycle without geometry.
|
||
|
||
The capability phase cannot know whether two B-reps intersect, but it can
|
||
mirror the ownership contract used by ``ExecutionSession``. This keeps a
|
||
historical feature's successful execution separate from its continued
|
||
availability as an independently selectable body. In particular, this
|
||
must never turn an absorbed feature or pattern replay into ``session.body``.
|
||
"""
|
||
atomic_id = node.atomic_id
|
||
feature_id = node.feature_id
|
||
|
||
if atomic_id == "boolean_bodies":
|
||
target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
|
||
target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs"))
|
||
tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()}
|
||
tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs"))
|
||
next_members = members - target_ids - tool_ids
|
||
next_members.add(feature_id)
|
||
if bool(node.params.get("keep_tools")):
|
||
next_members.update(tool_ids)
|
||
return next_members, True
|
||
|
||
if atomic_id == "transform_bodies":
|
||
source_ids = _transform_member_sources(node.params)
|
||
next_members = set(members)
|
||
if not bool(node.params.get("make_copy")):
|
||
next_members.difference_update(source_ids)
|
||
next_members.add(feature_id)
|
||
elif len(node.params.get("source_feature_ids") or ()) > 1:
|
||
# Multi-source COPY outputs have no aggregate body-member owner.
|
||
# Keep each transformed source addressable by its exact origin.
|
||
next_members.update(
|
||
transform_copy_member_id(feature_id, source_id)
|
||
for source_id in source_ids
|
||
)
|
||
else:
|
||
next_members.add(feature_id)
|
||
return next_members, True
|
||
|
||
if atomic_id == "delete_bodies":
|
||
source_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
|
||
next_members = members - source_ids
|
||
return next_members, bool(next_members)
|
||
|
||
if atomic_id in _PATTERN_ATOMICS:
|
||
source_ids = {str(value) for value in node.params.get("source_feature_ids") or ()}
|
||
if (
|
||
atomic_id == "pattern_mirror"
|
||
and source_ids
|
||
and source_ids <= members
|
||
and all(
|
||
(source := nodes_by_id.get(source_id)) is not None
|
||
and source.params.get("result_mode") == "new_body"
|
||
for source_id in source_ids
|
||
)
|
||
):
|
||
# A mirror produces an independently addressable COPY only when
|
||
# every source is an explicit NEW body. A hole, dress-up, or
|
||
# ordinary additive feature may be the current aggregate's
|
||
# successor, not a standalone body: its mirror must remain a
|
||
# feature replay and cannot be exposed as a body member.
|
||
next_members = set(members)
|
||
next_members.update(
|
||
pattern_instance_member_id(node.feature_id, source_id, 1)
|
||
for source_id in source_ids
|
||
)
|
||
return next_members, True
|
||
if (
|
||
atomic_id == "pattern_circular"
|
||
and str(node.params.get("operation_mode") or "add") == "add"
|
||
and source_ids
|
||
and source_ids <= members
|
||
):
|
||
count = int(node.params.get("pattern_count") or 0)
|
||
excluded = {int(value) for value in node.params.get("excluded_instance_indices") or ()}
|
||
next_members = set(members)
|
||
for instance in range(1, count):
|
||
if instance in excluded:
|
||
continue
|
||
next_members.update(
|
||
pattern_instance_member_id(node.feature_id, source_id, instance)
|
||
for source_id in source_ids
|
||
)
|
||
return next_members, True
|
||
# Replay/fused pattern output has no member-level contract. It creates
|
||
# active geometry, but cannot prove which replay instance a later body
|
||
# query names.
|
||
return set(), has_active_body
|
||
|
||
if atomic_id not in _BODY_MUTATING_ATOMICS:
|
||
return members, has_active_body
|
||
|
||
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
|
||
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
|
||
):
|
||
# Primary cuts execute per explicit member in the runtime so a later
|
||
# body query still addresses the same CADFS NEW/COPY lifecycle node.
|
||
return set(members), True
|
||
|
||
if atomic_id in _PRIMARY_ATOMICS and "cut" not in atomic_id and node.params.get("operation") != "cut" and node.params.get("result_mode") == "new_body":
|
||
return members | {feature_id}, True
|
||
|
||
# All remaining body-mutating executors register their output as the sole
|
||
# explicit member. This includes fused additive features, cuts and
|
||
# dress-ups, whose source-member topology no longer has an identity.
|
||
return {feature_id}, True
|
||
|
||
|
||
def _has_explicit_axis(axis: Any) -> bool:
|
||
return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None
|
||
|
||
|
||
def _has_resolvable_axis_selector(node: FeaturePlanNode) -> bool:
|
||
axis = node.params.get("axis") or {}
|
||
selector = axis.get("selector") if isinstance(axis, dict) else None
|
||
if not isinstance(selector, dict):
|
||
selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
|
||
# A source stable id does not survive SolidWorks -> OCC. An axis selector
|
||
# must therefore name the preceding context feature explicitly.
|
||
return isinstance(selector, dict) and selector.get("kind") == "axis" and bool(selector.get("owner_feature_id"))
|
||
|
||
|
||
def _has_explicit_host_frame(params: dict[str, Any]) -> bool:
|
||
host = params.get("host_face")
|
||
frame = host.get("frame") if isinstance(host, dict) else None
|
||
return isinstance(frame, dict) and all(frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal"))
|
||
|
||
|
||
def _is_host_face_source(source: FeaturePlanNode) -> bool:
|
||
"""Whether a ``face`` selector on the source is a fixed host face.
|
||
|
||
A patterned hole keeps its host face: the instance positions travel with
|
||
the pattern (``_translated_node`` shifts ``positions``) while the face
|
||
itself is resolved unchanged, so the face selector can stay untouched
|
||
(issue #6). A face selector on anything else (for example a fillet
|
||
selecting a face) would need per-instance edge geometry and must stay
|
||
blocked.
|
||
"""
|
||
return source.atomic_id in _HOLE_ATOMICS or (
|
||
isinstance(source.params.get("host_face"), dict)
|
||
and source.params.get("host_face", {}).get("kind") == "face"
|
||
)
|
||
|
||
|
||
def pattern_transform_blocker(source: FeaturePlanNode) -> str | None:
|
||
"""Return the selector dependency that cannot be transformed exactly.
|
||
|
||
Selectors fully captured by explicit coordinate data (a revolve axis with
|
||
origin/direction, or a host face with a complete world frame) are
|
||
transformed together with each patterned instance and are not blockers.
|
||
A patterned hole's host face and an up_to_surface extrusion's target face
|
||
are *fixed* body faces rather than instance geometry: positions and
|
||
profiles travel with the instance while the face itself is resolved
|
||
unchanged (issue #6). Selectors that must follow the instance but cannot
|
||
be translated (edges, vertices, …) stay blocked until their geometry
|
||
transform contract is implemented.
|
||
"""
|
||
for selector in source.selectors or ():
|
||
if selector.get("kind") == "axis" and _has_explicit_axis(source.params.get("axis")):
|
||
# The axis is coordinate data; _translated_node shifts its origin.
|
||
continue
|
||
if selector.get("kind") == "face" and _is_host_face_source(source):
|
||
# 孔宿主面:主体上的固定面,不随实例平移;实例位置由 positions
|
||
# 平移决定(_translated_node),selector 原样保留即可正确 resolve。
|
||
continue
|
||
if selector.get("kind") == "plane" and source.atomic_id == "pattern_mirror" and selector.get("owner_feature_id"):
|
||
# #6 pattern 引用重解析:pattern_mirror 的镜像面是对
|
||
# reference_plane 的 plane 引用(带 owner_feature_id),重放时
|
||
# 由运行时从 owner 解析显式 frame 并随实例变换
|
||
# (_translated_node/_mirrored_node 内联 frame),放行。
|
||
continue
|
||
return "feature selector"
|
||
host = source.params.get("host_face")
|
||
if host is not None and not _has_explicit_host_frame(source.params):
|
||
# 无 frame 的孔:宿主面以 face selector 形式给出(host_face 自身或
|
||
# selectors 列表)→ 上面的 face 分支已放行;其它形态(无 frame 也
|
||
# 非 face selector)仍阻塞。
|
||
if not (isinstance(host, dict) and host.get("kind") == "face"):
|
||
return "host face selector"
|
||
end_condition = source.params.get("end_condition") or {}
|
||
if isinstance(end_condition, dict) and isinstance(end_condition.get("reference"), dict):
|
||
# up_to_surface / offset_from_surface 的终止面是主体上的固定面:
|
||
# 不随实例平移,reference 原样保留即可正确 resolve(#5 裁剪已支持
|
||
# 非均匀相交)。顶点/主体目标无法构造"固定终止面",仍显式阻塞。
|
||
if end_condition["reference"].get("kind") == "face":
|
||
return None
|
||
return "extent target selector"
|
||
return None
|
||
|
||
|
||
def _schema_contract() -> dict[str, dict[str, Any]]:
|
||
path = Path(__file__).with_name("profile_schema.json")
|
||
return materialized_feature_contracts(json.loads(path.read_text(encoding="utf-8")))
|
||
|
||
|
||
def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
||
"""Return only sketches consumed by executable feature contracts.
|
||
|
||
CAD documents commonly preserve construction or abandoned sketches whose
|
||
contours are incomplete. They are semantic data, but must not make an
|
||
otherwise independent feature history ineligible for execution. Most
|
||
operations declare ``sketch_id``; loft declares its ordered section set
|
||
in ``params.profile_sketch_ids``.
|
||
"""
|
||
contracts = _schema_contract()
|
||
required: set[str] = set()
|
||
for feature in cdsl.get("features") or ():
|
||
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"}:
|
||
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
|
||
required.add(str(sketch_id))
|
||
return frozenset(required)
|
||
|
||
|
||
def _has_closed_region(sketch: dict[str, Any]) -> bool:
|
||
"""Mirror the adapter's input contract without importing the geometry kernel."""
|
||
profile = sketch.get("profile") or {}
|
||
if profile.get("type") == "planar_imprint":
|
||
# The exact bounded-region proof happens in the OCC splitter. At this
|
||
# stage the typed contract proves only that region selection work is
|
||
# possible; an unbounded or ambiguous runtime result remains a stable
|
||
# feature execution diagnostic rather than a guessed sketch contour.
|
||
return bool(sketch.get("imprint_entities_mm") and sketch.get("imprint_selections"))
|
||
regions = sketch.get("contour_regions_mm") or []
|
||
if any(len(region.get("outer") or []) >= 1 for region in regions if isinstance(region, dict)):
|
||
return True
|
||
if len(sketch.get("contour_edges_mm") or []) >= 1:
|
||
return True
|
||
return any(
|
||
entity.get("type") == "circle" and not entity.get("construction")
|
||
and float(entity.get("radius_mm") or 0.0) > 0
|
||
for entity in sketch.get("entities") or []
|
||
if isinstance(entity, dict)
|
||
)
|
||
|
||
|
||
def _has_single_loft_region(sketch: dict[str, Any]) -> bool:
|
||
"""Whether a resolved profile maps exactly to one solid loft section."""
|
||
regions = sketch.get("contour_regions_mm") or []
|
||
if regions:
|
||
return len(regions) == 1 and not (regions[0].get("holes") or [])
|
||
circles = [
|
||
entity for entity in sketch.get("entities") or []
|
||
if isinstance(entity, dict) and entity.get("type") == "circle" and not entity.get("construction")
|
||
]
|
||
return len(circles) == 1
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class CapabilityAnalysis:
|
||
plan: tuple[FeaturePlanNode, ...]
|
||
feature_results: tuple[CapabilityResult, ...]
|
||
document_blockers: tuple[RuntimeDiagnostic, ...] = ()
|
||
|
||
@property
|
||
def runtime_eligible(self) -> bool:
|
||
return not self.document_blockers and all(result.executable for result in self.feature_results)
|
||
|
||
def as_dict(self) -> dict[str, Any]:
|
||
return {
|
||
"runtime_eligible": self.runtime_eligible,
|
||
"feature_results": [result.as_dict() for result in self.feature_results],
|
||
"document_blockers": [blocker.as_dict() for blocker in self.document_blockers],
|
||
}
|
||
|
||
|
||
class CapabilityAnalyzer:
|
||
"""Determine whether a semantic document is executable by this runtime."""
|
||
|
||
def __init__(self, *, atomic_ids: Iterable[str], profile_types: Iterable[str]) -> None:
|
||
self.atomic_ids = frozenset(atomic_ids)
|
||
self.profile_types = frozenset(profile_types)
|
||
self.contracts = _schema_contract()
|
||
|
||
def _blocker(self, feature_id: str, code: str, message: str, **detail: Any) -> RuntimeDiagnostic:
|
||
return RuntimeDiagnostic(code=code, message=message, feature_id=feature_id, detail=detail)
|
||
|
||
def _plan(self, cdsl: dict[str, Any]) -> tuple[FeaturePlanNode, ...]:
|
||
return tuple(
|
||
FeaturePlanNode(
|
||
feature_id=str(feature.get("id") or ""),
|
||
atomic_id=str(feature.get("atomic_id") or ""),
|
||
name=feature.get("name"),
|
||
depends_on=tuple(feature.get("depends_on") or ()),
|
||
params=dict(feature.get("params") or {}),
|
||
selectors=tuple(feature.get("selectors") or ()),
|
||
sketch_id=feature.get("sketch_id"),
|
||
declared_status=feature.get("execution_status"),
|
||
source_feature=feature,
|
||
)
|
||
for feature in cdsl.get("features") or ()
|
||
)
|
||
|
||
def analyze(
|
||
self,
|
||
cdsl: dict[str, Any],
|
||
*,
|
||
sketch_errors: dict[str, str] | None = None,
|
||
) -> CapabilityAnalysis:
|
||
sketches = {str(sketch.get("id")): sketch for sketch in (cdsl.get("geometry") or {}).get("sketches") or ()}
|
||
sketch_errors = sketch_errors or {}
|
||
plan = self._plan(cdsl)
|
||
nodes_by_id = {node.feature_id: node for node in plan}
|
||
results: list[CapabilityResult] = []
|
||
completed: set[str] = set()
|
||
body_available = False
|
||
body_members: set[str] = set()
|
||
for node in plan:
|
||
blockers: list[RuntimeDiagnostic] = []
|
||
contract = self.contracts.get(node.atomic_id)
|
||
required = [f"atomic:{node.atomic_id}"]
|
||
if not contract:
|
||
blockers.append(self._blocker(node.feature_id, "unknown_atomic", "The semantic schema has no atomic contract", atomic_id=node.atomic_id))
|
||
elif node.atomic_id not in self.atomic_ids:
|
||
blockers.append(self._blocker(node.feature_id, "unsupported_atomic", "The current runtime has no registered executor", atomic_id=node.atomic_id))
|
||
for unresolved in node.source_feature.get("unresolved") or ():
|
||
blockers.append(self._blocker(node.feature_id, "unresolved_input", str(unresolved)))
|
||
for dependency in node.depends_on:
|
||
if dependency not in completed:
|
||
blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency))
|
||
if node.atomic_id in _ACTIVE_BODY_REQUIRED or (
|
||
node.atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
|
||
):
|
||
required.append("active_body")
|
||
if not body_available:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_active_body",
|
||
"This feature mutates an existing body, but no preceding executable feature created one",
|
||
))
|
||
params = node.params
|
||
if contract:
|
||
for parameter in contract.get("required_params") or ():
|
||
if params.get(parameter) is None:
|
||
blockers.append(self._blocker(node.feature_id, "missing_parameter", "Required parameter is missing", parameter=parameter))
|
||
if contract.get("requires_sketch"):
|
||
if not node.sketch_id or node.sketch_id not in sketches:
|
||
blockers.append(self._blocker(node.feature_id, "missing_sketch", "Feature requires an existing sketch", sketch_id=node.sketch_id))
|
||
else:
|
||
profile_type = str((sketches[node.sketch_id].get("profile") or {}).get("type") or "")
|
||
required.append(f"profile:{profile_type}")
|
||
resolution_error = sketch_errors.get(str(node.sketch_id))
|
||
if resolution_error:
|
||
blockers.append(self._blocker(
|
||
node.feature_id,
|
||
"profile_resolution_failed",
|
||
"The feature's sketch could not be resolved into executable regions",
|
||
sketch_id=node.sketch_id,
|
||
reason=resolution_error,
|
||
))
|
||
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]):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "profile_no_closed_region",
|
||
"The resolved sketch contains no closed profile region",
|
||
sketch_id=node.sketch_id,
|
||
))
|
||
elif (
|
||
not resolution_error
|
||
and sketches[node.sketch_id].get("_open_contour")
|
||
and node.atomic_id not in _OPEN_PROFILE_ATOMICS
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_open_profile",
|
||
"Open profiles are only supported for straight extruded cut features",
|
||
sketch_id=node.sketch_id,
|
||
atomic_id=node.atomic_id,
|
||
))
|
||
if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}:
|
||
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:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_loft_profiles",
|
||
f"Loft requires at least {minimum_profiles} profile sketch ids",
|
||
))
|
||
elif len({str(sketch_id) for sketch_id in profile_ids}) != len(profile_ids):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_loft_profiles",
|
||
"Loft profile sketch ids must be distinct",
|
||
))
|
||
else:
|
||
for sketch_id in profile_ids:
|
||
sketch_id = str(sketch_id)
|
||
profile = sketches.get(sketch_id)
|
||
if profile is None:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_loft_profile",
|
||
"Loft profile sketch does not exist", sketch_id=sketch_id,
|
||
))
|
||
continue
|
||
profile_type = str((profile.get("profile") or {}).get("type") or "")
|
||
required.append(f"loft_profile:{profile_type}")
|
||
resolution_error = sketch_errors.get(sketch_id)
|
||
if resolution_error:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "profile_resolution_failed",
|
||
"A loft profile sketch could not be resolved into executable regions",
|
||
sketch_id=sketch_id, reason=resolution_error,
|
||
))
|
||
elif profile_type not in self.profile_types:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_profile",
|
||
"The current runtime cannot resolve a loft profile",
|
||
sketch_id=sketch_id, profile_type=profile_type,
|
||
))
|
||
elif not _has_closed_region(profile):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "profile_no_closed_region",
|
||
"A loft profile contains no closed region", sketch_id=sketch_id,
|
||
))
|
||
elif not _has_single_loft_region(profile):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_loft_profile_regions",
|
||
"Loft currently requires exactly one outer profile without holes",
|
||
sketch_id=sketch_id,
|
||
))
|
||
contract_selectors = _contract_selectors(node, contract)
|
||
contract_selector_ids = {id(selector) for selector in contract_selectors}
|
||
for selector in _mappings(params):
|
||
if selector.get("output_role") is not None and id(selector) not in contract_selector_ids:
|
||
blockers.append(self._blocker(
|
||
node.feature_id,
|
||
"unsupported_output_role_selector_context",
|
||
"Feature output role selector is outside the operation contract slot",
|
||
))
|
||
for selector_index, selector in enumerate(contract_selectors):
|
||
if selector.get("output_role") is None:
|
||
continue
|
||
required.append("selector:feature_output_role")
|
||
if 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 selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
|
||
blockers.append(self._blocker(
|
||
node.feature_id,
|
||
"invalid_output_role_selector",
|
||
"A feature output role selector requires kind face and owner_feature_id",
|
||
selector_index=selector_index,
|
||
))
|
||
if selector.get("source") != "runtime_snapshot":
|
||
blockers.append(self._blocker(
|
||
node.feature_id,
|
||
"invalid_output_role_selector",
|
||
"A feature output role selector requires runtime_snapshot evidence",
|
||
selector_index=selector_index,
|
||
))
|
||
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
|
||
blockers.append(self._blocker(
|
||
node.feature_id,
|
||
"invalid_output_role_selector",
|
||
"A feature output role selector cannot mix stable or geometry evidence",
|
||
selector_index=selector_index,
|
||
))
|
||
role_source = selector.get("output_role_source")
|
||
if role_source is not None:
|
||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||
source = nodes_by_id.get(str(source_owner or ""))
|
||
source_params = (source.params if source is not None else {}) or {}
|
||
if not isinstance(source_owner, str) or not isinstance(source_role, str):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_output_role_source",
|
||
"An output role source requires owner_feature_id and output_role",
|
||
selector_index=selector_index,
|
||
))
|
||
elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell":
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_output_role_source",
|
||
"Output role sources are currently supported only for shell.offset_face",
|
||
selector_index=selector_index,
|
||
))
|
||
elif source_role not in {"extrude.start", "extrude.end"} or (
|
||
source is None
|
||
or source.atomic_id != "extrude_add_blind"
|
||
or source_params.get("result_mode") != "new_body"
|
||
or (source_params.get("end_condition") or {}).get("type") != "blind"
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_output_role_source",
|
||
"shell.offset_face requires a direct new_body blind extrusion cap source",
|
||
selector_index=selector_index,
|
||
))
|
||
if node.atomic_id == "sweep_add":
|
||
path = params.get("path")
|
||
segment = path.get("segment") if isinstance(path, dict) else None
|
||
kind = segment.get("type") if isinstance(segment, dict) else None
|
||
if kind not in {"line", "bspline"}:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_sweep_path",
|
||
"Sweep requires one captured line or B-spline path",
|
||
))
|
||
elif kind == "line" and not all(
|
||
isinstance(segment.get(key), list) and len(segment[key]) == 2
|
||
for key in ("start", "end")
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_sweep_path",
|
||
"Sweep line path requires two-dimensional start and end points",
|
||
))
|
||
elif kind == "bspline" and (
|
||
not isinstance(segment.get("points"), list)
|
||
or len(segment.get("points") or []) < 3
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_sweep_path",
|
||
"Sweep B-spline path requires at least three interpolation points",
|
||
))
|
||
if node.atomic_id == "boolean_bodies":
|
||
target_ids = params.get("target_feature_ids")
|
||
target_instance_refs = params.get("target_pattern_instance_refs")
|
||
tool_ids = params.get("tool_feature_ids")
|
||
tool_instance_refs = params.get("tool_pattern_instance_refs")
|
||
operation = params.get("operation")
|
||
if operation not in {"union", "subtract", "intersect"}:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_boolean_operation",
|
||
"booleanBodies requires union, subtract or intersect",
|
||
))
|
||
target_members: set[str] = set()
|
||
tool_members: set[str] = set()
|
||
for parameter, instance_parameter, feature_ids, instance_refs, selected_members in (
|
||
("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members),
|
||
("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members),
|
||
):
|
||
if feature_ids is None:
|
||
feature_ids = []
|
||
if instance_refs is None:
|
||
instance_refs = []
|
||
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_boolean_bodies",
|
||
"booleanBodies requires explicit target and tool body references", parameter=parameter,
|
||
))
|
||
continue
|
||
for source_id in feature_ids:
|
||
source = nodes_by_id.get(str(source_id))
|
||
if source is None:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "boolean_body_unavailable",
|
||
"booleanBodies source feature does not exist", source_feature_id=source_id,
|
||
))
|
||
elif source.feature_id not in completed:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "boolean_body_unavailable",
|
||
"booleanBodies source feature did not become executable", source_feature_id=source_id,
|
||
))
|
||
elif source.feature_id not in body_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "boolean_body_unavailable",
|
||
"Selected source no longer has an independently selectable body output",
|
||
source_feature_id=source_id,
|
||
))
|
||
else:
|
||
selected_members.add(str(source_id))
|
||
for index, reference in enumerate(instance_refs):
|
||
if not isinstance(reference, dict):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_pattern_instance_ref",
|
||
"Pattern instance body reference must be an object", parameter=instance_parameter, index=index,
|
||
))
|
||
continue
|
||
instance = reference.get("instance_index")
|
||
if not isinstance(instance, int):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_pattern_instance_ref",
|
||
"Pattern instance body reference requires an integer instance_index",
|
||
parameter=instance_parameter, index=index,
|
||
))
|
||
continue
|
||
member_id = pattern_instance_member_id(
|
||
str(reference.get("pattern_feature_id") or ""),
|
||
str(reference.get("source_feature_id") or ""),
|
||
instance,
|
||
)
|
||
if member_id not in body_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "pattern_instance_unavailable",
|
||
"Pattern instance has no independently selectable body output",
|
||
pattern_feature_id=reference.get("pattern_feature_id"),
|
||
source_feature_id=reference.get("source_feature_id"),
|
||
instance_index=reference.get("instance_index"),
|
||
))
|
||
continue
|
||
selected_members.add(member_id)
|
||
if target_members & tool_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "boolean_body_overlap",
|
||
"booleanBodies targets and tools must be disjoint",
|
||
))
|
||
if node.atomic_id == "shell" and params.get("target_feature_id") is not None:
|
||
target_feature_id = params.get("target_feature_id")
|
||
required.append("shell:explicit_target_body")
|
||
target = nodes_by_id.get(str(target_feature_id or ""))
|
||
if not isinstance(target_feature_id, str) or not target_feature_id:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_shell_target_body",
|
||
"shell target_feature_id must name one preceding body member",
|
||
))
|
||
elif target is None:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "shell_target_body_unavailable",
|
||
"shell target body feature does not exist",
|
||
target_feature_id=target_feature_id,
|
||
))
|
||
elif target.feature_id not in completed:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "shell_target_body_unavailable",
|
||
"shell target body feature did not become executable",
|
||
target_feature_id=target_feature_id,
|
||
))
|
||
elif target.feature_id not in body_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "shell_target_body_unavailable",
|
||
"shell target no longer has an independently selectable body output",
|
||
target_feature_id=target_feature_id,
|
||
))
|
||
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
|
||
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
|
||
source_ids = params.get(parameter)
|
||
pattern_instances = params.get("pattern_instance_refs") if node.atomic_id == "transform_bodies" else []
|
||
transform_copies = params.get("transform_copy_refs") if node.atomic_id == "transform_bodies" else []
|
||
if source_ids is None:
|
||
source_ids = []
|
||
if pattern_instances is None:
|
||
pattern_instances = []
|
||
if transform_copies is None:
|
||
transform_copies = []
|
||
if (
|
||
not isinstance(source_ids, list)
|
||
or not isinstance(pattern_instances, list)
|
||
or not isinstance(transform_copies, list)
|
||
or not (source_ids or pattern_instances or transform_copies)
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_body_sources",
|
||
f"{node.atomic_id} requires explicit source body feature ids", parameter=parameter,
|
||
))
|
||
else:
|
||
for source_id in source_ids:
|
||
source = nodes_by_id.get(str(source_id))
|
||
if source is None:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "body_source_unavailable",
|
||
"Selected body source feature does not exist", source_feature_id=source_id,
|
||
))
|
||
elif source.feature_id not in completed:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "body_source_unavailable",
|
||
"Selected body source did not become executable", source_feature_id=source_id,
|
||
))
|
||
elif source.feature_id not in body_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "body_source_unavailable",
|
||
"Selected source no longer has an independently selectable body output",
|
||
source_feature_id=source_id,
|
||
))
|
||
for reference in pattern_instances:
|
||
if not isinstance(reference, dict):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_pattern_instance_ref",
|
||
"Pattern instance body reference must be an object",
|
||
))
|
||
continue
|
||
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||
source_id = str(reference.get("source_feature_id") or "")
|
||
instance = reference.get("instance_index")
|
||
pattern = nodes_by_id.get(pattern_id)
|
||
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "pattern_instance_unavailable",
|
||
"Pattern instance owner is not a preceding circular or mirror pattern",
|
||
pattern_feature_id=pattern_id,
|
||
))
|
||
continue
|
||
if pattern.feature_id not in completed:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "pattern_instance_unavailable",
|
||
"Pattern instance owner did not become executable",
|
||
pattern_feature_id=pattern_id,
|
||
))
|
||
continue
|
||
count = int(pattern.params.get("pattern_count") or 0)
|
||
excluded = {int(value) for value in pattern.params.get("excluded_instance_indices") or ()}
|
||
surviving_instance = (
|
||
isinstance(instance, int)
|
||
and (
|
||
(pattern.atomic_id == "pattern_mirror" and instance == 1)
|
||
or (
|
||
pattern.atomic_id == "pattern_circular"
|
||
and 1 <= instance < count
|
||
and instance not in excluded
|
||
)
|
||
)
|
||
)
|
||
if (
|
||
not surviving_instance
|
||
or source_id not in {str(value) for value in pattern.params.get("source_feature_ids") or ()}
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "pattern_instance_unavailable",
|
||
"Pattern instance reference is not a surviving source copy",
|
||
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
|
||
))
|
||
continue
|
||
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
|
||
if member_id not in body_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "pattern_instance_unavailable",
|
||
"Pattern instance has no independently selectable body output",
|
||
pattern_feature_id=pattern_id, source_feature_id=source_id, instance_index=instance,
|
||
))
|
||
for reference in transform_copies:
|
||
if not isinstance(reference, dict):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_transform_copy_ref",
|
||
"Transform COPY body reference must be an object",
|
||
))
|
||
continue
|
||
transform_id = str(reference.get("transform_feature_id") or "")
|
||
source_id = str(reference.get("source_feature_id") or "")
|
||
transform = nodes_by_id.get(transform_id)
|
||
if transform is None or transform.atomic_id != "transform_bodies":
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "transform_copy_unavailable",
|
||
"Transform COPY owner is not a preceding body transform",
|
||
transform_feature_id=transform_id,
|
||
))
|
||
continue
|
||
if transform.feature_id not in completed:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "transform_copy_unavailable",
|
||
"Transform COPY owner did not become executable",
|
||
transform_feature_id=transform_id,
|
||
))
|
||
continue
|
||
transform_sources = transform.params.get("source_feature_ids") or []
|
||
if (
|
||
not bool(transform.params.get("make_copy"))
|
||
or not isinstance(transform_sources, list)
|
||
or len(transform_sources) < 2
|
||
or source_id not in {str(value) for value in transform_sources}
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "transform_copy_unavailable",
|
||
"Transform COPY reference is not a source-qualified multi-body copy",
|
||
transform_feature_id=transform_id, source_feature_id=source_id,
|
||
))
|
||
continue
|
||
member_id = transform_copy_member_id(transform_id, source_id)
|
||
if member_id not in body_members:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "transform_copy_unavailable",
|
||
"Transform COPY source has no independently selectable body output",
|
||
transform_feature_id=transform_id, source_feature_id=source_id,
|
||
))
|
||
if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
|
||
end_condition = params.get("end_condition") or {"type": "blind"}
|
||
end_type = end_condition.get("type")
|
||
draft = params.get("draft")
|
||
if draft is not None:
|
||
# 目前只将 CADFS 单侧盲向拔模映射到 build123d
|
||
# Solid.extrude_taper。双向、到面和非实体 profile 的中性面
|
||
# 语义尚无 CDSL 表达,必须保留为明确能力缺口。
|
||
if (
|
||
node.atomic_id not in {"extrude_add_blind", "extrude_cut_blind", "extrude_from_face"}
|
||
or end_type != "blind"
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_draft_extent",
|
||
"Draft currently supports only one-sided blind extrusions",
|
||
))
|
||
elif not (
|
||
isinstance(draft, dict)
|
||
and isinstance(draft.get("angle_deg"), (int, float))
|
||
and 0 < float(draft["angle_deg"]) < 90
|
||
and isinstance(draft.get("pull_direction"), bool)
|
||
):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_draft",
|
||
"Draft requires angle_deg in (0, 90) and boolean pull_direction",
|
||
))
|
||
required.append(f"extent:{end_type}")
|
||
if end_type not in _SUPPORTED_EXTENTS:
|
||
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 reference.get("kind") != 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"),
|
||
))
|
||
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",
|
||
"Offset-from-surface requires a non-zero captured offset distance",
|
||
))
|
||
if node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"} or bool(params.get("two_sided")):
|
||
reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
|
||
reverse_type = reverse_condition.get("type")
|
||
required.append(f"extent:reverse:{reverse_type}")
|
||
if reverse_type not in _SUPPORTED_EXTENTS:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_reverse_extent",
|
||
"The reverse extent is not implemented", extent=reverse_type,
|
||
))
|
||
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_reference.get("kind") != 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"),
|
||
))
|
||
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",
|
||
"Reverse offset-from-surface requires a non-zero captured offset distance",
|
||
))
|
||
if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
|
||
blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
|
||
if node.atomic_id in {"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face"}:
|
||
face_selectors = [selector for selector in node.selectors if selector.get("kind") == "face"]
|
||
if len(face_selectors) != 1 or len(node.selectors) != 1:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_profile_hole_selector",
|
||
"Derived profile features require exactly one cap-face selector",
|
||
))
|
||
if node.atomic_id in _HOLE_ATOMICS:
|
||
required.append("selector:host_face")
|
||
if not params.get("host_face"):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_host_face",
|
||
"Hole operations require a host face selector or an explicit host frame",
|
||
))
|
||
try:
|
||
HoleSpec.from_feature(node.atomic_id, params, wizard=node.atomic_id == "hole_wizard")
|
||
except ValueError as error:
|
||
blockers.append(self._blocker(node.feature_id, "invalid_hole_spec", str(error)))
|
||
if node.atomic_id == "hole_wizard":
|
||
# #9 hole thread:SolidWorks 螺纹孔的 thread 是装饰信息(无螺距、
|
||
# 不进实体几何,STEP 导出即光滑孔)。HoleSpec.from_feature 只读
|
||
# 直径/深度/位置/沉头/沉孔,thread 天然不参与几何计算 → 孔特征
|
||
# 直接按光滑圆柱孔执行,runtime 侧记录 thread_decoration_ignored
|
||
# info 诊断便于追溯(见 _execute_hole)。不再报
|
||
# unsupported_hole_subtype,使 ≈712 个带 thread 的孔恢复可执行。
|
||
hole_extent = (params.get("end_condition") or {"type": "blind"}).get("type")
|
||
if hole_extent not in {"blind", "through_all", "through_all_both"}:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_hole_extent",
|
||
"The current Hole Wizard runtime supports blind and through-all extents only",
|
||
extent=hole_extent,
|
||
))
|
||
if not params.get("positions"):
|
||
blockers.append(self._blocker(node.feature_id, "missing_hole_positions", "Hole Wizard requires captured positions"))
|
||
if node.atomic_id == "reference_plane" and not isinstance(params.get("plane"), dict):
|
||
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_axis":
|
||
axis = params.get("axis") or {}
|
||
if not (axis.get("origin_mm") and axis.get("direction")):
|
||
plane_selectors = [selector for selector in node.selectors if selector.get("kind") == "plane"]
|
||
if len(plane_selectors) < 2:
|
||
blockers.append(self._blocker(node.feature_id, "missing_reference_axis_geometry", "Reference axis requires explicit geometry or two reference planes"))
|
||
if node.atomic_id.startswith("revolve_"):
|
||
axis = params.get("axis")
|
||
if not _has_explicit_axis(axis) and not _has_resolvable_axis_selector(node):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_revolve_axis",
|
||
"Revolve requires an explicit axis or an owner-qualified reference-axis selector",
|
||
))
|
||
if node.atomic_id.startswith("pattern_"):
|
||
required.append("feature_replay")
|
||
sources = params.get("source_feature_ids") or []
|
||
if not sources:
|
||
blockers.append(self._blocker(node.feature_id, "missing_pattern_source", "Pattern has no source features"))
|
||
for source_id in sources:
|
||
source = nodes_by_id.get(str(source_id))
|
||
if source is None:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_pattern_source",
|
||
"Pattern source feature does not exist", source_feature_id=source_id,
|
||
))
|
||
continue
|
||
if source.atomic_id not in _REPLAYABLE_ATOMICS:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_pattern_source",
|
||
"Pattern source has no replayable body definition",
|
||
source_feature_id=source_id, atomic_id=source.atomic_id,
|
||
))
|
||
continue
|
||
if source.feature_id not in completed:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "pattern_source_unavailable",
|
||
"Pattern source did not become executable before this pattern",
|
||
source_feature_id=source_id,
|
||
))
|
||
continue
|
||
transform_dependency = pattern_transform_blocker(source) if source else None
|
||
if transform_dependency:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "unsupported_pattern_selector_transform",
|
||
"Pattern source uses a topology dependency that cannot yet be transformed",
|
||
source_feature_id=source_id, dependency=transform_dependency,
|
||
))
|
||
if node.atomic_id == "pattern_mirror" and not params.get("mirror_plane"):
|
||
blockers.append(self._blocker(node.feature_id, "missing_mirror_plane", "Mirror pattern has no mirror plane"))
|
||
if node.atomic_id == "pattern_circular":
|
||
if not _has_explicit_axis(params.get("axis")):
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "missing_circular_axis",
|
||
"Circular pattern requires an explicit axis with origin_mm and direction",
|
||
))
|
||
pattern_count = params.get("pattern_count")
|
||
if pattern_count is None or int(pattern_count) < 1:
|
||
blockers.append(self._blocker(
|
||
node.feature_id, "invalid_pattern_count",
|
||
"Circular pattern requires pattern_count >= 1",
|
||
))
|
||
status = "executable" if not blockers else ("unsupported" if any(b.code.startswith("unsupported") or b.code == "unknown_atomic" for b in blockers) else "blocked")
|
||
results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
|
||
if status == "executable":
|
||
completed.add(node.feature_id)
|
||
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
||
body_producers = {
|
||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
|
||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||
"thread_add", "bend_add", "gear_add", "rack_add",
|
||
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
|
||
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
|
||
"thread_cut",
|
||
}
|
||
surface_producers = {"extrude_surface", "revolve_surface"}
|
||
document_blockers: list[RuntimeDiagnostic] = []
|
||
if not any(node.atomic_id in body_producers | surface_producers for node in plan):
|
||
document_blockers.append(RuntimeDiagnostic(
|
||
"no_solid_feature", "CDSL contains no feature capable of creating a solid body",
|
||
))
|
||
return CapabilityAnalysis(plan=plan, feature_results=tuple(results), document_blockers=tuple(document_blockers))
|