feat(selector): 增加离线候选遍历与严格回放验证 Demo

- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
This commit is contained in:
2026-09-10 15:12:57 +08:00
parent d8fc2a9207
commit 994d06aaea
36 changed files with 9268 additions and 753 deletions
+498 -18
View File
@@ -9,17 +9,18 @@ from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Pl
from OCP.BOPAlgo import BOPAlgo_Splitter
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
from OCP.Geom import Geom_SurfaceOfRevolution
from OCP.GeomAbs import GeomAbs_Arc
from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL
from OCP.TopExp import TopExp_Explorer
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
@@ -30,7 +31,7 @@ from .parametric_gears import build_gear_solid, build_rack_solid
from .parametric_thread import build_thread_solid
from .runtime_types import (
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3,
canonical_plane_signature,
)
from .topology_export import (
@@ -45,6 +46,74 @@ def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
return Vector(float(value[0]), float(value[1]), float(value[2]))
def make_interpolated_bspline_edge(
points: list[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
periodic: bool = False,
parameters: list[float] | None = None,
) -> Edge:
"""Build the exact non-scaling interpolator used by CDSL sketch edges.
FeatureScript ``skFitSpline`` lowering retains its centripetal parameters
and endpoint derivatives. Keep this construction in one kernel helper so
geometry execution and consumers that need a source-curve differential do
not accidentally use a chord or a separately parameterized interpolator.
Callers remain responsible for their own CDSL contract validation.
"""
if (start_tangent is None) != (end_tangent is None):
raise ValueError("interpolated B-spline requires both endpoint tangents")
return Edge.make_spline(
[_vector(point) for point in points],
tangents=(
[_vector(start_tangent), _vector(end_tangent)]
if start_tangent is not None else None
),
periodic=periodic,
parameters=parameters,
scale=False,
)
def interpolated_bspline_point_and_tangent(
points: list[Vector3],
*,
start_tangent: Vector3,
end_tangent: Vector3,
parameters: list[float],
interpolation_index: int,
) -> tuple[Vector3, Vector3]:
"""Evaluate a CDSL interpolation point with the runtime's OCC curve.
``Edge.tangent_at`` accepts normalized edge positions, which need not be
the explicit interpolation parameters. Use ``BRepAdaptor_Curve.D1`` at
the source parameter and return the actual point as well, allowing the
caller to prove that OCC still interpolated the named source vertex.
"""
if interpolation_index < 0 or interpolation_index >= len(points):
raise ValueError("B-spline interpolation index is out of range")
if len(parameters) != len(points):
raise ValueError("B-spline interpolation parameters must match points")
if not all(math.isfinite(float(value)) for value in parameters):
raise ValueError("B-spline interpolation parameters must be finite")
if any(float(right) <= float(left) for left, right in zip(parameters, parameters[1:])):
raise ValueError("B-spline interpolation parameters must be strictly increasing")
edge = make_interpolated_bspline_edge(
points,
start_tangent=start_tangent,
end_tangent=end_tangent,
parameters=parameters,
)
point = gp_Pnt()
tangent = gp_Vec()
BRepAdaptor_Curve(edge.wrapped).D1(float(parameters[interpolation_index]), point, tangent)
return (
(float(point.X()), float(point.Y()), float(point.Z())),
(float(tangent.X()), float(tangent.Y()), float(tangent.Z())),
)
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
@@ -96,7 +165,7 @@ class Build123dGeometryAdapter:
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]:
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
@@ -137,12 +206,12 @@ class Build123dGeometryAdapter:
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
else:
parameter_values = [float(value) for value in parameters] if parameters is not None else None
built.append(Edge.make_spline(
points,
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
built.append(make_interpolated_bspline_edge(
[(point.X, point.Y, point.Z) for point in points],
start_tangent=start_tangent,
end_tangent=end_tangent,
periodic=bool(edge.get("periodic")),
parameters=parameter_values,
scale=False,
))
continue
if edge.get("type") == "ellipse":
@@ -163,7 +232,203 @@ class Build123dGeometryAdapter:
else:
# 直线边:直接连接首尾。
built.append(Edge.make_line(start, end))
return Wire(built)
return built
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
return Wire(Build123dGeometryAdapter._wire_edges(edges))
@staticmethod
def _wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Wire, list[tuple[str, Edge]]]:
"""Construct one wire and retain only explicit one-to-one source edges."""
built = Build123dGeometryAdapter._wire_edges(edges)
return Wire(built), [
(str(edge["source_entity_id"]), built[index])
for index, edge in enumerate(edges)
if isinstance(edge.get("source_entity_id"), str) and edge["source_entity_id"]
]
@staticmethod
def _direct_wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Any, list[tuple[str, Edge]]]:
"""Build one direct wire and retain the builder's exact source edges.
``Wire(built)`` may repair shared vertices by replacing individual
edges. ``BRepBuilderAPI_MakeWire.Edge()`` exposes each repaired edge at
insertion time. The caller must still prove those handles are members
of the final face before registering a source anchor.
"""
wire_builder = BRepBuilderAPI_MakeWire()
source_edges: list[tuple[str, Edge]] = []
for definition, edge in zip(edges, Build123dGeometryAdapter._wire_edges(edges)):
wire_builder.Add(edge.wrapped)
if not wire_builder.IsDone():
raise ValueError("analytic contour wire construction failed")
source_entity_id = definition.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
source_edges.append((source_entity_id, Edge.cast(wire_builder.Edge())))
return wire_builder.Wire(), source_edges
def _face_from_direct_wires(
self,
outer: list[dict[str, Any]],
holes: Iterable[list[dict[str, Any]]] = (),
*,
logical_circle_sources: dict[str, dict[str, Any]] | None = None,
plane_spec: PlaneSpec | None = None,
) -> tuple[Face, list[tuple[str, Edge]]]:
"""Build one direct profile face with exact outer and hole wire handles.
Constructing a face and then calling ``Face.make_holes`` can replace
linear outer-wire subshapes. A direct ``BRepBuilderAPI_MakeFace``
instead gives the face builder every source wire, so a later ``IsSame``
membership check can establish provenance without geometry matching.
"""
outer_wire, source_edges = self._direct_wire_with_source_edges(outer)
face_builder = BRepBuilderAPI_MakeFace(outer_wire, True)
for hole in holes:
hole_wire, hole_source_edges = self._direct_hole_wire_with_source_edges(
hole,
logical_circle_sources=logical_circle_sources,
plane_spec=plane_spec,
)
# OCC requires an inner wire to have the opposite orientation to
# its outer boundary. ``Wire.Reversed`` returns a generic shape,
# so cast it back to TopoDS_Wire for the face-builder API.
face_builder.Add(TopoDS.Wire_s(hole_wire.Reversed()))
source_edges.extend(hole_source_edges)
if not face_builder.IsDone():
raise ValueError("analytic contour face construction failed")
return Face.cast(face_builder.Face()), source_edges
def _face_from_direct_wire(self, edges: list[dict[str, Any]]) -> tuple[Face, list[tuple[str, Edge]]]:
"""Build a direct face with no inner wires for existing callers."""
return self._face_from_direct_wires(edges)
def _direct_hole_wire_with_source_edges(
self,
edges: list[dict[str, Any]],
*,
logical_circle_sources: dict[str, dict[str, Any]] | None,
plane_spec: PlaneSpec | None,
) -> tuple[Any, list[tuple[str, Edge]]]:
"""Restore one solver-split source circle only from explicit provenance.
``analytic_contours`` decomposes a circle into four arcs to classify
regions. Those arcs are not source edges. The solver records one
logical-circle source marker on every generated arc, so a complete
marked loop may be rebuilt as one native circle wire. Any missing,
mixed, or unregistered marker uses the ordinary direct wire path and
therefore retains no invented source edge.
"""
logical_ids = {
edge.get("logical_circle_source_entity_id")
for edge in edges
if isinstance(edge.get("logical_circle_source_entity_id"), str)
and edge["logical_circle_source_entity_id"]
}
if (
len(edges) == 4
and len(logical_ids) == 1
and all(edge.get("logical_circle_source_entity_id") in logical_ids for edge in edges)
and logical_circle_sources is not None
and plane_spec is not None
):
source_entity_id = next(iter(logical_ids))
source = logical_circle_sources.get(source_entity_id)
center = source.get("center") if isinstance(source, dict) else None
radius = source.get("radius_mm") if isinstance(source, dict) else None
if (
isinstance(center, list)
and len(center) >= 2
and isinstance(radius, (int, float))
and math.isfinite(float(radius))
and float(radius) > 0
):
wire = self._circle_wire([float(center[0]), float(center[1])], float(radius), plane_spec)
return wire.wrapped, [(source_entity_id, edge) for edge in wire.edges()]
return self._direct_wire_with_source_edges(edges)
@staticmethod
def _logical_circle_sources(profile: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Index uniquely named, unsplit circle source entities from one profile."""
sources: dict[str, dict[str, Any]] = {}
duplicates: set[str] = set()
for contour in profile.get("contours") or []:
if not isinstance(contour, dict):
continue
for segment in contour.get("segments") or []:
if not isinstance(segment, dict) or segment.get("type") != "circle":
continue
source_entity_id = segment.get("source_entity_id")
if not isinstance(source_entity_id, str) or not source_entity_id:
continue
if source_entity_id in sources:
duplicates.add(source_entity_id)
else:
sources[source_entity_id] = segment
for source_entity_id in duplicates:
sources.pop(source_entity_id, None)
return sources
@staticmethod
def _face_source_anchor_specs(
face: Face,
source_edges: list[tuple[str, Edge]],
source_sketch_id: str | None,
) -> list[dict[str, Any]]:
"""Bind direct source labels to the exact face boundary subshapes.
The caller retains the wire edges created for this profile. A source
label is emitted only when it has one exact final face edge; there is
no geometry-based reconstruction when splitting, trimming, or wire
construction changes that one-to-one relationship.
"""
if not isinstance(source_sketch_id, str) or not source_sketch_id:
return []
actual_edges = list(face.edges())
mapped: list[tuple[str, Edge]] = []
source_counts: dict[str, int] = {}
for source_entity_id, _edge in source_edges:
source_counts[source_entity_id] = source_counts.get(source_entity_id, 0) + 1
for source_entity_id, source_edge in source_edges:
if source_counts[source_entity_id] != 1:
continue
matches = [
edge for edge in actual_edges
if edge.wrapped.IsSame(source_edge.wrapped)
]
if len(matches) == 1:
mapped.append((source_entity_id, matches[0]))
specs: list[dict[str, Any]] = [
{
"kind": "edge",
"value": edge,
"source_entity": (source_sketch_id, source_entity_id),
}
for source_entity_id, edge in mapped
]
vertex_groups: list[tuple[Any, set[str]]] = []
for source_entity_id, edge in mapped:
for vertex in edge.vertices():
group = next(
(candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)),
None,
)
if group is None:
vertex_groups.append((vertex, {source_entity_id}))
else:
group[1].add(source_entity_id)
for vertex, entity_ids in vertex_groups:
if len(entity_ids) < 2:
continue
specs.append({
"kind": "vertex",
"value": vertex,
"source_entities": tuple(
(source_sketch_id, entity_id) for entity_id in sorted(entity_ids)
),
})
return specs
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
@@ -211,6 +476,59 @@ class Build123dGeometryAdapter:
faces.append(face.make_holes(holes) if holes else face)
return faces
def _faces_from_circles_with_source_anchors(
self,
entities: list[dict[str, Any]],
plane_spec: PlaneSpec,
source_sketch_id: str | None,
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Build direct circular profile faces while retaining their wire edges."""
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
if not circles:
return [], []
entries = []
for item in circles:
radius = float(item.get("radius_mm") or 0)
if radius <= 0:
continue
center = [float(value) for value in item.get("center") or [0, 0]]
entries.append({
"center": center,
"radius": radius,
"wire": self._circle_wire(center, radius, plane_spec),
"source_entity_id": item.get("source_entity_id"),
})
faces: list[Face] = []
anchors: list[dict[str, Any]] = []
for entry in entries:
containing = sum(
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
for other in entries
if other is not entry
)
if containing % 2:
continue
holes = [
other for other in entries
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
and sum(
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
for candidate in entries
if candidate is not other
) == containing + 1
]
face = Face(entry["wire"])
result = face.make_holes([item["wire"] for item in holes]) if holes else face
source_edges = [
(str(item["source_entity_id"]), edge)
for item in [entry, *holes]
if isinstance(item.get("source_entity_id"), str) and item["source_entity_id"]
for edge in item["wire"].edges()
]
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
faces.append(result)
return faces, anchors
@staticmethod
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
"""Return OCC split history, keeping an unchanged input as one image."""
@@ -476,6 +794,96 @@ class Build123dGeometryAdapter:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
def faces_for_sketch_with_source_anchors(
self,
sketch: dict[str, Any],
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Return profile faces plus direct source anchors for prism history.
This intentionally covers only the direct analytic profile builders.
IMPRINT, split, generated, or otherwise transformed profile paths keep
their normal geometry but expose no semantic source anchor.
"""
profile = sketch.get("profile") or {}
source_sketch_id = sketch.get("source_sketch_id")
if profile.get("type") == "planar_imprint" or not isinstance(source_sketch_id, str):
return self.faces_for_sketch(sketch), []
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
# Keep each original circular source edge before the sketch solver
# expands it into analytic arc fragments for region bookkeeping.
# ``_faces_from_circles_with_source_anchors`` verifies each wire
# against the resulting face by exact ``IsSame``; a missing source
# label remains unavailable rather than being inferred from its
# radius or centre. This also covers a direct annulus or multiple
# independent circular regions built by the same face constructor.
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
**(
{"source_entity_id": segment["source_entity_id"]}
if isinstance(segment.get("source_entity_id"), str) and segment["source_entity_id"]
else {}
),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles_with_source_anchors(
circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}), source_sketch_id,
)
regions = sketch.get("contour_regions_mm") or []
if regions:
# ``Face.make_holes`` remains the authoritative geometry path for
# a mixed multi-region profile. OCC can give a valid direct face
# for each individual region while changing how those regions
# interact when their prisms are combined. The direct builder is
# therefore limited to one hole-bearing region; keep the existing
# executable profile and withhold anchors for the broader case.
if len(regions) > 1 and any(region.get("holes") for region in regions):
return self.faces_for_sketch(sketch), []
logical_circle_sources = self._logical_circle_sources(profile)
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if logical_circle_sources else None
faces: list[Face] = []
anchors: list[dict[str, Any]] = []
for region in regions:
outer = region.get("outer") or []
if len(outer) < 1:
continue
# Build all direct boundary wires in one face builder. Each
# prospective source edge is verified below against the final
# face, so an OCC wire repair or an unsupported hole topology
# removes evidence rather than creating a geometric fallback.
try:
result, source_edges = self._face_from_direct_wires(
outer,
region.get("holes") or (),
logical_circle_sources=logical_circle_sources,
plane_spec=plane_spec,
)
except ValueError:
# Keep the established executable profile path when the
# direct builder cannot represent this analytic region.
# It deliberately carries no provenance anchor.
return self.faces_for_sketch(sketch), []
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
faces.append(result)
return faces, anchors
if profile.get("type") == "circle":
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles_with_source_anchors(
sketch.get("entities") or [], plane, source_sketch_id,
)
return self.faces_for_sketch(sketch), []
@staticmethod
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
"""Build one planar profile from a sketch outer wire and cap-face holes."""
@@ -582,7 +990,7 @@ class Build123dGeometryAdapter:
@staticmethod
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
"""Extrude one face with exact cap, side-wall, and swept-edge history."""
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("extrude direction must be non-zero")
@@ -600,6 +1008,46 @@ class Build123dGeometryAdapter:
relations.append(TopologyDeltaRelation(
"generated", "face", face.wrapped, (output,), output_role=role,
))
for source_edge in face.edges():
# ``Generated(edge)`` proves the lateral face only. Prism's
# source-edge overloads preserve the distinct start/end cap-edge
# mapping, verified against the final result snapshot here.
for role, cap_edge in (
("extrude.start", builder.FirstShape(source_edge.wrapped)),
("extrude.end", builder.LastShape(source_edge.wrapped)),
):
is_final_edge = (
not cap_edge.IsNull()
and cap_edge.ShapeType() == TopAbs_EDGE
and any(cap_edge.IsSame(edge.wrapped) for edge in result.edges())
)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped,
(cap_edge,) if is_final_edge else (),
output_role=role,
source_kind="edge",
result_kind="edge",
derivation="boundary",
coverage="complete" if is_final_edge else "partial",
status="proven" if is_final_edge else "unknown",
))
generated = tuple(builder.Generated(source_edge.wrapped))
side_faces = tuple(shape for shape in generated if shape.ShapeType() == TopAbs_FACE)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped, side_faces,
source_kind="edge", result_kind="face", derivation="boundary",
coverage="complete" if len(side_faces) == len(generated) and side_faces else "partial",
status="proven" if len(side_faces) == len(generated) and side_faces else "unknown",
))
for source_vertex in source_edge.vertices():
generated_edges = tuple(builder.Generated(source_vertex.wrapped))
swept_edges = tuple(shape for shape in generated_edges if shape.ShapeType() == TopAbs_EDGE)
relations.append(TopologyDeltaRelation(
"generated", "vertex", source_vertex.wrapped, swept_edges,
source_kind="vertex", result_kind="edge", derivation="boundary",
coverage="complete" if len(swept_edges) == len(generated_edges) and swept_edges else "partial",
status="proven" if len(swept_edges) == len(generated_edges) and swept_edges else "unknown",
))
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
@staticmethod
@@ -1241,8 +1689,11 @@ class Build123dGeometryAdapter:
@staticmethod
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
"""Translate OCC builder history into adapter-neutral opaque relations."""
source_bodies = tuple(sources)
relations: list[TopologyDeltaRelation] = []
for source in sources:
source_faces: list[tuple[int, Any]] = []
for source_index, source in enumerate(source_bodies):
source_faces.extend((source_index, face.wrapped) for face in source.faces())
for kind, shapes in (
("face", list(source.faces())),
("edge", list(source.edges())),
@@ -1274,6 +1725,7 @@ class Build123dGeometryAdapter:
if generated:
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
section_values: tuple[Any, ...] = ()
section_relations: list[TopologySectionRelation] = []
section_edges = getattr(operation, "SectionEdges", None)
if callable(section_edges):
try:
@@ -1283,10 +1735,37 @@ class Build123dGeometryAdapter:
section_values = tuple(section_edges())
except (AttributeError, TypeError, ValueError):
section_values = ()
if section_values and len(source_bodies) >= 2:
# A section edge becomes source-qualified only when OCC returns
# that exact edge from Generated(face) for one face from each
# boolean input. Do not inspect BOPDS internals here: those
# Python bindings are unsafe for this traversal and ordinary
# SectionEdges remain useful unqualified diagnostics.
for section_edge in section_values:
generators: list[tuple[int, Any]] = []
for source_index, source_face in source_faces:
try:
generated = tuple(operation.Generated(source_face))
except (AttributeError, TypeError, ValueError):
generated = ()
if any(candidate.IsSame(section_edge) for candidate in generated):
generators.append((source_index, source_face))
if len(generators) != 2 or {item[0] for item in generators} != {0, 1}:
continue
section_relations.append(TopologySectionRelation(
source_values=(generators[0][1], generators[1][1]),
result_value=section_edge,
))
qualified_section_values = tuple(item.result_value for item in section_relations)
unqualified_section_values = tuple(
value for value in section_values
if not any(value.IsSame(qualified) for qualified in qualified_section_values)
)
return TopologyDelta(
operation=operation_name,
relations=tuple(relations),
section_values=section_values,
section_values=unqualified_section_values,
section_relations=tuple(section_relations),
)
@staticmethod
@@ -1830,20 +2309,21 @@ class Build123dGeometryAdapter:
end_tangent: Vector3 | None = None,
parameters: list[float] | None = None,
) -> Edge | Wire:
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
# 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点
# 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束,
# 缺失时不能伪造,交给内核自动求解。
vertices = [_vector(point) for point in points]
if len(vertices) < 2:
raise ValueError("sweep path needs at least two points")
if len(vertices) == 2:
if start_tangent is not None or end_tangent is not None or parameters is not None:
raise ValueError("line sweep path does not accept B-spline tangents")
return Edge.make_line(vertices[0], vertices[1])
if (start_tangent is None) != (end_tangent is None):
raise ValueError("sweep B-spline path requires both endpoint tangents")
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
if len(vertices) == 2 and tangents is None:
if parameters is not None:
raise ValueError("line sweep path does not accept B-spline parameters")
return Edge.make_line(vertices[0], vertices[1])
direction = vertices[-1] - vertices[0]
tolerance = 1e-9 * max(1.0, direction.length)
collinear_points = direction.length > tolerance and all(
+116 -6
View File
@@ -17,6 +17,7 @@ from .runtime_types import (
pattern_instance_member_id, transform_copy_member_id,
)
from .operation_contracts import materialized_feature_contracts
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
@@ -97,6 +98,24 @@ def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None)
return [value for value in values if isinstance(value, dict)]
def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return the one nested output-role slot supported by extrusion extents."""
policy = ((contract or {}).get("nested_selector_policies") or {}).get("params.end_condition.reference")
if (
not isinstance(policy, dict)
or policy.get("end_condition_type") != "up_to_surface"
or policy.get("token_kind") != "face"
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
or policy.get("requires_immediate_owner") is not True
):
return None
end_condition = node.params.get("end_condition") or {}
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None:
return reference
return None
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
"""Return internal body-member keys named by a transform contract."""
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
@@ -232,6 +251,8 @@ def _next_body_graph(
return members, has_active_body
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
atomic_id in _HOLE_ATOMICS and node.params.get("scope_feature_id") is not None
) or (
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
# Primary cuts execute per explicit member in the runtime so a later
@@ -448,6 +469,7 @@ class CapabilityAnalyzer:
completed: set[str] = set()
body_available = False
body_members: set[str] = set()
previous_node: FeaturePlanNode | None = None
for node in plan:
blockers: list[RuntimeDiagnostic] = []
contract = self.contracts.get(node.atomic_id)
@@ -559,19 +581,38 @@ class CapabilityAnalyzer:
sketch_id=sketch_id,
))
contract_selectors = _contract_selectors(node, contract)
contract_selector_ids = {id(selector) for selector in contract_selectors}
extent_selector = _up_to_surface_output_role_reference(node, contract)
output_role_selectors = [
*contract_selectors,
*([extent_selector] if extent_selector is not None else []),
]
contract_selector_ids = {id(selector) for selector in output_role_selectors}
selector_intent_ids = {
id(selector.get("selector_intent"))
for selector in output_role_selectors
if isinstance(selector.get("selector_intent"), dict)
}
for selector in _mappings(params):
if selector.get("output_role") is not None and id(selector) not in contract_selector_ids:
if (
selector.get("output_role") is not None
and selector.get("kind") is not None
and id(selector) not in contract_selector_ids
and id(selector) not in selector_intent_ids
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector_context",
"Feature output role selector is outside the operation contract slot",
))
for selector_index, selector in enumerate(contract_selectors):
for selector_index, selector in enumerate(output_role_selectors):
if selector.get("output_role") is None:
continue
required.append("selector:feature_output_role")
if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
is_extent_reference = selector is extent_selector
if (
not is_extent_reference
and (contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face")
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector",
@@ -600,6 +641,39 @@ class CapabilityAnalyzer:
selector_index=selector_index,
))
role_source = selector.get("output_role_source")
selector_intent = selector.get("selector_intent")
is_shell_cap_face_output_role = (
node.atomic_id == "shell"
and isinstance(selector_intent, dict)
and selector_intent.get("query_family") == "CAP_FACE"
and selector.get("output_role") in {"extrude.start", "extrude.end"}
)
if is_extent_reference and (
previous_node is None
or selector.get("owner_feature_id") != previous_node.feature_id
or not is_direct_blind_extrude_cap_output_role(
selector, previous_node.source_feature, sketches,
)
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_extent_output_role_selector",
"up_to_surface output roles require the immediately preceding direct new_body blind extrusion cap",
selector_index=selector_index,
))
elif is_shell_cap_face_output_role and (
previous_node is None
or selector.get("owner_feature_id") != previous_node.feature_id
or not is_direct_blind_extrude_cap_output_role(
selector, previous_node.source_feature, sketches,
)
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_cap_face_output_role_selector",
"CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap",
selector_index=selector_index,
))
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
@@ -647,11 +721,25 @@ class CapabilityAnalyzer:
))
elif kind == "bspline" and (
not isinstance(segment.get("points"), list)
or len(segment.get("points") or []) < 3
or len(segment.get("points") or []) < 2
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"Sweep B-spline path requires at least three interpolation points",
"Sweep B-spline path requires at least two interpolation points",
))
elif (
kind == "bspline"
and len(segment.get("points") or []) == 2
and not (
isinstance(segment.get("start_tangent"), list)
and len(segment["start_tangent"]) == 2
and isinstance(segment.get("end_tangent"), list)
and len(segment["end_tangent"]) == 2
)
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"A two-point B-spline sweep path requires both endpoint tangents",
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
@@ -762,6 +850,27 @@ class CapabilityAnalyzer:
"shell target no longer has an independently selectable body output",
target_feature_id=target_feature_id,
))
if node.atomic_id in _HOLE_ATOMICS and params.get("scope_feature_id") is not None:
scope_feature_id = params.get("scope_feature_id")
required.append("hole:explicit_scope_body")
scope = nodes_by_id.get(str(scope_feature_id or ""))
if not isinstance(scope_feature_id, str) or not scope_feature_id:
blockers.append(self._blocker(
node.feature_id, "invalid_hole_scope_body",
"hole scope_feature_id must name one preceding body member",
))
elif scope is None or scope.feature_id not in completed or scope.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "hole_scope_body_unavailable",
"hole scope body is no longer an independently selectable body output",
scope_feature_id=scope_feature_id,
))
elif len(body_members) != 1:
blockers.append(self._blocker(
node.feature_id, "hole_scope_body_ambiguous",
"hole scope body must be the sole active member",
scope_feature_id=scope_feature_id,
))
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
source_ids = params.get(parameter)
@@ -1086,6 +1195,7 @@ class CapabilityAnalyzer:
if status == "executable":
completed.add(node.feature_id)
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
previous_node = node
body_producers = {
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
+62 -8
View File
@@ -14,7 +14,6 @@
"geometry": {
"type": "object",
"properties": {
"selector_intent_version": {"const": "1.0"},
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
},
"required": ["sketches"],
@@ -543,6 +542,7 @@
"end_condition": {"$ref": "#/$defs/endCondition"},
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
"host_face": {"$ref": "#/$defs/hostFace"},
"scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"thread": {"type": "object"},
"countersink": {"type": "object"},
"counterbore": {"type": "object"}
@@ -570,9 +570,22 @@
"properties": {
"ast": {},
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
"standard_library": {"type": "string", "minLength": 1}
"standard_library": {"type": "string", "minLength": 1},
"standard_library_version": {"type": "string", "minLength": 1},
"standard_library_imports": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string", "minLength": 1},
"version": {"type": "string", "minLength": 1}
},
"required": ["path"],
"additionalProperties": false
}
}
},
"required": ["ast", "featurescript_version"],
"required": ["ast"],
"additionalProperties": false
},
"selectorIntent": {
@@ -580,7 +593,7 @@
"properties": {
"version": {"const": "1.0"},
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]},
"query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"},
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
"source_entity": {
"type": "object",
@@ -591,7 +604,47 @@
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"source_entities": {
"type": "array",
"minItems": 2,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"intersection_sources": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": {
"type": "object",
"properties": {
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"output_role": {"enum": ["extrude.start", "extrude.end"]},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"required": ["query_family", "owner_feature_id"],
"additionalProperties": false
}
},
"output_role": {"$ref": "#/$defs/featureOutputRole"},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
"body_member_contract": {"enum": ["direct_new_body"]},
"derivation_policy": {
"type": "object",
"properties": {
@@ -601,7 +654,7 @@
"required": ["allowed", "multiplicity"],
"additionalProperties": false
},
"evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]},
"evidence": {"enum": ["kernel_history", "operation_role", "active_body_member", "feature_script_query", "explicit_datum", "geometry_hint"]},
"disambiguation": {"type": "object"}
},
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
@@ -628,7 +681,7 @@
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "source", "confidence"],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}],
"additionalProperties": false
},
"analyticSegment": {
@@ -649,6 +702,7 @@
"clockwise": {"type": "boolean"},
"start_tangent": {"$ref": "#/$defs/point2"},
"end_tangent": {"$ref": "#/$defs/point2"}
,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
},
"required": ["type"],
"allOf": [
@@ -798,7 +852,7 @@
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
"profile": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"},
{"$ref": "#/$defs/planarImprintProfile"}
@@ -806,7 +860,7 @@
},
"sketch": {
"type": "object",
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"required": ["id", "workplane", "profile"],
"additionalProperties": false
}
+133 -7
View File
@@ -73,13 +73,50 @@ def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[s
return members
def _can_register_primary_cut_tool_history(
session: "ExecutionSession",
tool: Any,
topology_delta: TopologyDelta | None,
topology_anchors: list[TopologyRecord] | None,
) -> bool:
"""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.
"""
if (
session.body is None
or topology_delta is None
or topology_delta.operation != "extrude"
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
return all(
anchor.kind in {"edge", "vertex"}
and (anchor.source_entity is not None or anchor.source_entities)
for anchor in topology_anchors
)
def _extruded_tool(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
*,
record_multiface_prism_history: bool = False,
) -> tuple[Any, TopologyDelta | None]:
"""Build one extrude tool, retaining caps only from one exact builder result."""
"""Build an extrude tool, retaining complete direct builder history."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
draft = node.params.get("draft")
taper_deg = 0.0
@@ -87,7 +124,7 @@ def _extruded_tool(
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_delta: TopologyDelta | None = None
topology_deltas: list[TopologyDelta] = []
solids: list[Any] = []
for face in faces:
for extent in extents:
@@ -96,11 +133,22 @@ def _extruded_tool(
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)
solids.append(solid)
else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
elif extent.trim_to is None and len(extents) == 1 and (
len(faces) == 1 or record_multiface_prism_history
):
# Each independently constructed profile face has its own OCC
# prism history only when the adapter retained direct source
# anchors for every participating profile path. Other complex
# 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)
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
@@ -111,7 +159,12 @@ def _extruded_tool(
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("extrude produced no solid")
return tool, topology_delta
if len(topology_deltas) != len(solids):
return tool, None
return tool, TopologyDelta(
operation="extrude",
relations=tuple(relation for delta in topology_deltas for relation in delta.relations),
)
def _apply_primary_tool(
@@ -121,8 +174,10 @@ def _apply_primary_tool(
*,
cutting: bool,
topology_delta: TopologyDelta | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> FeatureResult:
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
topology_predecessors: list[TopologyRecord] | None = None
if cutting:
if session.body is None:
raise ValueError("cut feature has no body")
@@ -130,8 +185,46 @@ def _apply_primary_tool(
if not members:
session.clear_body()
return session.result(node)
body = session.adapter.cut(session.body, tool)
topology_delta = None
tool_delta = topology_delta
tool_anchors = list(topology_anchors or ())
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)
# 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
# Modified/Preserved facts can still prove a unique continuation
# of the active target face or edge.
topology_anchors = None
if cut_delta is not 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:
# Retain cut history as partial diagnostic evidence. The
# absent transient source records prevent it from proving
# a source-qualified section edge.
topology_predecessors = None
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
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}
@@ -142,8 +235,10 @@ def _apply_primary_tool(
# 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,
)
return session.result(node)
@@ -157,7 +252,7 @@ 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 = session.adapter.faces_for_sketch(selected_sketch)
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
@@ -169,6 +264,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
topology_delta: TopologyDelta | None = None
topology_anchors: list[TopologyRecord] = []
profile = selected_sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
)
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -177,7 +282,27 @@ 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),
)
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,
))
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
@@ -199,6 +324,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
raise ValueError("revolve produced no solid")
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
+22 -1
View File
@@ -31,6 +31,23 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
# 1. 校验:孔是切除操作,必须先有主体。
if session.body is None:
raise ValueError("hole feature has no body")
scope_feature_id = node.params.get("scope_feature_id")
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")
# 2. 确定宿主面 host_face:
host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
@@ -74,5 +91,9 @@ 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,
))
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
result_body = session.adapter.cut(session.body, tool)
members = {scope_feature_id: result_body} if scope_feature_id is not None else None
session.register_body(
node.feature_id, result_body, replay_node=node, body_members=members,
)
return session.result(node, diagnostics=diagnostics)
@@ -31,12 +31,14 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
params_schema = raw.get("author_params_schema")
injected_paths = raw.get("server_injected_paths")
selector_policy = raw.get("selector_policy")
nested_selector_policies = raw.get("nested_selector_policies") or {}
runtime_capability = raw.get("runtime_capability")
if (
not isinstance(shape, dict)
or not isinstance(params_schema, dict)
or not isinstance(injected_paths, list)
or not isinstance(selector_policy, dict)
or not isinstance(nested_selector_policies, dict)
):
raise ValueError(f"operation contract is incomplete for {atomic_id}")
if not isinstance(runtime_capability, dict) or not all(
@@ -52,6 +54,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
raise ValueError(f"operation author params are invalid for {atomic_id}")
if not all(isinstance(name, str) and name in properties for name in required):
raise ValueError(f"operation required params are invalid for {atomic_id}")
if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()):
raise ValueError(f"operation nested selector policies are invalid for {atomic_id}")
materialized_required = list(dict.fromkeys(required))
for path in injected_paths:
if not isinstance(path, str):
@@ -67,6 +71,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
"requires_sketch": shape.get("sketch") == "required",
"selector_slot": selector_policy.get("slot"),
"selector_token_kind": selector_policy.get("token_kind"),
"nested_selector_policies": dict(nested_selector_policies),
"runtime_capability": dict(runtime_capability),
}
return derived
@@ -6,7 +6,7 @@
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"],
"operation_contracts": {
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
@@ -14,7 +14,7 @@
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
+101 -38
View File
@@ -20,6 +20,7 @@ working.
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -27,6 +28,7 @@ from . import executors # noqa: F401 (importing performs executor registration
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
# Historical private name still imported by the test suite.
from .executors.primitives import _execute_box # noqa: F401
from .executors.common import _selector_edges # noqa: F401
from .extents import (
_extent_reference,
_extent_vectors,
@@ -73,6 +75,9 @@ from .topology import (
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
)
# Historical private imports used by integration tests and local diagnostics.
_execute_node = execute_node
__all__ = [
"ALL_ATOMIC_IDS",
"EXECUTORS",
@@ -85,7 +90,10 @@ __all__ = [
"GeometryAdapter",
"RuntimeDiagnostic",
"RuntimeExecutionError",
"IncrementalCdslExecution",
"analyze_cdsl",
"prepare_cdsl_execution",
"finalize_cdsl_execution",
"execute_node",
"rebuild_cdsl",
]
@@ -101,8 +109,81 @@ def analyze_cdsl(cdsl: dict[str, Any]):
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
"""Rebuild CDSL through session-scoped atomic executors only."""
def _preflight_error(analysis: Any) -> ValueError | None:
if analysis.runtime_eligible:
return None
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
return ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
return ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
return ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
def _execution_diagnostic(
error: Exception,
node: FeaturePlanNode,
session: ExecutionSession,
) -> RuntimeDiagnostic:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
if isinstance(error, FeatureExecutionError):
return RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if failed_resolution and failed_resolution.get("diagnostic"):
diagnostic = failed_resolution["diagnostic"]
return RuntimeDiagnostic(
diagnostic["code"], diagnostic["message"], feature_id=node.feature_id,
detail=diagnostic.get("detail") or {},
)
return RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
@dataclass
class IncrementalCdslExecution:
"""One prepared CDSL replay with feature-at-a-time execution.
CADFS selector binding consumes this object before each feature executes.
It therefore observes the exact session topology snapshots generated by
the one real kernel replay rather than rebuilding a growing prefix for
every selector. The generic runtime also uses it through ``rebuild_cdsl``.
"""
resolved_cdsl: dict[str, Any]
analysis: Any
session: ExecutionSession
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
next_index: int = 0
def execute_next(self, *, strict: bool = True) -> FeatureResult | None:
if self.next_index >= len(self.analysis.plan):
raise ValueError("CDSL execution plan is already complete")
node = self.analysis.plan[self.next_index]
preflight = self.analysis.feature_results[self.next_index]
self.next_index += 1
if not preflight.executable:
self.diagnostics.extend(preflight.blockers)
if strict:
detail = "; ".join(blocker.code for blocker in preflight.blockers)
raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}")
return None
try:
return execute_node(node, self.session)
except Exception as error:
diagnostic = _execution_diagnostic(error, node, self.session)
self.diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(self.session.selector_resolutions)) from error
return None
def execute_all(self, *, strict: bool = True) -> None:
while self.next_index < len(self.analysis.plan):
self.execute_next(strict=strict)
def prepare_cdsl_execution(cdsl: dict[str, Any]) -> IncrementalCdslExecution:
"""Resolve CDSL once and return a reusable sequential execution session."""
sketch_errors: dict[str, str] = {}
resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
@@ -110,45 +191,16 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
resolved, sketch_errors=sketch_errors,
)
if strict and not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
session = ExecutionSession(
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
nodes={node.feature_id: node for node in analysis.plan},
)
diagnostics: list[RuntimeDiagnostic] = []
for node, preflight in zip(analysis.plan, analysis.feature_results):
if not preflight.executable:
diagnostics.extend(preflight.blockers)
if strict:
break
continue
try:
execute_node(node, session)
except Exception as error:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
diagnostic = (
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if isinstance(error, FeatureExecutionError)
else
RuntimeDiagnostic(
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
)
if failed_resolution and failed_resolution.get("diagnostic")
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
)
diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
return IncrementalCdslExecution(resolved, analysis, session)
def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]:
"""Export the current executable checkpoint from an incremental replay."""
session = execution.session
output = session.body
surface_geometry: dict[str, Any] | None = None
if output is None:
@@ -177,8 +229,19 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in execution.diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],
"topology_deltas": list(session.topology.topology_deltas()),
"selector_resolution": session.selector_resolutions,
}
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
"""Rebuild CDSL through session-scoped atomic executors only."""
execution = prepare_cdsl_execution(cdsl)
if strict:
error = _preflight_error(execution.analysis)
if error is not None:
raise error
execution.execute_all(strict=strict)
return finalize_cdsl_execution(execution, out_step)
@@ -43,6 +43,7 @@ from .topology import (
SelectorResolution,
TopologyDelta,
TopologyDeltaRelation,
TopologySectionRelation,
TopologyLineage,
TopologyRecord,
TopologyRegistry,
@@ -64,6 +65,7 @@ __all__ = [
"ThreadSpec",
"TopologyDelta",
"TopologyDeltaRelation",
"TopologySectionRelation",
"TopologyLineage",
"TopologyRecord",
"TopologyRegistry",
@@ -0,0 +1,173 @@
"""Verified FeatureScript query semantics available to the selector resolver.
This is intentionally a small, explicit allow-list. A numeric source version
only identifies a FeatureScript release; it does not establish that this
runtime has verified a query family's source semantics for that release.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class SelectorQueryCapability:
query_family: str
featurescript_version: str
standard_library: str
standard_library_version: str
source: str
contract: str
# These entries correspond to the direct builder contracts exercised by the
# CADFS 1511 corpus. New versions and query families must be registered with
# their source/API evidence before the runtime accepts them.
_CAPABILITIES = {
("CAP_FACE", "1511"): SelectorQueryCapability(
"CAP_FACE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"direct extrude cap operation role",
),
("CAP_EDGE", "1511"): SelectorQueryCapability(
"CAP_EDGE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"direct prism source edge to qualified start/end cap edge kernel lineage",
),
("OFFSET_FACE", "1511"): SelectorQueryCapability(
"OFFSET_FACE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"shell offset-face operation role with true dependency qualification",
),
# These entries authorize the deliberately narrow direct-prism lineage
# path. The capability matrix keeps the broader query families explicitly
# partial; all other generator and lifecycle combinations remain rejected.
("SWEPT_FACE", "1511"): SelectorQueryCapability(
"SWEPT_FACE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"kernel-lineage resolver contract only",
),
("SWEPT_EDGE", "1511"): SelectorQueryCapability(
"SWEPT_EDGE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"kernel-lineage resolver contract only",
),
("SWEPT_BODY", "1511"): SelectorQueryCapability(
"SWEPT_BODY",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history; one-item qUnion identity",
"active direct-new-body member selected by its producing operation",
),
("INTERSECT", "1511"): SelectorQueryCapability(
"INTERSECT",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history; BRepAlgoAPI boolean Generated(face) and SectionEdges() exact handles",
"two source-qualified boolean input faces to one final section edge kernel lineage",
),
}
def selector_query_capability(intent: dict[str, Any]) -> SelectorQueryCapability | None:
"""Return an explicitly verified query capability for ``intent`` only."""
source_query = intent.get("source_query")
if not isinstance(source_query, dict):
return None
family = intent.get("query_family")
version = source_query.get("featurescript_version")
if not isinstance(family, str) or not isinstance(version, str):
return None
capability = _CAPABILITIES.get((family, version))
if capability is None:
return None
if (
source_query.get("standard_library") != capability.standard_library
or source_query.get("standard_library_version") != capability.standard_library_version
):
return None
return capability
def known_selector_query_versions(query_family: str) -> tuple[str, ...]:
"""Expose registered versions for deterministic unsupported diagnostics."""
return tuple(sorted(version for family, version in _CAPABILITIES if family == query_family))
def known_selector_query_standard_library_versions(query_family: str) -> tuple[tuple[str, str], ...]:
"""Expose exact direct imports that back a registered query contract."""
return tuple(sorted({
(capability.standard_library, capability.standard_library_version)
for (family, _version), capability in _CAPABILITIES.items()
if family == query_family
}))
def is_direct_blind_extrude_cap_output_role(
selector: dict[str, Any],
producer: dict[str, Any] | None,
sketches: dict[str, dict[str, Any]],
) -> bool:
"""Whether a selector names one cap from the direct prism contract.
This is the consumer-side mirror of the lowerer's CAP_FACE constructor.
It deliberately describes a narrow builder contract, rather than treating
an output-role string as general permission to select arbitrary topology.
The runtime still has to prove the exact role in its active snapshot.
"""
if not isinstance(selector, dict) or not isinstance(producer, dict):
return False
intent = selector.get("selector_intent")
if (
selector.get("kind") != "face"
or selector.get("output_role") not in {"extrude.start", "extrude.end"}
or selector.get("source") != "runtime_snapshot"
or selector.get("output_role_source") is not None
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id"))
or not isinstance(intent, dict)
or intent.get("query_family") != "CAP_FACE"
or intent.get("evidence") != "operation_role"
or intent.get("output_role") != selector.get("output_role")
):
return False
policy = intent.get("derivation_policy")
if not isinstance(policy, dict) or policy.get("multiplicity") != "one" or set(policy.get("allowed") or ()) != {"boundary", "continuation"}:
return False
params = producer.get("params") or {}
if (
producer.get("atomic_id") != "extrude_add_blind"
or params.get("result_mode") != "new_body"
or (params.get("end_condition") or {}).get("type") != "blind"
):
return False
if params.get("draft") is None:
return True
sketch = sketches.get(str(producer.get("sketch_id") or "")) or {}
profile = sketch.get("profile") or {}
if profile.get("type") == "circle":
return True
contours = profile.get("contours")
return (
profile.get("type") == "analytic_contours"
and isinstance(contours, list)
and len(contours) == 1
and bool((contours[0] or {}).get("closed"))
)
+156 -10
View File
@@ -16,6 +16,7 @@ from typing import Any
from jsonschema import Draft202012Validator
from .operation_contracts import materialized_feature_contracts
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
@@ -45,6 +46,24 @@ def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> li
return [value for value in values if isinstance(value, dict)]
def _up_to_surface_output_role_reference(feature: dict[str, Any], contract: dict[str, Any]) -> dict[str, Any] | None:
"""Return the one nested output-role slot that an extrusion may consume."""
policy = (contract.get("nested_selector_policies") or {}).get("params.end_condition.reference")
if (
not isinstance(policy, dict)
or policy.get("end_condition_type") != "up_to_surface"
or policy.get("token_kind") != "face"
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
or policy.get("requires_immediate_owner") is not True
):
return None
end_condition = (feature.get("params") or {}).get("end_condition") or {}
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None:
return reference
return None
def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None:
"""Enforce the provenance boundary before the runtime can bind a selector."""
intent = selector.get("selector_intent")
@@ -52,14 +71,15 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index:
if selector.get("selector_intent_version") is not None:
raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent")
return
if selector.get("selector_intent_version") not in {None, "1.0"}:
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version")
if not isinstance(intent, dict) or intent.get("version") != "1.0":
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent")
legacy_version = selector.get("selector_intent_version")
if legacy_version is not None and legacy_version != intent["version"]:
raise ValueError(f"Feature {feature_id} selector {index} has conflicting selector intent versions")
if intent.get("kind") not in {None, selector.get("kind")}:
raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind")
family = intent.get("query_family")
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"}
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "SWEPT_BODY", "OFFSET_FACE", "INTERSECT", "COPY"}
if family in derived and not selector.get("owner_feature_id"):
raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id")
policy = intent.get("derivation_policy") or {}
@@ -76,10 +96,90 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index:
raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback")
source_query = intent.get("source_query") or {}
version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None
if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version):
raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version")
# An absent source version is a valid preservation state. The resolver,
# which owns execution eligibility, returns selector_query_version_unknown
# instead of rewriting it as a fake numeric version during validation.
if version is not None and (not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version)):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid FeatureScript query version")
if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"):
raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role")
source_entity = intent.get("source_entity")
source_entities = intent.get("source_entities")
if source_entity is not None and source_entities is not None:
raise ValueError(f"Feature {feature_id} selector {index} intent cannot mix one source entity with a source-vertex set")
lineage_role = intent.get("lineage_role")
if lineage_role is not None:
if (
family != "CAP_EDGE"
or selector.get("kind") != "edge"
or source_entity is None
or lineage_role not in {"extrude.start", "extrude.end"}
):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid CAP_EDGE lineage role")
if source_entities is not None:
if family != "SWEPT_EDGE" or not isinstance(source_entities, list) or len(source_entities) < 2:
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor requires SWEPT_EDGE and two source entities")
pairs = []
for source in source_entities:
if not isinstance(source, dict):
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is invalid")
sketch_id, entity_id = source.get("sketch_id"), source.get("entity_id")
if not isinstance(sketch_id, str) or not sketch_id or not isinstance(entity_id, str) or not entity_id:
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is incomplete")
pairs.append((sketch_id, entity_id))
if len(set(pairs)) != len(pairs):
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor repeats an entity")
intersection_sources = intent.get("intersection_sources")
deferred_source_query = (
policy.get("multiplicity") == "none"
and intent.get("evidence") == "feature_script_query"
)
if family == "SWEPT_BODY" and not deferred_source_query:
if (
selector.get("kind") != "body"
or selector.get("source") != "runtime_snapshot"
or intent.get("evidence") != "active_body_member"
or intent.get("body_member_contract") != "direct_new_body"
or policy.get("allowed") != ["boundary"]
or policy.get("multiplicity") != "one"
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role"))
):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid SWEPT_BODY member contract")
if family == "INTERSECT":
if deferred_source_query:
# CADFS source preservation deliberately keeps unsupported outer
# query semantics in the candidate. The resolver rejects this
# non-executable state before any topology/geometry fallback;
# validation must not erase the preceding executable checkpoint.
if intersection_sources is not None:
raise ValueError(f"Feature {feature_id} selector {index} deferred INTERSECT cannot declare executable section sources")
else:
if (
selector.get("kind") != "edge"
or policy.get("allowed") != ["intersection"]
or policy.get("multiplicity") != "one"
or not isinstance(intersection_sources, list)
or len(intersection_sources) != 2
):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid INTERSECT section contract")
owners = set()
for source in intersection_sources:
if not isinstance(source, dict):
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is invalid")
source_family = source.get("query_family")
source_owner = source.get("owner_feature_id")
if source_family not in {"CAP_FACE", "SWEPT_FACE"} or not isinstance(source_owner, str) or not source_owner:
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is incomplete")
owners.add(source_owner)
if source_family == "CAP_FACE":
if source.get("output_role") not in {"extrude.start", "extrude.end"} or source.get("source_entity") is not None:
raise ValueError(f"Feature {feature_id} selector {index} CAP_FACE INTERSECT source is invalid")
elif not isinstance(source.get("source_entity"), dict):
raise ValueError(f"Feature {feature_id} selector {index} SWEPT_FACE INTERSECT source is invalid")
if len(owners) != 2:
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT sources must have distinct owners")
elif intersection_sources is not None:
raise ValueError(f"Feature {feature_id} selector {index} only INTERSECT may declare section sources")
forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"}
stack = [intent]
while stack:
@@ -166,6 +266,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
feature_ids: set[str] = set()
preceding_features: dict[str, dict[str, Any]] = {}
previous_feature_id: str | None = None
deferred: list[str] = []
unresolved: list[dict[str, Any]] = []
contracts = _operation_contracts()
@@ -185,21 +286,26 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
deferred.append(fid)
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
feature_selectors = _contract_selectors(feature, contract)
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
extent_selector = _up_to_surface_output_role_reference(feature, contract)
output_role_selectors = [
*feature_selectors,
*([extent_selector] if extent_selector is not None else []),
]
output_role_selector_ids = {id(selector) for selector in output_role_selectors}
selector_intent_ids = {
id(selector.get("selector_intent"))
for selector in feature_selectors
for selector in output_role_selectors
if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict)
}
for index, selector in enumerate(feature_selectors):
validated_selector_ids: set[int] = set()
for index, selector in enumerate(output_role_selectors):
_validate_selector_intent(selector, fid, index)
validated_selector_ids.add(id(selector))
owner = selector.get("owner_feature_id")
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
if selector.get("output_role") is not None:
if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
if selector.get("source") != "runtime_snapshot":
@@ -207,6 +313,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
role_source = selector.get("output_role_source")
selector_intent = selector.get("selector_intent")
is_shell_cap_face_output_role = (
feature.get("atomic_id") == "shell"
and isinstance(selector_intent, dict)
and selector_intent.get("query_family") == "CAP_FACE"
and selector.get("output_role") in {"extrude.start", "extrude.end"}
)
if selector is extent_selector:
if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role(
selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches},
):
raise ValueError(
f"Feature {fid} up_to_surface output role requires the immediately preceding direct new_body blind extrusion cap"
)
elif is_shell_cap_face_output_role:
if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role(
selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches},
):
raise ValueError(
f"Feature {fid} CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap"
)
elif not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
@@ -234,6 +363,18 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
elif selector.get("output_role_source") is not None:
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
for selector in _mappings(feature):
if (
id(selector) not in validated_selector_ids
and (selector.get("selector_intent") is not None or selector.get("selector_intent_version") is not None)
):
_validate_selector_intent(selector, fid, len(validated_selector_ids))
validated_selector_ids.add(id(selector))
owner = selector.get("owner_feature_id")
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(
f"Feature {fid} nested selector has a forward or missing owner_feature_id"
)
# ``output_role_source`` is provenance metadata nested inside a
# feature selector, not a selector on its own.
if (
@@ -251,6 +392,10 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
if target_feature_id is not None and target_feature_id not in feature_ids:
raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature")
if feature.get("atomic_id") in {"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}:
scope_feature_id = (feature.get("params") or {}).get("scope_feature_id")
if scope_feature_id is not None and scope_feature_id not in feature_ids:
raise ValueError(f"Feature {fid} hole scope_feature_id requires a preceding body feature")
if feature.get("atomic_id") == "transform_bodies":
params = feature.get("params") or {}
references = params.get("pattern_instance_refs") or []
@@ -354,6 +499,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
feature_ids.add(fid)
preceding_features[fid] = feature
previous_feature_id = fid
return {
"schema_version": version,
+43 -2
View File
@@ -23,6 +23,7 @@ from .topology import (
TopologyDelta,
TopologyRecord,
TopologyRegistry,
validate_selector_provenance_intent,
)
@@ -38,6 +39,7 @@ class GeometryAdapter(Protocol):
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
@@ -115,6 +117,7 @@ class ExecutionSession:
body_members: dict[str, Any] | None = None,
topology_delta: TopologyDelta | None = None,
topology_predecessors: list[TopologyRecord] | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
@@ -123,12 +126,20 @@ class ExecutionSession:
self.body = body
self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
# Source-profile anchors are transient construction facts, but unlike
# generic role predecessors they must remain addressable by a later
# selector intent. They never receive a body id, so active selector
# scans cannot mistake them for current model topology.
anchors = list(topology_anchors or ())
for anchor in anchors:
self.topology.register(anchor)
predecessors = [*(topology_predecessors or ()), *anchors]
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
additional_predecessors=predecessors,
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
@@ -141,7 +152,7 @@ class ExecutionSession:
]
self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
additional_predecessors=predecessors,
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
@@ -150,6 +161,25 @@ class ExecutionSession:
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
def register_transient_prism_tool(
self,
feature_id: str,
tool: Any,
*,
topology_delta: TopologyDelta,
topology_anchors: list[TopologyRecord],
) -> list[TopologyRecord]:
"""Keep one direct-prism primary tool as boolean-input evidence only."""
snapshot_id = f"transient:{feature_id}"
records = self.adapter.topology_records(tool, feature_id, snapshot_id)
return list(self.topology.register_transient_snapshot(
feature_id,
snapshot_id,
records,
topology_delta=topology_delta,
anchors=topology_anchors,
))
def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
@@ -236,6 +266,17 @@ class ExecutionSession:
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None:
# The session computes vertex intersections directly from resolved
# face components, so enforce the same outer provenance gate that
# TopologyRegistry.resolve applies before any geometry operation.
validation_error = validate_selector_provenance_intent(selector)
if validation_error is not None:
return self._record_selector_resolution(SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=validation_error,
))
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
+61 -18
View File
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
_TOLERANCE_MM = 1e-5
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
def _circle(
center: list[float],
radius_mm: float,
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx:
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
def _line(
start: list[float],
end: list[float],
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _point(point: list[float]) -> list[float]:
@@ -118,7 +134,8 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
cx, cy = float(center[0]), float(center[1])
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
source_entity_id = profile.get("source_entity_id")
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
@@ -215,7 +232,16 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
source_entity_id = segment.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
# Region construction needs four arc segments, but this marker records
# that all four came from exactly one logical source circle. It is not
# an edge anchor: callers must rebuild one native circle wire and pass
# final-face identity checks before using it for lineage.
for edge in edges:
edge["logical_circle_source_entity_id"] = source_entity_id
return edges
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
@@ -242,14 +268,14 @@ def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type")
if kind == "line":
return [_contour_line(segment["start"], segment["end"])]
if kind == "arc":
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle":
return _circle_edges(segment)
if kind == "ellipse":
return _ellipse_edges(segment)
if kind == "bspline":
edges = [_contour_line(segment["start"], segment["end"])]
elif kind == "arc":
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
elif kind == "circle":
edges = _circle_edges(segment)
elif kind == "ellipse":
edges = _ellipse_edges(segment)
elif kind == "bspline":
points = segment.get("points") or []
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
@@ -304,8 +330,18 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent)
return [output]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
edges = [output]
else:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
source_entity_id = segment.get("source_entity_id")
# Only a one-edge construction has the direct, one-to-one source identity
# required by profile-to-prism lineage. Circles expanded to arcs carry a
# separate logical-circle marker, not a source edge identity; the adapter
# may use it only to reconstruct one native circle wire with exact final
# membership proof. Other multi-edge approximations remain unanchored.
if isinstance(source_entity_id, str) and len(edges) == 1:
edges[0]["source_entity_id"] = source_entity_id
return edges
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
@@ -466,11 +502,18 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
raw_edges: list[_Ctx] = []
for segment in contour.get("segments") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"]))
entities.append(_line(
segment["start"], segment["end"],
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
elif segment.get("type") == "circle":
if contour_open:
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
entities.append(_circle(
segment.get("center") or [0.0, 0.0],
float(segment.get("radius_mm") or 0.0),
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
raw_edges.extend(_segment_edges(segment))
if raw_edges:
edges = _join(raw_edges, allow_open=contour_open)
File diff suppressed because it is too large Load Diff