feat(cadfs): expand selector provenance coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user