Files
cdsl-cad/backend/engine/cdsl_engine/capabilities.py
T

1088 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
_SELECTOR_REQUIRED = frozenset({"extrude_add_blind_with_hole", "extrude_from_face", "loft_add_with_cap_face", "fillet", "chamfer", "shell"})
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
# 开放轮廓(closed=false / role=open)只有"刀具截面补槽口边闭合后作切除"的
# 物理意义:仅 extrude 直切类原子支持;add/回转对开放轮廓会造出无意义的封块。
_OPEN_PROFILE_ATOMICS = frozenset({"extrude_cut_blind", "extrude_cut_through"})
_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",
})
_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
_ACTIVE_BODY_REQUIRED = frozenset({
"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through",
"loft_add_with_cap_face", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
# thread_cut 是 cut 型特征:必须在已有主体(宿主)上做布尔差,不能凭空
# 造实体;无宿主时按 active_body 前置阻止而非让 executor 在 None 上崩溃。
"thread_cut",
})
_BODY_MUTATING_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_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add",
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", *_HOLE_ATOMICS, "fillet", "chamfer", "shell",
})
# ``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"})
# 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 = (
_BODY_MUTATING_ATOMICS - frozenset({"thread_add", "thread_cut", "bend_add", "gear_add", "rack_add"})
) | 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 _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 _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,
))
for selector in _mappings(params):
if selector.get("output_role") is not None:
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector_context",
"Feature output role selectors are only supported in feature.selectors",
))
for selector_index, selector in enumerate(node.selectors):
if selector.get("output_role") is None:
continue
required.append("selector:feature_output_role")
if contract is None or contract.get("selector_slot") != "feature.selectors" 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 threadSolidWorks 螺纹孔的 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))