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:
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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}},
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1391
-144
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user