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