refactor(cdsl_engine): executor registry + per-family executor package
Phase 3 of the decoupling refactor (behavior-preserving): - registry.py: atomic_executor decorator, ALL_ATOMIC_IDS with fail-fast registration validation, execute_node dispatcher - executors/: one module per family (extrude, revolve, surfaces, loft_sweep, bodies, context, primitives, parametric, holes, dressup, patterns) + shared helpers in executors/common - executors/__init__: explicit aggregation + completeness check (registry must cover every declared atomic id at import time) - runtime.py: slimmed to entry points (analyze_cdsl/rebuild_cdsl) plus full historical re-exports incl. test-referenced privates Adding an atomic operation now touches only one executor module and its schema contract; the shared registry never changes. Verified against baseline: zero new failures.
This commit is contained in:
@@ -0,0 +1,28 @@
|
|||||||
|
"""Executor modules for the session runtime.
|
||||||
|
|
||||||
|
Importing this package registers every atomic executor into
|
||||||
|
``cdsl_engine.registry.EXECUTORS`` exactly once, then verifies that the
|
||||||
|
registry covers the complete declared atomic-id set. Executor modules must
|
||||||
|
not import each other; shared helpers live in ``common``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ..registry import ALL_ATOMIC_IDS, EXECUTORS
|
||||||
|
from . import ( # noqa: F401 (importing the modules performs registration)
|
||||||
|
bodies,
|
||||||
|
context,
|
||||||
|
dressup,
|
||||||
|
extrude,
|
||||||
|
holes,
|
||||||
|
loft_sweep,
|
||||||
|
parametric,
|
||||||
|
patterns,
|
||||||
|
primitives,
|
||||||
|
revolve,
|
||||||
|
surfaces,
|
||||||
|
)
|
||||||
|
|
||||||
|
_missing = sorted(ALL_ATOMIC_IDS - set(EXECUTORS))
|
||||||
|
if _missing:
|
||||||
|
raise RuntimeError(f"atomic ids without a registered executor: {_missing}")
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
"""Body-graph executors (boolean_bodies / transform_bodies / delete_bodies).
|
||||||
|
|
||||||
|
These operate on explicitly named body members instead of the aggregate
|
||||||
|
session body, so adjacent independent solids never accidentally become tools
|
||||||
|
or targets of one another.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..specs import transform_copy_member_id
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta
|
||||||
|
from .common import _combine_members, _member_sources
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# booleanBodies 总是作用于 source feature 的明确 body 输出,不能回退为
|
||||||
|
# 当前聚合 body。这样相邻独立实体不会意外成为工具或目标。
|
||||||
|
params = node.params
|
||||||
|
target_ids = _member_sources(
|
||||||
|
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
|
||||||
|
)
|
||||||
|
tool_ids = _member_sources(
|
||||||
|
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
|
||||||
|
)
|
||||||
|
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
|
||||||
|
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
|
||||||
|
target = _combine_members(session, targets)
|
||||||
|
tool = _combine_members(session, tools)
|
||||||
|
operation = str(params.get("operation") or "")
|
||||||
|
topology_delta: TopologyDelta | None = None
|
||||||
|
if operation == "union":
|
||||||
|
result, topology_delta = session.adapter.fuse_with_topology_delta(target, tool)
|
||||||
|
elif operation == "subtract":
|
||||||
|
result, topology_delta = session.adapter.cut_with_topology_delta(target, tool)
|
||||||
|
elif operation == "intersect":
|
||||||
|
result, topology_delta = session.adapter.intersect_with_topology_delta(target, tool)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unsupported booleanBodies operation {operation!r}")
|
||||||
|
members = {
|
||||||
|
feature_id: body
|
||||||
|
for feature_id, body in session.body_members.items()
|
||||||
|
if feature_id not in set(target_ids + tool_ids)
|
||||||
|
}
|
||||||
|
members[node.feature_id] = result
|
||||||
|
if bool(params.get("keep_tools")):
|
||||||
|
members.update(tools)
|
||||||
|
session.register_body(
|
||||||
|
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||||
|
)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("boolean_bodies")
|
||||||
|
def _boolean_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_boolean_bodies(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_transform_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# FeatureScript transform targets explicit bodies. Do not move the
|
||||||
|
# aggregate session body, because it may include unrelated members.
|
||||||
|
source_ids = _member_sources(
|
||||||
|
node, session, "source_feature_ids", pattern_instance_parameter="pattern_instance_refs", allow_transform_copies=True,
|
||||||
|
)
|
||||||
|
make_copy = bool(node.params.get("make_copy"))
|
||||||
|
direct_sources = node.params.get("source_feature_ids") or []
|
||||||
|
if make_copy and isinstance(direct_sources, list) and len(direct_sources) > 1:
|
||||||
|
# The aggregate is only an export compound. Each source transform has
|
||||||
|
# its own B-rep builder and is the only output a later COPY query may
|
||||||
|
# select. Do not attach an aggregate topology delta to source members.
|
||||||
|
members = dict(session.body_members)
|
||||||
|
members.update({
|
||||||
|
transform_copy_member_id(node.feature_id, source_id): session.adapter.transform(
|
||||||
|
session.body_members[source_id], dict(node.params.get("transform") or {}),
|
||||||
|
)
|
||||||
|
for source_id in source_ids
|
||||||
|
})
|
||||||
|
session.register_body(
|
||||||
|
node.feature_id, _combine_members(session, members), body_members=members,
|
||||||
|
)
|
||||||
|
return session.result(node)
|
||||||
|
source = _combine_members(session, {feature_id: session.body_members[feature_id] for feature_id in source_ids})
|
||||||
|
transformed, topology_delta = session.adapter.transform_with_topology_delta(
|
||||||
|
source, dict(node.params.get("transform") or {}),
|
||||||
|
)
|
||||||
|
members = dict(session.body_members)
|
||||||
|
if not make_copy:
|
||||||
|
for feature_id in source_ids:
|
||||||
|
members.pop(feature_id)
|
||||||
|
members[node.feature_id] = transformed
|
||||||
|
session.register_body(
|
||||||
|
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
|
||||||
|
)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("transform_bodies")
|
||||||
|
def _transform_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_transform_bodies(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_delete_bodies(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# Deletion is a body-graph operation, never a Boolean subtraction. A
|
||||||
|
# selected member can be disjoint or overlap another independent body.
|
||||||
|
source_ids = _member_sources(node, session, "target_feature_ids")
|
||||||
|
members = {feature_id: body for feature_id, body in session.body_members.items() if feature_id not in set(source_ids)}
|
||||||
|
if members:
|
||||||
|
session.register_body(node.feature_id, _combine_members(session, members), body_members=members)
|
||||||
|
else:
|
||||||
|
session.clear_body()
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("delete_bodies")
|
||||||
|
def _delete_bodies_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_delete_bodies(node, session)
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
"""Helpers shared by executor family modules.
|
||||||
|
|
||||||
|
Every function here is imported by two or more executor modules. Anything
|
||||||
|
used by exactly one family lives in that family's module instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
|
||||||
|
from ..runtime_base import ExtentVector, FeatureExecutionError
|
||||||
|
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _revolve_axis(node: FeaturePlanNode, session: "ExecutionSession") -> AxisSpec:
|
||||||
|
raw_axis = node.params.get("axis") or {}
|
||||||
|
if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None:
|
||||||
|
return AxisSpec.from_mapping(raw_axis)
|
||||||
|
selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None
|
||||||
|
if not isinstance(selector, dict):
|
||||||
|
selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
|
||||||
|
if not isinstance(selector, dict):
|
||||||
|
raise FeatureExecutionError(
|
||||||
|
"missing_revolve_axis",
|
||||||
|
"Revolve requires an explicit axis or an owner-qualified reference-axis selector",
|
||||||
|
)
|
||||||
|
resolution = session.resolve(selector)
|
||||||
|
if resolution.status != "resolved" or resolution.record is None:
|
||||||
|
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved")
|
||||||
|
if not isinstance(resolution.record.value, AxisSpec):
|
||||||
|
raise FeatureExecutionError(
|
||||||
|
"unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind,
|
||||||
|
)
|
||||||
|
return resolution.record.value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any]) -> None:
|
||||||
|
"""Defend direct CDSL execution from an out-of-plane revolve axis."""
|
||||||
|
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||||
|
direction_normal_dot = abs(vector_dot(axis.direction, plane.normal))
|
||||||
|
if direction_normal_dot > 1e-7:
|
||||||
|
raise ValueError(
|
||||||
|
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.direction must be parallel to "
|
||||||
|
f"sketch.workplane; abs(dot(axis_direction, plane_normal))={direction_normal_dot:.3g}"
|
||||||
|
)
|
||||||
|
origin_plane_offset = abs(vector_dot(vector_subtract(axis.origin_mm, plane.origin_mm), plane.normal))
|
||||||
|
if origin_plane_offset > 1e-6:
|
||||||
|
raise ValueError(
|
||||||
|
"REVOLVE_AXIS_NOT_IN_SKETCH_PLANE: params.axis.origin_mm must lie in "
|
||||||
|
f"sketch.workplane; plane_offset_mm={origin_plane_offset:.3g}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
|
||||||
|
"""Apply a cut to each independently owned body without erasing ownership.
|
||||||
|
|
||||||
|
A CADFS NEW body stays independently addressable even when a later REMOVE
|
||||||
|
feature affects several active bodies. Cutting the aggregate first loses
|
||||||
|
that identity, so this path uses the equivalent per-member set difference
|
||||||
|
and drops only members that the tool removes completely.
|
||||||
|
"""
|
||||||
|
members: dict[str, Any] = {}
|
||||||
|
for feature_id, body in session.body_members.items():
|
||||||
|
result = session.adapter.cut(body, tool)
|
||||||
|
if abs(float(result.volume)) > 1e-12:
|
||||||
|
members[feature_id] = result
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def _extruded_tool(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
faces: list[Any],
|
||||||
|
profile_normal: Vector3,
|
||||||
|
session: "ExecutionSession",
|
||||||
|
) -> tuple[Any, TopologyDelta | None]:
|
||||||
|
"""Build one extrude tool, retaining caps only from one exact builder result."""
|
||||||
|
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||||
|
draft = node.params.get("draft")
|
||||||
|
taper_deg = 0.0
|
||||||
|
if isinstance(draft, dict):
|
||||||
|
taper_deg = float(draft["angle_deg"])
|
||||||
|
if not bool(draft["pull_direction"]):
|
||||||
|
taper_deg = -taper_deg
|
||||||
|
topology_delta: TopologyDelta | None = None
|
||||||
|
solids: list[Any] = []
|
||||||
|
for face in faces:
|
||||||
|
for extent in extents:
|
||||||
|
if draft is not None:
|
||||||
|
if len(faces) == 1 and len(extents) == 1:
|
||||||
|
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
|
||||||
|
face, extent.vector, taper_deg,
|
||||||
|
)
|
||||||
|
solids.append(solid)
|
||||||
|
else:
|
||||||
|
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||||||
|
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
|
||||||
|
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||||
|
solids.append(solid)
|
||||||
|
elif extent.trim_to is None:
|
||||||
|
solids.append(session.adapter.extrude(face, extent.vector))
|
||||||
|
else:
|
||||||
|
solids.append(session.adapter.extrude_trimmed(face, extent.trim_to, extent.vector))
|
||||||
|
tool = None
|
||||||
|
for solid in solids:
|
||||||
|
tool = session.adapter.fuse(tool, solid)
|
||||||
|
if tool is None:
|
||||||
|
raise ValueError("extrude produced no solid")
|
||||||
|
return tool, topology_delta
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_primary_tool(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
session: "ExecutionSession",
|
||||||
|
tool: Any,
|
||||||
|
*,
|
||||||
|
cutting: bool,
|
||||||
|
topology_delta: TopologyDelta | None = None,
|
||||||
|
) -> FeatureResult:
|
||||||
|
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||||||
|
if cutting:
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("cut feature has no body")
|
||||||
|
members = _cut_explicit_body_members(session, tool)
|
||||||
|
if not members:
|
||||||
|
session.clear_body()
|
||||||
|
return session.result(node)
|
||||||
|
body = session.adapter.cut(session.body, tool)
|
||||||
|
topology_delta = None
|
||||||
|
elif node.params.get("result_mode") == "new_body":
|
||||||
|
body = session.adapter.combine(session.body, tool)
|
||||||
|
members = {**session.body_members, node.feature_id: tool}
|
||||||
|
else:
|
||||||
|
body = session.adapter.fuse(session.body, tool)
|
||||||
|
members = {node.feature_id: body}
|
||||||
|
# A fuse rebuilds subshape identity. Builder evidence belongs only to
|
||||||
|
# an unchanged standalone/new-body prism snapshot.
|
||||||
|
if session.body is not None:
|
||||||
|
topology_delta = None
|
||||||
|
session.register_body(
|
||||||
|
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||||||
|
)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, sketch: dict[str, Any] | None = None) -> FeatureResult:
|
||||||
|
# 主形状特征(拉伸 / 旋转)的统一入口:由草图生成实体并与当前主体做布尔合并或切除。
|
||||||
|
|
||||||
|
# 1. 取草图:优先使用外部传入的 sketch_override(阵列/镜像等重放场景),
|
||||||
|
# 否则按 sketch_id 从会话草图表中取原始草图。
|
||||||
|
selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
|
||||||
|
if selected_sketch is None:
|
||||||
|
raise ValueError("primary feature has no resolved sketch")
|
||||||
|
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
|
||||||
|
faces = session.adapter.faces_for_sketch(selected_sketch)
|
||||||
|
if not faces:
|
||||||
|
raise ValueError("sketch does not create a closed profile region")
|
||||||
|
if node.atomic_id == "extrude_add_blind_with_hole":
|
||||||
|
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||||
|
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||||
|
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||||
|
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "profile hole selector is unresolved")
|
||||||
|
if len(faces) != 1:
|
||||||
|
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
|
||||||
|
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
|
||||||
|
topology_delta: TopologyDelta | None = None
|
||||||
|
# 3. 按特征类型生成子实体:
|
||||||
|
if node.atomic_id.startswith("extrude_"):
|
||||||
|
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||||
|
# 再对每个面沿每个向量做拉伸,得到实体列表。up_to_surface 在
|
||||||
|
# profile 与目标面非均匀相交时(extent.trim_to 非空)改用裁剪
|
||||||
|
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||||
|
tool, topology_delta = _extruded_tool(
|
||||||
|
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||||
|
axis = _revolve_axis(node, session)
|
||||||
|
_validate_revolve_axis_in_sketch_plane(axis, selected_sketch)
|
||||||
|
angle = float(node.params.get("angle_deg") or 0.0)
|
||||||
|
if angle <= 0:
|
||||||
|
raise ValueError("revolve requires angle_deg > 0")
|
||||||
|
# reverse=true 表示绕轴反向扫掠(SolidWorks 旋转方向反转):取负
|
||||||
|
# 旋转角,与 extrude 的 reverse(_extent_vectors 反转拉伸方向)同一
|
||||||
|
# 语义。profile_schema.json 已声明 revolve.* optional_params 含
|
||||||
|
# reverse,cdsl_schema.json revolveParams 也已允许,这里补齐 runtime
|
||||||
|
# 侧实现,使三方合同一致。
|
||||||
|
if bool(node.params.get("reverse")):
|
||||||
|
angle = -angle
|
||||||
|
tool = None
|
||||||
|
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
|
||||||
|
tool = session.adapter.fuse(tool, solid)
|
||||||
|
if tool is None:
|
||||||
|
raise ValueError("revolve produced no solid")
|
||||||
|
return _apply_primary_tool(
|
||||||
|
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _combine_members(session: "ExecutionSession", members: dict[str, Any]) -> Any:
|
||||||
|
body = None
|
||||||
|
for member in members.values():
|
||||||
|
body = session.adapter.combine(body, member)
|
||||||
|
if body is None:
|
||||||
|
raise ValueError("booleanBodies produced no result bodies")
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
|
def _pattern_instance_sources(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
session: "ExecutionSession",
|
||||||
|
parameter: str = "pattern_instance_refs",
|
||||||
|
) -> list[str]:
|
||||||
|
"""Resolve CDSL pattern-instance refs to their internal body-member keys."""
|
||||||
|
resolved: list[str] = []
|
||||||
|
for reference in node.params.get(parameter) or ():
|
||||||
|
if not isinstance(reference, dict):
|
||||||
|
raise ValueError("pattern instance reference must be an object")
|
||||||
|
pattern_id = str(reference.get("pattern_feature_id") or "")
|
||||||
|
source_id = str(reference.get("source_feature_id") or "")
|
||||||
|
instance = reference.get("instance_index")
|
||||||
|
if not pattern_id or not source_id or not isinstance(instance, int):
|
||||||
|
raise ValueError("pattern instance reference is incomplete")
|
||||||
|
pattern = session.nodes.get(pattern_id)
|
||||||
|
if pattern is None or pattern.atomic_id not in {"pattern_circular", "pattern_mirror"}:
|
||||||
|
raise ValueError(f"pattern instance owner is unavailable: {pattern_id}")
|
||||||
|
params = pattern.params
|
||||||
|
if source_id not in {str(value) for value in params.get("source_feature_ids") or ()}:
|
||||||
|
raise ValueError("pattern instance source is not selected by its pattern")
|
||||||
|
count = int(params.get("pattern_count") or 0)
|
||||||
|
excluded = {int(value) for value in params.get("excluded_instance_indices") or ()}
|
||||||
|
if (
|
||||||
|
pattern.atomic_id == "pattern_mirror" and instance != 1
|
||||||
|
) or (
|
||||||
|
pattern.atomic_id == "pattern_circular" and (instance < 1 or instance >= count or instance in excluded)
|
||||||
|
):
|
||||||
|
raise ValueError("pattern instance is outside the pattern's surviving instances")
|
||||||
|
member_id = pattern_instance_member_id(pattern_id, source_id, instance)
|
||||||
|
if member_id not in session.body_members:
|
||||||
|
raise ValueError(f"pattern instance body is unavailable: {pattern_id}/{source_id}/{instance}")
|
||||||
|
if member_id not in resolved:
|
||||||
|
resolved.append(member_id)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
|
||||||
|
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
|
||||||
|
resolved: list[str] = []
|
||||||
|
for reference in node.params.get("transform_copy_refs") or ():
|
||||||
|
if not isinstance(reference, dict):
|
||||||
|
raise ValueError("transform COPY reference must be an object")
|
||||||
|
transform_id = str(reference.get("transform_feature_id") or "")
|
||||||
|
source_id = str(reference.get("source_feature_id") or "")
|
||||||
|
if not transform_id or not source_id:
|
||||||
|
raise ValueError("transform COPY reference is incomplete")
|
||||||
|
transform = session.nodes.get(transform_id)
|
||||||
|
params = transform.params if transform is not None else {}
|
||||||
|
sources = params.get("source_feature_ids") or []
|
||||||
|
if (
|
||||||
|
transform is None
|
||||||
|
or transform.atomic_id != "transform_bodies"
|
||||||
|
or not bool(params.get("make_copy"))
|
||||||
|
or not isinstance(sources, list)
|
||||||
|
or len(sources) < 2
|
||||||
|
or source_id not in {str(value) for value in sources}
|
||||||
|
):
|
||||||
|
raise ValueError(f"transform COPY owner/source is unavailable: {transform_id}/{source_id}")
|
||||||
|
member_id = transform_copy_member_id(transform_id, source_id)
|
||||||
|
if member_id not in session.body_members:
|
||||||
|
raise ValueError(f"transform COPY body is unavailable: {transform_id}/{source_id}")
|
||||||
|
if member_id not in resolved:
|
||||||
|
resolved.append(member_id)
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def _member_sources(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
session: "ExecutionSession",
|
||||||
|
parameter: str,
|
||||||
|
*,
|
||||||
|
pattern_instance_parameter: str | None = None,
|
||||||
|
allow_transform_copies: bool = False,
|
||||||
|
) -> list[str]:
|
||||||
|
source_ids = [str(value) for value in node.params.get(parameter) or []]
|
||||||
|
if pattern_instance_parameter is not None:
|
||||||
|
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
|
||||||
|
if allow_transform_copies:
|
||||||
|
source_ids.extend(_transform_copy_sources(node, session))
|
||||||
|
if not source_ids:
|
||||||
|
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
|
||||||
|
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"{node.atomic_id} source bodies are unavailable: " + ", ".join(missing))
|
||||||
|
return source_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
|
||||||
|
# 路径是 self-contained CDSL 数据,避免重放时依赖临时草图或 source id。
|
||||||
|
path = node.params.get("path") or {}
|
||||||
|
if not isinstance(path, dict):
|
||||||
|
raise ValueError("sweep path must be an object")
|
||||||
|
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
|
||||||
|
segment = path.get("segment") or {}
|
||||||
|
if not isinstance(segment, dict):
|
||||||
|
raise ValueError("sweep path segment must be an object")
|
||||||
|
kind = str(segment.get("type") or "")
|
||||||
|
if kind == "line":
|
||||||
|
local_points = [segment.get("start"), segment.get("end")]
|
||||||
|
elif kind == "bspline":
|
||||||
|
local_points = segment.get("points") or []
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unsupported sweep path segment {kind!r}")
|
||||||
|
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
|
||||||
|
raise ValueError("sweep path requires two-dimensional points")
|
||||||
|
|
||||||
|
def point(value: list[float]) -> Vector3:
|
||||||
|
return vector_add(
|
||||||
|
plane.origin_mm,
|
||||||
|
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
|
||||||
|
)
|
||||||
|
|
||||||
|
def tangent(value: Any) -> Vector3 | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(value, list) or len(value) != 2:
|
||||||
|
raise ValueError("sweep path tangent must contain two coordinates")
|
||||||
|
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
|
||||||
|
|
||||||
|
return session.adapter.sweep_path(
|
||||||
|
[point(value) for value in local_points],
|
||||||
|
start_tangent=tangent(segment.get("start_tangent")),
|
||||||
|
end_tangent=tangent(segment.get("end_tangent")),
|
||||||
|
parameters=[float(value) for value in segment.get("parameters") or []] or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _register_added_solid(
|
||||||
|
session: "ExecutionSession",
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
solid: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Register an additive primitive solid (box/cyl/sphere/thread/gear/rack/bend).
|
||||||
|
|
||||||
|
When ``node.params['result_mode'] == "new_body"`` the primitive is kept as
|
||||||
|
an independent body member so that downstream ``boolean_bodies`` can
|
||||||
|
reference it without pulling in the accumulated fuse history. The current
|
||||||
|
body is replaced by a Compound that preserves both, matching the
|
||||||
|
``extrude_add_blind`` ``new_body`` semantics. Any other value (including
|
||||||
|
missing) falls back to the legacy fuse-into-body behavior.
|
||||||
|
"""
|
||||||
|
if node.params.get("result_mode") == "new_body":
|
||||||
|
combined = session.adapter.combine(session.body, solid)
|
||||||
|
members = {**session.body_members, node.feature_id: solid}
|
||||||
|
session.register_body(node.feature_id, combined, replay_node=node, body_members=members)
|
||||||
|
return
|
||||||
|
fused = session.adapter.fuse(session.body, solid)
|
||||||
|
session.register_body(node.feature_id, fused, replay_node=node)
|
||||||
|
|
||||||
|
|
||||||
|
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
|
||||||
|
if resolution.record is None:
|
||||||
|
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
|
||||||
|
geometry = resolution.record.geometry
|
||||||
|
return PlaneSpec.from_mapping({
|
||||||
|
"origin_mm": geometry["center_mm"],
|
||||||
|
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
|
||||||
|
"normal": geometry["normal"],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _hole_starts(
|
||||||
|
spec: HoleSpec,
|
||||||
|
*,
|
||||||
|
host_plane: PlaneSpec,
|
||||||
|
positions_are_local: bool,
|
||||||
|
) -> list[Vector3]:
|
||||||
|
starts: list[Vector3] = []
|
||||||
|
for point in spec.positions_mm:
|
||||||
|
if positions_are_local:
|
||||||
|
start = vector_add(
|
||||||
|
vector_add(
|
||||||
|
vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])),
|
||||||
|
vector_scale(host_plane.y_dir, point[1]),
|
||||||
|
),
|
||||||
|
vector_scale(host_plane.normal, point[2]),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
start = point
|
||||||
|
starts.append(start)
|
||||||
|
return starts
|
||||||
|
|
||||||
|
|
||||||
|
def _selector_edges(node: FeaturePlanNode, session: "ExecutionSession", *, tangent_propagation: bool = False) -> list[Any]:
|
||||||
|
resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors]
|
||||||
|
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||||
|
if failed:
|
||||||
|
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
|
||||||
|
|
||||||
|
def is_body_boundary(edge: Any) -> bool:
|
||||||
|
# 圆柱、圆锥等周期面会带一条仅属于自身的参数 seam。该线不是实体
|
||||||
|
# 边界;FeatureScript 以 FACE 选择倒角时不应将其当作额外的待倒角边,
|
||||||
|
# 否则连续的锥面会被错误切成两段。显式 EDGE selector 仍可表达真正的
|
||||||
|
# 单边选择,所以这里只约束由 FACE 展开的候选边。
|
||||||
|
face_count = sum(
|
||||||
|
1
|
||||||
|
for face in session.body.faces()
|
||||||
|
if any(candidate.is_same(edge) for candidate in face.edges())
|
||||||
|
)
|
||||||
|
return face_count >= 2
|
||||||
|
|
||||||
|
edges: list[Any] = []
|
||||||
|
for item in resolved:
|
||||||
|
if item.record.kind == "edge":
|
||||||
|
edges.append(item.record.value)
|
||||||
|
elif item.record.kind == "face":
|
||||||
|
edges.extend(edge for edge in item.record.value.edges() if is_body_boundary(edge))
|
||||||
|
if not edges:
|
||||||
|
raise ValueError("selectors did not resolve any edges")
|
||||||
|
return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
|
||||||
|
|
||||||
|
|
||||||
|
def _shell_target(node: FeaturePlanNode, session: "ExecutionSession") -> tuple[Any, list[Any]]:
|
||||||
|
# shell 的 remove-face selector 必须全部属于同一实体。CADFS 允许一个
|
||||||
|
# Compound 中保留多个独立 body,不能将整组 body 交给 OCC 后由内核猜测
|
||||||
|
# 应抽壳的成员。
|
||||||
|
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||||
|
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||||
|
if failed:
|
||||||
|
raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
|
||||||
|
records = [item.record for item in resolved if item.record is not None]
|
||||||
|
if not records or any(record.kind != "face" for record in records):
|
||||||
|
raise ValueError("shell selectors must resolve to faces")
|
||||||
|
target_ids = {record.body_id for record in records}
|
||||||
|
if len(target_ids) != 1:
|
||||||
|
raise ValueError("shell faces must belong to one target body")
|
||||||
|
target_id = next(iter(target_ids))
|
||||||
|
members = session.adapter.body_solids(session.body)
|
||||||
|
if len(members) == 1:
|
||||||
|
target = members[0]
|
||||||
|
else:
|
||||||
|
if target_id is None or session.body_id is None:
|
||||||
|
raise ValueError("shell target body is unresolved")
|
||||||
|
prefix = f"{session.body_id}:"
|
||||||
|
if not target_id.startswith(prefix):
|
||||||
|
raise ValueError("shell target body is outside the active body set")
|
||||||
|
try:
|
||||||
|
member_index = int(target_id[len(prefix):])
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("shell target body has an invalid member id") from error
|
||||||
|
if member_index < 0 or member_index >= len(members):
|
||||||
|
raise ValueError("shell target body member is unavailable")
|
||||||
|
target = members[member_index]
|
||||||
|
target_feature_id = node.params.get("target_feature_id")
|
||||||
|
if target_feature_id is not None:
|
||||||
|
if not isinstance(target_feature_id, str) or not target_feature_id:
|
||||||
|
raise ValueError("shell target_feature_id is invalid")
|
||||||
|
declared = session.body_members.get(target_feature_id)
|
||||||
|
if declared is None:
|
||||||
|
raise ValueError("shell target body is no longer an independently selectable member")
|
||||||
|
declared_solids = session.adapter.body_solids(declared)
|
||||||
|
if len(declared_solids) != 1:
|
||||||
|
raise ValueError("shell target body must resolve to exactly one active solid")
|
||||||
|
if not declared_solids[0].is_same(target):
|
||||||
|
raise ValueError("shell target body does not match the resolved face member")
|
||||||
|
return target, [record.value for record in records]
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_shell_target(session: "ExecutionSession", target: Any, replacement: Any) -> Any:
|
||||||
|
# 仅替换抽壳目标实体;其他独立实体保持原样和原有相对顺序。
|
||||||
|
members = session.adapter.body_solids(session.body)
|
||||||
|
if len(members) == 1:
|
||||||
|
return replacement
|
||||||
|
replaced = False
|
||||||
|
result = None
|
||||||
|
for member in members:
|
||||||
|
if member.is_same(target):
|
||||||
|
result = session.adapter.combine(result, replacement)
|
||||||
|
replaced = True
|
||||||
|
else:
|
||||||
|
result = session.adapter.combine(result, member)
|
||||||
|
if not replaced or result is None:
|
||||||
|
raise ValueError("shell target solid is no longer part of the active body")
|
||||||
|
return result
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Reference-geometry executors (reference_plane / reference_axis).
|
||||||
|
|
||||||
|
Context features produce no solid; they register durable topology contexts
|
||||||
|
that later features resolve through owner-qualified selectors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..specs import AxisSpec, PlaneSpec, vector_add, vector_cross, vector_dot, vector_scale, vector_unit
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("reference_plane")
|
||||||
|
def _reference_plane_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
# 基准面特征(reference_plane)执行入口:从参数解析平面并登记为拓扑上下文。
|
||||||
|
|
||||||
|
# 1. 从特征参数 plane 中解析出平面定义 PlaneSpec(原点到法向)。
|
||||||
|
plane = PlaneSpec.from_mapping(node.params.get("plane") or {})
|
||||||
|
# 2. 将该平面注册到拓扑上下文,供后续特征(如草图基准、参考轴)引用。
|
||||||
|
session.topology.register_context(node.feature_id, plane)
|
||||||
|
# 3. 返回结果对象,并将该平面作为上下文一并携带。
|
||||||
|
return session.result(node, context=plane)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("reference_axis")
|
||||||
|
def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
# 基准轴特征(reference_axis)执行入口:由参数直接定义轴,或由两个基准平面求交线得到轴。
|
||||||
|
|
||||||
|
# 1. 尝试直接取参数:若同时给出原点 origin_mm 与方向 direction,则直接构造轴。
|
||||||
|
params = node.params.get("axis") or {}
|
||||||
|
if params.get("origin_mm") and params.get("direction"):
|
||||||
|
axis = AxisSpec.from_mapping(params)
|
||||||
|
else:
|
||||||
|
# 2. 否则从特征选择器中筛选出已解析的基准平面。
|
||||||
|
planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"]
|
||||||
|
resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)]
|
||||||
|
# 3. 校验:轴需要两个非平行的平面,不足两个则报错。
|
||||||
|
if len(resolved) < 2:
|
||||||
|
raise ValueError("reference axis requires two uniquely resolved planes")
|
||||||
|
# 4. 用两平面法线叉积求交线方向;若方向长度接近 0 说明两平面平行,无法成轴。
|
||||||
|
first, second = resolved[0], resolved[1]
|
||||||
|
n1, n2 = first.normal, second.normal
|
||||||
|
direction = vector_cross(n1, n2)
|
||||||
|
squared_length = vector_dot(direction, direction)
|
||||||
|
if squared_length <= 1e-18:
|
||||||
|
raise ValueError("reference planes are parallel and cannot define an axis")
|
||||||
|
# 5. 求交线上的一点:两平面到各自原点的垂距参与线性组合,得到交线上的最近点。
|
||||||
|
d1 = vector_dot(n1, first.origin_mm)
|
||||||
|
d2 = vector_dot(n2, second.origin_mm)
|
||||||
|
point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length)
|
||||||
|
# 6. 由该点与归一化的交线方向组合成基准轴 AxisSpec。
|
||||||
|
axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis"))
|
||||||
|
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
|
||||||
|
session.topology.register_context(node.feature_id, axis)
|
||||||
|
return session.result(node, context=axis)
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""Dress-up executors (fillet / chamfer / shell).
|
||||||
|
|
||||||
|
These mutate an existing body through edge/face selectors resolved from the
|
||||||
|
current B-rep snapshot.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, TopologyDelta
|
||||||
|
from .common import _replace_shell_target, _selector_edges, _shell_target
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
|
||||||
|
|
||||||
|
# 1. 校验:圆角作用于已有主体,必须先有主体。
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("fillet has no body")
|
||||||
|
# 2. 解析圆角半径并校验必须大于 0。
|
||||||
|
radius = float(node.params.get("radius_mm") or 0)
|
||||||
|
if radius <= 0:
|
||||||
|
raise ValueError("fillet radius_mm must be > 0")
|
||||||
|
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
|
||||||
|
body, topology_delta = session.adapter.fillet_with_topology_delta(
|
||||||
|
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
|
||||||
|
)
|
||||||
|
# 4. 登记新主体并返回结果。
|
||||||
|
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("fillet")
|
||||||
|
def _fillet_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_fillet(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 倒角特征(chamfer)执行入口:对选中边按距离做倒角(可带第二距离形成不对称倒角)。
|
||||||
|
|
||||||
|
# 1. 校验:倒角作用于已有主体,必须先有主体。
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("chamfer has no body")
|
||||||
|
# 2. 解析主距离并校验必须大于 0。
|
||||||
|
distance = float(node.params.get("distance_mm") or 0)
|
||||||
|
if distance <= 0:
|
||||||
|
raise ValueError("chamfer distance_mm must be > 0")
|
||||||
|
# 3. 解析第二距离与角度(importer 对 SolidWorks Distance-Angle 倒角产出
|
||||||
|
# angle_rad,单位为弧度)。第二距离 = 主距离 * tan(angle);angle=45° 时
|
||||||
|
# tan=1,退化为等距倒角(与历史行为一致,零回归)。
|
||||||
|
# 注意:build123d 的 length/length2 侧向分配依赖面的枚举顺序,对非 45°
|
||||||
|
# 倒角仅保证量级正确,距离所在侧可能反转。
|
||||||
|
distance_2 = node.params.get("distance_2_mm")
|
||||||
|
angle_rad = node.params.get("angle_rad")
|
||||||
|
if distance_2 is None and angle_rad is not None:
|
||||||
|
distance_2 = distance * math.tan(float(angle_rad))
|
||||||
|
# 4. 解析目标边(支持相切传播),执行倒角。
|
||||||
|
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
|
||||||
|
diagnostics: list[RuntimeDiagnostic] = []
|
||||||
|
topology_delta: TopologyDelta | None = None
|
||||||
|
try:
|
||||||
|
body, topology_delta = session.adapter.chamfer_with_topology_delta(session.body, distance, distance_2, edges)
|
||||||
|
except ValueError as error:
|
||||||
|
# 显式 surfaceEntities 可以在后续实体上留下曲面分区边界。若标准
|
||||||
|
# OCC 倒角因环域宽度不足而拒绝,只允许在该 shell 给出同轴边界证据
|
||||||
|
# 时按原始距离构造受限倒角;没有证明时仍保留原始内核失败。
|
||||||
|
if distance_2 is not None or not session.surface_members:
|
||||||
|
raise
|
||||||
|
try:
|
||||||
|
body = session.adapter.surface_limited_chamfer(
|
||||||
|
session.body, distance, edges, list(session.surface_members.values()),
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
raise error
|
||||||
|
diagnostics.append(RuntimeDiagnostic(
|
||||||
|
"chamfer_surface_limited",
|
||||||
|
"Chamfer was limited by an explicit coaxial surface boundary",
|
||||||
|
feature_id=node.feature_id,
|
||||||
|
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
|
||||||
|
))
|
||||||
|
# 5. 登记新主体并返回结果。
|
||||||
|
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||||
|
return session.result(node, diagnostics=diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("chamfer")
|
||||||
|
def _chamfer_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_chamfer(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_shell(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 抽壳特征:移除 selector 所指面,并按 CADFS thickness 向实体内部偏置。
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("shell has no body")
|
||||||
|
thickness = float(node.params.get("thickness_mm") or 0)
|
||||||
|
if thickness <= 0:
|
||||||
|
raise ValueError("shell thickness_mm must be > 0")
|
||||||
|
target, faces = _shell_target(node, session)
|
||||||
|
result, topology_delta = session.adapter.shell_with_topology_delta(
|
||||||
|
target, faces, thickness, inward=bool(node.params.get("inward", True)),
|
||||||
|
)
|
||||||
|
session.register_body(
|
||||||
|
node.feature_id, _replace_shell_target(session, target, result), replay_node=node,
|
||||||
|
topology_delta=topology_delta,
|
||||||
|
)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("shell")
|
||||||
|
def _shell_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_shell(node, session)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Extrusion executors (blind / two-sided / cut / through / from-face)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult
|
||||||
|
from .common import _apply_primary_tool, _extruded_tool, _shape_from_primary
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor(
|
||||||
|
"extrude_add_blind",
|
||||||
|
"extrude_add_blind_with_hole",
|
||||||
|
"extrude_add_two_sided",
|
||||||
|
"extrude_cut_blind",
|
||||||
|
"extrude_cut_two_sided",
|
||||||
|
"extrude_cut_through",
|
||||||
|
)
|
||||||
|
def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
return _shape_from_primary(node, session, sketch=sketch)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_extrude_from_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||||
|
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||||
|
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||||
|
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "derived profile face is unresolved")
|
||||||
|
face = resolved[0].record.value
|
||||||
|
tool, topology_delta = _extruded_tool(node, [face], session.adapter.face_normal(face), session)
|
||||||
|
return _apply_primary_tool(
|
||||||
|
node, session, tool, cutting=node.params.get("operation") == "cut", topology_delta=topology_delta,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("extrude_from_face")
|
||||||
|
def _extrude_from_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_extrude_from_face(node, session)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Hole executors (hole_blind / hole_countersink / hole_counterbore / hole_wizard)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..specs import HoleSpec, PlaneSpec, vector_scale
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic
|
||||||
|
from .common import _hole_starts, _host_plane
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("hole_blind", "hole_countersink", "hole_counterbore")
|
||||||
|
def _hole_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_hole(node, session, wizard=False)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("hole_wizard")
|
||||||
|
def _hole_wizard_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_hole(node, session, wizard=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard: bool = False) -> FeatureResult:
|
||||||
|
# 孔特征(hole)执行入口:在指定宿主面上按孔规格生成切除工具,并从主体上减去。
|
||||||
|
|
||||||
|
# 1. 校验:孔是切除操作,必须先有主体。
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("hole feature has no body")
|
||||||
|
# 2. 确定宿主面 host_face:
|
||||||
|
host_selector = node.params.get("host_face")
|
||||||
|
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
||||||
|
# 若直接带 frame(平面定义),则以该平面为宿主,孔位按局部坐标解释。
|
||||||
|
host = PlaneSpec.from_mapping(host_selector["frame"])
|
||||||
|
positions_are_local = True
|
||||||
|
else:
|
||||||
|
# 否则从特征选择器中取 face,解析出宿主平面,孔位按世界坐标解释。
|
||||||
|
selectors = list(node.selectors)
|
||||||
|
if isinstance(host_selector, dict):
|
||||||
|
selectors.append(host_selector)
|
||||||
|
selector = next((item for item in selectors if item.get("kind") == "face"), None)
|
||||||
|
if selector is None:
|
||||||
|
raise ValueError("hole requires host_face selector or frame")
|
||||||
|
host = _host_plane(session.resolve(selector))
|
||||||
|
positions_are_local = False
|
||||||
|
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
|
||||||
|
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
|
||||||
|
# 4. A host-face normal is an outward B-rep orientation, so its inverse
|
||||||
|
# always enters the material. Inferring direction from the global body
|
||||||
|
# centre fails for concave or multi-leg parts: for example, the top face
|
||||||
|
# of an L bracket can sit below the whole body's centre and the old rule
|
||||||
|
# drilled outward, producing a no-op feature reported as successful.
|
||||||
|
# The selected topology face is the local, authoritative orientation.
|
||||||
|
inward = vector_scale(host.normal, -1)
|
||||||
|
# 5. 生成孔切除工具:按孔规格、起始位置、内方向及“贯穿到主体底面”的深度构造工具实体。
|
||||||
|
tool = session.adapter.hole_tool(
|
||||||
|
spec,
|
||||||
|
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
|
||||||
|
inward,
|
||||||
|
session.adapter.body_span(session.body, inward) + 2.0,
|
||||||
|
)
|
||||||
|
# 6. 从主体上减去工具实体,登记新主体并返回结果。
|
||||||
|
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
|
||||||
|
# 即光滑孔):孔按光滑圆柱孔执行,同时记录 info 级诊断便于批量报告
|
||||||
|
# 追溯降级数量(issue #9,capabilities 已不再拒绝 thread)。
|
||||||
|
diagnostics: list[RuntimeDiagnostic] = []
|
||||||
|
if wizard and node.params.get("thread"):
|
||||||
|
diagnostics.append(RuntimeDiagnostic(
|
||||||
|
code="thread_decoration_ignored",
|
||||||
|
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||||||
|
feature_id=node.feature_id,
|
||||||
|
))
|
||||||
|
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||||||
|
return session.result(node, diagnostics=diagnostics)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""Loft and sweep executors (loft_add / loft_add_with_cap_face / sweep_add)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult
|
||||||
|
from .common import _sweep_path
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_loft_add(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 放样截面不占用 feature.sketch_id;按有序 profile_sketch_ids 取已解析
|
||||||
|
# 草图,并由 adapter 统一校验单闭环、无内环等内核输入约束。
|
||||||
|
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||||
|
profiles: list[dict[str, Any]] = []
|
||||||
|
for sketch_id in profile_ids:
|
||||||
|
sketch = session.sketches.get(str(sketch_id))
|
||||||
|
if sketch is None:
|
||||||
|
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
|
||||||
|
profiles.append(sketch)
|
||||||
|
solid, topology_delta = session.adapter.loft_with_topology_delta(profiles)
|
||||||
|
body = session.adapter.fuse(session.body, solid)
|
||||||
|
# Fusing a loft into an existing body replaces its subshapes through a
|
||||||
|
# different builder. Only an initial direct loft can expose this builder's
|
||||||
|
# cap evidence for the final B-rep snapshot.
|
||||||
|
if session.body is not None:
|
||||||
|
topology_delta = None
|
||||||
|
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("loft_add")
|
||||||
|
def _loft_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_loft_add(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_loft_add_with_cap_face(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
resolved = [session.resolve(selector) for selector in node.selectors]
|
||||||
|
failed = next((item for item in resolved if item.status != "resolved"), None)
|
||||||
|
if failed or len(resolved) != 1 or resolved[0].record is None or resolved[0].record.kind != "face":
|
||||||
|
raise ValueError(failed.diagnostic.message if failed and failed.diagnostic else "cap-face loft selector is unresolved")
|
||||||
|
profile_ids = node.params.get("profile_sketch_ids") or []
|
||||||
|
profiles: list[dict[str, Any]] = []
|
||||||
|
for sketch_id in profile_ids:
|
||||||
|
sketch = session.sketches.get(str(sketch_id))
|
||||||
|
if sketch is None:
|
||||||
|
raise ValueError(f"loft profile sketch {sketch_id!r} is not resolved")
|
||||||
|
profiles.append(sketch)
|
||||||
|
solid = session.adapter.loft_with_cap_face(resolved[0].record.value, profiles)
|
||||||
|
session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("loft_add_with_cap_face")
|
||||||
|
def _loft_cap_face_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_loft_add_with_cap_face(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None = None) -> FeatureResult:
|
||||||
|
profile = sketch or session.sketches.get(str(node.sketch_id))
|
||||||
|
if profile is None:
|
||||||
|
raise ValueError("sweep has no resolved profile sketch")
|
||||||
|
faces = session.adapter.faces_for_sketch(profile)
|
||||||
|
if len(faces) != 1:
|
||||||
|
raise ValueError("sweep requires exactly one closed profile region")
|
||||||
|
solid, topology_delta = session.adapter.sweep_with_topology_delta(
|
||||||
|
faces[0], _sweep_path(node, session),
|
||||||
|
is_frenet=bool(node.params.get("is_frenet", False)),
|
||||||
|
)
|
||||||
|
is_new_body = node.params.get("result_mode") == "new_body"
|
||||||
|
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
|
||||||
|
# A union rebuilds topology, so the pipe-shell builder cannot prove the
|
||||||
|
# final aggregate's relations. The independent-body path retains its exact
|
||||||
|
# subshape identity and may expose evidence for the new member.
|
||||||
|
if session.body is not None and not is_new_body:
|
||||||
|
topology_delta = None
|
||||||
|
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("sweep_add")
|
||||||
|
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
return _execute_sweep_add(node, session, sketch)
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""Parametric feature executors (thread / bend / gear / rack).
|
||||||
|
|
||||||
|
Each executor parses its runtime-neutral spec from ``runtime_types`` specs,
|
||||||
|
asks the geometry adapter to build and place the local-frame solid, then
|
||||||
|
fuses it into the active body (or subtracts, for thread_cut).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..specs import BendSpec, GearSpec, RackSpec, ThreadSpec
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic
|
||||||
|
from .common import _register_added_solid
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_thread(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 螺纹特征(thread_add)执行入口:按规格生成参数化螺纹段并并入当前主体。
|
||||||
|
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
|
||||||
|
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
|
||||||
|
# 2. 由适配器门面生成沿 spec.axis 放置的外螺纹实心段。
|
||||||
|
solid = session.adapter.thread_solid(spec)
|
||||||
|
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_thread_cut(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 内螺纹(thread_cut)执行入口:ThreadSpec.from_feature 对 thread_cut 恒置
|
||||||
|
# internal=True,生成牙顶外放 INTERNAL_CUT_OVERLAP_MM 的切削刀具,沿
|
||||||
|
# spec.axis 放置后从当前主体布尔差出全深螺旋牙槽(宿主通常已预打光孔,
|
||||||
|
# 刀具 core 落在孔腔中,仅外放的牙槽层切入孔壁)。
|
||||||
|
# 1. 解析并校验尺寸/牙距/轴,非法输入抛出带具体原因的 ValueError。
|
||||||
|
spec = ThreadSpec.from_feature(node.atomic_id, node.params)
|
||||||
|
# 2. 由适配器门面生成沿 spec.axis 放置的内螺纹切削刀具实心段。
|
||||||
|
tool = session.adapter.thread_solid(spec)
|
||||||
|
# 3. 从当前主体布尔差(cut)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("thread_add", "thread_cut")
|
||||||
|
def _thread_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
# 螺纹特征(thread_add/thread_cut)不需要草图平面,丢弃该参数后执行。
|
||||||
|
# thread_cut 走布尔差分支:从已有主体切出内螺纹槽,而非并入外螺纹段。
|
||||||
|
del sketch
|
||||||
|
if node.atomic_id == "thread_cut":
|
||||||
|
return _execute_thread_cut(node, session)
|
||||||
|
return _execute_thread(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_bend(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 折弯特征(bend_add)执行入口:按规格生成等厚折弯板并并入当前主体。
|
||||||
|
# 1. 解析并校验板厚/宽度/折痕链与放置平面,非法输入抛出带具体原因的 ValueError。
|
||||||
|
spec = BendSpec.from_feature(node.params)
|
||||||
|
# 2. 由适配器门面生成沿 spec.frame 放置的折弯实心段。
|
||||||
|
solid = session.adapter.bend_solid(spec)
|
||||||
|
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("bend_add")
|
||||||
|
def _bend_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
# 折弯特征(bend_add)不需要草图平面,丢弃该参数后执行。
|
||||||
|
del sketch
|
||||||
|
return _execute_bend(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_gear(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 齿轮特征(gear_add)执行入口:按规格生成渐开线齿轮并入当前主体。
|
||||||
|
# 1. 解析并校验模数/齿数/齿宽/螺旋角与放置轴,非法输入抛出带具体原因的 ValueError。
|
||||||
|
spec = GearSpec.from_feature(node.params)
|
||||||
|
# 2. 由适配器门面生成沿 spec.axis 放置的齿轮实体(直齿/斜齿/人字齿)。
|
||||||
|
solid = session.adapter.gear_solid(spec)
|
||||||
|
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
# 4. 小齿数根切风险:不阻断执行,附加 info 级诊断供完成报告如实披露。
|
||||||
|
diagnostics: list[RuntimeDiagnostic] = []
|
||||||
|
if spec.teeth_count < 17:
|
||||||
|
diagnostics.append(RuntimeDiagnostic(
|
||||||
|
code="undercut_risk",
|
||||||
|
message=(
|
||||||
|
f"Gear with {spec.teeth_count} teeth and a 20 degree pressure angle is undercut-prone; "
|
||||||
|
"standard involute geometry is generated without profile shift"
|
||||||
|
),
|
||||||
|
feature_id=node.feature_id,
|
||||||
|
))
|
||||||
|
return session.result(node, diagnostics=diagnostics)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("gear_add")
|
||||||
|
def _gear_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
# 齿轮特征(gear_add)不需要草图平面,丢弃该参数后执行。
|
||||||
|
del sketch
|
||||||
|
return _execute_gear(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_rack(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 齿条特征(rack_add)执行入口:按规格生成直线齿条并入当前主体。
|
||||||
|
# 1. 解析并校验模数/齿数/厚度/压力角与放置轴,非法输入抛出带具体原因的 ValueError。
|
||||||
|
spec = RackSpec.from_feature(node.params)
|
||||||
|
# 2. 由适配器门面生成沿 spec.axis 放置的齿条实体(齿沿轴方向伸出)。
|
||||||
|
solid = session.adapter.rack_solid(spec)
|
||||||
|
# 3. 与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("rack_add")
|
||||||
|
def _rack_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
# 齿条特征(rack_add)不需要草图平面,丢弃该参数后执行。
|
||||||
|
del sketch
|
||||||
|
return _execute_rack(node, session)
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"""Pattern executors (pattern_linear / pattern_mirror / pattern_circular).
|
||||||
|
|
||||||
|
Instances replay their source features with transformed parameters rather
|
||||||
|
than copying the current body. NEW-body sources can additionally be
|
||||||
|
instanced as rigid body-graph copies, keeping each instance independently
|
||||||
|
addressable for later COPY/DELETE queries.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable
|
||||||
|
|
||||||
|
from ..capabilities import pattern_transform_blocker
|
||||||
|
from ..pattern_transform import (
|
||||||
|
_box_circular_is_exact,
|
||||||
|
_mirrored_node,
|
||||||
|
_mirrored_sketch,
|
||||||
|
_normal_is_coordinate_axis,
|
||||||
|
_pattern_operation_node,
|
||||||
|
_rotated_node,
|
||||||
|
_rotated_sketch,
|
||||||
|
_translated_node,
|
||||||
|
_translated_sketch,
|
||||||
|
)
|
||||||
|
from ..registry import atomic_executor, execute_node
|
||||||
|
from ..specs import AxisSpec, PlaneSpec, Vector3, pattern_instance_member_id, vector_add, vector_dot, vector_scale, vector_subtract, vector_unit
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
ExecutorFunction = Callable[[FeaturePlanNode, "ExecutionSession", dict[str, Any] | None], FeatureResult]
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_linear_pattern(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
session: "ExecutionSession",
|
||||||
|
execute: ExecutorFunction,
|
||||||
|
) -> FeatureResult:
|
||||||
|
# 线性阵列特征(pattern)执行入口:沿两个方向按数量与间距重放源特征形成阵列。
|
||||||
|
|
||||||
|
# 1. 取源特征的 replay 定义(源特征按 feature_id 在会话中登记,供本阵列重放)。
|
||||||
|
params = node.params
|
||||||
|
sources = session.replay_sources(params.get("source_feature_ids") or [])
|
||||||
|
if not sources:
|
||||||
|
raise ValueError("pattern source features have no replay definitions")
|
||||||
|
# 2. 解析两个方向的实例数量。
|
||||||
|
count_1 = int(params.get("pattern_count_1") or 1)
|
||||||
|
count_2 = int(params.get("pattern_count_2") or 1)
|
||||||
|
# 3. 解析两个方向的步长向量(方向单位向量 × 间距),作为阵列位移基准。
|
||||||
|
direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0))
|
||||||
|
direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0))
|
||||||
|
# 4. 双重循环生成每个阵列实例(跳过原点 0,0 处,那里是源特征本身)。
|
||||||
|
for first in range(count_1):
|
||||||
|
for second in range(count_2):
|
||||||
|
if first == 0 and second == 0:
|
||||||
|
continue
|
||||||
|
# 计算当前实例相对源特征的偏移向量。
|
||||||
|
offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second))
|
||||||
|
for source in sources:
|
||||||
|
# 逐个源特征克隆并按偏移平移后重放执行(草图也同步平移)。
|
||||||
|
dependency = pattern_transform_blocker(source)
|
||||||
|
if dependency:
|
||||||
|
raise ValueError(f"pattern source uses an unsupported {dependency}")
|
||||||
|
cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset, session)
|
||||||
|
sketch = session.sketches.get(str(source.sketch_id))
|
||||||
|
execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None)
|
||||||
|
# 5. 记录本阵列的 replay 定义:后续阵列若选中本阵列,按定义递归重放,
|
||||||
|
# 而非复制当前主体做近似。
|
||||||
|
# A later pattern may select this pattern feature. The definition is
|
||||||
|
# replayed recursively, never approximated by copying the current body.
|
||||||
|
session.replay_definitions[node.feature_id] = node
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("pattern_linear")
|
||||||
|
def _linear_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_linear_pattern(node, session, execute_node)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_mirror_pattern(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
mirror = node.params.get("mirror_plane") or {}
|
||||||
|
resolution = session.resolve(mirror)
|
||||||
|
if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec):
|
||||||
|
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved")
|
||||||
|
source_ids = [str(value) for value in node.params.get("source_feature_ids") or ()]
|
||||||
|
if (
|
||||||
|
source_ids
|
||||||
|
and all(source_id in session.body_members for source_id in source_ids)
|
||||||
|
and all(
|
||||||
|
(source := session.nodes.get(source_id)) is not None
|
||||||
|
and source.params.get("result_mode") == "new_body"
|
||||||
|
for source_id in source_ids
|
||||||
|
)
|
||||||
|
):
|
||||||
|
# Only a direct NEW body has a standalone source identity after a
|
||||||
|
# mirror. A hole, dress-up, or ordinary additive source is merely an
|
||||||
|
# aggregate successor and must use the feature-replay path below.
|
||||||
|
# Keeping this condition identical to capability preflight prevents a
|
||||||
|
# downstream COPY body query from selecting an arbitrary aggregate.
|
||||||
|
members = dict(session.body_members)
|
||||||
|
body = session.body
|
||||||
|
for source_id in source_ids:
|
||||||
|
mirrored = session.adapter.mirror(session.body_members[source_id], resolution.record.value)
|
||||||
|
members[pattern_instance_member_id(node.feature_id, source_id, 1)] = mirrored
|
||||||
|
body = session.adapter.fuse(body, mirrored)
|
||||||
|
if body is None:
|
||||||
|
raise ValueError("mirror pattern produced no body")
|
||||||
|
session.register_body(node.feature_id, body, replay_node=node, body_members=members)
|
||||||
|
return session.result(node)
|
||||||
|
if node.params.get("mirror_current_body"):
|
||||||
|
# CADFS SWEPT_BODY 表示被后续 feature 持续修改的同一实体。这里复制
|
||||||
|
# 当前 B-rep 再镜像并合并,不能重放其初始 additive feature,否则会
|
||||||
|
# 丢失后续 cut/fillet 并生成独立错误实体。
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("mirror current body has no active body")
|
||||||
|
mirrored = session.adapter.mirror(session.body, resolution.record.value)
|
||||||
|
session.register_body(node.feature_id, session.adapter.fuse(session.body, mirrored), replay_node=node)
|
||||||
|
return session.result(node)
|
||||||
|
sources = session.replay_sources(node.params.get("source_feature_ids") or [])
|
||||||
|
if not sources:
|
||||||
|
raise ValueError("mirror pattern source features have no replay definitions")
|
||||||
|
for source in sources:
|
||||||
|
dependency = pattern_transform_blocker(source)
|
||||||
|
if dependency:
|
||||||
|
raise ValueError(f"mirror pattern source uses an unsupported {dependency}")
|
||||||
|
if source.atomic_id == "box_add" and not _normal_is_coordinate_axis(resolution.record.value.normal):
|
||||||
|
# box_add 是固定世界轴对齐的原生图元:跨非坐标平面镜像会产生倾斜朝向,
|
||||||
|
# 当前参数语义无法表达,静默重放会得到错误几何 → 明确拒绝。跨坐标平面
|
||||||
|
# (法向平行于任一坐标轴)的镜像仍然精确。
|
||||||
|
raise ValueError("box_add mirror is exact only across coordinate-aligned mirror planes")
|
||||||
|
cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value, session)
|
||||||
|
sketch = session.sketches.get(str(source.sketch_id))
|
||||||
|
execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
|
||||||
|
session.replay_definitions[node.feature_id] = node
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("pattern_mirror")
|
||||||
|
def _mirror_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_mirror_pattern(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _circular_source_is_axisymmetric(node: FeaturePlanNode, session: "ExecutionSession", axis: AxisSpec) -> bool:
|
||||||
|
"""Whether rotating a direct circular extrusion creates no new geometry."""
|
||||||
|
if node.atomic_id not in {"extrude_add_blind", "extrude_add_two_sided"}:
|
||||||
|
return False
|
||||||
|
sketch = session.sketches.get(str(node.sketch_id))
|
||||||
|
if sketch is None:
|
||||||
|
return False
|
||||||
|
profile = sketch.get("profile") or {}
|
||||||
|
circle = profile if profile.get("type") == "circle" else None
|
||||||
|
if circle is None:
|
||||||
|
contours = profile.get("contours") or []
|
||||||
|
segments = (contours[0] or {}).get("segments") if len(contours) == 1 else []
|
||||||
|
circle = segments[0] if isinstance(segments, list) and len(segments) == 1 and segments[0].get("type") == "circle" else None
|
||||||
|
center = (circle or {}).get("center")
|
||||||
|
if not isinstance(center, list) or len(center) != 2:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if abs(vector_dot(plane.normal, axis.direction)) < 1 - 1e-7:
|
||||||
|
return False
|
||||||
|
world_center = vector_add(
|
||||||
|
plane.origin_mm,
|
||||||
|
vector_add(vector_scale(plane.x_dir, float(center[0])), vector_scale(plane.y_dir, float(center[1]))),
|
||||||
|
)
|
||||||
|
offset = vector_subtract(world_center, axis.origin_mm)
|
||||||
|
radial = vector_subtract(offset, vector_scale(axis.direction, vector_dot(offset, axis.direction)))
|
||||||
|
return math.sqrt(vector_dot(radial, radial)) <= 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_copy_topology_records(
|
||||||
|
records: list[TopologyRecord], topology_delta: TopologyDelta | None,
|
||||||
|
) -> list[TopologyRecord]:
|
||||||
|
"""Carry COPY provenance through one exact adapter-history operation.
|
||||||
|
|
||||||
|
Pattern copies are separate CDSL results even when their solids fuse into
|
||||||
|
a single final body. The temporary records here are never selector
|
||||||
|
candidates themselves. They only retain instance ownership while opaque
|
||||||
|
OCC history proves a unique subshape continuation to the final snapshot.
|
||||||
|
"""
|
||||||
|
if topology_delta is None:
|
||||||
|
return []
|
||||||
|
advanced: list[TopologyRecord] = []
|
||||||
|
for record in records:
|
||||||
|
values: list[Any] = []
|
||||||
|
for relation in topology_delta.relations:
|
||||||
|
if (
|
||||||
|
relation.kind != record.kind
|
||||||
|
or relation.event not in {"preserved", "modified"}
|
||||||
|
or not TopologyRegistry._same_topology_value(record.value, relation.source_value)
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
for value in relation.result_values:
|
||||||
|
if not any(TopologyRegistry._same_topology_value(value, known) for known in values):
|
||||||
|
values.append(value)
|
||||||
|
# A split/merge has no unique COPY owner in the present selector
|
||||||
|
# contract. Keep the executable model, but do not make a claim that a
|
||||||
|
# later COPY selector can bind one arbitrary descendant.
|
||||||
|
if len(values) != 1:
|
||||||
|
continue
|
||||||
|
advanced.append(TopologyRecord(
|
||||||
|
record_id=record.record_id,
|
||||||
|
kind=record.kind,
|
||||||
|
feature_id=record.feature_id,
|
||||||
|
body_id=record.body_id,
|
||||||
|
geometry=dict(record.geometry),
|
||||||
|
value=values[0],
|
||||||
|
owner_feature_ids=record.owners,
|
||||||
|
output_roles=record.output_roles,
|
||||||
|
output_role_sources=record.output_role_sources,
|
||||||
|
))
|
||||||
|
return advanced
|
||||||
|
|
||||||
|
|
||||||
|
def _copy_snapshot_topology_delta(records: list[TopologyRecord]) -> TopologyDelta | None:
|
||||||
|
"""Bridge traced final COPY handles into the one registered body snapshot."""
|
||||||
|
if not records:
|
||||||
|
return None
|
||||||
|
return TopologyDelta(
|
||||||
|
operation="pattern_circular_copy_snapshot",
|
||||||
|
relations=tuple(
|
||||||
|
# ``record.value`` has already passed through every transform/fuse
|
||||||
|
# builder in this pattern and is an actual final-B-rep handle. The
|
||||||
|
# identity relation merely connects that evidence to the fresh
|
||||||
|
# adapter snapshot; it is not a geometric rebinding shortcut.
|
||||||
|
TopologyDeltaRelation("preserved", record.kind, record.value, (record.value,))
|
||||||
|
for record in records
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_usable_pattern_body(session: "ExecutionSession", body: Any | None) -> bool:
|
||||||
|
"""Reject a formally valid but empty OCC boolean result before publishing it."""
|
||||||
|
if body is None or not session.adapter.body_solids(body):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return abs(float(body.volume)) > 1e-12
|
||||||
|
except (AttributeError, TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_circular_pattern(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
session: "ExecutionSession",
|
||||||
|
execute: ExecutorFunction,
|
||||||
|
) -> FeatureResult:
|
||||||
|
# 环形阵列特征(pattern_circular)执行入口:绕显式轴按数量与包角重放源特征
|
||||||
|
# 形成环形阵列。源特征整体绕轴旋转(绝对坐标变换),非复制当前主体的近似。
|
||||||
|
params = node.params
|
||||||
|
raw_axis = params.get("axis")
|
||||||
|
if not (isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None):
|
||||||
|
raise ValueError("circular pattern requires an explicit axis with origin_mm and direction")
|
||||||
|
axis = AxisSpec.from_mapping(raw_axis)
|
||||||
|
count = int(params.get("pattern_count") or 1)
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError("circular pattern pattern_count must be >= 1")
|
||||||
|
sweep_angle_deg = float(params.get("sweep_angle_deg") or 360.0)
|
||||||
|
operation_mode = str(params.get("operation_mode") or "add")
|
||||||
|
if operation_mode not in {"add", "remove"}:
|
||||||
|
raise ValueError("circular pattern operation_mode must be add or remove")
|
||||||
|
excluded = {int(value) for value in params.get("excluded_instance_indices") or []}
|
||||||
|
if any(instance < 1 or instance >= count for instance in excluded):
|
||||||
|
raise ValueError("circular pattern excluded instance is outside the generated range")
|
||||||
|
sources = session.replay_sources(params.get("source_feature_ids") or [])
|
||||||
|
if not sources:
|
||||||
|
raise ValueError("circular pattern source features have no replay definitions")
|
||||||
|
source_ids = [source.feature_id for source in sources]
|
||||||
|
pre_pattern_members = dict(session.body_members)
|
||||||
|
if operation_mode == "add" and all(source_id in session.body_members for source_id in source_ids):
|
||||||
|
# A pattern over explicit NEW/kept body members has a stronger contract
|
||||||
|
# than replay: each copy is an independently addressable rigid image of
|
||||||
|
# the named source member. Keep the instance keys in the body graph so
|
||||||
|
# a later CADFS COPY(BODY) transform/delete can name exactly one copy.
|
||||||
|
members = dict(session.body_members)
|
||||||
|
body = session.body
|
||||||
|
traced_copy_records: list[TopologyRecord] = []
|
||||||
|
for instance in range(1, count):
|
||||||
|
if instance in excluded:
|
||||||
|
continue
|
||||||
|
angle_deg = sweep_angle_deg * instance / count
|
||||||
|
transform = {
|
||||||
|
"type": "rotation",
|
||||||
|
"axis": {"origin_mm": list(axis.origin_mm), "direction": list(axis.direction)},
|
||||||
|
"angle_deg": angle_deg,
|
||||||
|
}
|
||||||
|
for source_id in source_ids:
|
||||||
|
member_id = pattern_instance_member_id(node.feature_id, source_id, instance)
|
||||||
|
owner_id = f"{node.feature_id}.c{instance}.{source_id}"
|
||||||
|
source_body = session.body_members[source_id]
|
||||||
|
copy, transform_delta = session.adapter.transform_with_topology_delta(source_body, transform)
|
||||||
|
source_records = session.adapter.topology_records(
|
||||||
|
source_body, owner_id, f"body:{node.feature_id}:copy:{instance}:{source_id}:source",
|
||||||
|
)
|
||||||
|
copy_records = _advance_copy_topology_records(source_records, transform_delta)
|
||||||
|
members[member_id] = copy
|
||||||
|
body, fuse_delta = session.adapter.fuse_with_topology_delta(body, copy)
|
||||||
|
traced_copy_records = _advance_copy_topology_records(
|
||||||
|
[*traced_copy_records, *copy_records], fuse_delta,
|
||||||
|
)
|
||||||
|
if _has_usable_pattern_body(session, body):
|
||||||
|
session.register_body(
|
||||||
|
node.feature_id, body, replay_node=node, body_members=members,
|
||||||
|
topology_delta=_copy_snapshot_topology_delta(traced_copy_records),
|
||||||
|
topology_predecessors=traced_copy_records,
|
||||||
|
)
|
||||||
|
return session.result(node)
|
||||||
|
# An OCC boolean may report IsDone/valid for an empty result when a
|
||||||
|
# copied fused body contains coincident internal topology. The normal
|
||||||
|
# pattern contract can replay the source feature contribution instead;
|
||||||
|
# it is the only sound fallback because it keeps source operation,
|
||||||
|
# sketch frame, and body lifecycle semantics intact.
|
||||||
|
for instance in range(1, count):
|
||||||
|
if instance in excluded:
|
||||||
|
continue
|
||||||
|
# 实例 i 位于包角 sweep_angle_deg 的 i/count 处(i=0 即源特征本身)。
|
||||||
|
angle_deg = sweep_angle_deg * instance / count
|
||||||
|
angle_rad = math.radians(angle_deg)
|
||||||
|
for source in sources:
|
||||||
|
# 与阵列轴同心、法向平行的圆形实体拉伸在任意环形实例中均与
|
||||||
|
# 原实体完全重合。重复执行它会把同一 B-rep 再次交给 OCC fuse,
|
||||||
|
# 后续非轴对称 source 可能因此丢失已生成的实体分支。
|
||||||
|
if _circular_source_is_axisymmetric(source, session, axis):
|
||||||
|
continue
|
||||||
|
dependency = pattern_transform_blocker(source)
|
||||||
|
if dependency:
|
||||||
|
raise ValueError(f"circular pattern source uses an unsupported {dependency}")
|
||||||
|
if source.atomic_id == "box_add" and not _box_circular_is_exact(axis, angle_rad):
|
||||||
|
raise ValueError(
|
||||||
|
"box_add circular pattern is exact only for coordinate-axis rotation "
|
||||||
|
"by multiples of 180 degrees"
|
||||||
|
)
|
||||||
|
cloned = _rotated_node(source, f"{node.feature_id}.c{instance}.{source.feature_id}", axis, angle_rad, session)
|
||||||
|
cloned = _pattern_operation_node(cloned, operation_mode)
|
||||||
|
# CADFS pattern instances are copies of the source result, not
|
||||||
|
# independent `NEW` operations. Replay them through normal add
|
||||||
|
# semantics: intersecting or face-sharing instances fuse, while
|
||||||
|
# spatially separate copies remain separate solids in the result.
|
||||||
|
if cloned.params.get("result_mode") == "new_body":
|
||||||
|
cloned = FeaturePlanNode(
|
||||||
|
cloned.feature_id, cloned.atomic_id, cloned.name, cloned.depends_on,
|
||||||
|
{key: value for key, value in cloned.params.items() if key != "result_mode"},
|
||||||
|
cloned.selectors, cloned.sketch_id, cloned.declared_status, cloned.source_feature,
|
||||||
|
)
|
||||||
|
sketch = session.sketches.get(str(source.sketch_id))
|
||||||
|
execute(cloned, session, _rotated_sketch(sketch, axis, angle_rad) if sketch else None)
|
||||||
|
# 环形阵列本身是完整 B-rep 结果的 producer。每个 replay 子特征都会更新
|
||||||
|
# active body;循环结束后必须用 pattern feature 重新登记最终快照,否则后续
|
||||||
|
# selector binding 会只保留最后一个实例的 body id,漏掉其它 COPY 实例。
|
||||||
|
if session.body is None:
|
||||||
|
raise ValueError("circular pattern produced no body")
|
||||||
|
# Replaying a fused sole-body source may be more robust than copying its
|
||||||
|
# full aggregate B-rep (for example, when a rotationally invariant base
|
||||||
|
# would otherwise be unioned with itself). If that replay still has one
|
||||||
|
# physical body, the direct source remains a proven alias of the current
|
||||||
|
# member. Preserve it for a following parts-scoped operation such as
|
||||||
|
# shell; do not extend this alias across multi-body patterns or multiple
|
||||||
|
# source members.
|
||||||
|
members = {node.feature_id: session.body}
|
||||||
|
if (
|
||||||
|
len(source_ids) == 1
|
||||||
|
and len(pre_pattern_members) == 1
|
||||||
|
and source_ids[0] in pre_pattern_members
|
||||||
|
and _has_usable_pattern_body(session, session.body)
|
||||||
|
and len(session.adapter.body_solids(session.body)) == 1
|
||||||
|
):
|
||||||
|
members[source_ids[0]] = session.body
|
||||||
|
session.register_body(node.feature_id, session.body, replay_node=node, body_members=members)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("pattern_circular")
|
||||||
|
def _circular_pattern_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_circular_pattern(node, session, execute_node)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Analytic primitive executors (sphere_add / box_add / cylinder_add)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..specs import AxisSpec, PlaneSpec
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult
|
||||||
|
from .common import _register_added_solid
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_sphere(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 球体特征(sphere_add)执行入口:按球心与半径生成球体并并入当前主体。
|
||||||
|
|
||||||
|
# 1. 解析参数:半径 radius_mm 与球心 center_mm。
|
||||||
|
radius = float(node.params.get("radius_mm") or 0.0)
|
||||||
|
center = node.params.get("center_mm") or []
|
||||||
|
# 2. 校验:半径必须大于 0,球心必须是三维坐标。
|
||||||
|
if radius <= 0 or len(center) != 3:
|
||||||
|
raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm")
|
||||||
|
# 3. 由适配器创建球体实体。
|
||||||
|
solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
|
||||||
|
# 4. 球体与当前主体做布尔并(fuse)后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_box(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 长方体特征(box_add)执行入口:以几何中心 center_mm 与三向尺寸生成原生长方体。
|
||||||
|
# 1. 解析并校验尺寸与中心,非法输入抛出带具体原因的 ValueError。
|
||||||
|
try:
|
||||||
|
length = float(node.params.get("length_mm") or 0.0)
|
||||||
|
width = float(node.params.get("width_mm") or 0.0)
|
||||||
|
height = float(node.params.get("height_mm") or 0.0)
|
||||||
|
center = node.params.get("center_mm") or []
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError("box dimensions must be numeric") from error
|
||||||
|
if length <= 0 or width <= 0 or height <= 0 or len(center) != 3:
|
||||||
|
raise ValueError("box_add requires positive length_mm/width_mm/height_mm and a three-dimensional center_mm")
|
||||||
|
# 2. 生成世界轴对齐的 plane frame:plane 原点是长方体的最小角点(中心减去半
|
||||||
|
# 尺寸),长/宽/高分别沿世界 x/y/z 生长(build123d Solid.make_box 语义)。
|
||||||
|
corner = (
|
||||||
|
float(center[0]) - length / 2,
|
||||||
|
float(center[1]) - width / 2,
|
||||||
|
float(center[2]) - height / 2,
|
||||||
|
)
|
||||||
|
plane = PlaneSpec.from_mapping({"origin_mm": corner, "x_dir": [1, 0, 0], "normal": [0, 0, 1]})
|
||||||
|
solid = session.adapter.box(length, width, height, plane)
|
||||||
|
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_cylinder(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# 圆柱特征(cylinder_add)执行入口:axis 的原点是底面圆心、方向为轴向;
|
||||||
|
# axis 缺省为世界 +Z 过原点(底面圆心落在 (0,0,0))。
|
||||||
|
# 1. 解析并校验半径与高度,非法输入抛出带具体原因的 ValueError。
|
||||||
|
try:
|
||||||
|
radius = float(node.params.get("radius_mm") or 0.0)
|
||||||
|
height = float(node.params.get("height_mm") or 0.0)
|
||||||
|
except (TypeError, ValueError) as error:
|
||||||
|
raise ValueError("cylinder dimensions must be numeric") from error
|
||||||
|
if radius <= 0 or height <= 0:
|
||||||
|
raise ValueError("cylinder_add requires positive radius_mm and height_mm")
|
||||||
|
raw_axis = node.params.get("axis")
|
||||||
|
if raw_axis is not None and not (
|
||||||
|
isinstance(raw_axis, dict) and raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None
|
||||||
|
):
|
||||||
|
raise ValueError("cylinder_add axis must define origin_mm and direction")
|
||||||
|
axis = AxisSpec.from_mapping(raw_axis) if isinstance(raw_axis, dict) else None
|
||||||
|
# 2. 由适配器创建原生圆柱(axis=None 即世界 +Z 过原点)。
|
||||||
|
solid = session.adapter.cylinder(radius, height, axis)
|
||||||
|
# 3. 与当前主体做布尔并后登记为新主体,并返回该特征的结果对象。
|
||||||
|
_register_added_solid(session, node, solid)
|
||||||
|
return session.result(node)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("sphere_add")
|
||||||
|
def _sphere_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_sphere(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("box_add")
|
||||||
|
def _box_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_box(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("cylinder_add")
|
||||||
|
def _cylinder_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_cylinder(node, session)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Revolution solid executors (revolve_add / revolve_cut)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult
|
||||||
|
from .common import _shape_from_primary
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("revolve_add", "revolve_cut")
|
||||||
|
def _primary_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
return _shape_from_primary(node, session, sketch=sketch)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""Surface-feature executors (extrude_surface / revolve_surface).
|
||||||
|
|
||||||
|
Surface features register an independent shell and never touch the active
|
||||||
|
solid body's fuse/cut lifecycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ..extents import _normal_from_sketch
|
||||||
|
from ..registry import atomic_executor
|
||||||
|
from ..specs import AxisSpec, PlaneSpec, vector_cross, vector_dot, vector_scale, vector_unit
|
||||||
|
from ..topology import FeaturePlanNode, FeatureResult
|
||||||
|
from .common import _revolve_axis, _validate_revolve_axis_in_sketch_plane
|
||||||
|
|
||||||
|
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
|
||||||
|
from ..session import ExecutionSession
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_revolve_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# Surface revolve 的 profile 是单一闭合 wire。它只生成独立 shell,不能参与
|
||||||
|
# 当前实体 body 的 fuse/cut,也不能把其结果误报为新的实体 body。
|
||||||
|
sketch = session.sketches.get(str(node.sketch_id))
|
||||||
|
if sketch is None:
|
||||||
|
raise ValueError("surface revolve has no resolved sketch")
|
||||||
|
faces = session.adapter.faces_for_sketch(sketch)
|
||||||
|
if len(faces) != 1 or faces[0].inner_wires():
|
||||||
|
raise ValueError("surface revolve requires exactly one closed profile without holes")
|
||||||
|
axis = _revolve_axis(node, session)
|
||||||
|
_validate_revolve_axis_in_sketch_plane(axis, sketch)
|
||||||
|
angle = float(node.params.get("angle_deg") or 0.0)
|
||||||
|
if angle <= 0:
|
||||||
|
raise ValueError("surface revolve requires angle_deg > 0")
|
||||||
|
if bool(node.params.get("reverse")):
|
||||||
|
angle = -angle
|
||||||
|
surface_id = session.register_surface(
|
||||||
|
node.feature_id,
|
||||||
|
session.adapter.revolve_surface(faces[0].outer_wire(), angle, axis),
|
||||||
|
)
|
||||||
|
return session.result(node, include_body=False, surface_id=surface_id)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("revolve_surface")
|
||||||
|
def _revolve_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_revolve_surface(node, session)
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
|
||||||
|
# surfaceEntities 的曲面拉伸沿用实体特征已 lower 的距离,但始终独立登记为
|
||||||
|
# shell。它既不改变 active solid,也不以曲面参与实体 fuse/cut。
|
||||||
|
sketch = session.sketches.get(str(node.sketch_id))
|
||||||
|
if sketch is None:
|
||||||
|
raise ValueError("surface extrude has no resolved sketch")
|
||||||
|
direction = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
|
||||||
|
if bool(node.params.get("reverse")):
|
||||||
|
direction = vector_scale(direction, -1)
|
||||||
|
distance = float(node.params.get("distance_mm") or 0.0)
|
||||||
|
if distance <= 0:
|
||||||
|
raise ValueError("surface extrude requires distance_mm > 0")
|
||||||
|
wires = session.adapter.surface_wires_for_sketch(sketch)
|
||||||
|
surface = session.adapter.extrude_surface(wires, vector_scale(direction, distance))
|
||||||
|
reverse_distance = float(node.params.get("reverse_distance_mm") or 0.0)
|
||||||
|
if reverse_distance > 0:
|
||||||
|
opposite = session.adapter.extrude_surface(wires, vector_scale(direction, -reverse_distance))
|
||||||
|
surface = session.adapter.combine_surfaces(surface, opposite)
|
||||||
|
surface_id = session.register_surface(node.feature_id, surface)
|
||||||
|
return session.result(node, include_body=False, surface_id=surface_id)
|
||||||
|
|
||||||
|
|
||||||
|
@atomic_executor("extrude_surface")
|
||||||
|
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
|
||||||
|
del sketch
|
||||||
|
return _execute_extrude_surface(node, session)
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Atomic executor registry and dispatch for the session runtime.
|
||||||
|
|
||||||
|
Executors register themselves with :func:`atomic_executor` from their own
|
||||||
|
modules; ``cdsl_engine.executors`` imports every executor module exactly once
|
||||||
|
to build the registry. Adding a new atomic operation therefore means adding
|
||||||
|
a decorated function in a family module -- the registry itself never changes.
|
||||||
|
|
||||||
|
``registry`` deliberately imports no executor module, so pattern executors
|
||||||
|
can re-enter :func:`execute_node` for replay without an import cycle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
|
from .session import ExecutionSession
|
||||||
|
from .topology import CapabilityResult, FeaturePlanNode, FeatureResult
|
||||||
|
|
||||||
|
|
||||||
|
class AtomicExecutor(Protocol):
|
||||||
|
atomic_id: str
|
||||||
|
|
||||||
|
def preflight(self, node: FeaturePlanNode, session: ExecutionSession) -> CapabilityResult: ...
|
||||||
|
def execute(self, node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
#: The complete capability contract of this runtime. Every registered
|
||||||
|
#: executor must name an id from this set, so a mistyped registration fails
|
||||||
|
#: at import time instead of surfacing as an unknown-atomic blocker later.
|
||||||
|
ALL_ATOMIC_IDS = frozenset({
|
||||||
|
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
|
||||||
|
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add",
|
||||||
|
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||||
|
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||||
|
"reference_plane", "reference_axis",
|
||||||
|
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
|
||||||
|
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
|
||||||
|
"thread_add", "thread_cut",
|
||||||
|
"bend_add",
|
||||||
|
"gear_add", "rack_add",
|
||||||
|
})
|
||||||
|
|
||||||
|
#: ``atomic_id -> executor``. Populated by ``cdsl_engine.executors``.
|
||||||
|
EXECUTORS: dict[str, "ExecutorFunction"] = {}
|
||||||
|
|
||||||
|
ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_executor(*atomic_ids: str) -> Callable[["ExecutorFunction"], "ExecutorFunction"]:
|
||||||
|
"""Register one executor function for one or more atomic ids.
|
||||||
|
|
||||||
|
Registration validates against ``ALL_ATOMIC_IDS`` and rejects duplicates,
|
||||||
|
keeping the registry consistent with the declared capability contract.
|
||||||
|
"""
|
||||||
|
unknown = [atomic_id for atomic_id in atomic_ids if atomic_id not in ALL_ATOMIC_IDS]
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"cannot register executors for unknown atomic ids: {unknown}")
|
||||||
|
|
||||||
|
def decorator(func: "ExecutorFunction") -> "ExecutorFunction":
|
||||||
|
for atomic_id in atomic_ids:
|
||||||
|
if atomic_id in EXECUTORS:
|
||||||
|
raise ValueError(f"duplicate executor registration for {atomic_id!r}")
|
||||||
|
EXECUTORS[atomic_id] = func
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def execute_node(
|
||||||
|
node: FeaturePlanNode,
|
||||||
|
session: ExecutionSession,
|
||||||
|
sketch_override: dict[str, Any] | None = None,
|
||||||
|
) -> FeatureResult:
|
||||||
|
"""Dispatch one plan node through its registered executor."""
|
||||||
|
executor = EXECUTORS.get(node.atomic_id)
|
||||||
|
if executor is None:
|
||||||
|
raise ValueError(f"No executor registered for {node.atomic_id!r}")
|
||||||
|
previous_feature_id = session.active_feature_id
|
||||||
|
session.active_feature_id = node.feature_id
|
||||||
|
try:
|
||||||
|
return executor(node, session, sketch_override)
|
||||||
|
finally:
|
||||||
|
session.active_feature_id = previous_feature_id
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user