feat(cadfs): expand selector provenance coverage

This commit is contained in:
2026-09-14 11:00:14 +08:00
parent 994d06aaea
commit de21ee8a5b
45 changed files with 19837 additions and 842 deletions
@@ -24,9 +24,11 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
params = node.params
target_ids = _member_sources(
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs",
)
tool_ids = _member_sources(
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_refs",
)
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
@@ -49,6 +51,12 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
}
members[node.feature_id] = result
if bool(params.get("keep_tools")):
# In a targetless FeatureScript body-set operation every selected
# member is semantically a tool. Lowering chooses the first source
# member only to satisfy CDSL's binary executor shape, so retain that
# left operand too when the explicit targetless contract requests it.
if bool(params.get("targetless_body_set")):
members.update(targets)
members.update(tools)
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
+301 -70
View File
@@ -6,13 +6,14 @@ used by exactly one family lives in that family's module instead.
from __future__ import annotations
from dataclasses import replace
import math
from typing import TYPE_CHECKING, Any, Callable
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
from ..runtime_base import ExtentVector, FeatureExecutionError
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@@ -57,20 +58,41 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
)
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
def _cut_explicit_body_members(
session: "ExecutionSession", tool: Any,
) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]:
"""Apply a cut to each independently owned body without erasing ownership.
A CADFS NEW body stays independently addressable even when a later REMOVE
feature affects several active bodies. Cutting the aggregate first loses
that identity, so this path uses the equivalent per-member set difference
and drops only members that the tool removes completely.
and drops only members that the tool removes completely. Each non-empty
member result retains its own OCC builder history; callers may compose
those disjoint exact relations into one operation-wide delta.
"""
members: dict[str, Any] = {}
deltas: list[TopologyDelta] = []
for feature_id, body in session.body_members.items():
result = session.adapter.cut(body, tool)
if abs(float(result.volume)) > 1e-12:
result, delta = session.adapter.cut_with_topology_delta(body, tool)
if delta is not None:
deltas.append(delta)
if result is not None and abs(float(result.volume)) > 1e-12:
members[feature_id] = result
return members
return members, tuple(deltas)
def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None:
"""Combine exact independent member-cut histories without inventing links."""
if not deltas or any(delta.history_status != "proven" for delta in deltas):
return None
return TopologyDelta(
operation="subtract",
relations=tuple(relation for delta in deltas for relation in delta.relations),
section_values=tuple(value for delta in deltas for value in delta.section_values),
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
history_reason="per_member_exact_cut_history",
)
def _can_register_primary_cut_tool_history(
@@ -82,12 +104,14 @@ def _can_register_primary_cut_tool_history(
"""Return whether a primary REMOVE can retain a transient tool snapshot.
The implicit CADFS primary boolean normally has no independently active
tool body. It may contribute selector provenance only when the target,
tool, profile anchors, and prism builder history are all singular and
exact. This gate applies only to the tool-side transient snapshot used by
source-qualified section queries. The cut builder can independently
prove a target-side continuation even when a trimmed or fallback tool has
no direct-prism history.
tool body. It may retain its direct-prism input snapshot whenever the
tool, profile anchors, and prism builder history are singular and exact.
The target may contain several independent members: that changes the cut
result cardinality but not the identity of the one transient tool input.
This gate does not make any multi-member selector executable; a later
resolver must still prove its complete operation-wide relation and active
member. The cut builder can independently prove a target-side continuation
even when a trimmed or fallback tool has no direct-prism history.
"""
if (
session.body is None
@@ -96,8 +120,6 @@ def _can_register_primary_cut_tool_history(
or topology_delta.history_status != "proven"
or not topology_delta.relations
or not topology_anchors
or len(session.body_members) != 1
or len(session.adapter.body_solids(session.body)) != 1
or len(session.adapter.body_solids(tool)) != 1
):
return False
@@ -115,6 +137,7 @@ def _extruded_tool(
session: "ExecutionSession",
*,
record_multiface_prism_history: bool = False,
use_operation_wide_prism_history: bool = False,
) -> tuple[Any, TopologyDelta | None]:
"""Build an extrude tool, retaining complete direct builder history."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
@@ -124,21 +147,38 @@ def _extruded_tool(
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_deltas: list[TopologyDelta] = []
if (
use_operation_wide_prism_history
and draft is None
and len(faces) > 1
and len(extents) == 1
and extents[0].trim_to is None
):
composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector)
if composed is not None:
return composed
topology_deltas: list[tuple[int, TopologyDelta]] = []
solids: list[Any] = []
exact_two_sided_prism = (
node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}
and draft is None
and len(faces) == 1
and len(extents) == 2
and all(extent.trim_to is None for extent in extents)
)
for face in faces:
for extent in extents:
for extent_index, extent in enumerate(extents):
if draft is not None:
if len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg,
)
if topology_delta is not None:
topology_deltas.append(topology_delta)
topology_deltas.append((extent_index, topology_delta))
solids.append(solid)
else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(extents) == 1 and (
elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) and (
len(faces) == 1 or record_multiface_prism_history
):
# Each independently constructed profile face has its own OCC
@@ -147,8 +187,19 @@ def _extruded_tool(
# multi-face profiles keep the established general-extrude
# path; forcing them through MakePrism can make a previously
# executable profile invalid without adding usable evidence.
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
topology_deltas.append(topology_delta)
try:
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
except ValueError:
# Keep the established executable profile result when a
# selected IMPRINT region is valid as a face but cannot be
# a standalone valid prism. Its later fuse may still be
# valid. There is no complete builder witness in this
# case, so the entire multi-region topology delta is
# withheld below instead of mixing proven and guessed
# source anchors.
solid = session.adapter.extrude(face, extent.vector)
else:
topology_deltas.append((extent_index, topology_delta))
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
@@ -161,9 +212,21 @@ def _extruded_tool(
raise ValueError("extrude produced no solid")
if len(topology_deltas) != len(solids):
return tool, None
relations: list[TopologyDeltaRelation] = []
for extent_index, topology_delta in topology_deltas:
for relation in topology_delta.relations:
# Both prism builders start on the source plane. Its two source
# caps are internal to the fused two-sided result. The reverse
# extent's far ``LastShape`` is the FeatureScript start cap; map
# that exact builder handle before the registry checks final
# membership. No source-plane or geometry-derived relation is
# promoted to a CAP role.
if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end":
relation = replace(relation, output_role="extrude.start")
relations.append(relation)
return tool, TopologyDelta(
operation="extrude",
relations=tuple(relation for delta in topology_deltas for relation in delta.relations),
relations=tuple(relations),
)
@@ -181,7 +244,7 @@ def _apply_primary_tool(
if cutting:
if session.body is None:
raise ValueError("cut feature has no body")
members = _cut_explicit_body_members(session, tool)
members, member_cut_deltas = _cut_explicit_body_members(session, tool)
if not members:
session.clear_body()
return session.result(node)
@@ -190,12 +253,10 @@ def _apply_primary_tool(
retain_transient_tool = _can_register_primary_cut_tool_history(
session, tool, tool_delta, topology_anchors,
)
if (
len(session.body_members) == 1
and len(session.adapter.body_solids(session.body)) == 1
and len(session.adapter.body_solids(tool)) == 1
):
body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool)
if len(session.body_members) == 1:
member_id = next(iter(session.body_members))
body = members[member_id]
cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None
# The target-side boolean history is independent of the source
# tool's construction history. A trimmed tool cannot support a
# source-qualified section query, but its exact BRepAlgoAPI_Cut
@@ -219,23 +280,64 @@ def _apply_primary_tool(
topology_delta = cut_delta
else:
topology_delta = None
member_id = next(iter(session.body_members))
members = {member_id: body}
else:
body = session.adapter.cut(session.body, tool)
topology_delta = None
if retain_transient_tool:
try:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=tool_delta,
topology_anchors=tool_anchors,
)
except ValueError:
# A transient tool is optional evidence. Keep the exact
# per-member target history when its tool snapshot cannot
# be registered completely.
topology_predecessors = None
body = session.adapter.combine(None, next(iter(members.values())))
for member in list(members.values())[1:]:
body = session.adapter.combine(body, member)
topology_delta = _compose_member_cut_deltas(member_cut_deltas)
topology_anchors = None
elif node.params.get("result_mode") == "new_body":
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
else:
body = session.adapter.fuse(session.body, tool)
# An ADD can replace both the prior active solid and its direct prism
# tool. Retain a selector relation only when both snapshots are
# singular and OCC supplies the exact union history. The direct-prism
# snapshot remains transient: its source anchors cannot be selected
# until the union proves a complete successor in the active result.
can_trace_add = (
session.body is not None
and topology_delta is not None
and topology_delta.operation == "extrude"
and topology_delta.history_status == "proven"
and bool(topology_delta.relations)
and bool(topology_anchors)
and len(session.adapter.body_solids(session.body)) == 1
and len(session.adapter.body_solids(tool)) == 1
)
if can_trace_add:
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool)
if fuse_delta is not None:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=topology_delta,
topology_anchors=list(topology_anchors or ()),
)
body = fused
topology_delta = fuse_delta
topology_anchors = None
else:
body = fused
topology_delta = None
topology_anchors = None
else:
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# A fuse rebuilds subshape identity. Builder evidence belongs only to
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = None
topology_anchors = None
session.register_body(
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
@@ -252,7 +354,11 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
if selected_sketch is None:
raise ValueError("primary feature has no resolved sketch")
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch)
support_face = session.sketch_attachment_faces.get(str(node.sketch_id))
external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id))
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(
selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
@@ -274,6 +380,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
)
planar_imprint_profile = profile.get("type") == "planar_imprint"
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -282,7 +389,15 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session,
record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces),
# A multi-region prism gets per-region builder history only when
# every profile region has at least one exact source boundary.
# This covers direct circles and the adapter's bounded IMPRINT
# splitter path, without turning arbitrary multi-face profiles
# into a different construction algorithm.
record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile)
and len(source_anchor_specs) >= len(faces),
use_operation_wide_prism_history=planar_imprint_profile
and len(source_anchor_specs) >= len(faces),
)
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
@@ -318,10 +433,44 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
if bool(node.params.get("reverse")):
angle = -angle
tool = None
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
tool = session.adapter.fuse(tool, solid)
can_record_revolve_history = (
node.atomic_id == "revolve_add"
and node.params.get("result_mode") == "new_body"
and len(faces) == 1
)
for face in faces:
if can_record_revolve_history:
try:
solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis)
except ValueError:
# Retain the established executable revolve when OCC
# cannot expose a complete builder-history witness.
solid = session.adapter.revolve(face, angle, axis)
topology_delta = None
else:
solid = session.adapter.revolve(face, angle, axis)
tool = solid if tool is None else session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("revolve produced no solid")
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
value = spec.get("value")
if kind not in {"edge", "vertex"} or value is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if source_entity is None and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=value,
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
topology_anchors=topology_anchors,
@@ -374,10 +523,14 @@ def _pattern_instance_sources(
return resolved
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
def _transform_copy_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str = "transform_copy_refs",
) -> list[str]:
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
resolved: list[str] = []
for reference in node.params.get("transform_copy_refs") or ():
for reference in node.params.get(parameter) or ():
if not isinstance(reference, dict):
raise ValueError("transform COPY reference must be an object")
transform_id = str(reference.get("transform_feature_id") or "")
@@ -411,12 +564,13 @@ def _member_sources(
*,
pattern_instance_parameter: str | None = None,
allow_transform_copies: bool = False,
transform_copy_parameter: str = "transform_copy_refs",
) -> list[str]:
source_ids = [str(value) for value in node.params.get(parameter) or []]
if pattern_instance_parameter is not None:
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
if allow_transform_copies:
source_ids.extend(_transform_copy_sources(node, session))
source_ids.extend(_transform_copy_sources(node, session, transform_copy_parameter))
if not source_ids:
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
@@ -430,37 +584,119 @@ def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
path = node.params.get("path") or {}
if not isinstance(path, dict):
raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
spatial = path.get("workplane") is None
plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {}
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
segments = path.get("segments")
def point(value: list[float]) -> Vector3:
def local_point(value: Any) -> Vector3:
if plane is None:
raise ValueError("planar sweep path requires a workplane")
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path requires two-dimensional points")
return vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
)
def tangent(value: Any) -> Vector3 | None:
if value is None:
return None
def local_vector(value: Any) -> Vector3:
if plane is None:
raise ValueError("planar sweep path requires a workplane")
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
if segments is not None:
if segment:
raise ValueError("sweep path cannot mix segment and segments")
if not isinstance(segments, list) or len(segments) < 2:
raise ValueError("sweep segmented path requires at least two segments")
materialized: list[dict[str, Any]] = []
for index, source in enumerate(segments):
if not isinstance(source, dict):
raise ValueError("sweep path segment must be an object")
kind = str(source.get("type") or "")
if kind not in {"line", "arc", "bspline"}:
raise ValueError(f"unsupported sweep path segment {kind!r}")
target: dict[str, Any] = {"type": kind}
if spatial:
if kind in {"line", "arc"}:
target["start_mm"] = source.get("start_mm")
target["end_mm"] = source.get("end_mm")
if kind == "arc":
target["center_mm"] = source.get("center_mm")
target["normal"] = source.get("normal")
target["radius_mm"] = source.get("radius_mm")
target["clockwise"] = bool(source.get("clockwise", False))
if kind == "bspline":
target["points_mm"] = source.get("points_mm")
if source.get("start_tangent_mm") is not None:
target["start_tangent_mm"] = source.get("start_tangent_mm")
if source.get("end_tangent_mm") is not None:
target["end_tangent_mm"] = source.get("end_tangent_mm")
if source.get("parameters") is not None:
target["parameters"] = [float(value) for value in source.get("parameters") or []]
if source.get("periodic") is not None:
target["periodic"] = bool(source.get("periodic"))
materialized.append(target)
continue
if kind in {"line", "arc"}:
target["start_mm"] = local_point(source.get("start"))
target["end_mm"] = local_point(source.get("end"))
if kind == "arc":
target["center_mm"] = local_point(source.get("center"))
target["normal"] = plane.normal
target["clockwise"] = bool(source.get("clockwise", False))
if kind == "bspline":
points = source.get("points")
if not isinstance(points, list) or len(points) < 2:
raise ValueError("sweep B-spline path requires at least two interpolation points")
target["points_mm"] = [local_point(value) for value in points]
if source.get("start_tangent") is not None:
target["start_tangent_mm"] = local_vector(source.get("start_tangent"))
if source.get("end_tangent") is not None:
target["end_tangent_mm"] = local_vector(source.get("end_tangent"))
if source.get("parameters") is not None:
target["parameters"] = [float(value) for value in source.get("parameters") or []]
if source.get("periodic") is not None:
target["periodic"] = bool(source.get("periodic"))
materialized.append(target)
return session.adapter.sweep_path_segments(materialized)
if spatial:
raise ValueError("spatial sweep path requires captured segments")
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "circle":
return session.adapter.sweep_circle_path(
local_point(segment.get("center")),
plane.x_dir,
plane.normal,
radius_mm=float(segment["radius_mm"]),
)
elif kind == "arc":
return session.adapter.sweep_arc_path(
local_point(segment.get("start")),
local_point(segment.get("end")),
local_point(segment.get("center")),
plane.normal,
radius_mm=float(segment["radius_mm"]),
clockwise=bool(segment["clockwise"]),
)
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2:
raise ValueError("sweep path requires two-dimensional points")
return session.adapter.sweep_path(
[point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")),
end_tangent=tangent(segment.get("end_tangent")),
[local_point(value) for value in local_points],
start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None,
end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None,
parameters=[float(value) for value in segment.get("parameters") or []] or None,
)
@@ -536,15 +772,10 @@ def _direct_output_role_records(
return result
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec:
if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
geometry = resolution.record.geometry
return PlaneSpec.from_mapping({
"origin_mm": geometry["center_mm"],
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
"normal": geometry["normal"],
})
return adapter.planar_face_workplane(resolution.record.value)
def _hole_starts(
@@ -6,6 +6,7 @@ that later features resolve through owner-qualified selectors.
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
@@ -61,3 +62,28 @@ def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession",
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
session.topology.register_context(node.feature_id, axis)
return session.result(node, context=axis)
@atomic_executor("reference_point")
def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
"""Execute an explicit source-datum point without changing model topology."""
del sketch
point = node.params.get("point_mm")
if (
not isinstance(point, list)
or len(point) != 3
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point)
):
raise ValueError("reference point requires one finite three-dimensional point")
return session.result(node, include_body=False)
@atomic_executor("assign_variable")
def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
"""Replay a source variable declaration after lowering has consumed it."""
del sketch
name = node.params.get("name")
value = node.params.get("value")
if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError("assign_variable requires one finite named value")
return session.result(node, include_body=False)
@@ -17,6 +17,60 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _single_member_dressup_members(
node: FeaturePlanNode,
session: "ExecutionSession",
body: Any,
selected_edges: list[Any],
) -> dict[str, Any] | None:
"""Preserve unchanged body members after one exact-member dress-up.
The adapter only reports Compound history when every selected edge maps to
one source solid. Keep the same proof at the body-graph layer: all other
members must appear unchanged in the result and exactly one result solid
must remain for the changed member. Otherwise aggregate replay remains
valid, but no member-lifecycle transfer is asserted.
"""
if not selected_edges or len(session.body_members) < 2:
return None
source_members: dict[str, Any] = {}
for member_id, member in session.body_members.items():
solids = session.adapter.body_solids(member)
if len(solids) != 1:
return None
source_members[member_id] = solids[0]
selected_members = {
member_id
for edge in selected_edges
for member_id, member in source_members.items()
if any(edge.is_same(candidate) for candidate in member.edges())
}
if len(selected_members) != 1:
return None
changed_member_id = next(iter(selected_members))
result_solids = session.adapter.body_solids(body)
unchanged: dict[str, Any] = {}
matched_result_indexes: set[int] = set()
for member_id, member in source_members.items():
if member_id == changed_member_id:
continue
matches = [
index for index, result in enumerate(result_solids)
if session.topology._same_topology_value(member, result)
]
if len(matches) != 1 or matches[0] in matched_result_indexes:
return None
matched_result_indexes.add(matches[0])
unchanged[member_id] = result_solids[matches[0]]
changed = [
result for index, result in enumerate(result_solids)
if index not in matched_result_indexes
]
if len(changed) != 1:
return None
return {**unchanged, node.feature_id: changed[0]}
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
@@ -28,11 +82,15 @@ def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> Featu
if radius <= 0:
raise ValueError("fillet radius_mm must be > 0")
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
body, topology_delta = session.adapter.fillet_with_topology_delta(
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
session.body, radius, edges,
)
# 4. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
body_members=_single_member_dressup_members(node, session, body, edges),
)
return session.result(node)
@@ -86,7 +144,10 @@ def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> Feat
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
))
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
body_members=_single_member_dressup_members(node, session, body, edges),
)
return session.result(node, diagnostics=diagnostics)
+28 -14
View File
@@ -32,22 +32,16 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
if session.body is None:
raise ValueError("hole feature has no body")
scope_feature_id = node.params.get("scope_feature_id")
scoped_body = None
if scope_feature_id is not None:
if not isinstance(scope_feature_id, str) or not scope_feature_id:
raise ValueError("hole scope_feature_id is invalid")
if len(session.body_members) != 1:
raise ValueError("hole scope body is no longer the sole active member")
scoped_body = session.body_members.get(scope_feature_id)
if scoped_body is None:
raise ValueError("hole scope body is no longer an independently selectable member")
scoped_solids = session.adapter.body_solids(scoped_body)
active_solids = session.adapter.body_solids(session.body)
if (
len(scoped_solids) != 1
or len(active_solids) != 1
or not scoped_solids[0].is_same(active_solids[0])
):
raise ValueError("hole scope body does not match the active body")
if not scoped_solids:
raise ValueError("hole scope body has no active solid")
# 2. 确定宿主面 host_face:
host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
@@ -62,8 +56,15 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
selector = next((item for item in selectors if item.get("kind") == "face"), None)
if selector is None:
raise ValueError("hole requires host_face selector or frame")
host = _host_plane(session.resolve(selector))
positions_are_local = False
host = _host_plane(session.resolve(selector), session.adapter)
intent = selector.get("selector_intent") if isinstance(selector, dict) else None
# Existing selector-hosted holes store world positions. Only the new
# runtime-attached COPY(CAP_FACE) sketch preserves local coordinates
# until its exact host relation has materialized.
positions_are_local = (
isinstance(intent, dict)
and intent.get("copy_contract") == "primary_cut_cap_face_workplane"
)
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
# 4. A host-face normal is an outward B-rep orientation, so its inverse
@@ -78,7 +79,7 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
spec,
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
inward,
session.adapter.body_span(session.body, inward) + 2.0,
session.adapter.body_span(scoped_body or session.body, inward) + 2.0,
)
# 6. 从主体上减去工具实体,登记新主体并返回结果。
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
@@ -91,8 +92,21 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
feature_id=node.feature_id,
))
result_body = session.adapter.cut(session.body, tool)
members = {scope_feature_id: result_body} if scope_feature_id is not None else None
if scope_feature_id is None:
result_body = session.adapter.cut(session.body, tool)
members = None
else:
# CADFS ``scope`` identifies the target body member. Never apply the
# cutter to the aggregate merely because unrelated live members share
# the exported part; unchanged members remain exact body-graph nodes.
result_body = session.adapter.cut(scoped_body, tool)
members = {**session.body_members, scope_feature_id: result_body}
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("hole scope cut produced no active body")
result_body = body
session.register_body(
node.feature_id, result_body, replay_node=node, body_members=members,
)
@@ -5,8 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult
from .common import _sweep_path
from ..topology import FeaturePlanNode, FeatureResult, TopologyRecord
from .common import _apply_primary_tool, _sweep_path
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@@ -66,13 +66,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
profile = sketch or session.sketches.get(str(node.sketch_id))
if profile is None:
raise ValueError("sweep has no resolved profile sketch")
faces = session.adapter.faces_for_sketch(profile)
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(profile)
if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region")
solid, topology_delta = session.adapter.sweep_with_topology_delta(
faces[0], _sweep_path(node, session),
is_frenet=bool(node.params.get("is_frenet", False)),
)
if node.atomic_id == "sweep_cut":
# The PipeShell is a transient cutting tool. Its own builder history
# cannot become selector provenance after the BRepAlgoAPI_Cut; only
# the cut's exact target-side delta may survive registration.
return _apply_primary_tool(node, session, solid, cutting=True)
is_new_body = node.params.get("result_mode") == "new_body"
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
# A union rebuilds topology, so the pipe-shell builder cannot prove the
@@ -80,10 +85,37 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
# subshape identity and may expose evidence for the new member.
if session.body is not None and not is_new_body:
topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
topology_anchors: list[TopologyRecord] = []
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
if kind not in {"edge", "vertex"} or spec.get("value") is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if not isinstance(source_entity, tuple) and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=spec["value"],
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
return session.result(node)
@atomic_executor("sweep_add")
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
@atomic_executor("sweep_cut")
def _sweep_cut_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
@@ -73,3 +73,21 @@ def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession")
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_extrude_surface(node, session)
def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
profile = session.sketches.get(str(sketch_id))
if profile is None:
raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved")
profiles.append(profile)
surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles))
return session.result(node, include_body=False, surface_id=surface_id)
@atomic_executor("loft_surface")
def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_surface(node, session)