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
+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(