Compare commits

...

2 Commits

Author SHA1 Message Date
likang c6d3bfddec Merge pull request 'feat(selector): 增加离线候选遍历与严格回放验证 Demo' (#22) from lk_dev into main
Reviewed-on: #22
2026-09-10 15:15:23 +08:00
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00
36 changed files with 9268 additions and 753 deletions
+134 -66
View File
@@ -13,24 +13,30 @@ snapshot, owner identifier, selector token, or `host_face`/`mirror_plane`
inside `params`. The server allocates identities and injects selector values
into the destination stated by the operation contract.
Literal request language such as `max_z`, `min_z`, `host face`, or a face name
is design intent, never a CDSL field or output role. Never emit `host_face`,
`max_z`, `min_z`, `max_z_face`, `min_z_face`, `top_face`, or `bottom_face` in
any JSON value, including `selector.source`. A selector is only `{kind,
source, match}`, and `source` must use an exact role advertised by the producer
operation.
Treat `requirements`, prior documents, and diagnostics as semantic input, not
CDSL templates. Their keys are descriptive labels. Copy geometry, values, and
relations only into fields allowed by the active schema and operation contract.
Thus `host_face: "max_z"` conveys placement intent, never output strings;
derive a selector from advertised roles or use explicit coordinates.
Use millimetres and a right-handed coordinate system unless the request says
otherwise. Put every reasonable but unstated design choice in `assumptions`.
Do not turn an assumed dimension into a deterministic acceptance target.
## Modeling Brief
Before authoring, derive this internal brief from the requirements:
- part or multi-body intent; explicit dimensions and units;
- functional datums, origin, base plane, and positive directions;
- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing;
- explicitly verifiable targets versus manual targets;
- assumptions that do not affect fit, safety, or compliance.
Dimensioned request facts take precedence over proportions inferred from an
image. Ask for clarification only when a missing interface, scale, safety, or
compliance value makes construction impossible. Otherwise choose a practical
engineering default and record it as an assumption.
Identify explicit dimensions, datums, directions, primary volumes, removals,
repetitions, finish features, targets, and noncritical assumptions. Explicit
dimensions take precedence over inferred proportions. Clarify only when an
omitted interface, scale, safety, or compliance value prevents construction;
otherwise record a practical default in `assumptions`.
## Construction Order
@@ -52,16 +58,74 @@ For through cuts, choose the operation's through extent and make the tool
cross the material; never rely on coincident faces or a guessed nearby face.
Delay dress-up operations because they can alter downstream topology.
The selector-source rule is an intentional exception to a generic “all adds,
then all cuts” sequence. For example, a bolt circle hosted on an original
flange cap must be placed immediately after the base when later additive
fusions replace that exact cap. A required final through-cut can still follow
all additive features and the earlier bolt operation.
## Operation Semantics
Use only the supplied operation list. Follow each operation's parameter schema
exactly, including required sketch state. Do not invent unsupported operation
parameters, implicit booleans, or a substitute operation after a capability
error.
Apply the following rules from the supplied operation contracts. They describe
the CDSL language itself and do not prescribe a particular part design.
### Hard Emission Boundaries
Do not emit `boolean_bodies` in `cad.author.v1`. Its body operand lifecycle is
not expressed by document-local names, so it cannot provide a reliable
model-facing construction path. Express additions with an additive operation
whose declared result mode supports the intended connected result, and express
removals with a cut operation and a sketch.
Prefer self-contained operations. An unavailable selector makes a hosted
operation unavailable even if requested; use a selector-free construction that
preserves geometry and record the method limitation. Every selector must pass
the exact-role and active-source checks.
- A primitive axis starts at `origin_mm` and grows in the supplied positive
`direction`. Heights and distances are positive magnitudes. A direction of
`[0,0,-1]` therefore models travel toward `-Z` without a negative height.
- A sketch has exactly one `profile`. A circle is one closed contour and a
polygon is one simple, non-self-intersecting closed loop. Never concatenate
disconnected circles or multiple polygon loops into one profile, and never
repeat a loop's start point to begin another loop.
- Model repeats as one valid seed plus `pattern_circular` or `pattern_linear`;
`source_feature_ids` contains local names and the count includes the seed.
If a repeated removal has no live host selector, use a selector-free cut seed
plus circular `operation_mode: "remove"`. Otherwise use one valid feature
per unrelated instance with its true dependency.
- Treat every operation schema as independent. Do not copy a parameter name,
enum value, or field shape from a similar operation into another operation;
emit only fields present in that feature's supplied `params_schema`.
- To extend an already connected solid, use an additive operation with the
explicit `result_mode: "fuse"` when its contract provides that mode. The new
profile must intersect the prior solid, and the feature depends on the prior
feature. Do not turn touching or overlapping primitive additions into
independent bodies followed by a boolean union.
- A primitive operation whose authoring schema has no `result_mode: "fuse"`
must be treated as a body-producing operation, not as a guaranteed extension
of an existing connected result. Use such an operation as an initial or
explicitly independent construction only. When the requested result must be
one connected body, continue it through an additive operation that explicitly
supports fusion rather than joining primitive outputs later with a boolean.
- Use a through-cut operation with a profile that intersects the material for
a through removal. Build all intended material before a removal that must
cross it. Do not make a temporary tool body and call `boolean_bodies` for a
removal expressible as a cut operation.
- Do not introduce an auxiliary feature solely to construct or delete a tool
for an operation that the contract already represents directly. A deletion
is a state-changing operation, never a no-op cleanup step, and downstream
dependencies must not rely on deleted geometry.
- Use a selector-hosted operation only when its required source output role is
available and the target geometry unambiguously lies on that output. Supply
every required selector and parameter from the operation contract; otherwise
choose a self-contained operation whose coordinates express the intent.
- A face-hosted extrusion reuses its host boundary. For any different boundary,
use a sketch-profile operation at an explicit workplane.
- Selector provenance is live. Do not select a source changed by an intervening
fuse, cut, shell, pattern, or finish. A through-cut has no cap role for a
later host; schedule host-dependent features before it or use coordinates.
For `max_z`/`min_z` placement intent, use an owner role such as
`source_feature.top_planar_face` or `source_feature.bottom_planar_face`, never
`max_z_face` or `min_z_face`; prefer explicit coordinates when available.
Use only supplied operations and their exact parameter schemas. Do not invent
parameters, implicit booleans, or substitutes after a capability error.
## Sketches And Coordinates
@@ -95,16 +159,15 @@ other key inside an Authoring sketch. The compiler derives Runtime radius and
sketch identity. Use a primitive such as `cylinder_add` when its axis, radius,
and height directly express the requested geometry and no sketch is needed.
Name features for their manufacturing role, for example `base_plate`,
`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and
Name features for their geometric role, for example `primary_add`,
`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and
repair diagnostics readable; they are not server identities.
## Selectors
The operation metadata states whether `selectors` are required, their kind,
cardinality, and server-side destination. Put only declarative selectors in a
feature's `selectors` array. Never write the destination field itself inside
`params`.
Operation metadata defines selector need, kind, cardinality, and server-side
destination. Put only declarative selectors in `selectors`, never the
destination field in `params`.
For an output face, use an exact local role embedded in `source` and state a
unique match:
@@ -112,54 +175,59 @@ unique match:
```json
{
"kind": "face",
"source": "front_hub_boss.top_planar_face",
"source": "source_feature.top_planar_face",
"match": "unique"
}
```
Do not add `role`, `query`, `host_face`, a face index, a coordinate selector,
or a Runtime selector token. The `source` value is the complete local intent.
The compiler adds its source feature as an auditable graph dependency; include
other true construction dependencies in `depends_on` yourself.
Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or
Runtime token. The compiler adds the selector source dependency; declare other
true construction dependencies yourself.
`top_planar_face` and `end_face` mean the positive-direction cap of a supported
extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean
the opposite cap. Select the most recent feature whose output is known to be
the required host; do not select a similar face by location, face index, or
proximity.
`top_planar_face`/`end_face` mean a positive-direction cap;
`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known
host, never a similar face by location, index, or proximity.
Translate a request's descriptive `max_z`/`min_z` wording into these output
roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`.
For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom
cap is `bottom_planar_face`; reverse-direction features swap their world-Z
position but retain their own start/end roles.
For a cap, `source` MUST match
`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`.
No other role spelling exists in Authoring CDSL. If the producer does not
advertise one of these roles, it cannot be the selector source.
`hole_wizard` requires exactly one `face` selector. It uses that selector as
its host face, so a central bore on a hub should select the hub cap, while a
bolt circle on an exposed flange should select the flange cap. A selector must
name a host that contains every requested hole position. If that cannot be
made unique, redesign the feature sequence or omit the unsupported feature;
never guess a face.
Translate descriptive maximum/minimum placement to an appropriate cap role;
never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its
top cap and `bottom_planar_face` for its bottom cap.
Before creating a hosted hole, calculate each position against the actual host
face. A local boss can be the global highest face while being too small to host
a larger bolt circle. In that case use the exposed flange cap at the bolt
radius, with its own plane height, rather than the global maximum-Z cap. Place
the hosted holes while that source cap's exact provenance is still active:
before a later fusion or cut would split, remove, or replace it. A final
through-cut may still follow all additive features, so a bolt circle can be
hosted before an unrelated final boss and before that final cut.
`hole_wizard` requires one `face` selector. It must be unique, planar, active,
and contain every position. Execute it before its source is replaced; otherwise
use a supported self-contained construction or report the limitation.
## Acceptance And Repair
Describe only user-requested, measurable acceptance targets in
`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair,
return a complete replacement document. Preserve feature names and every
feature listed in `executed_feature_ids`, except a feature explicitly named by
the diagnostic. Features that were never executed may be changed freely to
repair an invalid selector, parameter, dependency, or geometry construction.
Put only user-requested measurable targets in `acceptance_targets`; leave
inferred dimensions in `assumptions`. On repair return a complete document,
preserve executed features unless diagnosed, and change unexecuted features
only as needed. Read diagnostics literally; do not add IDs, weaken requested
values, silently delete a failed feature, or guess a selector.
Read structured diagnostics literally. Fix their named cause with the smallest
document change, then return the entire document. Do not add internal IDs,
weaken a requested value, silently delete a failed feature, or replace a
failed selector with an arbitrary topology element.
## Pre-Emission Check
Before returning the document, check every feature against these invariants:
1. The operation is in the supplied whitelist, its params use only the
supplied closed schema, and its sketch/selector presence matches the
operation contract. The document contains no `boolean_bodies` feature.
2. Each reference is a local name, each dependency is necessary, and no
operation relies on a deleted, transient, or merely assumed body.
3. Each sketch contains one valid profile and a complete right-handed
workplane; each dimension is positive where the contract requires a
magnitude.
4. Each selector source has the exact `<feature>.<output_role>` form. Its role
is one advertised by that producer operation, not a descriptive synonym,
coordinate extreme, or inferred face name. For a cap, verify the required
four-role source regex character-for-character.
5. Every requested removal intersects its intended material, every repeated
feature has a valid seed reference, and every connected addition uses an
operation whose contract explicitly supports the chosen result mode.
If any invariant is false, revise the construction before emitting the single
complete `cad.author.v1` document.
+24 -1
View File
@@ -172,7 +172,16 @@ class WorkflowCoordinator:
for atomic_id in self.runtime.supported_atomic_ids()
}
repair_instruction = "" if state.repair_count == 0 else "Return a complete replacement document. Preserve only features confirmed in executed_feature_ids unless the diagnostic identifies that feature. Features that did not execute may be corrected. Keep local names unless the diagnostic identifies a name conflict. Never add IDs, stable selectors, snapshots, or tokens."
content = json.dumps({"requirements": requirements, "supported_operations": operation_schemas, "previous_authoring": previous, "diagnostics": diagnostics}, ensure_ascii=False)
content = json.dumps({
"requirements": self._authoring_requirements_context(requirements),
"supported_operations": operation_schemas,
"authoring_selector_contract": {
"face_cap_source_pattern": r"^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$",
"face_cap_roles": ["top_planar_face", "bottom_planar_face", "end_face", "start_face"],
},
"previous_authoring": previous,
"diagnostics": diagnostics,
}, ensure_ascii=False)
try:
raw = await self._tool_call(
task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(),
@@ -343,6 +352,20 @@ class WorkflowCoordinator:
},
}
@staticmethod
def _authoring_requirements_context(requirements: dict[str, Any]) -> dict[str, list[str]]:
"""Pass requirement meaning to the author without leaking analysis field names.
``acceptance_targets.expected`` is intentionally an open-ended
reporting record. It may contain descriptive keys from a user request,
while Authoring CDSL has a closed protocol. Passing it through verbatim
invites a model to treat analysis labels as output fields.
"""
return {
key: [item for item in requirements.get(key, []) if isinstance(item, str)]
for key in ("explicit_requirements", "assumptions", "manual_targets")
}
@staticmethod
def _validate_repair_document(
previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None,
+498 -18
View File
@@ -9,17 +9,18 @@ from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Pl
from OCP.BOPAlgo import BOPAlgo_Splitter
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
from OCP.BRepOffset import BRepOffset_Skin
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_Transform
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
from OCP.Geom import Geom_SurfaceOfRevolution
from OCP.GeomAbs import GeomAbs_Arc
from OCP.LocOpe import LocOpe_DPrism
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL
from OCP.TopExp import TopExp_Explorer
from OCP.TopTools import TopTools_ListOfShape
from OCP.TopoDS import TopoDS
@@ -30,7 +31,7 @@ from .parametric_gears import build_gear_solid, build_rack_solid
from .parametric_thread import build_thread_solid
from .runtime_types import (
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3,
canonical_plane_signature,
)
from .topology_export import (
@@ -45,6 +46,74 @@ def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
return Vector(float(value[0]), float(value[1]), float(value[2]))
def make_interpolated_bspline_edge(
points: list[Vector3],
*,
start_tangent: Vector3 | None = None,
end_tangent: Vector3 | None = None,
periodic: bool = False,
parameters: list[float] | None = None,
) -> Edge:
"""Build the exact non-scaling interpolator used by CDSL sketch edges.
FeatureScript ``skFitSpline`` lowering retains its centripetal parameters
and endpoint derivatives. Keep this construction in one kernel helper so
geometry execution and consumers that need a source-curve differential do
not accidentally use a chord or a separately parameterized interpolator.
Callers remain responsible for their own CDSL contract validation.
"""
if (start_tangent is None) != (end_tangent is None):
raise ValueError("interpolated B-spline requires both endpoint tangents")
return Edge.make_spline(
[_vector(point) for point in points],
tangents=(
[_vector(start_tangent), _vector(end_tangent)]
if start_tangent is not None else None
),
periodic=periodic,
parameters=parameters,
scale=False,
)
def interpolated_bspline_point_and_tangent(
points: list[Vector3],
*,
start_tangent: Vector3,
end_tangent: Vector3,
parameters: list[float],
interpolation_index: int,
) -> tuple[Vector3, Vector3]:
"""Evaluate a CDSL interpolation point with the runtime's OCC curve.
``Edge.tangent_at`` accepts normalized edge positions, which need not be
the explicit interpolation parameters. Use ``BRepAdaptor_Curve.D1`` at
the source parameter and return the actual point as well, allowing the
caller to prove that OCC still interpolated the named source vertex.
"""
if interpolation_index < 0 or interpolation_index >= len(points):
raise ValueError("B-spline interpolation index is out of range")
if len(parameters) != len(points):
raise ValueError("B-spline interpolation parameters must match points")
if not all(math.isfinite(float(value)) for value in parameters):
raise ValueError("B-spline interpolation parameters must be finite")
if any(float(right) <= float(left) for left, right in zip(parameters, parameters[1:])):
raise ValueError("B-spline interpolation parameters must be strictly increasing")
edge = make_interpolated_bspline_edge(
points,
start_tangent=start_tangent,
end_tangent=end_tangent,
parameters=parameters,
)
point = gp_Pnt()
tangent = gp_Vec()
BRepAdaptor_Curve(edge.wrapped).D1(float(parameters[interpolation_index]), point, tangent)
return (
(float(point.X()), float(point.Y()), float(point.Z())),
(float(tangent.X()), float(tangent.Y()), float(tangent.Z())),
)
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
@@ -96,7 +165,7 @@ class Build123dGeometryAdapter:
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]:
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
built: list[Edge] = []
for edge in edges:
@@ -137,12 +206,12 @@ class Build123dGeometryAdapter:
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
else:
parameter_values = [float(value) for value in parameters] if parameters is not None else None
built.append(Edge.make_spline(
points,
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
built.append(make_interpolated_bspline_edge(
[(point.X, point.Y, point.Z) for point in points],
start_tangent=start_tangent,
end_tangent=end_tangent,
periodic=bool(edge.get("periodic")),
parameters=parameter_values,
scale=False,
))
continue
if edge.get("type") == "ellipse":
@@ -163,7 +232,203 @@ class Build123dGeometryAdapter:
else:
# 直线边:直接连接首尾。
built.append(Edge.make_line(start, end))
return Wire(built)
return built
@staticmethod
def _wire(edges: list[dict[str, Any]]) -> Wire:
return Wire(Build123dGeometryAdapter._wire_edges(edges))
@staticmethod
def _wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Wire, list[tuple[str, Edge]]]:
"""Construct one wire and retain only explicit one-to-one source edges."""
built = Build123dGeometryAdapter._wire_edges(edges)
return Wire(built), [
(str(edge["source_entity_id"]), built[index])
for index, edge in enumerate(edges)
if isinstance(edge.get("source_entity_id"), str) and edge["source_entity_id"]
]
@staticmethod
def _direct_wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Any, list[tuple[str, Edge]]]:
"""Build one direct wire and retain the builder's exact source edges.
``Wire(built)`` may repair shared vertices by replacing individual
edges. ``BRepBuilderAPI_MakeWire.Edge()`` exposes each repaired edge at
insertion time. The caller must still prove those handles are members
of the final face before registering a source anchor.
"""
wire_builder = BRepBuilderAPI_MakeWire()
source_edges: list[tuple[str, Edge]] = []
for definition, edge in zip(edges, Build123dGeometryAdapter._wire_edges(edges)):
wire_builder.Add(edge.wrapped)
if not wire_builder.IsDone():
raise ValueError("analytic contour wire construction failed")
source_entity_id = definition.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
source_edges.append((source_entity_id, Edge.cast(wire_builder.Edge())))
return wire_builder.Wire(), source_edges
def _face_from_direct_wires(
self,
outer: list[dict[str, Any]],
holes: Iterable[list[dict[str, Any]]] = (),
*,
logical_circle_sources: dict[str, dict[str, Any]] | None = None,
plane_spec: PlaneSpec | None = None,
) -> tuple[Face, list[tuple[str, Edge]]]:
"""Build one direct profile face with exact outer and hole wire handles.
Constructing a face and then calling ``Face.make_holes`` can replace
linear outer-wire subshapes. A direct ``BRepBuilderAPI_MakeFace``
instead gives the face builder every source wire, so a later ``IsSame``
membership check can establish provenance without geometry matching.
"""
outer_wire, source_edges = self._direct_wire_with_source_edges(outer)
face_builder = BRepBuilderAPI_MakeFace(outer_wire, True)
for hole in holes:
hole_wire, hole_source_edges = self._direct_hole_wire_with_source_edges(
hole,
logical_circle_sources=logical_circle_sources,
plane_spec=plane_spec,
)
# OCC requires an inner wire to have the opposite orientation to
# its outer boundary. ``Wire.Reversed`` returns a generic shape,
# so cast it back to TopoDS_Wire for the face-builder API.
face_builder.Add(TopoDS.Wire_s(hole_wire.Reversed()))
source_edges.extend(hole_source_edges)
if not face_builder.IsDone():
raise ValueError("analytic contour face construction failed")
return Face.cast(face_builder.Face()), source_edges
def _face_from_direct_wire(self, edges: list[dict[str, Any]]) -> tuple[Face, list[tuple[str, Edge]]]:
"""Build a direct face with no inner wires for existing callers."""
return self._face_from_direct_wires(edges)
def _direct_hole_wire_with_source_edges(
self,
edges: list[dict[str, Any]],
*,
logical_circle_sources: dict[str, dict[str, Any]] | None,
plane_spec: PlaneSpec | None,
) -> tuple[Any, list[tuple[str, Edge]]]:
"""Restore one solver-split source circle only from explicit provenance.
``analytic_contours`` decomposes a circle into four arcs to classify
regions. Those arcs are not source edges. The solver records one
logical-circle source marker on every generated arc, so a complete
marked loop may be rebuilt as one native circle wire. Any missing,
mixed, or unregistered marker uses the ordinary direct wire path and
therefore retains no invented source edge.
"""
logical_ids = {
edge.get("logical_circle_source_entity_id")
for edge in edges
if isinstance(edge.get("logical_circle_source_entity_id"), str)
and edge["logical_circle_source_entity_id"]
}
if (
len(edges) == 4
and len(logical_ids) == 1
and all(edge.get("logical_circle_source_entity_id") in logical_ids for edge in edges)
and logical_circle_sources is not None
and plane_spec is not None
):
source_entity_id = next(iter(logical_ids))
source = logical_circle_sources.get(source_entity_id)
center = source.get("center") if isinstance(source, dict) else None
radius = source.get("radius_mm") if isinstance(source, dict) else None
if (
isinstance(center, list)
and len(center) >= 2
and isinstance(radius, (int, float))
and math.isfinite(float(radius))
and float(radius) > 0
):
wire = self._circle_wire([float(center[0]), float(center[1])], float(radius), plane_spec)
return wire.wrapped, [(source_entity_id, edge) for edge in wire.edges()]
return self._direct_wire_with_source_edges(edges)
@staticmethod
def _logical_circle_sources(profile: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Index uniquely named, unsplit circle source entities from one profile."""
sources: dict[str, dict[str, Any]] = {}
duplicates: set[str] = set()
for contour in profile.get("contours") or []:
if not isinstance(contour, dict):
continue
for segment in contour.get("segments") or []:
if not isinstance(segment, dict) or segment.get("type") != "circle":
continue
source_entity_id = segment.get("source_entity_id")
if not isinstance(source_entity_id, str) or not source_entity_id:
continue
if source_entity_id in sources:
duplicates.add(source_entity_id)
else:
sources[source_entity_id] = segment
for source_entity_id in duplicates:
sources.pop(source_entity_id, None)
return sources
@staticmethod
def _face_source_anchor_specs(
face: Face,
source_edges: list[tuple[str, Edge]],
source_sketch_id: str | None,
) -> list[dict[str, Any]]:
"""Bind direct source labels to the exact face boundary subshapes.
The caller retains the wire edges created for this profile. A source
label is emitted only when it has one exact final face edge; there is
no geometry-based reconstruction when splitting, trimming, or wire
construction changes that one-to-one relationship.
"""
if not isinstance(source_sketch_id, str) or not source_sketch_id:
return []
actual_edges = list(face.edges())
mapped: list[tuple[str, Edge]] = []
source_counts: dict[str, int] = {}
for source_entity_id, _edge in source_edges:
source_counts[source_entity_id] = source_counts.get(source_entity_id, 0) + 1
for source_entity_id, source_edge in source_edges:
if source_counts[source_entity_id] != 1:
continue
matches = [
edge for edge in actual_edges
if edge.wrapped.IsSame(source_edge.wrapped)
]
if len(matches) == 1:
mapped.append((source_entity_id, matches[0]))
specs: list[dict[str, Any]] = [
{
"kind": "edge",
"value": edge,
"source_entity": (source_sketch_id, source_entity_id),
}
for source_entity_id, edge in mapped
]
vertex_groups: list[tuple[Any, set[str]]] = []
for source_entity_id, edge in mapped:
for vertex in edge.vertices():
group = next(
(candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)),
None,
)
if group is None:
vertex_groups.append((vertex, {source_entity_id}))
else:
group[1].add(source_entity_id)
for vertex, entity_ids in vertex_groups:
if len(entity_ids) < 2:
continue
specs.append({
"kind": "vertex",
"value": vertex,
"source_entities": tuple(
(source_sketch_id, entity_id) for entity_id in sorted(entity_ids)
),
})
return specs
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
@@ -211,6 +476,59 @@ class Build123dGeometryAdapter:
faces.append(face.make_holes(holes) if holes else face)
return faces
def _faces_from_circles_with_source_anchors(
self,
entities: list[dict[str, Any]],
plane_spec: PlaneSpec,
source_sketch_id: str | None,
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Build direct circular profile faces while retaining their wire edges."""
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
if not circles:
return [], []
entries = []
for item in circles:
radius = float(item.get("radius_mm") or 0)
if radius <= 0:
continue
center = [float(value) for value in item.get("center") or [0, 0]]
entries.append({
"center": center,
"radius": radius,
"wire": self._circle_wire(center, radius, plane_spec),
"source_entity_id": item.get("source_entity_id"),
})
faces: list[Face] = []
anchors: list[dict[str, Any]] = []
for entry in entries:
containing = sum(
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
for other in entries
if other is not entry
)
if containing % 2:
continue
holes = [
other for other in entries
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
and sum(
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
for candidate in entries
if candidate is not other
) == containing + 1
]
face = Face(entry["wire"])
result = face.make_holes([item["wire"] for item in holes]) if holes else face
source_edges = [
(str(item["source_entity_id"]), edge)
for item in [entry, *holes]
if isinstance(item.get("source_entity_id"), str) and item["source_entity_id"]
for edge in item["wire"].edges()
]
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
faces.append(result)
return faces, anchors
@staticmethod
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
"""Return OCC split history, keeping an unchanged input as one image."""
@@ -476,6 +794,96 @@ class Build123dGeometryAdapter:
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles(sketch.get("entities") or [], plane)
def faces_for_sketch_with_source_anchors(
self,
sketch: dict[str, Any],
) -> tuple[list[Face], list[dict[str, Any]]]:
"""Return profile faces plus direct source anchors for prism history.
This intentionally covers only the direct analytic profile builders.
IMPRINT, split, generated, or otherwise transformed profile paths keep
their normal geometry but expose no semantic source anchor.
"""
profile = sketch.get("profile") or {}
source_sketch_id = sketch.get("source_sketch_id")
if profile.get("type") == "planar_imprint" or not isinstance(source_sketch_id, str):
return self.faces_for_sketch(sketch), []
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
if isinstance(contours, list) and contours and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
):
# Keep each original circular source edge before the sketch solver
# expands it into analytic arc fragments for region bookkeeping.
# ``_faces_from_circles_with_source_anchors`` verifies each wire
# against the resulting face by exact ``IsSame``; a missing source
# label remains unavailable rather than being inferred from its
# radius or centre. This also covers a direct annulus or multiple
# independent circular regions built by the same face constructor.
circles = [
{
"type": "circle",
"center": segment.get("center"),
"radius_mm": segment.get("radius_mm"),
**(
{"source_entity_id": segment["source_entity_id"]}
if isinstance(segment.get("source_entity_id"), str) and segment["source_entity_id"]
else {}
),
}
for contour in contours
for segment in contour.get("segments") or []
]
return self._faces_from_circles_with_source_anchors(
circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}), source_sketch_id,
)
regions = sketch.get("contour_regions_mm") or []
if regions:
# ``Face.make_holes`` remains the authoritative geometry path for
# a mixed multi-region profile. OCC can give a valid direct face
# for each individual region while changing how those regions
# interact when their prisms are combined. The direct builder is
# therefore limited to one hole-bearing region; keep the existing
# executable profile and withhold anchors for the broader case.
if len(regions) > 1 and any(region.get("holes") for region in regions):
return self.faces_for_sketch(sketch), []
logical_circle_sources = self._logical_circle_sources(profile)
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if logical_circle_sources else None
faces: list[Face] = []
anchors: list[dict[str, Any]] = []
for region in regions:
outer = region.get("outer") or []
if len(outer) < 1:
continue
# Build all direct boundary wires in one face builder. Each
# prospective source edge is verified below against the final
# face, so an OCC wire repair or an unsupported hole topology
# removes evidence rather than creating a geometric fallback.
try:
result, source_edges = self._face_from_direct_wires(
outer,
region.get("holes") or (),
logical_circle_sources=logical_circle_sources,
plane_spec=plane_spec,
)
except ValueError:
# Keep the established executable profile path when the
# direct builder cannot represent this analytic region.
# It deliberately carries no provenance anchor.
return self.faces_for_sketch(sketch), []
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
faces.append(result)
return faces, anchors
if profile.get("type") == "circle":
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
return self._faces_from_circles_with_source_anchors(
sketch.get("entities") or [], plane, source_sketch_id,
)
return self.faces_for_sketch(sketch), []
@staticmethod
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
"""Build one planar profile from a sketch outer wire and cap-face holes."""
@@ -582,7 +990,7 @@ class Build123dGeometryAdapter:
@staticmethod
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
"""Extrude one face with exact cap, side-wall, and swept-edge history."""
vector = _vector(direction)
if vector.length <= 1e-9:
raise ValueError("extrude direction must be non-zero")
@@ -600,6 +1008,46 @@ class Build123dGeometryAdapter:
relations.append(TopologyDeltaRelation(
"generated", "face", face.wrapped, (output,), output_role=role,
))
for source_edge in face.edges():
# ``Generated(edge)`` proves the lateral face only. Prism's
# source-edge overloads preserve the distinct start/end cap-edge
# mapping, verified against the final result snapshot here.
for role, cap_edge in (
("extrude.start", builder.FirstShape(source_edge.wrapped)),
("extrude.end", builder.LastShape(source_edge.wrapped)),
):
is_final_edge = (
not cap_edge.IsNull()
and cap_edge.ShapeType() == TopAbs_EDGE
and any(cap_edge.IsSame(edge.wrapped) for edge in result.edges())
)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped,
(cap_edge,) if is_final_edge else (),
output_role=role,
source_kind="edge",
result_kind="edge",
derivation="boundary",
coverage="complete" if is_final_edge else "partial",
status="proven" if is_final_edge else "unknown",
))
generated = tuple(builder.Generated(source_edge.wrapped))
side_faces = tuple(shape for shape in generated if shape.ShapeType() == TopAbs_FACE)
relations.append(TopologyDeltaRelation(
"generated", "edge", source_edge.wrapped, side_faces,
source_kind="edge", result_kind="face", derivation="boundary",
coverage="complete" if len(side_faces) == len(generated) and side_faces else "partial",
status="proven" if len(side_faces) == len(generated) and side_faces else "unknown",
))
for source_vertex in source_edge.vertices():
generated_edges = tuple(builder.Generated(source_vertex.wrapped))
swept_edges = tuple(shape for shape in generated_edges if shape.ShapeType() == TopAbs_EDGE)
relations.append(TopologyDeltaRelation(
"generated", "vertex", source_vertex.wrapped, swept_edges,
source_kind="vertex", result_kind="edge", derivation="boundary",
coverage="complete" if len(swept_edges) == len(generated_edges) and swept_edges else "partial",
status="proven" if len(swept_edges) == len(generated_edges) and swept_edges else "unknown",
))
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
@staticmethod
@@ -1241,8 +1689,11 @@ class Build123dGeometryAdapter:
@staticmethod
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
"""Translate OCC builder history into adapter-neutral opaque relations."""
source_bodies = tuple(sources)
relations: list[TopologyDeltaRelation] = []
for source in sources:
source_faces: list[tuple[int, Any]] = []
for source_index, source in enumerate(source_bodies):
source_faces.extend((source_index, face.wrapped) for face in source.faces())
for kind, shapes in (
("face", list(source.faces())),
("edge", list(source.edges())),
@@ -1274,6 +1725,7 @@ class Build123dGeometryAdapter:
if generated:
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
section_values: tuple[Any, ...] = ()
section_relations: list[TopologySectionRelation] = []
section_edges = getattr(operation, "SectionEdges", None)
if callable(section_edges):
try:
@@ -1283,10 +1735,37 @@ class Build123dGeometryAdapter:
section_values = tuple(section_edges())
except (AttributeError, TypeError, ValueError):
section_values = ()
if section_values and len(source_bodies) >= 2:
# A section edge becomes source-qualified only when OCC returns
# that exact edge from Generated(face) for one face from each
# boolean input. Do not inspect BOPDS internals here: those
# Python bindings are unsafe for this traversal and ordinary
# SectionEdges remain useful unqualified diagnostics.
for section_edge in section_values:
generators: list[tuple[int, Any]] = []
for source_index, source_face in source_faces:
try:
generated = tuple(operation.Generated(source_face))
except (AttributeError, TypeError, ValueError):
generated = ()
if any(candidate.IsSame(section_edge) for candidate in generated):
generators.append((source_index, source_face))
if len(generators) != 2 or {item[0] for item in generators} != {0, 1}:
continue
section_relations.append(TopologySectionRelation(
source_values=(generators[0][1], generators[1][1]),
result_value=section_edge,
))
qualified_section_values = tuple(item.result_value for item in section_relations)
unqualified_section_values = tuple(
value for value in section_values
if not any(value.IsSame(qualified) for qualified in qualified_section_values)
)
return TopologyDelta(
operation=operation_name,
relations=tuple(relations),
section_values=section_values,
section_values=unqualified_section_values,
section_relations=tuple(section_relations),
)
@staticmethod
@@ -1830,20 +2309,21 @@ class Build123dGeometryAdapter:
end_tangent: Vector3 | None = None,
parameters: list[float] | None = None,
) -> Edge | Wire:
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
# 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点
# 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束,
# 缺失时不能伪造,交给内核自动求解。
vertices = [_vector(point) for point in points]
if len(vertices) < 2:
raise ValueError("sweep path needs at least two points")
if len(vertices) == 2:
if start_tangent is not None or end_tangent is not None or parameters is not None:
raise ValueError("line sweep path does not accept B-spline tangents")
return Edge.make_line(vertices[0], vertices[1])
if (start_tangent is None) != (end_tangent is None):
raise ValueError("sweep B-spline path requires both endpoint tangents")
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
if parameters is not None and len(parameters) != len(vertices):
raise ValueError("sweep B-spline path parameters must match point count")
if len(vertices) == 2 and tangents is None:
if parameters is not None:
raise ValueError("line sweep path does not accept B-spline parameters")
return Edge.make_line(vertices[0], vertices[1])
direction = vertices[-1] - vertices[0]
tolerance = 1e-9 * max(1.0, direction.length)
collinear_points = direction.length > tolerance and all(
+116 -6
View File
@@ -17,6 +17,7 @@ from .runtime_types import (
pattern_instance_member_id, transform_copy_member_id,
)
from .operation_contracts import materialized_feature_contracts
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
@@ -97,6 +98,24 @@ def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None)
return [value for value in values if isinstance(value, dict)]
def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return the one nested output-role slot supported by extrusion extents."""
policy = ((contract or {}).get("nested_selector_policies") or {}).get("params.end_condition.reference")
if (
not isinstance(policy, dict)
or policy.get("end_condition_type") != "up_to_surface"
or policy.get("token_kind") != "face"
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
or policy.get("requires_immediate_owner") is not True
):
return None
end_condition = node.params.get("end_condition") or {}
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None:
return reference
return None
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
"""Return internal body-member keys named by a transform contract."""
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
@@ -232,6 +251,8 @@ def _next_body_graph(
return members, has_active_body
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
atomic_id in _HOLE_ATOMICS and node.params.get("scope_feature_id") is not None
) or (
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
):
# Primary cuts execute per explicit member in the runtime so a later
@@ -448,6 +469,7 @@ class CapabilityAnalyzer:
completed: set[str] = set()
body_available = False
body_members: set[str] = set()
previous_node: FeaturePlanNode | None = None
for node in plan:
blockers: list[RuntimeDiagnostic] = []
contract = self.contracts.get(node.atomic_id)
@@ -559,19 +581,38 @@ class CapabilityAnalyzer:
sketch_id=sketch_id,
))
contract_selectors = _contract_selectors(node, contract)
contract_selector_ids = {id(selector) for selector in contract_selectors}
extent_selector = _up_to_surface_output_role_reference(node, contract)
output_role_selectors = [
*contract_selectors,
*([extent_selector] if extent_selector is not None else []),
]
contract_selector_ids = {id(selector) for selector in output_role_selectors}
selector_intent_ids = {
id(selector.get("selector_intent"))
for selector in output_role_selectors
if isinstance(selector.get("selector_intent"), dict)
}
for selector in _mappings(params):
if selector.get("output_role") is not None and id(selector) not in contract_selector_ids:
if (
selector.get("output_role") is not None
and selector.get("kind") is not None
and id(selector) not in contract_selector_ids
and id(selector) not in selector_intent_ids
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector_context",
"Feature output role selector is outside the operation contract slot",
))
for selector_index, selector in enumerate(contract_selectors):
for selector_index, selector in enumerate(output_role_selectors):
if selector.get("output_role") is None:
continue
required.append("selector:feature_output_role")
if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
is_extent_reference = selector is extent_selector
if (
not is_extent_reference
and (contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face")
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_output_role_selector",
@@ -600,6 +641,39 @@ class CapabilityAnalyzer:
selector_index=selector_index,
))
role_source = selector.get("output_role_source")
selector_intent = selector.get("selector_intent")
is_shell_cap_face_output_role = (
node.atomic_id == "shell"
and isinstance(selector_intent, dict)
and selector_intent.get("query_family") == "CAP_FACE"
and selector.get("output_role") in {"extrude.start", "extrude.end"}
)
if is_extent_reference and (
previous_node is None
or selector.get("owner_feature_id") != previous_node.feature_id
or not is_direct_blind_extrude_cap_output_role(
selector, previous_node.source_feature, sketches,
)
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_extent_output_role_selector",
"up_to_surface output roles require the immediately preceding direct new_body blind extrusion cap",
selector_index=selector_index,
))
elif is_shell_cap_face_output_role and (
previous_node is None
or selector.get("owner_feature_id") != previous_node.feature_id
or not is_direct_blind_extrude_cap_output_role(
selector, previous_node.source_feature, sketches,
)
):
blockers.append(self._blocker(
node.feature_id,
"unsupported_cap_face_output_role_selector",
"CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap",
selector_index=selector_index,
))
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
@@ -647,11 +721,25 @@ class CapabilityAnalyzer:
))
elif kind == "bspline" and (
not isinstance(segment.get("points"), list)
or len(segment.get("points") or []) < 3
or len(segment.get("points") or []) < 2
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"Sweep B-spline path requires at least three interpolation points",
"Sweep B-spline path requires at least two interpolation points",
))
elif (
kind == "bspline"
and len(segment.get("points") or []) == 2
and not (
isinstance(segment.get("start_tangent"), list)
and len(segment["start_tangent"]) == 2
and isinstance(segment.get("end_tangent"), list)
and len(segment["end_tangent"]) == 2
)
):
blockers.append(self._blocker(
node.feature_id, "invalid_sweep_path",
"A two-point B-spline sweep path requires both endpoint tangents",
))
if node.atomic_id == "boolean_bodies":
target_ids = params.get("target_feature_ids")
@@ -762,6 +850,27 @@ class CapabilityAnalyzer:
"shell target no longer has an independently selectable body output",
target_feature_id=target_feature_id,
))
if node.atomic_id in _HOLE_ATOMICS and params.get("scope_feature_id") is not None:
scope_feature_id = params.get("scope_feature_id")
required.append("hole:explicit_scope_body")
scope = nodes_by_id.get(str(scope_feature_id or ""))
if not isinstance(scope_feature_id, str) or not scope_feature_id:
blockers.append(self._blocker(
node.feature_id, "invalid_hole_scope_body",
"hole scope_feature_id must name one preceding body member",
))
elif scope is None or scope.feature_id not in completed or scope.feature_id not in body_members:
blockers.append(self._blocker(
node.feature_id, "hole_scope_body_unavailable",
"hole scope body is no longer an independently selectable body output",
scope_feature_id=scope_feature_id,
))
elif len(body_members) != 1:
blockers.append(self._blocker(
node.feature_id, "hole_scope_body_ambiguous",
"hole scope body must be the sole active member",
scope_feature_id=scope_feature_id,
))
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
source_ids = params.get(parameter)
@@ -1086,6 +1195,7 @@ class CapabilityAnalyzer:
if status == "executable":
completed.add(node.feature_id)
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
previous_node = node
body_producers = {
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
+62 -8
View File
@@ -14,7 +14,6 @@
"geometry": {
"type": "object",
"properties": {
"selector_intent_version": {"const": "1.0"},
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
},
"required": ["sketches"],
@@ -543,6 +542,7 @@
"end_condition": {"$ref": "#/$defs/endCondition"},
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
"host_face": {"$ref": "#/$defs/hostFace"},
"scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"thread": {"type": "object"},
"countersink": {"type": "object"},
"counterbore": {"type": "object"}
@@ -570,9 +570,22 @@
"properties": {
"ast": {},
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
"standard_library": {"type": "string", "minLength": 1}
"standard_library": {"type": "string", "minLength": 1},
"standard_library_version": {"type": "string", "minLength": 1},
"standard_library_imports": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string", "minLength": 1},
"version": {"type": "string", "minLength": 1}
},
"required": ["path"],
"additionalProperties": false
}
}
},
"required": ["ast", "featurescript_version"],
"required": ["ast"],
"additionalProperties": false
},
"selectorIntent": {
@@ -580,7 +593,7 @@
"properties": {
"version": {"const": "1.0"},
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
"query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]},
"query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"},
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
"source_entity": {
"type": "object",
@@ -591,7 +604,47 @@
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"source_entities": {
"type": "array",
"minItems": 2,
"uniqueItems": true,
"items": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"intersection_sources": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"items": {
"type": "object",
"properties": {
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"output_role": {"enum": ["extrude.start", "extrude.end"]},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
}
},
"required": ["query_family", "owner_feature_id"],
"additionalProperties": false
}
},
"output_role": {"$ref": "#/$defs/featureOutputRole"},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
"body_member_contract": {"enum": ["direct_new_body"]},
"derivation_policy": {
"type": "object",
"properties": {
@@ -601,7 +654,7 @@
"required": ["allowed", "multiplicity"],
"additionalProperties": false
},
"evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]},
"evidence": {"enum": ["kernel_history", "operation_role", "active_body_member", "feature_script_query", "explicit_datum", "geometry_hint"]},
"disambiguation": {"type": "object"}
},
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
@@ -628,7 +681,7 @@
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["kind", "source", "confidence"],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}],
"additionalProperties": false
},
"analyticSegment": {
@@ -649,6 +702,7 @@
"clockwise": {"type": "boolean"},
"start_tangent": {"$ref": "#/$defs/point2"},
"end_tangent": {"$ref": "#/$defs/point2"}
,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
},
"required": ["type"],
"allOf": [
@@ -798,7 +852,7 @@
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
"profile": {
"oneOf": [
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
{"$ref": "#/$defs/analyticProfile"},
{"$ref": "#/$defs/planarImprintProfile"}
@@ -806,7 +860,7 @@
},
"sketch": {
"type": "object",
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"required": ["id", "workplane", "profile"],
"additionalProperties": false
}
+133 -7
View File
@@ -73,13 +73,50 @@ def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[s
return members
def _can_register_primary_cut_tool_history(
session: "ExecutionSession",
tool: Any,
topology_delta: TopologyDelta | None,
topology_anchors: list[TopologyRecord] | None,
) -> bool:
"""Return whether a primary REMOVE can retain a transient tool snapshot.
The implicit CADFS primary boolean normally has no independently active
tool body. It may contribute selector provenance only when the target,
tool, profile anchors, and prism builder history are all singular and
exact. This gate applies only to the tool-side transient snapshot used by
source-qualified section queries. The cut builder can independently
prove a target-side continuation even when a trimmed or fallback tool has
no direct-prism history.
"""
if (
session.body is None
or topology_delta is None
or topology_delta.operation != "extrude"
or topology_delta.history_status != "proven"
or not topology_delta.relations
or not topology_anchors
or len(session.body_members) != 1
or len(session.adapter.body_solids(session.body)) != 1
or len(session.adapter.body_solids(tool)) != 1
):
return False
return all(
anchor.kind in {"edge", "vertex"}
and (anchor.source_entity is not None or anchor.source_entities)
for anchor in topology_anchors
)
def _extruded_tool(
node: FeaturePlanNode,
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
*,
record_multiface_prism_history: bool = False,
) -> tuple[Any, TopologyDelta | None]:
"""Build one extrude tool, retaining caps only from one exact builder result."""
"""Build an extrude tool, retaining complete direct builder history."""
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
draft = node.params.get("draft")
taper_deg = 0.0
@@ -87,7 +124,7 @@ def _extruded_tool(
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_delta: TopologyDelta | None = None
topology_deltas: list[TopologyDelta] = []
solids: list[Any] = []
for face in faces:
for extent in extents:
@@ -96,11 +133,22 @@ def _extruded_tool(
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg,
)
if topology_delta is not None:
topology_deltas.append(topology_delta)
solids.append(solid)
else:
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
elif extent.trim_to is None and len(extents) == 1 and (
len(faces) == 1 or record_multiface_prism_history
):
# Each independently constructed profile face has its own OCC
# prism history only when the adapter retained direct source
# anchors for every participating profile path. Other complex
# multi-face profiles keep the established general-extrude
# path; forcing them through MakePrism can make a previously
# executable profile invalid without adding usable evidence.
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
topology_deltas.append(topology_delta)
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
@@ -111,7 +159,12 @@ def _extruded_tool(
tool = session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("extrude produced no solid")
return tool, topology_delta
if len(topology_deltas) != len(solids):
return tool, None
return tool, TopologyDelta(
operation="extrude",
relations=tuple(relation for delta in topology_deltas for relation in delta.relations),
)
def _apply_primary_tool(
@@ -121,8 +174,10 @@ def _apply_primary_tool(
*,
cutting: bool,
topology_delta: TopologyDelta | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> FeatureResult:
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
topology_predecessors: list[TopologyRecord] | None = None
if cutting:
if session.body is None:
raise ValueError("cut feature has no body")
@@ -130,8 +185,46 @@ def _apply_primary_tool(
if not members:
session.clear_body()
return session.result(node)
body = session.adapter.cut(session.body, tool)
topology_delta = None
tool_delta = topology_delta
tool_anchors = list(topology_anchors or ())
retain_transient_tool = _can_register_primary_cut_tool_history(
session, tool, tool_delta, topology_anchors,
)
if (
len(session.body_members) == 1
and len(session.adapter.body_solids(session.body)) == 1
and len(session.adapter.body_solids(tool)) == 1
):
body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool)
# The target-side boolean history is independent of the source
# tool's construction history. A trimmed tool cannot support a
# source-qualified section query, but its exact BRepAlgoAPI_Cut
# Modified/Preserved facts can still prove a unique continuation
# of the active target face or edge.
topology_anchors = None
if cut_delta is not None:
if retain_transient_tool:
try:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=tool_delta,
topology_anchors=tool_anchors,
)
except ValueError:
# Retain cut history as partial diagnostic evidence. The
# absent transient source records prevent it from proving
# a source-qualified section edge.
topology_predecessors = None
topology_delta = cut_delta
else:
topology_delta = None
member_id = next(iter(session.body_members))
members = {member_id: body}
else:
body = session.adapter.cut(session.body, tool)
topology_delta = None
topology_anchors = None
elif node.params.get("result_mode") == "new_body":
body = session.adapter.combine(session.body, tool)
members = {**session.body_members, node.feature_id: tool}
@@ -142,8 +235,10 @@ def _apply_primary_tool(
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = None
topology_anchors = None
session.register_body(
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
)
return session.result(node)
@@ -157,7 +252,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
if selected_sketch is None:
raise ValueError("primary feature has no resolved sketch")
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
faces = session.adapter.faces_for_sketch(selected_sketch)
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
@@ -169,6 +264,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
topology_delta: TopologyDelta | None = None
topology_anchors: list[TopologyRecord] = []
profile = selected_sketch.get("profile") or {}
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
isinstance(contour, dict)
and bool(contour.get("closed"))
and len(contour.get("segments") or []) == 1
and (contour.get("segments") or [{}])[0].get("type") == "circle"
for contour in contours
)
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -177,7 +282,27 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session,
record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces),
)
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
value = spec.get("value")
if kind not in {"edge", "vertex"} or value is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if source_entity is None and not source_entities:
continue
topology_anchors.append(TopologyRecord(
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
kind=kind,
feature_id=node.feature_id,
geometry={},
value=value,
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
else:
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
axis = _revolve_axis(node, session)
@@ -199,6 +324,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
raise ValueError("revolve produced no solid")
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
+22 -1
View File
@@ -31,6 +31,23 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
# 1. 校验:孔是切除操作,必须先有主体。
if session.body is None:
raise ValueError("hole feature has no body")
scope_feature_id = node.params.get("scope_feature_id")
if scope_feature_id is not None:
if not isinstance(scope_feature_id, str) or not scope_feature_id:
raise ValueError("hole scope_feature_id is invalid")
if len(session.body_members) != 1:
raise ValueError("hole scope body is no longer the sole active member")
scoped_body = session.body_members.get(scope_feature_id)
if scoped_body is None:
raise ValueError("hole scope body is no longer an independently selectable member")
scoped_solids = session.adapter.body_solids(scoped_body)
active_solids = session.adapter.body_solids(session.body)
if (
len(scoped_solids) != 1
or len(active_solids) != 1
or not scoped_solids[0].is_same(active_solids[0])
):
raise ValueError("hole scope body does not match the active body")
# 2. 确定宿主面 host_face
host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
@@ -74,5 +91,9 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
feature_id=node.feature_id,
))
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
result_body = session.adapter.cut(session.body, tool)
members = {scope_feature_id: result_body} if scope_feature_id is not None else None
session.register_body(
node.feature_id, result_body, replay_node=node, body_members=members,
)
return session.result(node, diagnostics=diagnostics)
@@ -31,12 +31,14 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
params_schema = raw.get("author_params_schema")
injected_paths = raw.get("server_injected_paths")
selector_policy = raw.get("selector_policy")
nested_selector_policies = raw.get("nested_selector_policies") or {}
runtime_capability = raw.get("runtime_capability")
if (
not isinstance(shape, dict)
or not isinstance(params_schema, dict)
or not isinstance(injected_paths, list)
or not isinstance(selector_policy, dict)
or not isinstance(nested_selector_policies, dict)
):
raise ValueError(f"operation contract is incomplete for {atomic_id}")
if not isinstance(runtime_capability, dict) or not all(
@@ -52,6 +54,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
raise ValueError(f"operation author params are invalid for {atomic_id}")
if not all(isinstance(name, str) and name in properties for name in required):
raise ValueError(f"operation required params are invalid for {atomic_id}")
if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()):
raise ValueError(f"operation nested selector policies are invalid for {atomic_id}")
materialized_required = list(dict.fromkeys(required))
for path in injected_paths:
if not isinstance(path, str):
@@ -67,6 +71,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
"requires_sketch": shape.get("sketch") == "required",
"selector_slot": selector_policy.get("slot"),
"selector_token_kind": selector_policy.get("token_kind"),
"nested_selector_policies": dict(nested_selector_policies),
"runtime_capability": dict(runtime_capability),
}
return derived
@@ -6,7 +6,7 @@
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"],
"operation_contracts": {
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
@@ -14,7 +14,7 @@
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
+101 -38
View File
@@ -20,6 +20,7 @@ working.
from __future__ import annotations
from copy import deepcopy
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
@@ -27,6 +28,7 @@ from . import executors # noqa: F401 (importing performs executor registration
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
# Historical private name still imported by the test suite.
from .executors.primitives import _execute_box # noqa: F401
from .executors.common import _selector_edges # noqa: F401
from .extents import (
_extent_reference,
_extent_vectors,
@@ -73,6 +75,9 @@ from .topology import (
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
)
# Historical private imports used by integration tests and local diagnostics.
_execute_node = execute_node
__all__ = [
"ALL_ATOMIC_IDS",
"EXECUTORS",
@@ -85,7 +90,10 @@ __all__ = [
"GeometryAdapter",
"RuntimeDiagnostic",
"RuntimeExecutionError",
"IncrementalCdslExecution",
"analyze_cdsl",
"prepare_cdsl_execution",
"finalize_cdsl_execution",
"execute_node",
"rebuild_cdsl",
]
@@ -101,8 +109,81 @@ def analyze_cdsl(cdsl: dict[str, Any]):
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
"""Rebuild CDSL through session-scoped atomic executors only."""
def _preflight_error(analysis: Any) -> ValueError | None:
if analysis.runtime_eligible:
return None
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
return ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
return ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
return ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
def _execution_diagnostic(
error: Exception,
node: FeaturePlanNode,
session: ExecutionSession,
) -> RuntimeDiagnostic:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
if isinstance(error, FeatureExecutionError):
return RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if failed_resolution and failed_resolution.get("diagnostic"):
diagnostic = failed_resolution["diagnostic"]
return RuntimeDiagnostic(
diagnostic["code"], diagnostic["message"], feature_id=node.feature_id,
detail=diagnostic.get("detail") or {},
)
return RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
@dataclass
class IncrementalCdslExecution:
"""One prepared CDSL replay with feature-at-a-time execution.
CADFS selector binding consumes this object before each feature executes.
It therefore observes the exact session topology snapshots generated by
the one real kernel replay rather than rebuilding a growing prefix for
every selector. The generic runtime also uses it through ``rebuild_cdsl``.
"""
resolved_cdsl: dict[str, Any]
analysis: Any
session: ExecutionSession
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
next_index: int = 0
def execute_next(self, *, strict: bool = True) -> FeatureResult | None:
if self.next_index >= len(self.analysis.plan):
raise ValueError("CDSL execution plan is already complete")
node = self.analysis.plan[self.next_index]
preflight = self.analysis.feature_results[self.next_index]
self.next_index += 1
if not preflight.executable:
self.diagnostics.extend(preflight.blockers)
if strict:
detail = "; ".join(blocker.code for blocker in preflight.blockers)
raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}")
return None
try:
return execute_node(node, self.session)
except Exception as error:
diagnostic = _execution_diagnostic(error, node, self.session)
self.diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(self.session.selector_resolutions)) from error
return None
def execute_all(self, *, strict: bool = True) -> None:
while self.next_index < len(self.analysis.plan):
self.execute_next(strict=strict)
def prepare_cdsl_execution(cdsl: dict[str, Any]) -> IncrementalCdslExecution:
"""Resolve CDSL once and return a reusable sequential execution session."""
sketch_errors: dict[str, str] = {}
resolved = resolve_required_sketches(
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
@@ -110,45 +191,16 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
resolved, sketch_errors=sketch_errors,
)
if strict and not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(analysis.document_blockers[0].code)
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
detail = "; ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
session = ExecutionSession(
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
nodes={node.feature_id: node for node in analysis.plan},
)
diagnostics: list[RuntimeDiagnostic] = []
for node, preflight in zip(analysis.plan, analysis.feature_results):
if not preflight.executable:
diagnostics.extend(preflight.blockers)
if strict:
break
continue
try:
execute_node(node, session)
except Exception as error:
failed_resolution = next(
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
)
diagnostic = (
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
if isinstance(error, FeatureExecutionError)
else
RuntimeDiagnostic(
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
)
if failed_resolution and failed_resolution.get("diagnostic")
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
)
diagnostics.append(diagnostic)
if strict:
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
return IncrementalCdslExecution(resolved, analysis, session)
def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]:
"""Export the current executable checkpoint from an incremental replay."""
session = execution.session
output = session.body
surface_geometry: dict[str, Any] | None = None
if output is None:
@@ -177,8 +229,19 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
"feature_results": [result.as_dict() for result in session.results.values()],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in execution.diagnostics],
"topology_records": [record.public_dict() for record in session.topology.records()],
"topology_deltas": list(session.topology.topology_deltas()),
"selector_resolution": session.selector_resolutions,
}
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
"""Rebuild CDSL through session-scoped atomic executors only."""
execution = prepare_cdsl_execution(cdsl)
if strict:
error = _preflight_error(execution.analysis)
if error is not None:
raise error
execution.execute_all(strict=strict)
return finalize_cdsl_execution(execution, out_step)
@@ -43,6 +43,7 @@ from .topology import (
SelectorResolution,
TopologyDelta,
TopologyDeltaRelation,
TopologySectionRelation,
TopologyLineage,
TopologyRecord,
TopologyRegistry,
@@ -64,6 +65,7 @@ __all__ = [
"ThreadSpec",
"TopologyDelta",
"TopologyDeltaRelation",
"TopologySectionRelation",
"TopologyLineage",
"TopologyRecord",
"TopologyRegistry",
@@ -0,0 +1,173 @@
"""Verified FeatureScript query semantics available to the selector resolver.
This is intentionally a small, explicit allow-list. A numeric source version
only identifies a FeatureScript release; it does not establish that this
runtime has verified a query family's source semantics for that release.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
@dataclass(frozen=True)
class SelectorQueryCapability:
query_family: str
featurescript_version: str
standard_library: str
standard_library_version: str
source: str
contract: str
# These entries correspond to the direct builder contracts exercised by the
# CADFS 1511 corpus. New versions and query families must be registered with
# their source/API evidence before the runtime accepts them.
_CAPABILITIES = {
("CAP_FACE", "1511"): SelectorQueryCapability(
"CAP_FACE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"direct extrude cap operation role",
),
("CAP_EDGE", "1511"): SelectorQueryCapability(
"CAP_EDGE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"direct prism source edge to qualified start/end cap edge kernel lineage",
),
("OFFSET_FACE", "1511"): SelectorQueryCapability(
"OFFSET_FACE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"shell offset-face operation role with true dependency qualification",
),
# These entries authorize the deliberately narrow direct-prism lineage
# path. The capability matrix keeps the broader query families explicitly
# partial; all other generator and lifecycle combinations remain rejected.
("SWEPT_FACE", "1511"): SelectorQueryCapability(
"SWEPT_FACE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"kernel-lineage resolver contract only",
),
("SWEPT_EDGE", "1511"): SelectorQueryCapability(
"SWEPT_EDGE",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history",
"kernel-lineage resolver contract only",
),
("SWEPT_BODY", "1511"): SelectorQueryCapability(
"SWEPT_BODY",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history; one-item qUnion identity",
"active direct-new-body member selected by its producing operation",
),
("INTERSECT", "1511"): SelectorQueryCapability(
"INTERSECT",
"1511",
"onshape/std/geometry.fs",
"1511.0",
"CADFS FeatureScript 1511 exported query history; BRepAlgoAPI boolean Generated(face) and SectionEdges() exact handles",
"two source-qualified boolean input faces to one final section edge kernel lineage",
),
}
def selector_query_capability(intent: dict[str, Any]) -> SelectorQueryCapability | None:
"""Return an explicitly verified query capability for ``intent`` only."""
source_query = intent.get("source_query")
if not isinstance(source_query, dict):
return None
family = intent.get("query_family")
version = source_query.get("featurescript_version")
if not isinstance(family, str) or not isinstance(version, str):
return None
capability = _CAPABILITIES.get((family, version))
if capability is None:
return None
if (
source_query.get("standard_library") != capability.standard_library
or source_query.get("standard_library_version") != capability.standard_library_version
):
return None
return capability
def known_selector_query_versions(query_family: str) -> tuple[str, ...]:
"""Expose registered versions for deterministic unsupported diagnostics."""
return tuple(sorted(version for family, version in _CAPABILITIES if family == query_family))
def known_selector_query_standard_library_versions(query_family: str) -> tuple[tuple[str, str], ...]:
"""Expose exact direct imports that back a registered query contract."""
return tuple(sorted({
(capability.standard_library, capability.standard_library_version)
for (family, _version), capability in _CAPABILITIES.items()
if family == query_family
}))
def is_direct_blind_extrude_cap_output_role(
selector: dict[str, Any],
producer: dict[str, Any] | None,
sketches: dict[str, dict[str, Any]],
) -> bool:
"""Whether a selector names one cap from the direct prism contract.
This is the consumer-side mirror of the lowerer's CAP_FACE constructor.
It deliberately describes a narrow builder contract, rather than treating
an output-role string as general permission to select arbitrary topology.
The runtime still has to prove the exact role in its active snapshot.
"""
if not isinstance(selector, dict) or not isinstance(producer, dict):
return False
intent = selector.get("selector_intent")
if (
selector.get("kind") != "face"
or selector.get("output_role") not in {"extrude.start", "extrude.end"}
or selector.get("source") != "runtime_snapshot"
or selector.get("output_role_source") is not None
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id"))
or not isinstance(intent, dict)
or intent.get("query_family") != "CAP_FACE"
or intent.get("evidence") != "operation_role"
or intent.get("output_role") != selector.get("output_role")
):
return False
policy = intent.get("derivation_policy")
if not isinstance(policy, dict) or policy.get("multiplicity") != "one" or set(policy.get("allowed") or ()) != {"boundary", "continuation"}:
return False
params = producer.get("params") or {}
if (
producer.get("atomic_id") != "extrude_add_blind"
or params.get("result_mode") != "new_body"
or (params.get("end_condition") or {}).get("type") != "blind"
):
return False
if params.get("draft") is None:
return True
sketch = sketches.get(str(producer.get("sketch_id") or "")) or {}
profile = sketch.get("profile") or {}
if profile.get("type") == "circle":
return True
contours = profile.get("contours")
return (
profile.get("type") == "analytic_contours"
and isinstance(contours, list)
and len(contours) == 1
and bool((contours[0] or {}).get("closed"))
)
+156 -10
View File
@@ -16,6 +16,7 @@ from typing import Any
from jsonschema import Draft202012Validator
from .operation_contracts import materialized_feature_contracts
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
@@ -45,6 +46,24 @@ def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> li
return [value for value in values if isinstance(value, dict)]
def _up_to_surface_output_role_reference(feature: dict[str, Any], contract: dict[str, Any]) -> dict[str, Any] | None:
"""Return the one nested output-role slot that an extrusion may consume."""
policy = (contract.get("nested_selector_policies") or {}).get("params.end_condition.reference")
if (
not isinstance(policy, dict)
or policy.get("end_condition_type") != "up_to_surface"
or policy.get("token_kind") != "face"
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
or policy.get("requires_immediate_owner") is not True
):
return None
end_condition = (feature.get("params") or {}).get("end_condition") or {}
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None:
return reference
return None
def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None:
"""Enforce the provenance boundary before the runtime can bind a selector."""
intent = selector.get("selector_intent")
@@ -52,14 +71,15 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index:
if selector.get("selector_intent_version") is not None:
raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent")
return
if selector.get("selector_intent_version") not in {None, "1.0"}:
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version")
if not isinstance(intent, dict) or intent.get("version") != "1.0":
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent")
legacy_version = selector.get("selector_intent_version")
if legacy_version is not None and legacy_version != intent["version"]:
raise ValueError(f"Feature {feature_id} selector {index} has conflicting selector intent versions")
if intent.get("kind") not in {None, selector.get("kind")}:
raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind")
family = intent.get("query_family")
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"}
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "SWEPT_BODY", "OFFSET_FACE", "INTERSECT", "COPY"}
if family in derived and not selector.get("owner_feature_id"):
raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id")
policy = intent.get("derivation_policy") or {}
@@ -76,10 +96,90 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index:
raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback")
source_query = intent.get("source_query") or {}
version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None
if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version):
raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version")
# An absent source version is a valid preservation state. The resolver,
# which owns execution eligibility, returns selector_query_version_unknown
# instead of rewriting it as a fake numeric version during validation.
if version is not None and (not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version)):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid FeatureScript query version")
if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"):
raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role")
source_entity = intent.get("source_entity")
source_entities = intent.get("source_entities")
if source_entity is not None and source_entities is not None:
raise ValueError(f"Feature {feature_id} selector {index} intent cannot mix one source entity with a source-vertex set")
lineage_role = intent.get("lineage_role")
if lineage_role is not None:
if (
family != "CAP_EDGE"
or selector.get("kind") != "edge"
or source_entity is None
or lineage_role not in {"extrude.start", "extrude.end"}
):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid CAP_EDGE lineage role")
if source_entities is not None:
if family != "SWEPT_EDGE" or not isinstance(source_entities, list) or len(source_entities) < 2:
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor requires SWEPT_EDGE and two source entities")
pairs = []
for source in source_entities:
if not isinstance(source, dict):
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is invalid")
sketch_id, entity_id = source.get("sketch_id"), source.get("entity_id")
if not isinstance(sketch_id, str) or not sketch_id or not isinstance(entity_id, str) or not entity_id:
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is incomplete")
pairs.append((sketch_id, entity_id))
if len(set(pairs)) != len(pairs):
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor repeats an entity")
intersection_sources = intent.get("intersection_sources")
deferred_source_query = (
policy.get("multiplicity") == "none"
and intent.get("evidence") == "feature_script_query"
)
if family == "SWEPT_BODY" and not deferred_source_query:
if (
selector.get("kind") != "body"
or selector.get("source") != "runtime_snapshot"
or intent.get("evidence") != "active_body_member"
or intent.get("body_member_contract") != "direct_new_body"
or policy.get("allowed") != ["boundary"]
or policy.get("multiplicity") != "one"
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role"))
):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid SWEPT_BODY member contract")
if family == "INTERSECT":
if deferred_source_query:
# CADFS source preservation deliberately keeps unsupported outer
# query semantics in the candidate. The resolver rejects this
# non-executable state before any topology/geometry fallback;
# validation must not erase the preceding executable checkpoint.
if intersection_sources is not None:
raise ValueError(f"Feature {feature_id} selector {index} deferred INTERSECT cannot declare executable section sources")
else:
if (
selector.get("kind") != "edge"
or policy.get("allowed") != ["intersection"]
or policy.get("multiplicity") != "one"
or not isinstance(intersection_sources, list)
or len(intersection_sources) != 2
):
raise ValueError(f"Feature {feature_id} selector {index} has an invalid INTERSECT section contract")
owners = set()
for source in intersection_sources:
if not isinstance(source, dict):
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is invalid")
source_family = source.get("query_family")
source_owner = source.get("owner_feature_id")
if source_family not in {"CAP_FACE", "SWEPT_FACE"} or not isinstance(source_owner, str) or not source_owner:
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is incomplete")
owners.add(source_owner)
if source_family == "CAP_FACE":
if source.get("output_role") not in {"extrude.start", "extrude.end"} or source.get("source_entity") is not None:
raise ValueError(f"Feature {feature_id} selector {index} CAP_FACE INTERSECT source is invalid")
elif not isinstance(source.get("source_entity"), dict):
raise ValueError(f"Feature {feature_id} selector {index} SWEPT_FACE INTERSECT source is invalid")
if len(owners) != 2:
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT sources must have distinct owners")
elif intersection_sources is not None:
raise ValueError(f"Feature {feature_id} selector {index} only INTERSECT may declare section sources")
forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"}
stack = [intent]
while stack:
@@ -166,6 +266,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
feature_ids: set[str] = set()
preceding_features: dict[str, dict[str, Any]] = {}
previous_feature_id: str | None = None
deferred: list[str] = []
unresolved: list[dict[str, Any]] = []
contracts = _operation_contracts()
@@ -185,21 +286,26 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
deferred.append(fid)
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
feature_selectors = _contract_selectors(feature, contract)
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
extent_selector = _up_to_surface_output_role_reference(feature, contract)
output_role_selectors = [
*feature_selectors,
*([extent_selector] if extent_selector is not None else []),
]
output_role_selector_ids = {id(selector) for selector in output_role_selectors}
selector_intent_ids = {
id(selector.get("selector_intent"))
for selector in feature_selectors
for selector in output_role_selectors
if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict)
}
for index, selector in enumerate(feature_selectors):
validated_selector_ids: set[int] = set()
for index, selector in enumerate(output_role_selectors):
_validate_selector_intent(selector, fid, index)
validated_selector_ids.add(id(selector))
owner = selector.get("owner_feature_id")
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
if selector.get("output_role") is not None:
if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
if selector.get("source") != "runtime_snapshot":
@@ -207,6 +313,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
role_source = selector.get("output_role_source")
selector_intent = selector.get("selector_intent")
is_shell_cap_face_output_role = (
feature.get("atomic_id") == "shell"
and isinstance(selector_intent, dict)
and selector_intent.get("query_family") == "CAP_FACE"
and selector.get("output_role") in {"extrude.start", "extrude.end"}
)
if selector is extent_selector:
if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role(
selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches},
):
raise ValueError(
f"Feature {fid} up_to_surface output role requires the immediately preceding direct new_body blind extrusion cap"
)
elif is_shell_cap_face_output_role:
if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role(
selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches},
):
raise ValueError(
f"Feature {fid} CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap"
)
elif not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
if role_source is not None:
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
@@ -234,6 +363,18 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
elif selector.get("output_role_source") is not None:
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
for selector in _mappings(feature):
if (
id(selector) not in validated_selector_ids
and (selector.get("selector_intent") is not None or selector.get("selector_intent_version") is not None)
):
_validate_selector_intent(selector, fid, len(validated_selector_ids))
validated_selector_ids.add(id(selector))
owner = selector.get("owner_feature_id")
binding_owner = selector.get("binding_feature_id")
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
raise ValueError(
f"Feature {fid} nested selector has a forward or missing owner_feature_id"
)
# ``output_role_source`` is provenance metadata nested inside a
# feature selector, not a selector on its own.
if (
@@ -251,6 +392,10 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
if target_feature_id is not None and target_feature_id not in feature_ids:
raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature")
if feature.get("atomic_id") in {"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}:
scope_feature_id = (feature.get("params") or {}).get("scope_feature_id")
if scope_feature_id is not None and scope_feature_id not in feature_ids:
raise ValueError(f"Feature {fid} hole scope_feature_id requires a preceding body feature")
if feature.get("atomic_id") == "transform_bodies":
params = feature.get("params") or {}
references = params.get("pattern_instance_refs") or []
@@ -354,6 +499,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
feature_ids.add(fid)
preceding_features[fid] = feature
previous_feature_id = fid
return {
"schema_version": version,
+43 -2
View File
@@ -23,6 +23,7 @@ from .topology import (
TopologyDelta,
TopologyRecord,
TopologyRegistry,
validate_selector_provenance_intent,
)
@@ -38,6 +39,7 @@ class GeometryAdapter(Protocol):
def body_geometry(self, body: Any) -> dict[str, Any]: ...
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]: ...
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
@@ -115,6 +117,7 @@ class ExecutionSession:
body_members: dict[str, Any] | None = None,
topology_delta: TopologyDelta | None = None,
topology_predecessors: list[TopologyRecord] | None = None,
topology_anchors: list[TopologyRecord] | None = None,
) -> None:
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id"
@@ -123,12 +126,20 @@ class ExecutionSession:
self.body = body
self.body_id = f"body:{feature_id}"
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
# Source-profile anchors are transient construction facts, but unlike
# generic role predecessors they must remain addressable by a later
# selector intent. They never receive a body id, so active selector
# scans cannot mistake them for current model topology.
anchors = list(topology_anchors or ())
for anchor in anchors:
self.topology.register(anchor)
predecessors = [*(topology_predecessors or ()), *anchors]
solids = self.adapter.body_solids(body)
if len(solids) <= 1:
self.topology.replace_body_topology(
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
additional_predecessors=predecessors,
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
@@ -141,7 +152,7 @@ class ExecutionSession:
]
self.topology.replace_body_topologies(
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
additional_predecessors=topology_predecessors or (),
additional_predecessors=predecessors,
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
@@ -150,6 +161,25 @@ class ExecutionSession:
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
def register_transient_prism_tool(
self,
feature_id: str,
tool: Any,
*,
topology_delta: TopologyDelta,
topology_anchors: list[TopologyRecord],
) -> list[TopologyRecord]:
"""Keep one direct-prism primary tool as boolean-input evidence only."""
snapshot_id = f"transient:{feature_id}"
records = self.adapter.topology_records(tool, feature_id, snapshot_id)
return list(self.topology.register_transient_snapshot(
feature_id,
snapshot_id,
records,
topology_delta=topology_delta,
anchors=topology_anchors,
))
def register_surface(self, feature_id: str, surface: Any) -> str:
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
@@ -236,6 +266,17 @@ class ExecutionSession:
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
if selector.get("intersection_of") is not None:
# The session computes vertex intersections directly from resolved
# face components, so enforce the same outer provenance gate that
# TopologyRegistry.resolve applies before any geometry operation.
validation_error = validate_selector_provenance_intent(selector)
if validation_error is not None:
return self._record_selector_resolution(SelectorResolution(
selector=selector,
status="not_found",
candidates=(),
diagnostic=validation_error,
))
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
owner = str(selector.get("owner_feature_id") or "")
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
+61 -18
View File
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
_TOLERANCE_MM = 1e-5
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
def _circle(
center: list[float],
radius_mm: float,
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx:
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
def _line(
start: list[float],
end: list[float],
construction: bool = False,
source_entity_id: str | None = None,
) -> _Ctx:
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
if source_entity_id is not None:
output["source_entity_id"] = source_entity_id
return output
def _point(point: list[float]) -> list[float]:
@@ -118,7 +134,8 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
cx, cy = float(center[0]), float(center[1])
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
return [_circle([cx, cy], radius)], []
source_entity_id = profile.get("source_entity_id")
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
@@ -215,7 +232,16 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
source_entity_id = segment.get("source_entity_id")
if isinstance(source_entity_id, str) and source_entity_id:
# Region construction needs four arc segments, but this marker records
# that all four came from exactly one logical source circle. It is not
# an edge anchor: callers must rebuild one native circle wire and pass
# final-face identity checks before using it for lineage.
for edge in edges:
edge["logical_circle_source_entity_id"] = source_entity_id
return edges
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
@@ -242,14 +268,14 @@ def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
kind = segment.get("type")
if kind == "line":
return [_contour_line(segment["start"], segment["end"])]
if kind == "arc":
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
if kind == "circle":
return _circle_edges(segment)
if kind == "ellipse":
return _ellipse_edges(segment)
if kind == "bspline":
edges = [_contour_line(segment["start"], segment["end"])]
elif kind == "arc":
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
elif kind == "circle":
edges = _circle_edges(segment)
elif kind == "ellipse":
edges = _ellipse_edges(segment)
elif kind == "bspline":
points = segment.get("points") or []
if len(points) < 2:
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
@@ -304,8 +330,18 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
output["start_tangent_mm"] = _point(start_tangent)
output["end_tangent_mm"] = _point(end_tangent)
return [output]
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
edges = [output]
else:
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
source_entity_id = segment.get("source_entity_id")
# Only a one-edge construction has the direct, one-to-one source identity
# required by profile-to-prism lineage. Circles expanded to arcs carry a
# separate logical-circle marker, not a source edge identity; the adapter
# may use it only to reconstruct one native circle wire with exact final
# membership proof. Other multi-edge approximations remain unanchored.
if isinstance(source_entity_id, str) and len(edges) == 1:
edges[0]["source_entity_id"] = source_entity_id
return edges
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
@@ -466,11 +502,18 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
raw_edges: list[_Ctx] = []
for segment in contour.get("segments") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"]))
entities.append(_line(
segment["start"], segment["end"],
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
elif segment.get("type") == "circle":
if contour_open:
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
entities.append(_circle(
segment.get("center") or [0.0, 0.0],
float(segment.get("radius_mm") or 0.0),
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
))
raw_edges.extend(_segment_edges(segment))
if raw_edges:
edges = _join(raw_edges, allow_open=contour_open)
File diff suppressed because it is too large Load Diff
+552 -24
View File
@@ -395,6 +395,23 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6)
self.assertAlmostEqual(edge.tangent_at(1).X, -1.0, places=6)
def test_bspline_interpolation_tangent_uses_the_runtime_parameter_domain(self) -> None:
from cdsl_engine.build123d_adapter import interpolated_bspline_point_and_tangent
points = [(0.0, -23.11, 0.0), (0.0, -5.33, 0.0), (26.7, 9.39, 0.0)]
parameters = [0.0, 0.43299474427180723, 1.0]
point, tangent = interpolated_bspline_point_and_tangent(
points,
start_tangent=(0.0, 64.52, 0.0),
end_tangent=(141.23, -13.99, 0.0),
parameters=parameters,
interpolation_index=1,
)
for actual, expected in zip(point, points[1]):
self.assertAlmostEqual(actual, expected, places=12)
self.assertAlmostEqual(tangent[0], 0.008342033594358346, places=10)
self.assertAlmostEqual(tangent[1], 36.52292778091852, places=10)
def test_two_point_bspline_profile_requires_and_preserves_endpoint_tangents(self) -> None:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
@@ -930,51 +947,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
source = object()
exact_result = object()
geometrically_similar = object()
registry.replace_body_topology("base", "body:base", [
TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
])
source_anchor = TopologyRecord(
"anchor:base:circle", "edge", "base", geometry={}, value=source,
source_entity=("sketch_base", "circle"),
)
registry.register(source_anchor)
registry.replace_body_topology("later", "body:later", [
TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result),
TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar),
], topology_delta=TopologyDelta("transform", (
TopologyDeltaRelation("modified", "face", source, (exact_result,)),
)))
], topology_delta=TopologyDelta("extrude", (
TopologyDeltaRelation(
"generated", "edge", source, (exact_result,),
source_kind="edge", result_kind="face", derivation="boundary",
),
)), additional_predecessors=[source_anchor])
selector = {
"kind": "face", "owner_feature_id": "base", "stable_id": "base:face",
"kind": "face", "owner_feature_id": "base",
"source": "runtime_snapshot", "confidence": 1.0,
"geometry": {"center_mm": [0, 0, 0]},
"selector_intent": {
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
"source_query": {"ast": {}, "featurescript_version": "1511"},
"derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"},
"source_query": {
"ast": {}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"source_entity": {"sketch_id": "sketch_base", "entity_id": "circle"},
"derivation_policy": {"allowed": ["boundary"], "multiplicity": "one"},
"evidence": "kernel_history",
},
}
resolution = registry.resolve(selector, active_body_id="body:later")
self.assertEqual(resolution.status, "resolved")
self.assertEqual(resolution.record.record_id, "later:exact")
self.assertEqual(registry.lineage()[0].derivation, "continuation")
self.assertEqual(registry.lineage()[0].derivation, "boundary")
def test_complete_continuation_ignores_nonfinal_builder_handles(self) -> None:
"""A boolean's intermediate Generated handles are not face fragments.
OCC can return both one final ``Modified(face)`` and Generated faces
which are absent from the result snapshot for the same source face.
The latter are useful diagnostics but cannot invalidate the exact
one-to-one target continuation.
"""
registry = TopologyRegistry()
source_edge = object()
initial_face = object()
final_face = object()
intermediate_face = object()
anchor = TopologyRecord(
"anchor:base:edge", "edge", "base", geometry={}, value=source_edge,
source_entity=("sketch_base", "edge"),
)
registry.register(anchor)
registry.replace_body_topology("base", "body:base", [
TopologyRecord("base:wall", "face", "base", "body:base", {}, initial_face),
], topology_delta=TopologyDelta("extrude", (
TopologyDeltaRelation(
"generated", "edge", source_edge, (initial_face,),
source_kind="edge", result_kind="face", derivation="boundary",
),
)), additional_predecessors=[anchor])
registry.replace_body_topology("cut", "body:cut", [
TopologyRecord("cut:wall", "face", "cut", "body:cut", {}, final_face),
], topology_delta=TopologyDelta("subtract", (
TopologyDeltaRelation("modified", "face", initial_face, (final_face,)),
TopologyDeltaRelation(
"generated", "face", initial_face, (intermediate_face,),
derivation="boundary",
),
)))
resolution = registry.resolve({
"kind": "face", "owner_feature_id": "base",
"source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
"source_query": {
"ast": {}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"source_entity": {"sketch_id": "sketch_base", "entity_id": "edge"},
"derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"},
"evidence": "kernel_history",
},
}, active_body_id="body:cut")
self.assertEqual(resolution.status, "resolved")
self.assertEqual(resolution.record.record_id, "cut:wall")
cut_relations = registry.topology_deltas()[-1]["relations"]
self.assertTrue(any(item["coverage"] == "partial" for item in cut_relations))
def test_provenance_selector_rejects_non_unique_fragment(self) -> None:
registry = TopologyRegistry()
source = object()
first = object()
second = object()
registry.replace_body_topology("base", "body:base", [
TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
])
source_anchor = TopologyRecord(
"anchor:base:vertex", "vertex", "base", geometry={}, value=source,
source_entities=(("sketch_base", "left"), ("sketch_base", "right")),
)
registry.register(source_anchor)
registry.replace_body_topology("fillet", "body:fillet", [
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first),
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second),
], topology_delta=TopologyDelta("fillet", (
TopologyDeltaRelation("modified", "edge", source, (first, second)),
)))
TopologyDeltaRelation(
"modified", "vertex", source, (first, second),
source_kind="vertex", result_kind="edge", derivation="fragment",
),
)), additional_predecessors=[source_anchor])
resolution = registry.resolve({
"kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge",
"kind": "edge", "owner_feature_id": "base",
"source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
"source_query": {"ast": {}, "featurescript_version": "1511"},
"source_query": {
"ast": {}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"source_entities": [
{"sketch_id": "sketch_base", "entity_id": "left"},
{"sketch_id": "sketch_base", "entity_id": "right"},
],
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"},
"evidence": "kernel_history",
},
@@ -987,21 +1084,34 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
source = object()
first = object()
second = object()
registry.replace_body_topology("base", "body:base", [
TopologyRecord("base:edge", "edge", "base", "body:base", {}, source),
])
source_anchor = TopologyRecord(
"anchor:base:vertex", "vertex", "base", geometry={}, value=source,
source_entities=(("sketch_base", "left"), ("sketch_base", "right")),
)
registry.register(source_anchor)
registry.replace_body_topology("fillet", "body:fillet", [
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first),
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second),
], topology_delta=TopologyDelta("fillet", (
TopologyDeltaRelation("modified", "edge", source, (first, second)),
)))
TopologyDeltaRelation(
"modified", "vertex", source, (first, second),
source_kind="vertex", result_kind="edge", derivation="fragment",
),
)), additional_predecessors=[source_anchor])
resolution = registry.resolve({
"kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge",
"kind": "edge", "owner_feature_id": "base",
"source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
"source_query": {"ast": {}, "featurescript_version": "1511"},
"source_query": {
"ast": {}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"source_entities": [
{"sketch_id": "sketch_base", "entity_id": "left"},
{"sketch_id": "sketch_base", "entity_id": "right"},
],
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"},
"evidence": "kernel_history",
},
@@ -1010,6 +1120,75 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertIsNone(resolution.record)
self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"])
def test_fillet_consumes_all_proven_fragment_records(self) -> None:
"""A dress-up must consume every record allowed by all_fragments."""
from build123d import Box
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
from cdsl_engine.runtime import ExecutionSession, FeaturePlanNode, execute_node
class RecordingAdapter(Build123dGeometryAdapter):
def __init__(self) -> None:
self.selected_edges: list[object] = []
def fillet_with_topology_delta(self, body, radius_mm, edges):
self.selected_edges = list(edges)
# Selection delivery is the behavior under test. Returning the
# same valid B-rep keeps this focused on the executor contract.
return body, None
adapter = RecordingAdapter()
body = Box(10, 10, 10)
first, second = body.edges()[:2]
source = object()
session = ExecutionSession(sketches={}, nodes={}, adapter=adapter)
session.body = body
session.body_id = "body:fragments"
session.body_members = {"fragments": body}
source_anchor = TopologyRecord(
"anchor:source:vertex", "vertex", "source", geometry={}, value=source,
source_entities=(("sketch_source", "left"), ("sketch_source", "right")),
)
session.topology.register(source_anchor)
session.topology.replace_body_topology("fragments", "body:fragments", [
TopologyRecord("fragments:first", "edge", "fragments", "body:fragments", value=first),
TopologyRecord("fragments:second", "edge", "fragments", "body:fragments", value=second),
], topology_delta=TopologyDelta("split", (
TopologyDeltaRelation(
"modified", "vertex", source, (first, second),
source_kind="vertex", result_kind="edge", derivation="fragment",
),
)), additional_predecessors=[source_anchor])
node = FeaturePlanNode(
"fillet", "fillet", None, ("fragments",), {"radius_mm": 0.5}, ({
"kind": "edge", "owner_feature_id": "source",
"source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
"source_query": {
"ast": {}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"source_entities": [
{"sketch_id": "sketch_source", "entity_id": "left"},
{"sketch_id": "sketch_source", "entity_id": "right"},
],
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"},
},
},), None, "supported", {"id": "fillet"},
)
result = execute_node(node, session)
self.assertEqual(result.status, "executed")
self.assertEqual(len(adapter.selected_edges), 2)
self.assertTrue(adapter.selected_edges[0].is_same(first))
self.assertTrue(adapter.selected_edges[1].is_same(second))
self.assertEqual(
[record["record_id"] for record in session.selector_resolutions[-1]["records"]],
["fragments:first", "fragments:second"],
)
def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None:
registry = TopologyRegistry()
section_edge = object()
@@ -1022,6 +1201,138 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
self.assertEqual(lineage["result_record_ids"], ["boolean:section"])
self.assertTrue(delta["relations"][0]["section_edge"])
def test_boolean_builder_qualifies_section_edges_with_exact_input_faces(self) -> None:
from build123d import Location, Solid
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
adapter = Build123dGeometryAdapter()
target = Solid.make_box(10, 10, 10)
tool = Solid.make_box(10, 10, 10).moved(Location((5, 5, 5)))
result, delta = adapter.cut_with_topology_delta(target, tool)
self.assertTrue(result.is_valid)
self.assertIsNotNone(delta)
self.assertTrue(delta.section_relations)
final_edges = [edge.wrapped for edge in result.edges()]
target_faces = [face.wrapped for face in target.faces()]
tool_faces = [face.wrapped for face in tool.faces()]
for relation in delta.section_relations:
self.assertTrue(any(relation.result_value.IsSame(edge) for edge in final_edges))
self.assertTrue(any(relation.source_values[0].IsSame(face) for face in target_faces))
self.assertTrue(any(relation.source_values[1].IsSame(face) for face in tool_faces))
# Qualified results must not also be exposed through the unqualified
# SectionEdges-only diagnostic channel.
self.assertFalse(delta.section_values)
def test_boolean_section_selector_executes_through_source_qualified_lineage(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
def source_rectangle(points: list[list[float]]) -> list[dict]:
return [
{"type": "line", "start": start, "end": end, "source_entity_id": f"E{index}"}
for index, (start, end) in enumerate(zip(points, [*points[1:], points[0]]))
]
workplane = _workplane()
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "section-selector",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{
"id": "left_sketch", "source_sketch_id": "L", "workplane": workplane,
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": source_rectangle([[0, 0], [10, 0], [10, 10], [0, 10]]),
}]},
},
{
"id": "right_sketch", "source_sketch_id": "R", "workplane": workplane,
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": source_rectangle([[5, -5], [15, -5], [15, 5], [5, 5]]),
}]},
},
]},
"features": [
{
"id": "left", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "left_sketch",
"params": {"distance_mm": 4, "result_mode": "new_body"},
},
{
"id": "right", "atomic_id": "extrude_add_blind", "depends_on": ["left"], "sketch_id": "right_sketch",
"params": {"distance_mm": 4, "result_mode": "new_body"},
},
{
"id": "boolean", "atomic_id": "boolean_bodies", "depends_on": ["left", "right"],
"params": {
"operation": "subtract", "target_feature_ids": ["left"],
"tool_feature_ids": ["right"], "keep_tools": False,
},
},
{
"id": "fillet", "atomic_id": "fillet", "depends_on": ["boolean"], "params": {"radius_mm": 0.1},
"selectors": [{
"kind": "edge", "owner_feature_id": "boolean", "source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "edge", "query_family": "INTERSECT",
"source_query": {
"ast": {"call": "makeQuery"}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"derivation_policy": {"allowed": ["intersection"], "multiplicity": "one"},
"evidence": "kernel_history",
"intersection_sources": [
{
"query_family": "SWEPT_FACE", "owner_feature_id": "left",
"source_entity": {"sketch_id": "L", "entity_id": "E0"},
},
{
"query_family": "SWEPT_FACE", "owner_feature_id": "right",
"source_entity": {"sketch_id": "R", "entity_id": "E3"},
},
],
},
}],
},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "section-selector.step")
self.assertEqual(result["feature_results"][-1]["feature_id"], "fillet")
section_relations = [
relation for delta in result["topology_deltas"] if delta["feature_id"] == "boolean"
for relation in delta["relations"] if relation.get("source_qualified")
]
self.assertTrue(section_relations)
# A primary REMOVE owns no active tool member. It can still retain the
# same source-qualified section evidence only through its transient
# direct-prism tool snapshot.
primary = deepcopy(cdsl)
primary["part_id"] = "primary-section-selector"
primary["features"][1].update({
"id": "cut", "atomic_id": "extrude_cut_blind", "depends_on": ["left"],
"params": {"distance_mm": 4},
})
primary["features"] = [primary["features"][0], primary["features"][1], primary["features"][3]]
primary["features"][2]["depends_on"] = ["cut"]
selector = primary["features"][2]["selectors"][0]
selector["owner_feature_id"] = "cut"
selector["selector_intent"]["intersection_sources"][1]["owner_feature_id"] = "cut"
with tempfile.TemporaryDirectory() as directory:
primary_result = rebuild_cdsl(primary, Path(directory) / "primary-section-selector.step")
self.assertEqual(primary_result["feature_results"][-1]["feature_id"], "fillet")
primary_delta = next(
delta for delta in primary_result["topology_deltas"]
if delta["feature_id"] == "cut" and delta["operation"] == "subtract"
)
self.assertEqual(set(primary_delta["input_snapshot_ids"]), {"body:left", "transient:cut"})
self.assertTrue(any(relation.get("source_qualified") for relation in primary_delta["relations"]))
self.assertTrue(any(record.get("transient") for record in primary_result["topology_records"]))
def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None:
registry = TopologyRegistry()
extrude_start = object()
@@ -1177,6 +1488,124 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "requires a preceding body feature"):
validate_semantic_cdsl(invalid_shell_target)
def test_up_to_surface_consumes_only_an_immediate_direct_cap_output_role(self) -> None:
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from cdsl_engine.semantic_validation import validate_semantic_cdsl
cap_reference = {
"kind": "face", "owner_feature_id": "base_add", "output_role": "extrude.end",
"source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "face", "query_family": "CAP_FACE",
"source_query": {
"ast": {"call": "makeQuery"}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"},
"evidence": "operation_role", "output_role": "extrude.end",
},
}
cdsl = self._base_block()
cdsl["features"][0]["execution_status"] = "supported"
cdsl["features"][0]["params"].update({
"result_mode": "new_body", "end_condition": {"type": "blind", "solidworks_code": 0},
})
cdsl["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
cdsl["features"].append({
"id": "cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {
"distance_mm": 1,
"end_condition": {"type": "up_to_surface", "solidworks_code": 2, "reference": cap_reference},
},
"sketch_id": "cut", "execution_status": "supported",
})
self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"])
self.assertTrue(analyze_cdsl(cdsl).feature_results[-1].executable)
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "up-to-surface-cap-role.step")
resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "cut")
self.assertEqual(resolution["status"], "resolved")
self.assertEqual(resolution["resolution_mode"], "operation_role")
self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"])
non_new_body = deepcopy(cdsl)
non_new_body["features"][0]["params"]["result_mode"] = "fuse"
with self.assertRaisesRegex(ValueError, "direct new_body blind extrusion cap"):
validate_semantic_cdsl(non_new_body)
self.assertIn(
"unsupported_extent_output_role_selector",
[blocker.code for blocker in analyze_cdsl(non_new_body).feature_results[-1].blockers],
)
non_immediate = deepcopy(cdsl)
non_immediate["features"].insert(1, {
"id": "gap", "atomic_id": "reference_plane", "depends_on": ["base_add"],
"params": {"plane": _workplane()}, "execution_status": "supported",
})
non_immediate["features"][-1]["depends_on"] = ["gap"]
with self.assertRaisesRegex(ValueError, "immediately preceding direct new_body blind extrusion cap"):
validate_semantic_cdsl(non_immediate)
self.assertIn(
"unsupported_extent_output_role_selector",
[blocker.code for blocker in analyze_cdsl(non_immediate).feature_results[-1].blockers],
)
def test_shell_consumes_only_an_immediate_direct_cap_output_role(self) -> None:
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from cdsl_engine.semantic_validation import validate_semantic_cdsl
cap_selector = {
"kind": "face", "owner_feature_id": "base_add", "output_role": "extrude.end",
"source": "runtime_snapshot", "confidence": 1.0,
"selector_intent": {
"version": "1.0", "kind": "face", "query_family": "CAP_FACE",
"source_query": {
"ast": {"call": "makeQuery"}, "featurescript_version": "1511",
"standard_library": "onshape/std/geometry.fs",
"standard_library_version": "1511.0",
},
"derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"},
"evidence": "operation_role", "output_role": "extrude.end",
},
}
cdsl = self._base_block()
cdsl["features"][0]["execution_status"] = "supported"
cdsl["features"][0]["params"].update({
"result_mode": "new_body", "end_condition": {"type": "blind", "solidworks_code": 0},
})
cdsl["features"].append({
"id": "shell", "atomic_id": "shell", "depends_on": ["base_add"],
"params": {"thickness_mm": 1, "inward": True}, "selectors": [cap_selector],
"execution_status": "supported",
})
self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"])
self.assertTrue(analyze_cdsl(cdsl).feature_results[-1].executable)
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "cap-role-shell.step")
resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "shell")
self.assertEqual(resolution["status"], "resolved")
self.assertEqual(resolution["resolution_mode"], "operation_role")
self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"])
non_immediate = deepcopy(cdsl)
non_immediate["features"].insert(1, {
"id": "gap", "atomic_id": "reference_plane", "depends_on": ["base_add"],
"params": {"plane": _workplane()}, "execution_status": "supported",
})
non_immediate["features"][-1]["depends_on"] = ["gap"]
with self.assertRaisesRegex(ValueError, "CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap"):
validate_semantic_cdsl(non_immediate)
self.assertIn(
"unsupported_cap_face_output_role_selector",
[blocker.code for blocker in analyze_cdsl(non_immediate).feature_results[-1].blockers],
)
def test_transformed_owner_selector_executes_a_downstream_fillet(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
@@ -2303,6 +2732,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
for item in downstream["selector_resolution"]
))
def test_sweep_add_builds_a_solid_from_two_point_bspline_with_endpoint_tangents(self) -> None:
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "two-point-sweep",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile", "workplane": profile_plane,
"profile": {"type": "circle", "radius_mm": 2},
}]},
"features": [{
"id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile",
"params": {"path": {
"workplane": _workplane(),
"segment": {
"type": "bspline", "start": [0, 0], "end": [6, 10],
"points": [[0, 0], [6, 10]], "parameters": [0, 1],
"start_tangent": [0, 10], "end_tangent": [10, 0],
},
}},
}],
}
self.assertTrue(analyze_cdsl(cdsl).runtime_eligible)
missing_tangent = deepcopy(cdsl)
missing_tangent["features"][0]["params"]["path"]["segment"].pop("end_tangent")
blocker = analyze_cdsl(missing_tangent).feature_results[0].blockers[0]
self.assertEqual(blocker.code, "invalid_sweep_path")
self.assertEqual(blocker.message, "A two-point B-spline sweep path requires both endpoint tangents")
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "two-point-sweep.step")
self.assertEqual(result["solid_count"], 1)
self.assertGreater(result["volume_mm3"], 0)
def test_sweep_history_falls_back_for_hollow_profiles(self) -> None:
from build123d import Edge, Face, Plane, Vector, Wire
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
@@ -2572,6 +3035,71 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
result = rebuild_cdsl(base, Path(directory) / "local-hole.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
def test_scoped_holes_preserve_the_original_body_member(self) -> None:
from cdsl_engine.runtime import prepare_cdsl_execution
cdsl = self._base_block()
host_frame = {
"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0],
"y_dir": [0, 1, 0], "normal": [0, 0, 1],
}
for feature_id, position in (("first_hole", [-2, 0, 0]), ("second_hole", [2, 0, 0])):
cdsl["features"].append({
"id": feature_id, "atomic_id": "hole_wizard",
"depends_on": ["base_add" if feature_id == "first_hole" else "first_hole"],
"params": {
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 5,
"end_condition": {"type": "blind", "solidworks_code": 0},
"positions": [{"mm": position}], "host_face": {"frame": host_frame},
"scope_feature_id": "base_add",
},
})
execution = prepare_cdsl_execution(cdsl)
self.assertTrue(all(result.executable for result in execution.analysis.feature_results))
execution.execute_all()
self.assertEqual(set(execution.session.body_members), {"base_add"})
self.assertLess(float(execution.session.body.volume), 1000.0)
def test_unscoped_hole_keeps_the_legacy_feature_owned_member(self) -> None:
from cdsl_engine.runtime import prepare_cdsl_execution
cdsl = self._base_block()
cdsl["features"].append({
"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
"params": {
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 5,
"end_condition": {"type": "blind", "solidworks_code": 0},
"positions": [{"mm": [0, 0, 0]}],
"host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}},
},
})
execution = prepare_cdsl_execution(cdsl)
self.assertTrue(all(result.executable for result in execution.analysis.feature_results))
execution.execute_all()
self.assertEqual(set(execution.session.body_members), {"hole"})
def test_scoped_hole_requires_a_sole_live_member_before_execution(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = _two_body_boolean_cdsl(
"union", _rectangle([-5, -5], [5, 5]), _rectangle([20, -5], [30, 5]),
)
cdsl["features"] = cdsl["features"][:2]
cdsl["features"].append({
"id": "scoped_hole", "atomic_id": "hole_wizard", "depends_on": ["left_body", "right_body"],
"params": {
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 2,
"end_condition": {"type": "blind", "solidworks_code": 0},
"positions": [{"mm": [0, 0, 0]}],
"host_face": {"frame": {"origin_mm": [0, 0, 4], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}},
"scope_feature_id": "left_body",
},
})
analysis = analyze_cdsl(cdsl)
result = analysis.feature_results[-1]
self.assertFalse(result.executable)
self.assertIn("hole_scope_body_ambiguous", [blocker.code for blocker in result.blockers])
def test_counterbore_and_countersink_holes_execute_with_explicit_host_frames(self) -> None:
"""Keep both legacy hole contracts covered by an actual kernel rebuild."""
from cdsl_engine.runtime import rebuild_cdsl
File diff suppressed because it is too large Load Diff
@@ -133,6 +133,21 @@ def test_author_operation_context_exposes_server_injected_selector_contract():
assert context["authoring_sketch_template"] is None
def test_authoring_requirements_context_does_not_leak_open_analysis_fields():
context = WorkflowCoordinator._authoring_requirements_context({
"explicit_requirements": ["make a plate"],
"assumptions": ["use millimetres"],
"manual_targets": ["inspect the result"],
"acceptance_targets": [{"kind": "plate", "expected": {"host_face": "max_z"}}],
})
assert context == {
"explicit_requirements": ["make a plate"],
"assumptions": ["use millimetres"],
"manual_targets": ["inspect the result"],
}
def test_schema_repair_hint_contains_the_exact_authoring_sketch_form():
assert "diameter_mm" in WorkflowCoordinator._repair_hint(
"AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile",
+346 -13
View File
@@ -202,6 +202,18 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖
copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member
chain 仍属未完成 contract,继续保留明确诊断。
`SWEPT_BODY@1511` 另有一条不能与上述 successor lineage 混同的即时终止 contract:只有
singleton `qUnion([makeQuery(..., SWEPT_BODY, EntityType.BODY)])` 引用紧邻前序独立、无 draft、
`new_body` blind prism,且其 producer body record 仍唯一 active 时,单侧 `up_to_body` 才能
`active_body_member` / `direct_new_body` 解析该 record。resolver 以 producer identity 与
active-member lifecycle 证明 `body_member`,不读 stable ID、binding ID、几何、aggregate 或
current body。`00694309` F3 是正向 body-member evidence;该样本的 F4 strict/RP pass 由下述
独立 `CAP_EDGE` continuation contract 证明,而非放宽 body-member 规则。later successor、`ADD`、cut、
revolve、sweep、`COPY`、boolean/pattern/delete、多 body/multiple-query 和其它 FeatureScript
version 都不属于这个 contract。fresh `output/core-17-swept-body-up-to-body-member-20260910`
`tier=all` pipeline/RP run 完成 17/17,分类为 1 `comparison_timeout`、10 `rebuild_failed`
1 `rebuilt_approximate`、5 `rebuilt_rejected`;它只证明本窄 selector contract 未改变核心集
的既有分类,不能标记 general `SWEPT_BODY``up_to_body` 完成。
2. 在 adapter 层记录每次建模操作的 `preserved``modified``generated``deleted`
topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector
绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体
@@ -232,7 +244,13 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
sweep/loft direct-builder cap roleregistry 只会在
relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`resolver 要求
owner、active body snapshot 与唯一 role 命中,并拒绝 stable ID、geometry、snapshot ID
混用。CADFS lowering 现对独立 `new_body` blind prism 的 CAP_FACE,以及满足全部条件
混用。`extrude_add_blind`/`extrude_cut_blind`
`params.end_condition.reference``shell``feature.selectors` 各有一个有界 direct-cap
contract:仅紧邻前序、`result_mode: new_body`、blind extrusion 的 `extrude.start`/
`extrude.end` 可作为 `up_to_surface` reference 或 shell removal face,且必须由该 producer 的
完整/已证明 cap role 解析;schema、semantic preflight 与 runtime 同时拒绝非紧邻 producer、
其它 role、stable ID、binding ID 和 geometry fallback。CADFS lowering 现对独立 `new_body`
blind prism 的 CAP_FACE,以及满足全部条件的
`LocOpe_DPrism` drafted extrusion 生成该 contract:单一闭合外环、无内环、单向 blind
extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把
CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨
@@ -244,8 +262,99 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差
`0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行
前缀,不伪造可绑定 selector。
3. 建立统一 selector resolverowner body、source feature、output role、几何签名、
snapshot 和唯一性证明。找不到或多解必须稳定诊断。
`TopologyDeltaRelation` 还必须显式记录 `source_kind``result_kind`,禁止把 face、edge
或其它 subshape 的 relation 混作同一种后继。每个 `TopologyDelta` 必须随操作输出 input/
output snapshot、body member 和 history 状态证据;当前 contract 的状态为 `proven`
`partial``unknown``rejected`,并可带原因。只有完整、可追溯的 kernel relation 才能
进入 lineage`IsDeleted` 不等于存在 replacement,缺失的 `IsDeleted` 也不等于 deleted。
boolean 的 section edge 可以作为 builder 生成事实记录,但没有 source-face 的精确 relation
时不得被提升为 source-qualified lineage。当前 `INTERSECT@1511` 仅有一个受限 consumer
explicit `boolean_bodies` 的单一 target/tool subtract/intersect 中,两个 direct-prism
`CAP_FACE`/`SWEPT_FACE` input 必须分别由 OCC `Generated(face)` 返回同一 `SectionEdges()`
edge,且三个 handle 都以 `IsSame` 绑定 input/final snapshotresolver 再要求唯一 relation、
`intersection`/`one` policy 和 active result member。另有一条同样受限的 primary `REMOVE` 路径:
immediate `extrude_cut_blind` 只可在一个 active target solid/member、一个 direct-prism tool、完整
source anchor/history 且 source query 没有未核实 disambiguation 时注册 `transient:<feature>` tool
snapshot;它仅作紧随 cut 的 source-qualified section 历史输入,永不成为 active/selectable topology。
普通 `SectionEdges()`、多输出、keep-tools、多 member、COPY/transform/pattern/TDD/IMPRINT/non-prism、
primary query 的未核实 `disambiguationData` inputs 均不执行。
这不构成通用 INTERSECT coverage,真实语料 RP 矩阵仍待补齐。这些记录是通用 topology 事实,不是由 stable ID
或最终几何相似性反推的替代品。
3. 建立统一 selector resolver,并让 runtime 与诊断性的 prefix binder 共用
`TopologyRegistry.resolve` 及已导出的 topology facts。resolver 按“语义 source -> kernel
lineage -> derivation policy/cardinality -> active body member”顺序绑定,报告实际分支使用的
`resolution_mode` 和 evidence,而不是从 selector 字段事后推断模式。stable ID 只可定位
source,不能直接在 active record 上成功;owner、output role 或 geometry 也不能绕过完整
lineage、允许的 derivation policy、operation-wide cardinality 与 active-body 证明。
`none``source_qualified``one``all_fragments` 都必须有明确、可审计的语义:merge 即使
最终只有一个 result 仍按 N:1 检查,`all_fragments` 遇到任一分支缺失或 partial 必须拒绝。
resolver 失败不得自动改走 legacy 成功路径;只有已文档化的 legacy explicit-geometry selector
可保留 geometry fallbackprovenance/COPY instance selector 不得回退到几何相近、current body
或普通 context selector。找不到、多解、历史不完整、版本不支持和 policy/cardinality 不满足
都必须稳定诊断并保留可执行前缀。
`selector_intent.version` 是唯一 canonical intent version;顶层
`selector_intent_version` 仅为兼容字段,存在时必须一致。schema 可接收只有
`selector_intent` 的语义 selector,并将 FeatureScript 标准库路径、精确 import version、全部 direct
import 列表与 source language version 元数据和 runtime ID 分离保存。lowering 不得为缺失 source
version 虚构 `"0"`resolver 应返回 `selector_query_version_unknown`。query family/language
version/direct standard-library revision 通过显式 allow-list 注册,不得从 `15xx` 数字前缀、相同 import
path 或单一样本推断兼容性。当前
`FEATURESCRIPT_QUERY_CAPABILITY_MATRIX.md` 将 FeatureScript `1511` 的受限
`CAP_FACE``OFFSET_FACE` 以及 direct-prism `SWEPT_FACE`/`SWEPT_EDGE` contract 标为
端到端可执行。后两者只适用于紧邻独立、无 draft、`new_body` blind prism 的 consumer
adapter 必须保留 direct source-profile edge/vertex construction handle,并分别由
`BRepPrimAPI_MakePrism.Generated(edge)` 的 edge -> side-face 或
`Generated(vertex)` 的 vertex -> vertical-edge relation 完整证明;resolver 再检查
`boundary` policy、source qualification 和 active member。`00111611` F3、`00974931` F2、
`00594348` F2 和 `00000715` F3 是这些受限路径的真实语料证据。`SWEPT_EDGE` 的 direct
profile contract 现还接受一个 analytic contour region 的 hole wiresadapter 将外环和全部内环一次
交给 `BRepBuilderAPI_MakeFace`,只在 source edge/vertex 经 finished-face `IsSame` 验证后登记
anchor`00007264` F2 的四个 selector 已 `kernel_lineage` resolved(最终 comparison rejected),
`00039669` F2 strict/RP passing`00151159` F2 RP passing。含 hole 的多 region profile 必须继续走
既有 `Face.make_holes` geometry path 且不登记 anchor,避免改变 prism combine 语义;`00248377`
该拒绝/保留边界,仍导出 F4 checkpoint。`00594348` 的 immediate
shell 仅接受 direct source 的 non-construction line wall,并从同一 edge -> face relation
解析两张 removal face;它不输出 geometry 或 stable-ID hint。direct all-circle construction 可保留一个
annular hole 的 outer/inner source edge;多个独立 direct circle region 只在每个 prism
result 都仍位于 final snapshot 时保留各自 relation(当前为原子证据,尚非多语料完成项)。
`CAP_FACE` 还可作为上述受限 `up_to_surface` nested reference 或 immediate shell removal
face 的 cap role`00212904` F2 与 `00789939` F2 分别以 `extrude.start``extrude.end`
`operation_role` 执行。fresh `output/cap-face-immediate-shell-00212904-20260910-v1`
证明前者执行到 F5,但 strict/RP 均拒绝;fresh
`output/cap-face-immediate-shell-00789939-20260910-v1` 证明 F2 及后续同一 direct-cap
shell F5 都执行,F6 仍为 `selector_query_unsupported` 并保留 F5 STEP checkpoint。fresh
`output/core-17-cap-face-immediate-shell-20260910-v1``tier=all` / pipeline / RP run 为
17/17 completed、1 `rebuilt_strict`、1 `rebuilt_approximate`、6 `rebuilt_rejected`、8
`rebuild_failed`、1 `comparison_timeout`;其中 `00212904` 的分类由 selector failure 变为
executable-but-rejected,不能将此记为几何相似通过。它们不允许在 producer 后续 mutation、
non-immediate owner、stable ID、binding ID 或 geometry fallback 中继续解析;
direct-prism `SWEPT_FACE` 也可作为单侧 `up_to_surface` reference,但它不是 output rolelowering
只保留一个 direct source-profile edge anchorresolver 必须逐段验证 `boundary` 以及后续
`continuation` 的 complete/proven kernel relation、operation-wide cardinality 和 active body。
`00925274` 的 F3 以 `extrude.start``operation_role` 解析;F6、F9、F12 与 F15 都以 F1 E0
side-wall `kernel_lineage` 依次经过 complete/proven 的 F3、F6、F9、F12 target-side subtract
continuation 解析。单实体 primary cut 现在始终保留其 cut builder 的 target snapshot history;仅
transient tool snapshot 仍要求 direct-prism tool history,故没有扩大 source-qualified `INTERSECT`
的工具可选范围。resolver 只在 selector 请求的 source/result topology kind 内计算 complete/proven
final-snapshot relation 的基数;没有 final snapshot member 的 partial intermediate handle 及 cross-kind
section diagnostic 保留在 delta 中但不伪造成 face fragment。任何绑定到 final result 的 partial、split、
merge、inactive 或无 history relation 仍稳定拒绝,绝不以 geometry、stable ID、binding ID 或 current
body 猜测。fresh `output/core-17-swept-face-final-20260910``tier=all` / pipeline / RP run 完成
17/172 `rebuilt_strict``00694309``00925274`)、1 `rebuilt_approximate`、6
`rebuilt_rejected`、7 `rebuild_failed`、1 `comparison_timeout``00925274` 为 strict/RP pass,不能
推广为一般到面终止或 general SWEPT_FACE completion。受控完整 `0040` shard 的 fresh
`output/shard-0040-swept-face-final-20260910-v2` 也完成 71/717 strict、9 approximate、16 rejected、
34 rebuild-failed、3 runtime-ineligible、1 timeout、1 deferred,并保留 32 个 comparison artifact;它是
shared runtime 回归分类证据,不把非 strict/RP 样本重述为 selector success。IMPRINT/split source profile、draft、多/two-sided extent、fused source result、未证明或
split continuation、boolean/SPLIT 后继、revolve、
sweep、loft、copy/pattern 及一般 SWEPT query 仍未完成,绝不能标记为 query-family 完成。
CADFS production rebuild 以一个 `IncrementalCdslExecution` 按 history 顺序执行:每个 feature
在执行前只对本 session 已登记的 topology snapshot 调用 resolver,随后立即执行并登记新的
body/history evidence。`binding_feature_id` 读取同一 session 保留的历史 snapshot,不能通过
重建 prefix 或复制 OCC body 获得结果。feature 或 selector 失败时,系统直接从该 session 导出
最后已执行 checkpoint;不会为尝试更早 prefix 而重放历史。`bind_candidate_selectors` 仍可为
不完整的外部/诊断 CDSL 显式运行 legacy prefix adapter,但不得进入 production success/failure
path,也不得将 provenance 或 output-role selector 降级为 geometry binding。
4. 完成草图 region/wire 模型:多轮廓、内环、开口 reference geometry、B-spline/ellipse、
profile query、显式 construction 和退化检测。
非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且
@@ -263,7 +372,62 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
`SWEPT_EDGE``OFFSET_FACE``INTERSECT``MID_CAP_EDGE`,将其物化为可追溯的
profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、
法向同向的 `LocOpe_DPrism` draft CAP_FACE,是受限例外:它们以 builder-proven output
role 直接消费物理 B-rep 面,未试图物化或重建 profile。
role 直接消费物理 B-rep 面,未试图物化或重建 profile。`OFFSET_FACE` 的端到端 status
同样只限于 capability matrix 所列、带 TDD source cap 的 direct shell output role;它不代表
任意 offset query 已可重放。`SWEPT_FACE``SWEPT_EDGE` 现在各有一条 1511 direct-prism
纵向 contractsource profile edge 经 `Generated(edge)` 到 side face,以及由完整 incident
source-edge set 唯一命名的 source vertex 经 `Generated(vertex)` 到 vertical edge。lowering 只在
producer 紧邻、独立、无 draft、`new_body` blind prism 且原 profile 未变时产生语义 selector
adapter 只将 direct wire/face construction 中 exact `IsSame` anchor 注册为 transient source fact
resolver 则只沿 complete/proven 跨 kind relation 解析。单一 analytic region 的 direct hole wires
也可在 finished face identity 未改变时提供这份 evidence`00007264` 的四个 vertical-edge selector
皆已解析,`00039669` strict/RP passing`00151159` RP passing;含 hole 的 multi-region profile
继续使用旧 geometry path 并明确不产生 anchor。`00111611` F3 的圆 edge -> side face、
`00974931` F2 的 direct annulus outer edge -> side face,与 `00000715` F3 的两个 incident-edge
pair -> vertical edge 为其它真实语料证据。direct all-circle construction 还保留 annular inner edge
及每个 direct circle region 的独立 delta;后者目前只有原子 executor evidence。IMPRINT 或 split
profile、draft、multiple/two-sided extent、fused result、boolean/SPLIT、revolve、
sweep、loft、copy/pattern 及 profile/wire consumer 也未覆盖。因此这只是两个受限 end-to-end
contract`SWEPT_*` family 和 P1 的通用派生 profile 目标仍未完成,不能由 AST、unit relation
或上述两个样本提前完成。
`CAP_EDGE` 现在也有一条独立的 1511 direct-prism contract,但它不物化 profile/wiresource
sketch entity 必须仍对应一个 exact direct-profile edgequery 的 `isStart` 被保存为
`lineage_role: extrude.start|extrude.end`adapter 以
`BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` 取得 cap edge,并以
final snapshot 的 `IsSame` 证明该 handle 未失效。resolver 只沿这个 source edge 与 role-qualified
`boundary` relation 解析;stable ID、几何、owner propagation 和 current-body 均不是回退路径。
`00021014` F2 的九个 CAP_EDGE selector 覆盖两个 role,完整 history strict/RP 通过。该例外只接受
紧邻、独立、无 draft 的 `new_body` blind prism 与未变 direct profile。一个 direct all-circle
annulus(一个外圆和一个包含的内圆)也可保留 outer/inner 的 exact source anchor`00479470`
`00501170``00526649` 在 F2 的 CAP_EDGE consumer 后 RP 通过,`00621329` strict/RP 通过;
`00566233` 的两个 F2 selector 已由 kernel lineage 解析,随后才因 OCC chamfer feasibility 失败,
`00614954`/`00678961` 也都在 F2 解析后才被后续未支持 query 截断。对于线段外环加圆孔,solver
仅给同一 source circle 拆出的全部四条 arc 加 `logical_circle_source_entity_id`adapter 再要求这个
ID 在原 profile 中唯一指向一个未拆分圆、重建 native wire,并以 finished-face `IsSame` 重新证明。
任一 marker、source 或 final membership 缺失都没有 anchor。`00735367` 的 F2 因而以
`kernel_lineage` 解析、F1--F3 执行,随后 F4 才以 `selector_query_unsupported` 导出 F3 checkpoint。
不完整 logical-circle loop 不产生该 circle 的 anchor;含 hole 的 multi-region、trimmed/split profile、
draft、multiple/two-sided extent、fused result、boolean/SPLIT successor、revolve、sweep、loft、copy/
pattern 继续拒绝。
对 direct-prism `CAP_EDGE` 的 fillet/chamfer consumerboundary-only policy 另可在运行时存在
完整 kernel continuation 时扩展为 `boundary + continuation`:每一跳都必须为 exact
`preserved`/`modified` relation、`coverage: complete``status: proven`,且 operation-wide
cardinality 保持 1:1;任何 partial、split/merge、inactive result 或无 history 都稳定拒绝。
`00694309` 证明这一条:F4 的 F1 `extrude.start` cap edge 经 F3 primary-cut `preserved`
continuation 解析为 `body:f_F3:edge:1`,完整 pipeline `output/cap-edge-continuation-after-primary-cut-20260910-v1`
的三 feature history strict/RP passing。任何 boolean/SPLIT、COPY/pattern 或其它中间操作也必须
提供同样完整的 1:1 relation;它们目前没有独立的 corpus coverage,不能仅按 owner/operation 名称
通过。这个 contract 也不接受任意 CAP_EDGE source,且没有 geometry、stable-ID、
binding-ID 或 current-body fallback。fresh
`output/core-17-cap-edge-continuation-20260910-v1``tier=all` / pipeline / RP run 完成 17/17
1 `rebuilt_strict``00694309`)、1 `rebuilt_approximate`、5 `rebuilt_rejected`、9
`rebuild_failed`、1 `comparison_timeout`。这只确认该完整 relation contract 将此样本从 F3
executable prefix 推进为实际 strict/RP pass,不能作为 CAP_EDGE family completion 或 general
boolean continuation 的证据。
共享 selector/runtime suite 为 261 passed、1 skipped;强制 core-17
重跑 `output/core-17-direct-prism-cap-edge-hole-20260910` 为 1 RP、5 rejected、10 rebuild-failed、1
comparison timeout,与 selector-migration 基线分类一致。因此 `CAP_EDGE` family 与 P1 的通用派生
profile 目标仍未完成。
`OFFSET_FACE` 现有一个独立的受限 profile 物化路径:只有 inward shell 直接消费一个
`new_body` blind additive prismshell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向
其未修改、单闭合凸线性 profile 中的一条非 construction linelowering 才根据 source
@@ -302,11 +466,23 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
`INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始
analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment sidesolver
仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际
B-rep region。没有唯一 fragment、选中无界 support-boundary region、construction/source
alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc 采样成多边形,也不复用
整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline arrangement 的 shared
contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/pattern 后继或完整
FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。工件
B-rep region。top-level `IMPRINT FACE` 的 multi-face query 只有既有的
`_profile_selection_sketch` 不能将它缩减为单一/圆形/open 专用 profile 时,才进入该 typed
arrangement profile;不能把 nested `INTERSECT` 误当作 outer profile family,也不能抢占
existing circle/open/surface IMPRINT contracts。没有唯一 fragment、选中无界 support-boundary
region、construction/source alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc
采样成多边形,也不复用整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline
arrangement 的 shared contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/
pattern 后继或完整 FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。
`output/planar-imprint-multiface-20260910` 的 seven-source forced RP matrix 验证这个收紧后的
分派:`00304488``00253824` rebuilt_rejected`00354246` 在 F2 deferred `SWEPT_FACE` 时保留
F1 checkpoint`00306689` 在 F1 OCC `BRep_API: command not done` 失败,`00784127` 在 F1、
`00782218` 在 F2 因 selected region unbounded 拒绝(后者保留 F1 checkpoint),`00429745`
在 F1 OCC extrude 无有效 solid 时拒绝。该 matrix 不把任一 IMPRINT-derived profile 的下游
`SWEPT_FACE` 视为完成:它们保留 source intent 并由 shared resolver 有界拒绝。当前 shared
selector/runtime suite 为 262 passed、1 skipped;强制 core-17
`output/core-17-planar-imprint-multiface-20260910` 为 1 RP、5 rejected、10 rebuild-failed、1
comparison timeout,与 selector-migration 基线一致。工件
`output/planar-imprint-intersect-20260909-runtime` 是 CAP_FACE 扩展前的历史工件:
`00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false``00071859`
`00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。
@@ -350,6 +526,26 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持
independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的
derived-topology selector 语义,CADFS lowering 也尚未以此表示一般 CAP/SWEPT query。
capability matrix 中 `SWEPT_FACE@1511``SWEPT_EDGE@1511` 另有 direct-prism 的受限
end-to-end contract:独立、无 draft、`new_body` blind prism 的 direct source profile edge/
vertex(含一个 analytic region 的 exact hole wires)由 exact construction anchor 和 `Generated`
cross-kind history 绑定给紧邻 consumer;含 hole 的 multi-region profile 不产生这类 anchor。
这不覆盖本段 sweep/loft/revolve 的 output role,亦不把任一受限 output role 或特征专用
selector 升级为通用 CADFS query-family 支持。
`sweep.path`,另有一个 source-sketch-only lowering contractFeatureScript 1511 的 exact
`qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` 仅在 source 还直接 import
`onshape/std/geometry.fs@1511.0`、owner 是 source
sketch、恰有一个 non-construction `line``bspline` entity 时解为路径,不能作为 runtime
`qBodyType` selector。两点 `skFitSpline` 只有 source 明确给出 start/end derivative 时才会保留为
B-spline 并由 OCC 执行;没有两个导数时拒绝,绝不改成 chord。`00896761` F2F1/E2 two-point
B-spline)现在 converted/rebuilt 且无 runtime diagnosticartifact bundle
`output/qbodytype-direct-sketch-wire-20260910-v2` 包含 STEP、GLB 和七视图;但 RP comparison 在 60s
预算超时,分类为 `comparison_timeout`,不是相似通过。fresh seven-sample RP matrix
`output/qbodytype-direct-sketch-wire-matrix-20260910-v1` confirms all direct-path cases rebuild: `00191739`
strict, `00726304` RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` rejected, and
`00896761` timed out during comparison. These classifications are evidence of executable paths, not a claim
that rejected/timeout output is similar. `00786708` F2 因多个 non-construction path entity 继续 deferred。这不是 generic `qBodyType`、qConstructionFilter、sweep
path、loft guide、arc/circle path 或 sweep complete 的完成声明。
2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role
evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或
two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非
@@ -413,7 +609,22 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
都不继承该 contract。该受限路径不覆盖 sweep、surface/partial/fused revolve、
multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一
端点、axis 或 owner 不唯一时必须保留前缀并诊断。内核不能完成时不能伪造较小半径或不同
孔型。
孔型。这里的 endpoint-bbox/revolve selector 不属于 capability matrix 的 direct-prism
`SWEPT_EDGE` provenance contract,不能借其成功。只有该受限 blind-prism path 具备 query
source semantics、lowering、adapter evidence、consumer 和真实语料回归;其它 generator/
consumer 仍须逐一补齐后,才可将整个 query family 标记为完成。
`hole` 现增加一个独立且更窄的 source-location/body-scope contractlowering 仅接受原始
`sQuery`/`sketchEntityQuery(VERTEX, ...)``skPoint``skCircle.center``skLineSegment`
`skArc``.start`/`.end`,并要求所有 location 都来自同一显式草图平面;派生 suffix、
CAP/SWEPT/COPY topology、query combinator 与几何邻近性均不会成为 location fallback。CADFS
`scope` 必须是一个直接 `SWEPT_BODY`,并在 lowering-time body graph 中仍是唯一独立成员,才写入
`hole_wizard.params.scope_feature_id`。schema、semantic validation、capability preflight 和 executor
共同检查该成员仍是唯一 active solidexecutor 在每次切除后保留原 scope member key,因而后续
CADFS hole 可以继续引用同一 body,而无 scope 的既有 CDSL hole 仍保留 feature-owned lifecycle。
`00406667` 的 F1/F3/F5 是端到端证据:两个 `skCircle.center` hole 与同一 F1 revolve scope 完整执行,
`output/hole-direct-sketch-vertex-20260910-v2` 分类为 `rebuilt_approximate`RP passedstrict failed)。
这不覆盖 B-spline/interior vertex、多个 host plane、多个 scope body、COPY/boolean/pattern successor、
non-direct query、全部 hole start/end styles 或完整孔型/螺纹语义。
4. `booleanBodies``mirror``circularPattern`:显式 source/target bodies、实例输出、
nested pattern、remove/intersect/keep-tools 和 transform 后继。当前 direct circular
`NEW` COPY -> rigid transform,以及 direct mirror 的 `NEW` source -> `instance 1`
@@ -471,6 +682,69 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
`output/core-17-cplane-offset-opposite-20260909` 分类保持 3 strict、3 RP、9 rejected、1
rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖
曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。
`LINE_ANGLE` 现额外覆盖单一 direct `skCircle` edgeFeatureScript 1511 的
`cplane.fs::lineAnglePlane` 将该选择传入 `evAxis`,而同版本 `query.fs` 将 circle
列为 `ALLOWS_AXIS`。lowering 因此只用 circle centre 与 source sketch plane normal
作为 axis,并按同版本 `vector.fs::perpendicularVector` 的确定性阈值分支适配 zero-angle
direction,再按
`angle``oppositeDirection` 的 signed angle 旋转;不会以 circle centre vertex、
`CAP_EDGE`/`SWEPT_*`/COPY 等派生 topology 或 current body 替代这个 source contract。
该语义依据来自 MIT 许可的 Onshape standard-library mirror 的 1511.0 commit
`eaef87b22d3b15e9377d8b1c6cb701f8111b6a0c`;适配该 MIT 算法但未引入依赖。`00030209`
的 F1/F2/F4/F5 是现有的四个真实 direct-circle evidence
`output/cplane-line-angle-direct-circle-20260910` 不再含 cPlane diagnostic,并保留
四个 executable `reference_plane`。该 source 后续因 arc sweep path 未实现而没有
solid featurepipeline 正确分类为 `runtime_ineligible`,没有将工作平面 lowering
误报为 STEP/RP 成功。多实体 reference、direct arc/cylinder/cone 等其他 `evAxis`
输入和一般 derived topology 仍未覆盖。focused shared selector/runtime suite 为
`263 passed, 1 skipped`142.98 s;仅既有 build123d deprecation 与 workplane
re-orthogonalization warnings)。强制 RP Core-17 refresh
`output/core-17-line-angle-direct-circle-20260910` 为 1 `comparison_timeout`、10
`rebuild_failed`、1 `rebuilt_approximate`、5 `rebuilt_rejected`,与既有核心集分类一致;
它只证明共享改动没有改写这些既有结果,不构成 generic `LINE_ANGLE` 或 STEP/RP success。
两实体 `LINE_ANGLE` 现额外覆盖 source-only contractdirect `skLineSegment`/`skCircle`
提供 axis,默认 datum 或已 lower reference plane、direct `skPoint` 或 direct line endpoint
提供第二 reference。实现严格镜像 1511 `cplane.fs::lineAnglePlane`:第一个 selection
不是 axis 而第二个是 axis 时交换;两个非平行 axis 用第二 direction,平行 axis 用两 origin
之差,plane 用 `cross(axis, plane.normal)`point 用 `point - axis.origin`,再以 signed angle
旋转 `cross(axis, secondInPlaneDirection)`。共线/零长度选择明确 defer。此 direct branch
只接受未组合的 `sQuery`/`sketchEntityQuery`,不通过 `_query_line`、current body 或 geometry hint
推导 CAP/SWEPT/COPY/trim/derived query
它继续引用 MIT-licensed Onshape standard-library mirror commit
`eaef87b22d3b15e9377d8b1c6cb701f8111b6a0c`,未引入依赖或复制外部代码。lowering regression
覆盖 datum-first swap、axis-first、nonparallel/parallel-offset axes、direct point、reversed
point/axis、`oppositeDirection`、degenerate reject,并断言 direct helper 不接受 `CAP_EDGE`
真实 matrix `output/cplane-line-angle-two-entity-matrix-20260910` 的六条直接 axis/default-plane
history`00067847``00185138``00217351``00298026``00586156``00831400`)均无 cPlane
diagnostic 且保留 rebuild STEP`00586156` strict/RP pass`00298026` RP pass/strict diagnostic
前三条为独立 `rebuilt_rejected``00831400` 在后续 F5 `BRep_API: command not done` 保留 prefix。
这只证明受限 frame lowering,不能将后续 geometry 分类归为 `LINE_ANGLE` 成功;direct arc、
non-source vertex、任意 derived topology、一般 face/curve/mate-connector axis 和完整 cPlane
coverage 仍未完成。既有圆柱母线 compatibility path 也已收紧为单一 direct `makeQuery`
`CAP_EDGE``SWEPT_FACE` 的 source `skCircle`,要求 producer start/end frame 与另一显式 datum/
lowered reference planeqAdjacent/qUnion composition、CAP/SWEPT line、trim/COPY/boolean source 均稳定
defer,不能因 AST 内出现 source token 获得 `_query_line` fallback。1511 `query.fs`/`evaluate.fs` 确实
将 arc 列为 `ALLOWS_AXIS`/`evAxis` 输入,但当前 9,347 条 corpus 未发现 direct `skArc`
`LINE_ANGLE` 实际选择的 history;这只是待验证语义,不能提前宣称 direct-arc coverage。
`CURVE_POINT` 现额外接受同一 direct source curve 的精确切线:`skArc.start/end` 由解析
圆心、半径和方向生成切线,`skFitSpline.start/end` 只在 FeatureScript 导出了相应 endpoint
derivative 时执行。它还受限支持 source token `E<n>.<index>.internal`:必须是同一 direct、
non-periodic `skFitSpline`,并有完整 interpolation points、centripetal parameters 与两端
derivatives。lowerer 复用 runtime 的 `Edge.make_spline(..., scale=False)` 构造,再在显式
parameter[index] 以 OCC `BRepAdaptor_Curve.D1` 读取导数,同时核验返回点仍等于 source
interpolation point;绝不以 adjacent interpolation-point chord 代替曲线切线。`00827798`
F1 与 `00845891` F3 是两个真实 B-spline endpoint 证据,`00456498` F1 (`E0.2.internal`)
`00812557` F2 (`E0.3.internal`) 是 source interpolation-token 证据;`00057273` 的受控
`skArc` endpoint 变体覆盖圆弧方向。derived suffix、越界 index、无完整 interpolation
data、无导数/periodic spline、curve/surface topology source 和退化 tangent 继续明确拒绝。
两条新增样本 pipeline `output/cplane-curve-point-bspline-interpolation-20260910` 均已无
cPlane diagnostic`00456498` 在后续 sweep OCC solid failure 停止,`00812557` 在后续
sweep OCC valid-solid failure 停止,保留诊断和可用工件,不误报为整体成功。shared
selector/runtime suite 为 `277 passed, 1 skipped`(既有 workplane re-orthogonalization
warning)。RP Core-17 refresh `output/core-17-cplane-curve-point-bspline-interpolation-20260910`
为 1 `comparison_timeout`、10 `rebuild_failed`、1 `rebuilt_approximate`、5
`rebuilt_rejected`,与 prior semantic-selector baseline 分类一致;它不构成完整 cPlane
或 generic curve-attachment completion。
### P3:尚未支持的 FeatureScript 操作
@@ -495,14 +769,22 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
一项能力仅在满足以下条件时可标记完成:
1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。
1. 有版本化的 CDSL 表达,且 schema/semantic validation 拒绝不完整或歧义输入。
FeatureScript query`selector_intent.version`、source query family、精确 language version 和直接
standard-library import path/version 必须有明确契约;缺版本和未注册 family/language/library revision
必须诊断,不能以 legacy 顶层版本、数字前缀、相同路径或默认版本补齐。
2. lowering 仅根据 FeatureScript source 产生该表达,并保留 source feature、selector
和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为
部分完成。
3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。
3. runtime/adapter 按通用算法执行,记录结果 body 和 topology delta,不依赖样本信息。派生
selector 必须具有 source/result kind、输入/输出 snapshot、body member、history status 及
kernel relation evidenceresolver 必须据此证明 derivation policy、operation-wide cardinality
和 active member,并输出实际 `resolution_mode` 与 evidence。
4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和
未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖
不同几何和生命周期组合。
不同几何和生命周期组合。只有 parser/AST 或 resolver unit test 的 query family 只能标为
resolver-level/部分完成;必须同时具备 lowering、schema、adapter、consumer 和多个真实样本
的端到端证据后才可标为 complete。
5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余
exception 均更新到本地台账。
6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为
@@ -518,3 +800,54 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力”
和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须
保留输出,不能因未满足最终几何目标而丢弃。
## Selector 迁移台账(2026-09-09
已完成第一条迁移边界,而非完整 query-family 实现:CDSL schema 允许保留未知的
大写 FeatureScript query familyCADFS lowering 对全部 topology fallback 写入 source
AST、版本、语义 family 和 `feature_script_query` evidence。未在版本化 capability matrix
登记的 query 使用非空 derivation policy 加 `multiplicity: none`,因此 resolver 在 stable ID、
geometry、owner 或 current-body fallback 前稳定返回 `selector_query_unsupported`。嵌套的
extent、COPY 和 INTERSECT selector 同样递归写入 source metadataCOPY/INTERSECT 保留外层
query family,不错误继承其内部 CAP/SWEPT component。显式 datum plane 单独为
`GEOMETRIC` / `explicit_datum`,不构成派生 topology 的逃逸通道。
此前 core-17 candidate inventory 中有 14 个 runtime-snapshot edge、7 个 runtime-snapshot
face、7 个 SolidWorks face 和 2 个 plane legacy selector,只有少数 direct-prism provenance
selector。此次不把这些历史 geometry hint 宣称为 lineage`00005267`FeatureScript 1793F4
的四个 loft `SWEPT_EDGE` 已从 strict geometry success 改为 F3 executable checkpoint 加
`selector_query_unsupported`。同类未实现的 CAP/SWEPT/OFFSET/COPY/INTERSECT paths 必须保留
候选、bound CDSL、最后 STEP/GLB(可生成时)和诊断,直到具备 source API evidence、lowering、
adapter history、resolver policy 和 corpus regression 的完整 contract。受限
`CAP_EDGE@1511``SWEPT_FACE@1511``SWEPT_EDGE@1511` direct-prism contracts 不受影响;
它们仍只在 capability matrix 的已证明边界内执行。
2026-09-10 的 source-version migration 补齐了 import-level evidenceparser 逐条保留直接
`onshape/std/*` import 的 `{path, version}`CDSL 顶层 `source_featurescript` 和嵌套 selector
`source_query` 同时保留 primary import 与完整 import list。resolver 对 provenance selector 先要求
language version、direct `standard_library``standard_library_version`,再按 capability matrix 的精确
三元组匹配。缺 import version 返回 `selector_query_version_unknown`;未登记 library revision 返回
`selector_query_unsupported`,且不会进入 stable-ID、geometry、owner 或 current-body fallback。全量
9,347 条 source scan 记录到 `geometry.fs` 与 FeatureScript 版本一一成对变化;只有现有
`1511` / `geometry.fs@1511.0` direct contracts 保持 executable。该迁移只完善来源证据门槛,不新增
`COPY``SPLIT` 或其他 query family 的执行能力。验证包括 parser/lowering/provenance suite
`140 passed`、runtime foundation `130 passed, 1 skipped`、以及 fresh `00212904` RP pipeline
(完整 F1--F5 replay`rebuilt_rejected`)和 `core-17-selector-standard-library-revision-20260910-v1`
1 strict、1 approximate、6 rejected、8 rebuild-failed、1 timeout);所有结果保留各自 STEP/checkpoint。
同步的 integration/binder regression 也确认现有 immediate CAP shell contract 的真实前缀:`00789939`
执行 F2/F5 后在 F6 停止并保留两-solid F5 checkpoint`00090436``00107631` 的 outward F1/F2
prefix 都可 rebuildcombined selector/lowering/binding/integration suite 为 `168 passed`
`intersection_of` vertex 是由 `ExecutionSession` 从已解析的 face component 直接计算的特殊
consumer,因而不能绕开 outer selector 的 provenance gate。runtime 现将 nested/legacy version
一致性、FeatureScript source version、capability allow-list 和 derivation policy 抽为
`TopologyRegistry.resolve` 与该 session path 共用的 contract`multiplicity:none`、未知 version 或
未登记 capability 的 outer `INTERSECT` 在任何 component resolve 或 OCC 求交前稳定拒绝。没有
`selector_intent` 的既有显式几何 intersection selector 仍维持其 legacy contract。原子回归覆盖这三种
拒绝以及 legacy 正向路径(`backend.tests.test_selector_provenance_contract`: 21 passed);这只关闭
provenance bypass,不新增 general `INTERSECT`、vertex provenance 或 source-qualified section-edge 覆盖。
当前代码的 shared selector/runtime suite 为 `264 passed, 1 skipped, 43 subtests`142.57 s;仅既有
build123d deprecation 与 workplane re-orthogonalization warnings)。真实 `00423838`
`output/session-intersection-outer-gate-20260910` 强制 single-sample pipeline 验证:F7 outer deferred
`INTERSECT` 在 incremental replay 中报告 `selector_query_unsupported`,并导出 F6 的三个 feature
executable `rebuild.step` checkpoint;结果是 `rebuild_failed`,不是该 query family 的 RP 成功。
@@ -0,0 +1,54 @@
# FeatureScript Query Capability Matrix
This matrix is the versioned allow-list consumed by the CDSL selector resolver.
It records what the current runtime may execute, not what FeatureScript text it
can parse. A missing entry is intentionally rejected with
`selector_query_unsupported`; a missing source version is rejected with
`selector_query_version_unknown`. Every registered row currently requires the
direct source import `onshape/std/geometry.fs@1511.0` in addition to its
FeatureScript language version. The lowerer preserves every direct
`onshape/std/*` import as `{path, version}` metadata, but an unregistered
library revision is not treated as compatible merely because its path matches.
| Query family | FeatureScript version | Source evidence | CDSL/runtime contract | Verified boundary |
| --- | --- | --- | --- | --- |
| `CAP_FACE` | `1511` | CADFS FeatureScript 1511 exported query history | Direct `extrude.start` / `extrude.end` builder output role; an immediate `up_to_surface` reference or a shell face-removal selector may consume that role | The consumer must immediately follow a direct `extrude_add_blind` `new_body` blind producer, with a complete/proven cap role and no stable-ID, binding-ID, or geometry fallback. `00925274` F3 resolves `extrude.start` for `up_to_surface`; `00212904` F2 and `00789939` F2 resolve immediate shell removals through `extrude.start` and `extrude.end`, respectively. Fresh pipelines preserve the distinction between execution and similarity: `00212904` executes through F5 but is RP-rejected, while `00789939` executes F2/F5 then preserves the F5 checkpoint when F6 remains unsupported. Later mutations, draft-with-holes, fused/multi-region results and generic CAP queries remain unsupported. |
| `CAP_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; `BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` exact cap-edge handles | A direct fillet/chamfer consumer of an independent, undrafted, `new_body` blind prism: one exact direct source-profile edge -> one role-qualified `extrude.start` or `extrude.end` cap edge, followed only by complete/proven one-to-one continuations to the active body | The selector must name one retained direct source entity and explicit CAP side. The adapter accepts the cap handle only after `IsSame` verifies it is present in the final snapshot; resolver follows the source anchor and role through `boundary`, and accepts subsequent `continuation` only when every relation is complete/proven and operation cardinality remains one-to-one. A direct all-circle annulus with one contained circle is included when both source wires retain exact final-face membership; outer/inner roles remain distinct. A direct analytic region with a solver-split circular hole is included only when all four arcs carry the same explicit logical-circle source marker and that source maps uniquely to one unsplit profile circle; the adapter reconstructs one native wire before the same final-face check. Corpus evidence: `00021014` F2 resolves nine non-hole start/end selectors and is strict/RP passing; hole matrix `00479470`, `00501170`, `00526649` is RP passing and `00621329` is strict/RP passing. `00694309` F4 resolves F1's start CAP edge through F3's proven primary-cut continuation and is strict/RP passing. `00566233` resolves both F2 selectors before an OCC chamfer feasibility failure, while `00614954`/`00678961` resolve F2 before later unsupported queries. `00735367` resolves F2 from its line-outer/circular-hole region and executes F3 before an unrelated F4 unsupported query. Incomplete/mixed logical-circle markers do not create a circular-hole anchor; partial/branched continuation, trimmed/split profiles, multi-region-with-hole profiles, draft, multiple/two-sided extents, fused results, and unproven boolean/SPLIT/COPY/pattern continuations are rejected. This is not general CAP_EDGE replay. |
| `SWEPT_BODY` | `1511` | CADFS FeatureScript 1511 exported singleton `qUnion([makeQuery(..., SWEPT_BODY, EntityType.BODY)])` history | An immediate one-sided `up_to_body` extent may reference exactly one active body record produced by a preceding independent `new_body` blind prism | The source body must be the immediately preceding `extrude_add_blind`, with `result_mode: new_body`, blind undrafted direct-prism semantics, and an active producer body record. Lowering emits only `active_body_member` evidence with `body_member_contract: direct_new_body`; resolver proves the active record and producer identity, then returns `body_member`. `00694309` F3 resolves this reference; its F4 strict/RP pass is separately established by the bounded `CAP_EDGE` continuation contract, not by widening this body-member contract. Later successors, `ADD`, cut/revolve/sweep, `COPY`, boolean/pattern/delete, multiple active bodies, multiple query items, other versions, and all stable-ID, geometry, binding-ID, aggregate/current-body fallbacks are rejected. |
| `OFFSET_FACE` | `1511` | CADFS FeatureScript 1511 exported query history | `shell.offset_face` builder output role plus one TDD source cap | Immediate direct shell owner and one explicit `extrude.start` or `extrude.end` true dependency. |
| `SWEPT_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(edge)` relation plus final-snapshot `BRepAlgoAPI_Cut.Modified/Preserved` target history | An immediate fillet/chamfer consumer or shell removal, or a one-sided `up_to_surface` extent: one retained direct source-profile edge -> its generated side face, followed only by complete/proven kernel continuations to one active face | Limited end-to-end contract. The source prism must be independent, undrafted, `new_body`, blind, and retain one exact direct wire/face construction anchor. The extent variant carries no output role, stable ID, binding ID, or geometry hint. For a single-solid primary cut, target-side builder history is registered independently of whether its tool has direct-prism history; transient tool topology remains restricted to the separate source-qualified `INTERSECT` contract. Resolver traverses only the requested result topology kind, measures cardinality over complete/proven final-snapshot relations of that source/result kind, and still rejects any bound partial, split, merge, or inactive branch. Non-final intermediate handles and cross-type section diagnostics remain diagnostic only. `00925274` resolves F1 E0 through F3, F6, F9 and F12 target continuations and is strict/RP passing. The shell variant accepts only original non-construction lines. Direct all-circle construction includes one annular hole; independently constructed direct circle regions retain one delta per source face only when every generated result remains in the final snapshot. IMPRINT/split source profiles, draft, multiple/two-sided extents, fused source results, unproven/split continuations, revolve, sweep, loft, copy and pattern are rejected. Fresh corpus evidence: `00594348` and `00925274` are strict in `output/swept-face-target-continuation-20260910`; `00111611` and `00974931` retain rebuild failures. This is not family completion. |
| `SWEPT_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(vertex)` relation | One immediate downstream consumer of an independent, undrafted, `new_body` blind prism: a source profile vertex, identified by its complete incident source-edge set -> its generated vertical edge | Limited end-to-end contract. The source vertex must retain two exact direct-profile edge anchors and the generated relation must be complete and active. One analytic contour region may contain hole wires: all wires are passed once to `BRepBuilderAPI_MakeFace`, then each retained anchor is checked against the finished face with `IsSame`. A profile with multiple regions and any hole stays on the established `Face.make_holes` path with no anchors, preserving its executable geometry. IMPRINT/split or otherwise mutated profiles, draft, multiple/two-sided extents, fused results, boolean/SPLIT successors, revolve, sweep, loft, copy and pattern are rejected; this is not family completion. Corpus evidence: `00000715` F3; `00007264` F2 resolves four selectors by kernel lineage but is comparison-rejected; `00039669` F2 is strict/RP passing; `00151159` F2 is RP passing. |
| `INTERSECT` | `1511` | CADFS FeatureScript 1511 exported query history; OCC boolean `SectionEdges()` plus exact `Generated(face)` handles from both inputs | One `INTERSECT EDGE` of exactly two source-qualified direct-prism `CAP_FACE`/`SWEPT_FACE` inputs from either one explicit `boolean_bodies` target/tool pair, or one immediate primary `extrude_cut_blind` target plus its transient direct-prism tool snapshot | The adapter creates a relation only when the same section edge is returned by `Generated(face)` for exactly one face in each boolean input and `IsSame` binds all three handles to the input/final snapshots. A primary cut records its tool only as transient historical topology: exactly one active target solid/member, one tool solid, complete direct-prism anchors/history and one immediate consumer are required; the tool itself cannot be selected. Resolver requires one complete relation, the `intersection`/`one` policy and final active member. Unqualified `SectionEdges()`, duplicate output edges, incomplete source snapshots, keep-tools, multiple target/tool members, copied/transformed/patterned/TDD/IMPRINT inputs, non-direct prisms, primary query `disambiguationData` whose FeatureScript semantics are unverified, and unknown versions remain rejected. This is a contract test boundary; `00020311`/`00029250` retain their F3 checkpoints because their `OD(0/1)` selectors are deferred. Real-corpus RP evidence is not yet sufficient to mark general `INTERSECT` replay complete. |
## Source Sketch Path Queries (Not Runtime Selector Capabilities)
This section is intentionally outside the resolver allow-list above. The query
does not select rebuilt body topology: it selects source sketch reference-wire
geometry while lowering a `sweep` path. It therefore creates no
`selector_intent`, kernel-lineage relation, or stable-ID fallback permission.
| Source query / consumer | FeatureScript version | Source evidence | Lowering and runtime boundary | Evidence |
| --- | --- | --- | --- | --- |
| `qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` as `sweep.path` | `1511` + direct `onshape/std/geometry.fs@1511.0` | Local Onshape standard-library mirror `query.fs`: `BodyType.WIRE` describes sketch curves (lines or curves), `qBodyType` retains entities owned by that body type, and `qConstructionFilter(..., NO)` retains only non-construction entities. The repository does not vendor the exact 1511 standard-library snapshot, so this is source-semantics evidence rather than a claim that all revisions are compatible. | The owner must resolve to a source sketch with exactly one non-construction entity, whose exact source type is `line` or `bspline`; the outer query must be the exact singleton `qUnion` wrapper. `line` paths retain their endpoints. `bspline` paths retain the exported interpolation points, parameters, and endpoint derivatives; a two-point B-spline is executable only when both derivatives are present. Multiple entities, arcs/circles, construction-only geometry, omitted wrapper/filter, other query composition, other language/library revisions, loft guides, surface/profile consumers, and all runtime-body uses remain rejected or deferred. | The fresh seven-sample RP matrix `output/qbodytype-direct-sketch-wire-matrix-20260910-v1` rebuilds all direct-path cases: `00191739` is strict, `00726304` is RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` are comparison-rejected, and two-point B-spline `00896761` is `comparison_timeout` at 60 seconds. These are execution classifications, not a claim that rejected/timeout models are similar. `00786708` F2 remains deferred because F0 contains multiple non-construction path entities; its independent F5 single-line path still lowers. |
## Deferred source queries
Every CADFS topology selector now retains a `selector_intent`, including
selectors for query families and source versions not present in this matrix.
Such selectors use `evidence: "feature_script_query"` and a non-executable
`multiplicity: "none"` policy. Their geometry and legacy stable ID remain
diagnostic context only: `TopologyRegistry.resolve` rejects them before either
can bind. Direct datum planes are the sole exception; they use
`query_family: "GEOMETRIC"` with `evidence: "explicit_datum"` and retain the
explicit-frame resolution path.
This closes the former legacy path where `1793` loft `SWEPT_EDGE` selectors in
`00005267` F4 could bind from endpoint bounding boxes despite no registered
FeatureScript query contract. The current result is a `selector_query_unsupported`
failure with the executable F3 checkpoint exported. Equivalent bounded
rejections now cover unproven CAP/SWEPT/OFFSET/COPY/INTERSECT fallbacks; they do
not reduce the matrix's direct-prism coverage or claim general query support.
The repository does not currently vendor the FeatureScript standard-library
sources for other versions or `makeQuery` encodings. They are not inferred
from a `15xx` prefix or a single corpus sample.
+26 -5
View File
@@ -6,6 +6,27 @@ from .featurescript_lexer import Token, lex
from .ir import Call, FeatureIR, ModelIR, SketchIR
_IMPORT_RE = re.compile(r"\bimport\s*\(\s*(?P<arguments>.*?)\s*\)\s*;", re.DOTALL)
_IMPORT_PATH_RE = re.compile(r"\bpath\s*:\s*['\"]([^'\"]+)['\"]")
_IMPORT_VERSION_RE = re.compile(r"\bversion\s*:\s*['\"]([^'\"]+)['\"]")
def _standard_library_imports(source: str) -> list[dict[str, str]]:
"""Preserve each directly imported Onshape standard-library revision."""
imports: list[dict[str, str]] = []
for match in _IMPORT_RE.finditer(source):
arguments = match.group("arguments")
path = _IMPORT_PATH_RE.search(arguments)
if path is None or "onshape/std/" not in path.group(1):
continue
item = {"path": path.group(1)}
version = _IMPORT_VERSION_RE.search(arguments)
if version is not None:
item["version"] = version.group(1)
imports.append(item)
return imports
def _string(value: Any) -> Any:
if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]:
try:
@@ -133,15 +154,15 @@ def _arg_map(call: Call) -> dict[str, Any]:
def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
parser = Parser(source); calls = parser.statements()
version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source)
standard_library = re.search(
r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']",
source,
)
standard_library_imports = _standard_library_imports(source)
primary_standard_library = standard_library_imports[0] if standard_library_imports else {}
model = ModelIR(
sample_id,
raw_source=source,
featurescript_version=version.group(1) if version else None,
standard_library=standard_library.group(1) if standard_library else None,
standard_library=primary_standard_library.get("path"),
standard_library_version=primary_standard_library.get("version"),
standard_library_imports=standard_library_imports,
)
for call in calls:
if call.name == "newSketch":
+2
View File
@@ -41,3 +41,5 @@ class ModelIR:
# selector can be audited against the FeatureScript API contract it used.
featurescript_version: str | None = None
standard_library: str | None = None
standard_library_version: str | None = None
standard_library_imports: list[dict[str, str]] = field(default_factory=list)
+1468 -67
View File
File diff suppressed because it is too large Load Diff
+51 -37
View File
@@ -6,36 +6,38 @@ import re
from typing import Any
def _last_executable_prefix(
cdsl: dict[str, Any],
failed_feature_id: str | None,
output: Path,
) -> dict[str, Any] | None:
"""Bind and export the longest verified prefix before a failed feature."""
from .selector_binding import bind_candidate_selectors
from engine.cdsl_engine.runtime import rebuild_cdsl
def _last_executable_prefix(error: Exception, output: Path) -> dict[str, Any] | None:
"""Export the checkpoint already produced by the failed live replay."""
execution = getattr(error, "incremental_execution", None)
bound = getattr(error, "bound_cdsl", None)
if execution is None or not isinstance(bound, dict):
return None
session = execution.session
if not session.results or (session.body is None and not session.surface_members):
return None
from engine.cdsl_engine.runtime import finalize_cdsl_execution
features = list(cdsl.get("features") or [])
failed_index = next(
(index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id),
len(features),
try:
result = finalize_cdsl_execution(execution, output)
except Exception:
return None
last_feature_id = next(reversed(session.results))
features = list(bound.get("features") or [])
feature_count = next(
(index + 1 for index, feature in enumerate(features) if feature.get("id") == last_feature_id),
0,
)
for feature_count in range(failed_index, 0, -1):
prefix = deepcopy(cdsl)
prefix["features"] = features[:feature_count]
try:
bound_prefix, _binding = bind_candidate_selectors(prefix)
result = rebuild_cdsl(bound_prefix, output, strict=True)
except Exception:
continue
return {
"failed_feature_id": failed_feature_id,
"feature_count": feature_count,
"last_feature_id": str(features[feature_count - 1].get("id") or ""),
"bound_cdsl": bound_prefix,
"result": result,
}
return None
if feature_count == 0:
return None
prefix = deepcopy(bound)
prefix["features"] = features[:feature_count]
return {
"failed_feature_id": getattr(error, "failed_feature_id", None) or _failed_feature_id(error),
"feature_count": feature_count,
"last_feature_id": last_feature_id,
"bound_cdsl": prefix,
"result": result,
}
def _failed_feature_id(error: Exception) -> str | None:
@@ -48,22 +50,34 @@ def _failed_feature_id(error: Exception) -> str | None:
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from .selector_binding import bind_candidate_selectors
from engine.cdsl_engine.runtime import analyze_cdsl, finalize_cdsl_execution
from .selector_binding import bind_and_execute_candidate_selectors
analysis = analyze_cdsl(cdsl)
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
if not analysis.runtime_eligible:
return {"status": "runtime_ineligible", "analysis": analysis_dict}
bound: dict[str, Any] | None = None
try:
bound, binding = bind_candidate_selectors(cdsl)
result = rebuild_cdsl(bound, output, strict=True)
return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result}
replay = bind_and_execute_candidate_selectors(cdsl)
result = finalize_cdsl_execution(replay.execution, output)
return {
"status": "rebuilt",
"analysis": analysis_dict,
"selector_binding": replay.evidence,
"bound_cdsl": replay.bound_cdsl,
"result": result,
}
except Exception as exc:
detail = {"type": type(exc).__name__, "message": str(exc)}
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output)
if hasattr(exc, "selector_resolutions"):
detail["selector_resolutions"] = exc.selector_resolutions
prefix = _last_executable_prefix(exc, output)
result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
if bound is not None: result["bound_cdsl"] = bound
bound = getattr(exc, "bound_cdsl", None)
if isinstance(bound, dict):
result["bound_cdsl"] = bound
binding = getattr(exc, "selector_binding", None)
if isinstance(binding, list):
result["selector_binding"] = binding
if prefix is not None: result["last_executable_prefix"] = prefix
return result
+230 -140
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import math
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
import tempfile
from typing import Any
@@ -95,6 +96,14 @@ def _binding_targets(selector: dict[str, Any]):
yield selector
def _runtime_selector(placeholder: dict[str, Any]) -> dict[str, Any]:
"""Convert a legacy prefix placeholder to the runtime selector contract."""
selector = dict(placeholder)
selector.setdefault("source", "runtime_snapshot")
selector.setdefault("confidence", 1.0)
return selector
def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
bound = []
for record in records:
@@ -109,160 +118,241 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]])
return bound[0], bound
def _selector_roots(feature: dict[str, Any]) -> list[dict[str, Any]]:
roots = list(feature.get("selectors") or [])
for name in ("end_condition", "reverse_end_condition"):
condition = (feature.get("params") or {}).get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
if isinstance(reference, dict):
roots.append(reference)
return roots
def _selector_key(selector: dict[str, Any]) -> tuple[Any, ...]:
stable_id = selector.get("stable_id")
if stable_id is not None:
return ("stable_id", str(stable_id))
source = selector.get("output_role_source")
return (
"output_role",
selector.get("owner_feature_id"),
selector.get("kind"),
selector.get("output_role"),
source.get("owner_feature_id") if isinstance(source, dict) else None,
source.get("output_role") if isinstance(source, dict) else None,
)
def _bind_feature_selectors(
feature: dict[str, Any],
*,
registry: Any,
body_id_for_feature: dict[str, str | None],
) -> list[dict[str, Any]]:
"""Bind one feature immediately before it runs in the shared session."""
targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)]
resolved: list[dict[str, Any]] = []
for placeholder in targets:
binding_feature_id = placeholder.get("binding_feature_id")
if binding_feature_id is None:
active_body_id = body_id_for_feature.get("__current__")
else:
if not isinstance(binding_feature_id, str) or binding_feature_id not in body_id_for_feature:
raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
active_body_id = body_id_for_feature[binding_feature_id]
intent = placeholder.get("selector_intent")
runtime_selector = _runtime_selector(placeholder)
resolution = registry.resolve(runtime_selector, active_body_id=active_body_id)
# Legacy owner-qualified, geometry-free context selectors predate the
# runtime's canonical evidence fields. Their compatibility contract
# permits a unique active context object, but never relaxes a
# provenance, output-role, or instance-locked selector.
if (
resolution.status != "resolved"
and not isinstance(intent, dict)
and not runtime_selector.get("owner_match_required")
and not runtime_selector.get("output_role")
):
fallback = dict(runtime_selector)
fallback.pop("owner_feature_id", None)
resolution = registry.resolve(fallback, active_body_id=active_body_id)
if resolution.status != "resolved":
code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}"
raise ValueError(f"{feature['id']}: {code} during incremental replay")
selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ()))
if not selected:
raise ValueError(f"{feature['id']}: selector_not_found during incremental replay")
public_records = [record.public_dict() for record in selected]
# Operation-role and provenance selectors stay declarative in bound
# CDSL. A runtime record ID is execution evidence, never their durable
# semantic replacement.
if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"):
resolved.extend(public_records)
continue
selector, bound_selectors = _bound_selector(placeholder, public_records)
placeholder.clear()
placeholder.update(selector)
resolved.extend(bound_selectors)
selectors = feature.get("selectors") or []
feature["selectors"] = list({_selector_key(selector): selector for selector in selectors}.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0]
return resolved
@dataclass
class IncrementalBindingReplay:
"""Bound candidate plus the one session that produced its evidence."""
bound_cdsl: dict[str, Any]
evidence: list[dict[str, Any]]
execution: Any
def bind_and_execute_candidate_selectors(cdsl: dict[str, Any]) -> IncrementalBindingReplay:
"""Bind and execute a CADFS candidate in one ordered kernel replay.
A feature is bound only against topology facts registered by earlier
features in this session. Historical ``binding_feature_id`` values select
a retained snapshot ID, so the binder never needs to re-run a prefix or
recreate an OCC body. Resolver failures retain the live session for the
caller to export its last executable checkpoint.
"""
from engine.cdsl_engine.runtime import prepare_cdsl_execution
bound = deepcopy(cdsl)
execution = prepare_cdsl_execution(bound)
if not execution.analysis.runtime_eligible:
first = next((result for result in execution.analysis.feature_results if not result.executable), None)
code = first.blockers[0].code if first and first.blockers else "runtime_ineligible"
raise ValueError(code)
bound_features = {str(feature.get("id") or ""): feature for feature in bound.get("features") or []}
body_id_for_feature: dict[str, str | None] = {"__current__": None}
evidence: list[dict[str, Any]] = []
for index, node in enumerate(execution.analysis.plan):
feature = node.source_feature
try:
resolved = _bind_feature_selectors(
feature,
registry=execution.session.topology,
body_id_for_feature=body_id_for_feature,
)
public_feature = bound_features[node.feature_id]
public_feature["selectors"] = deepcopy(feature.get("selectors") or [])
public_feature["params"] = deepcopy(feature.get("params") or {})
evidence.append({
"feature_id": node.feature_id,
"prefix_feature_count": index,
"selectors": public_feature["selectors"],
"resolved": resolved,
})
execution.execute_next(strict=True)
body_id_for_feature[node.feature_id] = execution.session.body_id
body_id_for_feature["__current__"] = execution.session.body_id
except Exception as error:
setattr(error, "bound_cdsl", bound)
setattr(error, "selector_binding", evidence)
setattr(error, "incremental_execution", execution)
setattr(error, "failed_feature_id", node.feature_id)
raise
return IncrementalBindingReplay(bound, evidence, execution)
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Rebuild every selector-bearing prefix and bind against its active body."""
"""Legacy diagnostic binder for incomplete or externally supplied CDSL.
The production CADFS rebuild path uses ``bind_and_execute_candidate_selectors``.
This compatibility entry point deliberately keeps prefix replay explicit
for callers that need to inspect a partial document before it is runtime
eligible. It rehydrates exact exported topology facts and calls the same
resolver; provenance selectors never become geometry guesses here.
"""
from engine.cdsl_engine.runtime import rebuild_cdsl
bound = deepcopy(cdsl); evidence = []
from engine.cdsl_engine.topology import TopologyRegistry
bound = deepcopy(cdsl)
evidence: list[dict[str, Any]] = []
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
for index, feature in enumerate(bound.get("features") or []):
roots = list(feature.get("selectors") or [])
for name in ("end_condition", "reverse_end_condition"):
condition = (feature.get("params") or {}).get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
if isinstance(reference, dict): roots.append(reference)
targets = [target for root in roots for target in _binding_targets(root)]
if not targets: continue
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {}
targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)]
if not targets:
continue
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]] = {}
def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]:
def prefix_snapshot(binding_feature_id: str | None) -> tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]:
prefix_count = index
if binding_feature_id is not None:
binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None)
if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
binding_index = next(
(item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id),
None,
)
if binding_index is None:
raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
prefix_count = binding_index + 1
if prefix_count not in prefix_cache:
prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count]
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True)
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id)
all_records, body_id = prefix_cache[prefix_count]
return [
item for item in all_records
# Reference planes and axes are session context, not body
# topology. They must remain available while binding a mirror
# or extent selector against a body-bearing prefix.
if item.get("kind") in {"plane", "axis"}
or not body_id
or item.get("body_id") == body_id
or str(item.get("body_id") or "").startswith(f"{body_id}:")
]
resolved = []
for placeholder in targets:
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
output_role = str(placeholder.get("output_role") or "").strip()
intent = placeholder.get("selector_intent")
if (
isinstance(intent, dict)
and intent.get("query_family") != "GEOMETRIC"
and not output_role
):
# Prefix rebuilding is retained as a diagnostic adapter.
# It must not turn an unproven FeatureScript provenance
# query into a geometry-scored stable selector.
raise ValueError(
f"{feature['id']}: selector_kernel_history_missing after prefix rebuild"
prefix = deepcopy(bound)
prefix["features"] = bound["features"][:prefix_count]
if not prefix["features"]:
raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(
prefix,
Path(temporary) / f"prefix-{index}-{prefix_count}.step",
strict=True,
)
if output_role:
# Builder output roles are not geometry placeholders. They
# remain in the bound CDSL so runtime can resolve the
# current active B-rep face through exact kernel history.
# Replacing one with stable_id/geometry would mix evidence
# and make a stale snapshot appear durable.
owner = placeholder.get("owner_feature_id")
role_source = placeholder.get("output_role_source")
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
candidates = [
record for record in records
if record.get("kind") == placeholder.get("kind")
and owner in (record.get("owner_feature_ids") or [record.get("feature_id")])
and output_role in (record.get("output_roles") or [])
and (
role_source is None
or any(
item.get("output_role") == output_role
and item.get("owner_feature_id") == source_owner
and item.get("source_output_role") == source_role
for item in record.get("output_role_sources") or []
)
)
]
if len(candidates) != 1:
status = "not_found" if not candidates else "ambiguous"
raise ValueError(
f"{feature['id']}: selector_output_role_{status} after prefix rebuild"
)
resolved.append(candidates[0])
continue
geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates:
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
owner = placeholder.get("owner_feature_id")
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
# Pattern instance provenance is stronger than an ordinary
# feature owner: an unresolved instance cannot fall back to
# an aggregate face with matching geometry. Ordinary source
# selectors retain their established geometry-binding path
# across topology-changing dress-up operations.
pool = owner_matches if placeholder.get("owner_match_required") else (owner_matches or same_kind)
if not geometry:
# Context selectors (notably a generated mirror plane)
# may have no geometric snapshot. Their owner-qualified
# singleton identity is sufficient and must not be scored
# as a zero-information geometric match.
if len(pool) != 1:
status = "not_found" if not pool else "ambiguous"
raise ValueError(f"{feature['id']}: selector_{status} after prefix rebuild")
candidates = [pool[0]]
else:
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
# A source owner is preferred for ordinary geometry
# selectors, but can be retained by an unrelated exact
# continuation after a dress-up. If none of that
# owner's active records satisfies the full geometry
# signature, bind against the current active body and
# still require a unique threshold-qualified match.
# Instance-qualified selectors never take this path.
if not scored and not placeholder.get("owner_match_required"):
scored = [
(score, record)
for record in same_kind
if (score := _score(geometry, record.get("geometry") or {})) is not None
and score >= 0.8
]
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
if scored:
if placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
candidates = [item[1] for item in scored] if placeholder.get("match_mode") == "all" else [scored[0][1]]
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
selector, bound_selectors = _bound_selector(placeholder, candidates)
placeholder.clear(); placeholder.update(selector)
resolved.extend(bound_selectors)
feature_selectors = feature.get("selectors") or []
def selector_key(selector: dict[str, Any]) -> tuple[Any, ...]:
stable_id = selector.get("stable_id")
if stable_id is not None:
return ("stable_id", str(stable_id))
source = selector.get("output_role_source")
return (
"output_role",
selector.get("owner_feature_id"),
selector.get("kind"),
selector.get("output_role"),
source.get("owner_feature_id") if isinstance(source, dict) else None,
source.get("output_role") if isinstance(source, dict) else None,
)
body_id = next(
(item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")),
None,
)
prefix_cache[prefix_count] = (
list(report.get("topology_records") or []),
body_id,
list(report.get("topology_deltas") or []),
)
return prefix_cache[prefix_count]
unique = {selector_key(selector): selector for selector in feature_selectors}
feature["selectors"] = list(unique.values())
resolved: list[dict[str, Any]] = []
for placeholder in targets:
records, body_id, topology_deltas = prefix_snapshot(placeholder.get("binding_feature_id"))
registry = TopologyRegistry.from_public_snapshot(records, topology_deltas)
intent = placeholder.get("selector_intent")
runtime_selector = _runtime_selector(placeholder)
resolution = registry.resolve(runtime_selector, active_body_id=body_id)
if (
resolution.status != "resolved"
and not isinstance(intent, dict)
and not runtime_selector.get("owner_match_required")
and not runtime_selector.get("output_role")
):
fallback = dict(runtime_selector)
fallback.pop("owner_feature_id", None)
resolution = registry.resolve(fallback, active_body_id=body_id)
if resolution.status != "resolved":
code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}"
raise ValueError(f"{feature['id']}: {code} after prefix rebuild")
selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ()))
if not selected:
raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
public_records = [record.public_dict() for record in selected]
if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"):
resolved.extend(public_records)
continue
selector, bound_selectors = _bound_selector(placeholder, public_records)
placeholder.clear()
placeholder.update(selector)
resolved.extend(bound_selectors)
feature["selectors"] = list({_selector_key(selector): selector for selector in feature.get("selectors") or []}.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0]
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved})
evidence.append({
"feature_id": feature["id"],
"prefix_feature_count": index,
"selectors": feature["selectors"],
"resolved": resolved,
})
return bound, evidence
+897
View File
@@ -0,0 +1,897 @@
"""Offline experiment for geometry-only CADFS selector candidates.
The production resolver must not consume this module. It removes provenance
intent only in a copied CDSL document, then compares that diagnostic replay to
the source's final STEP. A strict passing probe is replayed a second time from
its bound CDSL and can emit a *behavioural* selector record. The record is
training/diagnostic evidence, not a claim that a heuristic recovered Onshape's
hidden topology identity.
"""
from __future__ import annotations
import argparse
from copy import deepcopy
import json
import multiprocessing
from pathlib import Path
from typing import Any
from .compare import compare_steps
from .dataset import Sample, scan_dataset
from .featurescript_parser import parse_featurescript
from .lowering import lower_model
from .rebuild import rebuild_candidate
from .reports import read_json, write_json, write_manifest
def strip_provenance_intents(value: Any) -> int:
"""Remove provenance-only selector fields from one copied CDSL value."""
removed = 0
if isinstance(value, dict):
for key in ("selector_intent", "selector_intent_version"):
if key in value:
value.pop(key)
removed += 1
for child in value.values():
removed += strip_provenance_intents(child)
elif isinstance(value, list):
for child in value:
removed += strip_provenance_intents(child)
return removed
def _write_rebuild_artifacts(directory: Path, name: str, outcome: dict[str, Any]) -> None:
"""Persist the same useful replay artifacts as the regular pipeline."""
artifact = deepcopy(outcome)
bound = artifact.pop("bound_cdsl", None)
prefix = artifact.get("last_executable_prefix")
if isinstance(prefix, dict):
prefix_bound = prefix.pop("bound_cdsl", None)
if bound is None:
bound = prefix_bound
write_json(directory / f"{name}.rebuild.json", artifact)
if isinstance(bound, dict):
write_json(directory / f"{name}.bound.cdsl.json", bound)
def _rebuild_worker(candidate: str, step: str, result: str) -> None:
write_json(Path(result), rebuild_candidate(read_json(Path(candidate)), Path(step)))
def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str:
"""Bound an OCC experiment so one candidate cannot stall the demo."""
result_path.unlink(missing_ok=True)
process = multiprocessing.get_context("spawn").Process(target=target, args=args)
process.start()
process.join(timeout_seconds)
if process.is_alive():
process.terminate()
process.join(5)
if process.is_alive():
process.kill()
process.join()
return "timeout"
return "completed" if process.exitcode == 0 and result_path.exists() else "failed"
def _run_rebuild(candidate_path: Path, step: Path, *, timeout_seconds: float) -> dict[str, Any]:
worker = step.with_suffix(".worker.json")
outcome = _isolated(_rebuild_worker, (str(candidate_path), str(step), str(worker)), worker, timeout_seconds)
if outcome == "completed":
result = read_json(worker)
worker.unlink(missing_ok=True)
return result
step.unlink(missing_ok=True)
return {
"status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed",
"error": {
"type": "TimeoutError" if outcome == "timeout" else "WorkerProcessError",
"message": f"selector candidate rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "selector candidate rebuild worker exited without a result",
},
}
def _run_comparison(gold_step: Path, rebuilt_step: Path, output: Path, *, timeout_seconds: float) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
worker = output.with_suffix(".worker.json")
outcome = _isolated(_compare_worker, (str(gold_step), str(rebuilt_step), str(worker)), worker, timeout_seconds)
if outcome == "completed":
comparison = read_json(worker)
worker.unlink(missing_ok=True)
return comparison, None
return None, {
"type": "TimeoutError" if outcome == "timeout" else "WorkerProcessError",
"message": f"selector candidate comparison exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "selector candidate comparison worker exited without a result",
}
def _outcome_summary(outcome: dict[str, Any], comparison: dict[str, Any] | None) -> dict[str, Any]:
summary: dict[str, Any] = {
"rebuild_status": outcome.get("status"),
"last_executable_prefix": {
key: value
for key, value in (outcome.get("last_executable_prefix") or {}).items()
if key in {"failed_feature_id", "feature_count", "last_feature_id"}
},
}
error = outcome.get("error")
if isinstance(error, dict):
summary["error"] = {key: error.get(key) for key in ("type", "message")}
if comparison is not None:
summary["comparison"] = {
"decision": comparison.get("decision"),
"strict_passed": bool((comparison.get("strict") or {}).get("passed")),
"rp_passed": bool((comparison.get("rp") or {}).get("passed")),
}
return summary
def _strict_passed(comparison: dict[str, Any] | None) -> bool:
return bool(isinstance(comparison, dict) and (comparison.get("strict") or {}).get("passed"))
def _selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
"""Return the public selector positions of one CDSL feature.
The locations are deliberately structural rather than runtime IDs. A
selector record must remain useful when a fresh replay creates different
topology-record instances.
"""
sites: list[tuple[str, dict[str, Any]]] = []
for index, selector in enumerate(feature.get("selectors") or []):
if isinstance(selector, dict):
sites.append((f"selectors[{index}]", selector))
params = feature.get("params") or {}
for name in ("end_condition", "reverse_end_condition"):
condition = params.get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
if isinstance(reference, dict):
sites.append((f"params.{name}.reference", reference))
return sites
def _semantic_source_selector(selector: dict[str, Any]) -> dict[str, Any] | None:
"""Keep the FeatureScript meaning, never a source runtime topology ID."""
intent = selector.get("selector_intent")
if not isinstance(intent, dict):
return None
result = {
key: deepcopy(selector[key])
for key in ("kind", "owner_feature_id", "binding_feature_id", "output_role")
if selector.get(key) is not None
}
result["selector_intent"] = deepcopy(intent)
return result
def _selector_record_entries(
sample_id: str,
provenance_candidate: dict[str, Any],
compiled_cdsl: dict[str, Any],
selector_binding: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Extract FeatureScript meaning plus a freshly replayable binding plan.
A geometry probe has only the selected record, not rejected alternatives.
We preserve that limitation explicitly. When source and compiled selector
counts differ (for example, one source query expands to several topology
records), emit a feature group instead of inventing a one-to-one mapping.
"""
source_by_id = {
str(feature.get("id")): feature
for feature in provenance_candidate.get("features") or []
if isinstance(feature, dict) and isinstance(feature.get("id"), str)
}
compiled_by_id = {
str(feature.get("id")): feature
for feature in compiled_cdsl.get("features") or []
if isinstance(feature, dict) and isinstance(feature.get("id"), str)
}
entries: list[dict[str, Any]] = []
for binding in selector_binding:
feature_id = binding.get("feature_id")
if not isinstance(feature_id, str):
continue
source_feature = source_by_id.get(feature_id)
compiled_feature = compiled_by_id.get(feature_id)
if source_feature is None or compiled_feature is None:
continue
source_sites = [
(location, semantic)
for location, selector in _selector_sites(source_feature)
if (semantic := _semantic_source_selector(selector)) is not None
]
if not source_sites:
continue
compiled_sites = _selector_sites(compiled_feature)
resolved = [deepcopy(item) for item in binding.get("resolved") or [] if isinstance(item, dict)]
feature_context = {
"sample_id": sample_id,
"feature_id": feature_id,
"atomic_id": compiled_feature.get("atomic_id"),
"prefix_feature_count": binding.get("prefix_feature_count"),
"verification_scope": "behavioural_strict_replay",
}
if len(source_sites) == len(compiled_sites) == len(resolved):
for index, ((location, source_selector), (_compiled_location, compiled_selector), resolved_record) in enumerate(
zip(source_sites, compiled_sites, resolved)
):
entries.append({
"schema": "cadfs_to_cdsl.selector_record.v1",
"entry_id": f"{sample_id}:{feature_id}:{location}",
**feature_context,
"source_location": location,
"source_selector": source_selector,
"compiled_selector": deepcopy(compiled_selector),
"resolved_record": resolved_record,
"mapping": "one_to_one",
"limitations": [
"The source dataset has no per-feature Onshape query witness.",
"The compiled selector is verified only by a fresh local strict replay.",
"Runtime snapshot IDs are replay evidence, not portable topology identities.",
],
})
continue
entries.append({
"schema": "cadfs_to_cdsl.selector_record.v1",
"entry_id": f"{sample_id}:{feature_id}:feature_group",
**feature_context,
"source_selectors": [
{"location": location, "selector": selector}
for location, selector in source_sites
],
"compiled_selectors": [
{"location": location, "selector": deepcopy(selector)}
for location, selector in compiled_sites
],
"resolved_records": resolved,
"mapping": "feature_group",
"limitations": [
"The source query expanded or contracted during local binding, so this record does not claim a one-to-one mapping.",
"The source dataset has no per-feature Onshape query witness.",
"The compiled selector is verified only by a fresh local strict replay.",
"Runtime snapshot IDs are replay evidence, not portable topology identities.",
],
})
return entries
def _emit_strict_selector_record(
sample: Sample,
directory: Path,
provenance_candidate: dict[str, Any],
geometry_probe: dict[str, Any],
geometry_comparison: dict[str, Any] | None,
gold_step: Path,
*,
rebuild_timeout_seconds: float,
comparison_timeout_seconds: float,
) -> dict[str, Any]:
"""Create and independently verify a compiled CDSL only after strict proof."""
if not _strict_passed(geometry_comparison):
return {
"status": "not_emitted",
"reason": "geometry_probe_not_strict",
}
bound_cdsl = geometry_probe.get("bound_cdsl")
binding = geometry_probe.get("selector_binding")
if not isinstance(bound_cdsl, dict) or not isinstance(binding, list):
return {
"status": "not_emitted",
"reason": "geometry_probe_binding_missing",
}
compiled_candidate = deepcopy(bound_cdsl)
compiled_path = directory / "selector_record.compiled.cdsl.json"
write_json(compiled_path, compiled_candidate)
compiled = _run_rebuild(
compiled_path,
directory / "selector_record.compiled.rebuild.step",
timeout_seconds=rebuild_timeout_seconds,
)
_write_rebuild_artifacts(directory, "selector_record.compiled", compiled)
comparison = None
comparison_error = None
if compiled.get("status") == "rebuilt":
comparison, comparison_error = _run_comparison(
gold_step,
directory / "selector_record.compiled.rebuild.step",
directory / "selector_record.compiled.comparison.json",
timeout_seconds=comparison_timeout_seconds,
)
if comparison is not None:
write_json(directory / "selector_record.compiled.comparison.json", comparison)
result = {
"status": "not_emitted",
"compiled_cdsl": str(compiled_path),
"compiled_replay": _outcome_summary(compiled, comparison),
}
if comparison_error is not None:
result["compiled_replay"]["comparison_error"] = comparison_error
if not _strict_passed(comparison):
result["reason"] = "compiled_cdsl_not_strict"
return result
records = _selector_record_entries(sample.sample_id, provenance_candidate, compiled_candidate, binding)
if not records:
result["reason"] = "no_featurescript_selector_binding"
return result
record_path = directory / "selector-record.json"
write_json(record_path, {
"schema": "cadfs_to_cdsl.selector_record_set.v1",
"sample_id": sample.sample_id,
"source": {
"featurescript": sample.files.get("featurescript"),
"step": sample.files.get("step"),
},
"verification": {
"geometry_probe_strict": True,
"compiled_cdsl_strict": True,
"compiled_cdsl": str(compiled_path),
},
"records": records,
})
result.update({
"status": "strict_replayed",
"record_count": len(records),
"record_path": str(record_path),
})
return result
def _searchable_selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
"""Return direct selector roots that can be replaced as a search branch.
The experimental search does not flatten an intersection query into an
unrelated member. It can search direct roots and extent references; other
query trees remain a bounded unsupported search outcome.
"""
sites = _selector_sites(feature)
return [
(location, selector)
for location, selector in sites
if not isinstance(selector.get("intersection_of"), list)
]
def _replace_selector_site(cdsl: dict[str, Any], feature_id: str, location: str, selector: dict[str, Any]) -> None:
"""Replace one public direct selector site in a copied candidate CDSL."""
feature = next(
(item for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id") == feature_id),
None,
)
if feature is None:
raise ValueError(f"search branch cannot find feature {feature_id}")
if location.startswith("selectors[") and location.endswith("]"):
index = int(location[len("selectors["):-1])
selectors = feature.get("selectors") or []
if index < 0 or index >= len(selectors) or not isinstance(selectors[index], dict):
raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}")
selectors[index] = deepcopy(selector)
return
prefix = "params."
suffix = ".reference"
if location.startswith(prefix) and location.endswith(suffix):
name = location[len(prefix):-len(suffix)]
condition = (feature.get("params") or {}).get(name)
if not isinstance(condition, dict) or not isinstance(condition.get("reference"), dict):
raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}")
condition["reference"] = deepcopy(selector)
return
raise ValueError(f"search branch does not support nested selector site: {feature_id}:{location}")
def _forced_candidate_selector(placeholder: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
"""Turn one public runtime record into a branch-local explicit selector."""
from .selector_binding import _bound_selector
selector, _bound = _bound_selector(placeholder, [candidate])
if not isinstance(selector, dict):
raise ValueError("search branch produced an invalid explicit selector")
return selector
def _resolve_search_selector(placeholder: dict[str, Any], *, registry: Any, active_body_id: str | None) -> Any:
"""Mirror the legacy diagnostic binding policy without changing it."""
from .selector_binding import _runtime_selector
runtime_selector = _runtime_selector(placeholder)
resolution = registry.resolve(runtime_selector, active_body_id=active_body_id)
intent = placeholder.get("selector_intent")
if (
resolution.status != "resolved"
and not isinstance(intent, dict)
and not runtime_selector.get("owner_match_required")
and not runtime_selector.get("output_role")
):
fallback = dict(runtime_selector)
fallback.pop("owner_feature_id", None)
resolution = registry.resolve(fallback, active_body_id=active_body_id)
return resolution
def _search_candidate_records(
resolution: Any,
*,
maximum: int,
minimum_score: float = 0.8,
) -> tuple[list[dict[str, Any]], int]:
"""Keep only candidates the normal resolver considers geometrically valid."""
candidates = [
item for item in resolution.candidates
if isinstance(item, dict)
and item.get("record_id")
and float(item.get("score") or 0.0) >= minimum_score
]
candidates.sort(key=lambda item: (-float(item.get("score") or 0.0), str(item.get("record_id"))))
return [deepcopy(item) for item in candidates[:maximum]], len(candidates)
def _search_replay_worker(candidate: str, step: str, result: str, maximum_candidates: int) -> None:
"""Replay one branch until it builds or exposes its next selector decision."""
from engine.cdsl_engine.runtime import finalize_cdsl_execution, prepare_cdsl_execution
from .selector_binding import _bound_selector, _selector_key
bound = read_json(Path(candidate))
execution = prepare_cdsl_execution(bound)
if not execution.analysis.runtime_eligible:
first = next((item for item in execution.analysis.feature_results if not item.executable), None)
code = first.blockers[0].code if first and first.blockers else "runtime_ineligible"
write_json(Path(result), {"status": "runtime_ineligible", "reason": code})
return
bound_features = {
str(feature.get("id") or ""): feature
for feature in bound.get("features") or []
if isinstance(feature, dict)
}
body_id_for_feature: dict[str, str | None] = {"__current__": None}
evidence: list[dict[str, Any]] = []
for index, node in enumerate(execution.analysis.plan):
feature = node.source_feature
sites = _searchable_selector_sites(feature)
if len(sites) != len(_selector_sites(feature)):
write_json(Path(result), {
"status": "search_unsupported",
"feature_id": node.feature_id,
"reason": "nested_selector_tree",
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
resolved: list[dict[str, Any]] = []
for location, placeholder in sites:
binding_feature_id = placeholder.get("binding_feature_id")
if binding_feature_id is None:
active_body_id = body_id_for_feature["__current__"]
elif isinstance(binding_feature_id, str) and binding_feature_id in body_id_for_feature:
active_body_id = body_id_for_feature[binding_feature_id]
else:
write_json(Path(result), {
"status": "search_failed",
"feature_id": node.feature_id,
"reason": "selector_binding_feature_missing_or_forward",
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
resolution = _resolve_search_selector(
placeholder,
registry=execution.session.topology,
active_body_id=active_body_id,
)
if resolution.status != "resolved":
candidates, eligible_count = _search_candidate_records(
resolution,
maximum=maximum_candidates,
)
if candidates:
write_json(Path(result), {
"status": "branchable_selector",
"feature_id": node.feature_id,
"source_location": location,
"resolution_status": resolution.status,
"diagnostic": resolution.diagnostic.as_dict() if resolution.diagnostic is not None else None,
"candidates": candidates,
"candidate_count": eligible_count,
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}"
write_json(Path(result), {
"status": "search_failed",
"feature_id": node.feature_id,
"reason": code,
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ()))
if not selected:
write_json(Path(result), {
"status": "search_failed",
"feature_id": node.feature_id,
"reason": "selector_not_found",
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
public_records = [record.public_dict() for record in selected]
selector, bound_selectors = _bound_selector(placeholder, public_records)
placeholder.clear()
placeholder.update(selector)
resolved.extend(bound_selectors)
feature["selectors"] = list({
_selector_key(selector): selector
for selector in feature.get("selectors") or []
}.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
write_json(Path(result), {
"status": "search_failed",
"feature_id": node.feature_id,
"reason": "mirror_plane_binding_not_unique",
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
feature.setdefault("params", {})["mirror_plane"] = planes[0]
public_feature = bound_features[node.feature_id]
public_feature["selectors"] = deepcopy(feature.get("selectors") or [])
public_feature["params"] = deepcopy(feature.get("params") or {})
evidence.append({
"feature_id": node.feature_id,
"prefix_feature_count": index,
"selectors": public_feature["selectors"],
"resolved": resolved,
})
try:
execution.execute_next(strict=True)
except Exception as error:
write_json(Path(result), {
"status": "search_failed",
"feature_id": node.feature_id,
"reason": str(error),
"bound_cdsl": bound,
"selector_binding": evidence,
})
return
body_id_for_feature[node.feature_id] = execution.session.body_id
body_id_for_feature["__current__"] = execution.session.body_id
write_json(Path(result), {
"status": "rebuilt",
"bound_cdsl": bound,
"selector_binding": evidence,
"result": finalize_cdsl_execution(execution, Path(step)),
})
def _run_search_replay(candidate_path: Path, step: Path, *, timeout_seconds: float, maximum_candidates: int) -> dict[str, Any]:
worker = step.with_suffix(".worker.json")
outcome = _isolated(
_search_replay_worker,
(str(candidate_path), str(step), str(worker), maximum_candidates),
worker,
timeout_seconds,
)
if outcome == "completed":
result = read_json(worker)
worker.unlink(missing_ok=True)
return result
step.unlink(missing_ok=True)
return {
"status": "search_timeout" if outcome == "timeout" else "search_failed",
"reason": "search_worker_timeout" if outcome == "timeout" else "search_worker_failed",
}
def _public_branch_summary(result: dict[str, Any], choices: list[dict[str, Any]], comparison: dict[str, Any] | None = None) -> dict[str, Any]:
summary = {
"status": result.get("status"),
"feature_id": result.get("feature_id"),
"source_location": result.get("source_location"),
"reason": result.get("reason"),
"resolution_status": result.get("resolution_status"),
"choice_path": choices,
}
if comparison is not None:
summary["comparison"] = {
"decision": comparison.get("decision"),
"strict_passed": _strict_passed(comparison),
}
return {key: value for key, value in summary.items() if value is not None}
def _run_selector_search(
sample: Sample,
directory: Path,
provenance_candidate: dict[str, Any],
geometry_candidate: dict[str, Any],
gold_step: Path,
*,
rebuild_timeout_seconds: float,
comparison_timeout_seconds: float,
maximum_branches: int,
maximum_candidates: int,
) -> tuple[dict[str, Any], dict[str, Any] | None]:
"""Explore selector alternatives, accepting only a unique strict winner.
Each branch is re-executed from an untouched CDSL document. We therefore
never share mutable OCC topology between alternatives and cannot quietly
inherit the result of a previous candidate.
"""
if maximum_branches <= 0 or maximum_candidates <= 0:
return {"status": "disabled"}, None
search_dir = directory / "selector-search"
search_dir.mkdir(parents=True, exist_ok=True)
pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(deepcopy(geometry_candidate), [])]
seen: set[str] = set()
terminals: list[tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]], dict[str, Any] | None]] = []
branch_summaries: list[dict[str, Any]] = []
budget_exhausted = False
candidates_truncated = False
branch_index = 0
while pending:
if branch_index >= maximum_branches:
budget_exhausted = True
break
candidate, choices = pending.pop(0)
fingerprint = json.dumps(candidate, sort_keys=True, separators=(",", ":"))
if fingerprint in seen:
continue
seen.add(fingerprint)
branch_index += 1
branch_name = f"branch-{branch_index:03d}"
candidate_path = search_dir / f"{branch_name}.candidate.cdsl.json"
step_path = search_dir / f"{branch_name}.rebuild.step"
write_json(candidate_path, candidate)
outcome = _run_search_replay(
candidate_path,
step_path,
timeout_seconds=rebuild_timeout_seconds,
maximum_candidates=maximum_candidates,
)
_write_rebuild_artifacts(search_dir, branch_name, outcome)
if outcome.get("status") == "branchable_selector":
candidates = list(outcome.get("candidates") or [])
total = int(outcome.get("candidate_count") or len(candidates))
if total > len(candidates):
candidates_truncated = True
branch_summaries.append(_public_branch_summary(outcome, choices))
for candidate_record in candidates:
child = deepcopy(outcome["bound_cdsl"])
feature_id = str(outcome["feature_id"])
location = str(outcome["source_location"])
source_feature = next(item for item in child["features"] if item.get("id") == feature_id)
source_selector = dict(_searchable_selector_sites(source_feature)[
next(index for index, (name, _selector) in enumerate(_searchable_selector_sites(source_feature)) if name == location)
][1])
forced = _forced_candidate_selector(source_selector, candidate_record)
_replace_selector_site(child, feature_id, location, forced)
pending.append((child, [*choices, {
"feature_id": feature_id,
"source_location": location,
"record_id": candidate_record.get("record_id"),
"score": candidate_record.get("score"),
}]))
continue
comparison = None
if outcome.get("status") == "rebuilt":
comparison, comparison_error = _run_comparison(
gold_step,
step_path,
search_dir / f"{branch_name}.comparison.json",
timeout_seconds=comparison_timeout_seconds,
)
if comparison is not None:
write_json(search_dir / f"{branch_name}.comparison.json", comparison)
elif comparison_error is not None:
outcome["comparison_error"] = comparison_error
terminals.append((outcome, candidate, choices, comparison))
branch_summaries.append(_public_branch_summary(outcome, choices, comparison))
if pending:
budget_exhausted = True
strict_winners = [item for item in terminals if _strict_passed(item[3])]
search_complete = not budget_exhausted and not candidates_truncated
report: dict[str, Any] = {
"status": "unique_strict" if search_complete and len(strict_winners) == 1 else "no_unique_strict",
"branches_executed": branch_index,
"maximum_branches": maximum_branches,
"maximum_candidates_per_selector": maximum_candidates,
"search_complete": search_complete,
"budget_exhausted": budget_exhausted,
"candidates_truncated": candidates_truncated,
"strict_winner_count": len(strict_winners),
"branches": branch_summaries,
}
if not search_complete:
report["reason"] = "search_budget_incomplete"
return report, None
if not strict_winners:
report["reason"] = "no_strict_branch"
return report, None
if len(strict_winners) > 1:
report["reason"] = "multiple_strict_branches"
report["strict_choice_paths"] = [item[2] for item in strict_winners]
return report, None
winner, _candidate, choices, comparison = strict_winners[0]
report["strict_choice_path"] = choices
selector_record = _emit_strict_selector_record(
sample,
search_dir,
provenance_candidate,
winner,
comparison,
gold_step,
rebuild_timeout_seconds=rebuild_timeout_seconds,
comparison_timeout_seconds=comparison_timeout_seconds,
)
report["selector_record"] = selector_record
return report, selector_record if selector_record.get("status") == "strict_replayed" else None
def run_geometry_probe(
sample: Sample,
output: Path,
*,
rebuild_timeout_seconds: float = 30.0,
comparison_timeout_seconds: float = 60.0,
search_maximum_branches: int = 32,
search_maximum_candidates: int = 8,
) -> dict[str, Any]:
"""Compare normal provenance replay with an explicitly heuristic replay.
The geometry replay reuses only geometry/stable-ID fields already emitted as
diagnostic context. It never invents a selector, writes a new source
parameter, or changes the production candidate.
"""
featurescript = Path(sample.files["featurescript"])
gold_step = Path(sample.files["step"])
if not featurescript.is_file() or not gold_step.is_file():
raise FileNotFoundError(f"{sample.sample_id}: the FeatureScript and source STEP are required")
directory = output / "samples" / sample.sample_id
directory.mkdir(parents=True, exist_ok=True)
result = lower_model(parse_featurescript(featurescript.read_text(encoding="utf-8"), sample.sample_id), {})
if not isinstance(result.cdsl, dict):
raise ValueError(f"{sample.sample_id}: lowering produced no CDSL candidate")
provenance_candidate = result.cdsl
geometry_candidate = deepcopy(provenance_candidate)
removed_intents = strip_provenance_intents(geometry_candidate)
write_json(directory / "provenance.candidate.cdsl.json", provenance_candidate)
write_json(directory / "geometry_probe.candidate.cdsl.json", geometry_candidate)
provenance = _run_rebuild(
directory / "provenance.candidate.cdsl.json",
directory / "provenance.rebuild.step",
timeout_seconds=rebuild_timeout_seconds,
)
geometry_probe = _run_rebuild(
directory / "geometry_probe.candidate.cdsl.json",
directory / "geometry_probe.rebuild.step",
timeout_seconds=rebuild_timeout_seconds,
)
_write_rebuild_artifacts(directory, "provenance", provenance)
_write_rebuild_artifacts(directory, "geometry_probe", geometry_probe)
comparison = None
comparison_error = None
if geometry_probe.get("status") == "rebuilt":
comparison, comparison_error = _run_comparison(
gold_step,
directory / "geometry_probe.rebuild.step",
directory / "geometry_probe.comparison.json",
timeout_seconds=comparison_timeout_seconds,
)
if comparison is not None:
write_json(directory / "geometry_probe.comparison.json", comparison)
selector_record = _emit_strict_selector_record(
sample,
directory,
provenance_candidate,
geometry_probe,
comparison,
gold_step,
rebuild_timeout_seconds=rebuild_timeout_seconds,
comparison_timeout_seconds=comparison_timeout_seconds,
)
selector_search = {"status": "not_needed"}
if not _strict_passed(comparison):
selector_search, searched_record = _run_selector_search(
sample,
directory,
provenance_candidate,
geometry_candidate,
gold_step,
rebuild_timeout_seconds=rebuild_timeout_seconds,
comparison_timeout_seconds=comparison_timeout_seconds,
maximum_branches=search_maximum_branches,
maximum_candidates=search_maximum_candidates,
)
if searched_record is not None:
selector_record = searched_record
report = {
"schema": "cadfs_to_cdsl.selector_candidate_geometry_probe.v1",
"sample_id": sample.sample_id,
"source": {"featurescript": str(featurescript), "step": str(gold_step)},
"strategy": {
"name": "geometry_diagnostic_context",
"removed_provenance_intent_fields": removed_intents,
"classification": "heuristic_only",
"limitations": [
"The source dataset contains only a final STEP, not per-feature source checkpoints.",
"A final strict or RP pass does not prove that the FeatureScript selector is semantically correct.",
"This probe must not be used by the production resolver or counted as provenance-selector coverage.",
],
},
"provenance": _outcome_summary(provenance, None),
"geometry_probe": _outcome_summary(geometry_probe, comparison),
"selector_record": selector_record,
"selector_search": selector_search,
}
if comparison_error is not None:
report["geometry_probe"]["comparison_error"] = comparison_error
write_json(directory / "report.json", report)
return report
def _arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run an offline geometry-only selector candidate probe and strict record replay")
parser.add_argument("--input", type=Path, default=Path("data/cadfs-sample/CADFS_test"))
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--sample-id", action="append", required=True)
parser.add_argument("--rebuild-timeout-seconds", type=float, default=30.0)
parser.add_argument("--comparison-timeout-seconds", type=float, default=60.0)
parser.add_argument("--search-maximum-branches", type=int, default=32)
parser.add_argument("--search-maximum-candidates", type=int, default=8)
return parser.parse_args()
def main() -> int:
args = _arguments()
samples = {sample.sample_id: sample for sample in scan_dataset(args.input, include_hashes=False)}
missing = sorted(set(args.sample_id) - samples.keys())
if missing:
raise ValueError("unknown sample ids: " + ", ".join(missing))
if args.rebuild_timeout_seconds <= 0 or args.comparison_timeout_seconds <= 0:
raise ValueError("candidate probe timeouts must be positive")
if args.search_maximum_branches < 0 or args.search_maximum_candidates < 0:
raise ValueError("search budgets cannot be negative")
reports = [
run_geometry_probe(
samples[sample_id],
args.output,
rebuild_timeout_seconds=args.rebuild_timeout_seconds,
comparison_timeout_seconds=args.comparison_timeout_seconds,
search_maximum_branches=args.search_maximum_branches,
search_maximum_candidates=args.search_maximum_candidates,
)
for sample_id in args.sample_id
]
records: list[dict[str, Any]] = []
for report in reports:
record = report.get("selector_record") or {}
record_path = record.get("record_path") if isinstance(record, dict) else None
if isinstance(record_path, str) and Path(record_path).is_file():
payload = read_json(Path(record_path))
records.extend(item for item in payload.get("records") or [] if isinstance(item, dict))
write_manifest(args.output / "selector-records.jsonl", records)
write_json(args.output / "summary.json", {
"schema": "cadfs_to_cdsl.selector_candidate_geometry_probe_summary.v2",
"sample_count": len(reports),
"strict_selector_record_count": len(records),
"reports": reports,
})
return 0
if __name__ == "__main__":
raise SystemExit(main())
+260 -62
View File
@@ -10,7 +10,7 @@ from cadfs_to_cdsl.rebuild import rebuild_candidate
class IntegrationTests(unittest.TestCase):
def test_known_rp_roundtrip_00000173(self):
def test_known_rp_source_preserves_prefix_when_selector_query_is_deferred(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
@@ -18,11 +18,9 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
result = rebuild_candidate(cdsl, rebuilt)
self.assertEqual(result["status"], "rebuilt")
comparison = compare_steps(gold, rebuilt)
self.assertFalse(comparison["strict"]["passed"])
self.assertTrue(comparison["rp"]["passed"])
self.assertEqual(comparison["decision"], "approximate_pass")
self.assertEqual(result["status"], "rebuild_failed")
self.assertEqual(result["error"]["message"], "f_F2: selector_query_unsupported during incremental replay")
self.assertEqual(result["last_executable_prefix"]["last_feature_id"], "f_F1")
def test_standard_tapped_through_counterbore_00002243(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -43,9 +41,30 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt")
comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3")
def test_direct_sketch_vertex_holes_preserve_scoped_body_00406667(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0040/00406667.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00406667"), {}).cdsl
holes = [item for item in candidate["features"] if item["atomic_id"] == "hole_wizard"]
self.assertEqual([item["id"] for item in holes], ["f_F3", "f_F5"])
self.assertEqual([item["params"]["scope_feature_id"] for item in holes], ["f_F1", "f_F1"])
self.assertEqual([item["params"]["positions"] for item in holes], [
[{"mm": [0.0, 0.0, 0.0]}], [{"mm": [0.0, 26.69, 0.0]}],
])
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(
[item["feature_id"] for item in outcome["result"]["feature_results"]],
["f_F1", "f_F3", "f_F5"],
)
def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -75,9 +94,15 @@ class IntegrationTests(unittest.TestCase):
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(fillet["selectors"][0]["kind"], "face")
self.assertNotIn("geometry", fillet["selectors"][0])
self.assertEqual(fillet["selectors"][0]["selector_intent"]["query_family"], "SWEPT_FACE")
self.assertEqual(
fillet["selectors"][0]["geometry"],
{"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66},
fillet["selectors"][0]["selector_intent"]["source_entity"],
{"sketch_id": "F0", "entity_id": "E0"},
)
self.assertEqual(
fillet["selectors"][0]["selector_intent"]["derivation_policy"],
{"allowed": ["boundary"], "multiplicity": "one"},
)
chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
@@ -101,18 +126,183 @@ class IntegrationTests(unittest.TestCase):
self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"])
self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body")
from build123d import import_step
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
runtime_box = outcome["result"]["bbox_mm"]
imported_box = import_step(str(rebuilt)).bounding_box()
actual = [imported_box.min.X, imported_box.min.Y, imported_box.min.Z, imported_box.max.X, imported_box.max.Y, imported_box.max.Z]
expected = runtime_box["min"] + runtime_box["max"]
for value, target in zip(actual, expected): self.assertAlmostEqual(value, target, places=5)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F5: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F4")
def test_inward_shells_00789939_lower_and_rebuild(self):
def test_swept_face_extent_continues_through_primary_cuts_00925274(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}).cdsl
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
result = outcome["result"]
self.assertEqual(
[item["feature_id"] for item in result["feature_results"]],
["f_F1", "f_F3", "f_F4", "f_F6", "f_F7", "f_F9", "f_F10", "f_F12", "f_F13", "f_F15", "f_F17", "f_F18_plane", "f_F18"],
)
f9_resolution = next(
item for item in result["selector_resolution"] if item["feature_id"] == "f_F9"
)
self.assertEqual(f9_resolution["status"], "resolved")
self.assertEqual(f9_resolution["resolution_mode"], "kernel_lineage")
self.assertEqual(f9_resolution["evidence"]["result_records"], ["body:f_F6:face:0"])
self.assertEqual(
[item["feature_id"] for item in f9_resolution["evidence"]["relations"]],
["f_F1", "f_F3", "f_F6"],
)
def test_direct_hole_prism_swept_edge_resolves_00007264(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00007264.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00007264"), {})
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertTrue(all(
selector["selector_intent"]["query_family"] == "SWEPT_EDGE"
for selector in fillet["selectors"]
))
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(result.cdsl, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
binding = next(item for item in outcome["selector_binding"] if item["feature_id"] == "f_F2")
self.assertEqual(len(binding["resolved"]), 4)
vertical_edges = [
relation
for delta in outcome["result"]["topology_deltas"]
if not any(str(snapshot_id).startswith("transient:") for snapshot_id in delta["output_snapshot_ids"])
for relation in delta["relations"]
if relation["source_kind"] == "vertex"
and relation["result_kind"] == "edge"
and relation["source_record_ids"]
]
self.assertEqual(
len({(tuple(item["source_record_ids"]), tuple(item["result_record_ids"])) for item in vertical_edges}),
4,
)
self.assertTrue(all(
relation["coverage"] == "complete" and relation["lineage_status"] == "proven"
for relation in vertical_edges
))
def test_annular_swept_face_selector_00974931(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0097/00974931.txt"
gold = root / "step_abc/0097/00974931.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00974931"), {}).cdsl
fillet = next(item for item in candidate["features"] if item["id"] == "f_F2")
selector = fillet["selectors"][0]
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertNotIn("geometry", selector)
self.assertNotIn("stable_id", selector)
self.assertEqual(selector["selector_intent"]["query_family"], "SWEPT_FACE")
self.assertEqual(
selector["selector_intent"]["source_entity"],
{"sketch_id": "F0", "entity_id": "E0"},
)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertEqual(outcome["status"], "rebuild_failed")
f2_resolution = next(
item for item in outcome["last_executable_prefix"]["result"]["selector_resolution"]
if item["feature_id"] == "f_F2"
)
self.assertEqual(f2_resolution["status"], "resolved")
self.assertEqual(f2_resolution["resolution_mode"], "kernel_lineage")
self.assertEqual(f2_resolution["evidence"]["semantic_anchor"], {
"type": "source_entity", "sketch_id": "F0", "entity_id": "E0",
})
self.assertEqual(
[item["feature_id"] for item in outcome["last_executable_prefix"]["result"]["feature_results"]],
["f_F1", "f_F2"],
)
self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay")
def test_direct_prism_cap_edge_lineage_00021014(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0002/00021014.txt"
gold = root / "step_abc/0002/00021014.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00021014"), {}).cdsl
fillet = next(item for item in candidate["features"] if item["id"] == "f_F2")
cap_edges = [
selector for selector in fillet["selectors"]
if (selector.get("selector_intent") or {}).get("query_family") == "CAP_EDGE"
]
self.assertEqual(len(cap_edges), 9)
self.assertTrue(all("stable_id" not in selector and "geometry" not in selector for selector in cap_edges))
self.assertEqual(
{selector["selector_intent"]["lineage_role"] for selector in cap_edges},
{"extrude.start", "extrude.end"},
)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
comparison = compare_steps(gold, rebuilt)
cap_resolutions = [
item for item in outcome["result"]["selector_resolution"]
if item.get("feature_id") == "f_F2"
and item.get("evidence", {}).get("semantic_anchor", {}).get("type") == "source_entity"
]
self.assertEqual(len(cap_resolutions), 10)
direct_caps = [
item for item in cap_resolutions
if item["evidence"]["relations"][0].get("output_role") in {"extrude.start", "extrude.end"}
]
self.assertEqual(len(direct_caps), 9)
self.assertTrue(all(item["resolution_mode"] == "kernel_lineage" for item in direct_caps))
self.assertTrue(comparison["strict"]["passed"])
def test_direct_mixed_hole_cap_edge_lineage_00735367(self):
"""A solver-split circular hole restores one proven source cap edge."""
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0073/00735367.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00735367"), {}).cdsl
fillet = next(item for item in candidate["features"] if item["id"] == "f_F2")
selector = fillet["selectors"][0]
self.assertEqual(selector["selector_intent"]["query_family"], "CAP_EDGE")
self.assertEqual(selector["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E1"})
self.assertEqual(selector["selector_intent"]["lineage_role"], "extrude.end")
self.assertNotIn("geometry", selector)
self.assertNotIn("stable_id", selector)
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay")
prefix = outcome["last_executable_prefix"]["result"]
self.assertEqual([item["feature_id"] for item in prefix["feature_results"]], ["f_F1", "f_F2", "f_F3"])
resolution = next(item for item in prefix["selector_resolution"] if item["feature_id"] == "f_F2")
self.assertEqual(resolution["status"], "resolved")
self.assertEqual(resolution["resolution_mode"], "kernel_lineage")
self.assertEqual(resolution["evidence"]["semantic_anchor"], {
"type": "source_entity", "sketch_id": "F0", "entity_id": "E1",
})
self.assertEqual(resolution["evidence"]["relations"][0]["output_role"], "extrude.end")
self.assertTrue(resolution["evidence"]["relations"][0]["source_record_ids"])
def test_inward_shells_00789939_preserve_prefix_when_face_query_is_deferred(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00789939.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -128,8 +318,10 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 2)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F6: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F5")
self.assertEqual(outcome["last_executable_prefix"]["result"]["solid_count"], 2)
def test_outward_cap_shells_lower_and_rebuild_prefixes(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -155,7 +347,10 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 1)
self.assertEqual(
[item["feature_id"] for item in outcome["result"]["feature_results"]],
["f_F1", "f_F2"],
)
def test_sweep_00542223_preserves_its_open_bspline_path(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -173,6 +368,42 @@ class IntegrationTests(unittest.TestCase):
self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
def test_direct_sketch_wire_qbodytype_path_lowers_and_rebuilds_00896761(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0089/00896761.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00896761"), {}).cdsl
sweep = next(item for item in candidate["features"] if item["id"] == "f_F2")
segment = sweep["params"]["path"]["segment"]
self.assertEqual(sweep["atomic_id"], "sweep_add")
self.assertEqual(segment["type"], "bspline")
self.assertEqual(segment["source_entity_id"], "E2")
self.assertEqual(segment["points"], [[0.0, 0.0], [87.21, 282.41]])
self.assertEqual(segment["start_tangent"], [0.0, 936.12])
self.assertEqual(segment["end_tangent"], [168.9, 48.72])
from engine.cdsl_engine.runtime import analyze_cdsl
self.assertTrue(analyze_cdsl(candidate).runtime_eligible)
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual([item["feature_id"] for item in outcome["result"]["feature_results"]], ["f_F2"])
def test_multi_entity_sketch_wire_qbodytype_path_remains_deferred_00786708(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00786708.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00786708"), {})
self.assertIn({
"code": "unsupported_engine_capability", "feature_id": "F2", "operation": "sweep", "capability": "sweep_path_query",
"message": "current CDSL sweep requires one direct sketch line or B-spline path",
}, result.diagnostics)
self.assertNotIn("f_F2", {item["id"] for item in result.cdsl["features"]})
later_sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
self.assertEqual(later_sweep["params"]["path"]["segment"]["source_entity_id"], "E5")
def test_circular_pattern_00542223_preserves_all_sweep_arms(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
@@ -193,7 +424,7 @@ class IntegrationTests(unittest.TestCase):
self.assertAlmostEqual(bbox["max"][0], 52.14101625137762)
self.assertAlmostEqual(bbox["max"][1], 37.5000001000001)
def test_fused_body_circular_copy_faces_bind_and_shell_00542223(self):
def test_fused_body_circular_copy_faces_preserve_shell_prefix_00542223(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -202,53 +433,20 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
result = outcome["result"]
self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]])
shell_selectors = [
item for item in result["selector_resolution"]
if item["feature_id"] == "f_F7"
]
self.assertEqual(
{item["selector"]["owner_feature_id"] for item in shell_selectors},
{"f_F5", "f_F6.c1.f_F5", "f_F6.c2.f_F5", "f_F1"},
)
self.assertTrue(all(item["status"] == "resolved" for item in shell_selectors))
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6")
def test_face_chamfer_ignores_periodic_seams_00111611(self):
def test_face_chamfer_source_query_is_not_geometry_bound_00111611(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
from copy import deepcopy
from engine.cdsl_engine.capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract
from engine.cdsl_engine.runtime import EXECUTORS, ExecutionSession, _execute_node, _selector_edges
from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl
bound, _ = bind_candidate_selectors(candidate)
resolved = resolve_required_sketches(
deepcopy(bound), sketch_ids_required_by_contract(bound), errors={},
)
analysis = CapabilityAnalyzer(
atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS,
).analyze(resolved)
session = ExecutionSession(
sketches={str(item["id"]): item for item in resolved["geometry"]["sketches"]},
nodes={node.feature_id: node for node in analysis.plan},
)
for node in analysis.plan:
if node.feature_id == "f_F5":
edges = _selector_edges(node, session, tangent_propagation=True)
break
_execute_node(node, session)
else:
self.fail("F5 chamfer was not planned")
self.assertEqual(len(edges), 8)
self.assertTrue(all(str(edge.geom_type).endswith("CIRCLE") for edge in edges))
with self.assertRaisesRegex(ValueError, "f_F5: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(candidate)
def test_circular_remove_pattern_replays_cut_sources_00159804(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
+719 -55
View File
@@ -6,7 +6,12 @@ from pathlib import Path
from cadfs_to_cdsl.compare import compare_steps
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.ir import Call
from cadfs_to_cdsl.lowering import _arc, _contours, _global, _number, _record_single_body_successor, lower_model
from cadfs_to_cdsl.lowering import (
UnsupportedCapability, _arc, _contours, _curve_endpoint_tangent,
_direct_hole_location, _direct_line_angle_axis, _direct_sketch_wire_path,
_direct_boolean_intersection_selector, _direct_primary_cut_intersection_selector, _global, _number,
_record_single_body_successor, lower_model,
)
from cadfs_to_cdsl.pipeline import compare_one, convert_one, rebuild_one
from cadfs_to_cdsl.rebuild import rebuild_candidate
from cadfs_to_cdsl.dataset import Sample
@@ -14,7 +19,92 @@ from cadfs_to_cdsl.dataset import scan_dataset
from cadfs_to_cdsl.tests.test_parser import SOURCE, TRANSFORM_SOURCE
LINE_ANGLE_TWO_ENTITY_SOURCE = '''FeatureScript 1511;
import(path : "onshape/std/geometry.fs", version : "1511.0");
const mm = millimeter;
const VERTEX = EntityType.VERTEX;
const EDGE = EntityType.EDGE;
const FACE = EntityType.FACE;
function v(x, y){return vector(x, y);}
annotation { "Feature Type Name" : "Feature" }
export const myFeature = defineFeature(function(context is Context, id is Id, definition is map)
precondition{}
{
{ var Q0; Q0=qCreatedBy(makeId("Top.planeOp"),FACE); var sketch=newSketch(context,id+"F0",{"sketchPlane":qUnion([Q0])}); skLineSegment(sketch,"E0",{"start":v(0,0)*mm,"end":v(10,0)*mm}); skLineSegment(sketch,"E1",{"start":v(0,0)*mm,"end":v(0,10)*mm}); skLineSegment(sketch,"E2",{"start":v(0,5)*mm,"end":v(10,5)*mm}); skPoint(sketch,"P0",{"position":v(0,10)*mm}); skPoint(sketch,"P1",{"position":v(10,0)*mm}); skSolve(sketch); }
{ var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",EDGE,"E1"); cPlane(context,id+"F1",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); }
{ var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",EDGE,"E2"); cPlane(context,id+"F2",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); }
{ var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",VERTEX,"P0"); cPlane(context,id+"F3",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); }
{ var Q0; Q0=sQuery(id+"F0.wireOp",VERTEX,"P0"); var Q1; Q1=sQuery(id+"F0.wireOp",EDGE,"E0"); cPlane(context,id+"F4",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":90*degree,"oppositeDirection":true}); }
{ var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=sQuery(id+"F0.wireOp",VERTEX,"P1"); cPlane(context,id+"F5",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); }
{ var Q0; Q0=sQuery(id+"F0.wireOp",EDGE,"E0"); var Q1; Q1=qCreatedBy(id+"F1.planeOp",FACE); cPlane(context,id+"F6",{"entities":qUnion([Q0,Q1]),"cplaneType":CPlaneType.LINE_ANGLE,"angle":0*degree}); }
}'''
class LoweringTests(unittest.TestCase):
def test_direct_sketch_wire_path_accepts_only_one_nonconstruction_line_or_bspline(self):
plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]}
line = {"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "source_entity_id": "E0"}
query = Call("qUnion", [[
Call("qConstructionFilter", [
Call("qBodyType", [
Call("qCreatedBy", [Call("__binary__", ["id", "+", "F0"]), "EDGE"]),
"BodyType.WIRE",
]),
"ConstructionObject.NO",
]),
]])
sketches = {"F0": {"workplane": plane}}
entities = {"F0": {"E0": line, "axis": {"type": "line", "start": [0.0, 0.0], "end": [0.0, 5.0], "construction": True}}}
self.assertEqual(
_direct_sketch_wire_path(
query, sketches, entities, featurescript_version="1511",
standard_library="onshape/std/geometry.fs", standard_library_version="1511.0",
),
("F0", "E0", line, sketches["F0"]),
)
self.assertIsNone(_direct_sketch_wire_path(
query, sketches, entities, featurescript_version="1512",
standard_library="onshape/std/geometry.fs", standard_library_version="1511.0",
))
self.assertIsNone(_direct_sketch_wire_path(
query, sketches, entities, featurescript_version="1511",
standard_library="onshape/std/geometry.fs", standard_library_version="1512.0",
))
self.assertIsNone(_direct_sketch_wire_path(
query, sketches, {"F0": {"E0": line, "E1": {**line, "source_entity_id": "E1"}}},
featurescript_version="1511", standard_library="onshape/std/geometry.fs", standard_library_version="1511.0",
))
self.assertIsNone(_direct_sketch_wire_path(
Call("qBodyType", [Call("qCreatedBy", ["F0", "EDGE"]), "BodyType.WIRE"]),
sketches, entities, featurescript_version="1511",
standard_library="onshape/std/geometry.fs", standard_library_version="1511.0",
))
self.assertIsNone(_direct_sketch_wire_path(
query.args[0][0], sketches, entities, featurescript_version="1511",
standard_library="onshape/std/geometry.fs", standard_library_version="1511.0",
))
def test_direct_hole_location_accepts_only_original_sketch_vertices(self):
plane = {"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0]}
sketches = {"F2": {"workplane": plane}}
entities = {"F2": {
"P0": {"type": "point", "point": [1.0, 2.0]},
"C0": {"type": "circle", "center": [3.0, 4.0], "radius_mm": 1.0},
"L0": {"type": "line", "start": [5.0, 6.0], "end": [7.0, 8.0]},
"A0": {"type": "arc", "start": [9.0, 10.0], "end": [11.0, 12.0]},
}}
def vertex(token: str) -> Call:
return Call("sQuery", ["F2.wireOp", "VERTEX", token])
self.assertEqual(_direct_hole_location(vertex("P0"), sketches, entities), ([1.0, 2.0, 0.0], plane))
self.assertEqual(_direct_hole_location(vertex("C0.center"), sketches, entities), ([3.0, 4.0, 0.0], plane))
self.assertEqual(_direct_hole_location(vertex("L0.end"), sketches, entities), ([7.0, 8.0, 0.0], plane))
self.assertEqual(_direct_hole_location(vertex("A0.start"), sketches, entities), ([9.0, 10.0, 0.0], plane))
self.assertIsNone(_direct_hole_location(Call("qAdjacent", [vertex("C0.center")]), sketches, entities))
self.assertIsNone(_direct_hole_location(vertex("C0.center.copy"), sketches, entities))
def test_strict_comparison_status_is_not_labeled_approximate(self):
with tempfile.TemporaryDirectory() as tmp:
output = Path(tmp); directory = output / "samples" / "strict-status"; directory.mkdir(parents=True)
@@ -53,7 +143,7 @@ class LoweringTests(unittest.TestCase):
self.assertAlmostEqual(arc["center"][1], 0.5)
self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 0.5))
def test_direct_extrude_and_two_section_loft_swept_edges_bind_for_fillet(self):
def test_direct_extrude_lineage_succeeds_and_unregistered_loft_query_preserves_prefix(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = {
"00000715": ("f_F2", 2, True),
@@ -67,14 +157,37 @@ class LoweringTests(unittest.TestCase):
fillet = next(item for item in result.cdsl["features"] if item["id"] == fillet_id)
self.assertEqual(len(fillet["selectors"]), selector_count)
self.assertTrue(all(item["owner_feature_id"] == fillet["depends_on"][0] for item in fillet["selectors"]))
self.assertTrue(all("bbox_mm" in item["geometry"] for item in fillet["selectors"]))
self.assertEqual(["curve_type" in item["geometry"] for item in fillet["selectors"]], [line_type] * selector_count)
if sample_id == "00000715":
self.assertTrue(all("geometry" not in item for item in fillet["selectors"]))
self.assertTrue(all(
item["selector_intent"]["query_family"] == "SWEPT_EDGE"
and item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"}
and len(item["selector_intent"]["source_entities"]) == 2
for item in fillet["selectors"]
))
else:
self.assertTrue(all("bbox_mm" in item["geometry"] for item in fillet["selectors"]))
self.assertEqual(["curve_type" in item["geometry"] for item in fillet["selectors"]], [line_type] * selector_count)
self.assertTrue(all(
item["selector_intent"]["query_family"] == "SWEPT_EDGE"
and item["selector_intent"]["evidence"] == "feature_script_query"
and item["selector_intent"]["derivation_policy"] == {"allowed": ["continuation"], "multiplicity": "none"}
and item["selector_intent"]["source_query"]["featurescript_version"] == "1793"
for item in fillet["selectors"]
))
with tempfile.TemporaryDirectory() as directory:
rebuilt = Path(directory) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
if sample_id == "00005267":
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay")
self.assertTrue(rebuilt.exists())
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F4")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3")
else:
self.assertEqual(outcome["status"], "rebuilt")
comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
def test_swept_edge_requires_one_direct_shared_source_endpoint(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -93,7 +206,7 @@ class LoweringTests(unittest.TestCase):
"message": "swept edge source endpoint provenance is unsupported",
}])
def test_direct_full_revolve_swept_circle_edges_bind_for_dressups(self):
def test_full_revolve_swept_circle_edges_remain_deferred_with_prefixes(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
for sample_id, dressup_id, selector_count in (
("00048326", "f_F3", 2),
@@ -109,14 +222,17 @@ class LoweringTests(unittest.TestCase):
selector["geometry"].get("curve_type") == "circle"
and len(selector["geometry"].get("circle_center_mm") or []) == 3
and selector["geometry"].get("radius_mm", 0) > 0
and selector["selector_intent"]["query_family"] == "SWEPT_EDGE"
and selector["selector_intent"]["evidence"] == "feature_script_query"
and selector["selector_intent"]["derivation_policy"]["multiplicity"] == "none"
for selector in dressup["selectors"]
))
with tempfile.TemporaryDirectory() as directory:
rebuilt = Path(directory) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], f"{dressup_id}: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], dressup_id)
def test_full_revolve_swept_circle_requires_one_direct_shared_source_endpoint(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -135,7 +251,7 @@ class LoweringTests(unittest.TestCase):
"message": "swept edge source endpoint provenance is unsupported",
}, result.diagnostics)
def test_equivalent_imprint_profile_retains_direct_full_revolve_swept_edges(self):
def test_equivalent_imprint_profile_preserves_but_does_not_bind_full_revolve_queries(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
source = root / "featurescript_rp/0011/00112257.txt"
result = lower_model(parse_featurescript(source.read_text(), "00112257"), {})
@@ -144,17 +260,16 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(len(fillet["selectors"]), 5)
self.assertTrue(all(
selector["geometry"].get("curve_type") == "circle"
and selector["selector_intent"]["query_family"] == "SWEPT_EDGE"
and selector["selector_intent"]["derivation_policy"]["multiplicity"] == "none"
for selector in fillet["selectors"]
))
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
prefix = dict(result.cdsl)
prefix["features"] = result.cdsl["features"][:2]
bound, evidence = bind_candidate_selectors(prefix)
selectors = next(item for item in bound["features"] if item["id"] == "f_F2")["selectors"]
self.assertEqual(len(evidence), 1)
self.assertEqual(len(selectors), 5)
self.assertTrue(all(selector.get("snapshot_id") for selector in selectors))
with self.assertRaisesRegex(ValueError, "f_F2: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(prefix)
def test_changed_imprint_profile_does_not_inherit_full_revolve_source_edges(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -689,6 +804,30 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(end["offset_mm"], 3.0)
self.assertEqual(end["reference"]["kind"], "body")
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
self.assertEqual(end["reference"]["source"], "runtime_snapshot")
self.assertNotIn("stable_id", end["reference"])
self.assertEqual(end["reference"]["selector_intent"]["query_family"], "SWEPT_BODY")
self.assertEqual(end["reference"]["selector_intent"]["evidence"], "active_body_member")
self.assertEqual(
end["reference"]["selector_intent"]["derivation_policy"],
{"allowed": ["boundary"], "multiplicity": "one"},
)
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F4")
self.assertEqual(len(fillet["selectors"]), 1)
self.assertEqual(fillet["selectors"][0]["selector_intent"]["query_family"], "CAP_EDGE")
self.assertEqual(
fillet["selectors"][0]["selector_intent"]["derivation_policy"],
{"allowed": ["boundary", "continuation"], "multiplicity": "one"},
)
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
mixed_evidence = deepcopy(result.cdsl)
mixed_reference = next(
item for item in mixed_evidence["features"] if item["id"] == "f_F3"
)["params"]["end_condition"]["reference"]
mixed_reference["stable_id"] = "body:f_F1"
with self.assertRaisesRegex(ValueError, "invalid SWEPT_BODY member contract"):
validate_semantic_cdsl(mixed_evidence)
def test_up_to_surface_extrude_captures_the_cap_face(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -700,9 +839,24 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(end["type"], "up_to_surface")
self.assertEqual(end["reference"]["kind"], "face")
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
self.assertEqual(end["reference"]["geometry"]["plane_offset_mm"], 0.0)
self.assertEqual(end["reference"]["output_role"], "extrude.start")
self.assertEqual(end["reference"]["source"], "runtime_snapshot")
self.assertNotIn("geometry", end["reference"])
self.assertEqual(end["reference"]["selector_intent"]["query_family"], "CAP_FACE")
self.assertEqual(end["reference"]["selector_intent"]["evidence"], "operation_role")
swept = next(item for item in result.cdsl["features"] if item["id"] == "f_F6")
self.assertEqual(swept["params"]["end_condition"]["reference"]["geometry"]["radius_mm"], 24.3)
swept_reference = swept["params"]["end_condition"]["reference"]
self.assertEqual(swept_reference["selector_intent"]["query_family"], "SWEPT_FACE")
self.assertEqual(
swept_reference["selector_intent"]["derivation_policy"],
{"allowed": ["boundary", "continuation"], "multiplicity": "one"},
)
self.assertEqual(swept_reference["selector_intent"]["evidence"], "kernel_history")
self.assertEqual(swept_reference["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E0"})
self.assertEqual(swept_reference["source"], "runtime_snapshot")
self.assertNotIn("stable_id", swept_reference)
self.assertNotIn("binding_feature_id", swept_reference)
self.assertNotIn("geometry", swept_reference)
mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18")
self.assertTrue(mirror["params"]["mirror_current_body"])
@@ -756,6 +910,160 @@ class LoweringTests(unittest.TestCase):
self.assertAlmostEqual(plane["origin_mm"][0], -12.15)
self.assertAlmostEqual(plane["origin_mm"][1], -24.3 * math.sqrt(3) / 2)
def test_line_angle_direct_circle_uses_the_featurescript_circle_axis(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0003/00030209.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00030209"), {})
self.assertEqual(result.status, "converted_partial")
planes = {
item["id"]: item["params"]["plane"]
for item in result.cdsl["features"]
if item["atomic_id"] == "reference_plane"
}
expected_centers = {
"f_F1": [45.78, 0.0, 26.2],
"f_F2": [13.9, 0.0, 26.5],
"f_F4": [-17.08, 0.0, 23.46],
"f_F5": [-44.4, 0.0, 19.8],
}
self.assertEqual(set(planes), set(expected_centers))
for feature_id, center in expected_centers.items():
with self.subTest(feature_id=feature_id):
plane = planes[feature_id]
self.assertEqual(plane["origin_mm"], center)
self.assertEqual(plane["x_dir"], [0.0, -1.0, 0.0])
self.assertAlmostEqual(plane["normal"][0], 1.0)
self.assertAlmostEqual(plane["normal"][1], 0.0)
self.assertAlmostEqual(plane["normal"][2], 0.0)
self.assertFalse(any(
diagnostic.get("operation") == "cPlane"
for diagnostic in result.diagnostics
))
opposite_source = feature.read_text().replace(
'"angle" : 90 * degree, "width"',
'"angle" : 90 * degree, "oppositeDirection" : true, "width"',
1,
)
opposite = lower_model(parse_featurescript(opposite_source, "line-angle-circle-opposite"), {})
opposite_plane = next(
item for item in opposite.cdsl["features"] if item["id"] == "f_F1"
)["params"]["plane"]
self.assertAlmostEqual(opposite_plane["normal"][0], -1.0)
self.assertAlmostEqual(opposite_plane["normal"][1], 0.0)
self.assertAlmostEqual(opposite_plane["normal"][2], 0.0)
def test_line_angle_two_entities_follow_axis_plane_order_and_swap_rules(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = {
# F8 selects Right plane first, then the direct F7 line. The
# standard library must swap them before calculating the frame.
"00298026": ("f_F8", [0.0, -1.0, 0.0], [0.43837114678907757, 0.0, -0.898794046299167]),
# F1 supplies the direct line first and the default plane second.
"00586156": ("f_F1", [0.0, 0.0, 1.0], [-math.sqrt(0.5), -math.sqrt(0.5), 0.0]),
}
for sample_id, (feature_id, expected_x, expected_normal) in cases.items():
with self.subTest(sample_id=sample_id):
source = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
result = lower_model(parse_featurescript(source.read_text(), sample_id), {})
plane = next(item for item in result.cdsl["features"] if item["id"] == feature_id)["params"]["plane"]
self.assertEqual(plane["x_dir"], expected_x)
for actual, expected in zip(plane["normal"], expected_normal):
self.assertAlmostEqual(actual, expected)
self.assertFalse(any(
diagnostic.get("operation") == "cPlane"
and diagnostic.get("feature_id") == feature_id.removeprefix("f_")
for diagnostic in result.diagnostics
))
def test_line_angle_two_direct_entities_cover_axis_point_and_degenerate_contracts(self):
result = lower_model(parse_featurescript(LINE_ANGLE_TWO_ENTITY_SOURCE, "line-angle-two-entities"), {})
self.assertEqual(result.status, "converted_partial")
planes = {
item["id"]: item["params"]["plane"]
for item in result.cdsl["features"]
if item["atomic_id"] == "reference_plane"
}
# Nonparallel axes, parallel offset axes, and an axis plus a direct
# point each use the exact source direction, rather than a sketch
# workplane frame.
for feature_id in ("f_F1", "f_F2", "f_F3"):
self.assertEqual(planes[feature_id]["x_dir"], [1.0, 0.0, 0.0])
self.assertEqual(planes[feature_id]["normal"], [0.0, 0.0, 1.0])
# A point selected before its axis triggers FeatureScript's swap. The
# signed angle also follows oppositeDirection.
self.assertEqual(planes["f_F4"]["x_dir"], [1.0, 0.0, 0.0])
self.assertAlmostEqual(planes["f_F4"]["normal"][0], 0.0)
self.assertAlmostEqual(planes["f_F4"]["normal"][1], 1.0)
self.assertAlmostEqual(planes["f_F4"]["normal"][2], 0.0)
# A cPlane output is represented as qCreatedBy(...planeOp, FACE),
# not a sketch source. It is accepted only because F1 is an existing
# reference-plane frame, and it supplies the true zero-angle side.
self.assertEqual(planes["f_F6"]["x_dir"], [1.0, 0.0, 0.0])
self.assertEqual(planes["f_F6"]["normal"], [0.0, 0.0, -1.0])
self.assertIn({
"code": "feature_deferred",
"feature_id": "F5",
"operation": "cPlane",
"message": "line-angle reference selection is degenerate",
}, result.diagnostics)
def test_line_angle_direct_axis_refuses_a_topology_derived_query(self):
direct = Call("sQuery", ["F0.wireOp", "EDGE", "E0"], 1)
derived = Call("makeQuery", [
"F1.opExtrude", "CAP_EDGE", "EDGE",
{"derivedFrom": direct, "isStart": False},
], 2)
sketch_by_source = {"F0": {"workplane": {
"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0],
}}}
entity_by_sketch = {"F0": {"E0": {
"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "source_entity_id": "E0",
}}}
self.assertIsNotNone(_direct_line_angle_axis(direct, sketch_by_source, entity_by_sketch))
self.assertIsNone(_direct_line_angle_axis(derived, sketch_by_source, entity_by_sketch))
def test_line_angle_direct_axis_refuses_a_query_wrapper_and_keeps_the_prefix(self):
direct = Call("sQuery", ["F0.wireOp", "EDGE", "E0"], 1)
wrapped = Call("qAdjacent", [direct, "VERTEX", "EDGE"], 2)
sketch_by_source = {"F0": {"workplane": {
"origin_mm": [0.0, 0.0, 0.0], "x_dir": [1.0, 0.0, 0.0], "normal": [0.0, 0.0, 1.0],
}}}
entity_by_sketch = {"F0": {"E0": {
"type": "line", "start": [0.0, 0.0], "end": [10.0, 0.0], "source_entity_id": "E0",
}}}
self.assertIsNone(_direct_line_angle_axis(wrapped, sketch_by_source, entity_by_sketch))
model = parse_featurescript(LINE_ANGLE_TWO_ENTITY_SOURCE, "line-angle-wrapped-axis")
plane = next(item for item in model.features if item.feature_id == "F1")
plane.params["entities"].args[0][0] = wrapped
result = lower_model(model, {})
self.assertIn({
"code": "feature_deferred",
"feature_id": "F1",
"operation": "cPlane",
"message": "line-angle reference selection is unsupported",
}, result.diagnostics)
self.assertIn("f_F2", {item["id"] for item in result.cdsl["features"]})
def test_line_angle_rejects_a_circle_center_vertex_as_an_axis(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0003/00030209.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
source = feature.read_text().replace(
'Q0=sQuery(id+"F0.wireOp",EDGE,"E0");',
'Q0=sQuery(id+"F0.wireOp",VERTEX,"E0.center");',
1,
)
result = lower_model(parse_featurescript(source, "line-angle-circle-center"), {})
self.assertIn({
"code": "feature_deferred",
"feature_id": "F1",
"operation": "cPlane",
"message": "line-angle reference selection is unsupported",
}, result.diagnostics)
def test_open_nonconstruction_geometry_is_preserved_as_reference(self):
source = SOURCE.replace('skSolve(sketch);', 'skLineSegment(sketch, "open", {"start":v(0, 0) * mm, "end":v(20, 0) * mm}); skSolve(sketch);', 1)
result = lower_model(parse_featurescript(source, "open-profile"), {})
@@ -931,7 +1239,7 @@ class LoweringTests(unittest.TestCase):
[("outer", 25.5), ("inner", 19.0)],
)
def test_mixed_extrude_rebuilds_surface_limited_chamfer_history(self):
def test_mixed_extrude_preserves_prefix_when_cap_edge_chamfer_query_is_deferred(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0071/00710855.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -941,10 +1249,10 @@ class LoweringTests(unittest.TestCase):
from cadfs_to_cdsl.rebuild import rebuild_candidate
rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step")
self.assertEqual(rebuilt["status"], "rebuilt")
chamfer = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F10")
self.assertIn("chamfer_surface_limited", [item["code"] for item in chamfer["diagnostics"]])
self.assertAlmostEqual(rebuilt["result"]["volume_mm3"], 72577.33528261917, places=6)
self.assertEqual(rebuilt["status"], "rebuild_failed")
self.assertEqual(rebuilt["error"]["message"], "f_F10: selector_query_unsupported during incremental replay")
self.assertEqual(rebuilt["last_executable_prefix"]["failed_feature_id"], "f_F10")
self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F9")
def test_fit_spline_loft_lowers_to_executable_loft_add(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -1048,7 +1356,7 @@ class LoweringTests(unittest.TestCase):
"kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start",
"source": "runtime_snapshot", "confidence": 1.0,
})
self.assertEqual(selector["selector_intent_version"], "1.0")
self.assertNotIn("selector_intent_version", selector)
self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE")
from engine.cdsl_engine.runtime import rebuild_cdsl
@@ -1130,21 +1438,19 @@ class LoweringTests(unittest.TestCase):
})
# F9 rotates F4 only after F5/F7 have consumed it. It cannot be baked
# into the earlier F4 sketch; the unsupported body continuation stays
# explicit and F1--F7 remains an executable prefix.
# into the earlier F4 sketch. The CAP_EDGE geometry is preserved for
# diagnostics, but no source-qualified cap-edge lineage exists for
# this two-sided profile contract.
prefix = deepcopy(result.cdsl)
prefix["features"] = [
item for item in prefix["features"]
if item["id"] in {"f_F1", "f_F2", "f_F4", "f_F5", "f_F7"}
]
from engine.cdsl_engine.runtime import rebuild_cdsl
with tempfile.TemporaryDirectory() as directory:
rebuilt = rebuild_cdsl(prefix, Path(directory) / "two-sided-cap-prefix.step")
self.assertFalse(rebuilt["runtime_diagnostics"])
self.assertEqual(
[item["feature_id"] for item in rebuilt["feature_results"]],
["f_F1", "f_F2", "f_F4", "f_F5", "f_F7"],
)
rebuilt = rebuild_candidate(prefix, Path(directory) / "two-sided-cap-prefix.step")
self.assertEqual(rebuilt["status"], "rebuild_failed")
self.assertEqual(rebuilt["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F5")
from engine.cdsl_engine.runtime import analyze_cdsl
f9 = next(item for item in analyze_cdsl(result.cdsl).as_dict()["feature_results"] if item["feature_id"] == "f_F9")
@@ -1338,6 +1644,41 @@ class LoweringTests(unittest.TestCase):
["E14", "E14"],
)
def test_direct_imprint_profile_preserves_selector_intent_and_f1_checkpoint(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0035/00354246.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00354246"), {})
self.assertEqual(result.status, "converted_complete")
features = {item["id"]: item for item in result.cdsl["features"]}
sketches = {item["id"]: item for item in result.cdsl["geometry"]["sketches"]}
profile = sketches[features["f_F1"]["sketch_id"]]["profile"]
self.assertEqual(profile["type"], "planar_imprint")
self.assertEqual(profile["selections"], [
{"source_entity_id": "E6.MirrorCS", "face_side": -1.0},
{"source_entity_id": "E0.MirrorCS", "face_side": 1.0},
])
selectors = features["f_F2"]["selectors"]
self.assertEqual(len(selectors), 7)
self.assertTrue(all(
selector["selector_intent"]["query_family"] == "SWEPT_FACE"
and selector["selector_intent"]["derivation_policy"] == {
"allowed": ["continuation"], "multiplicity": "none",
}
for selector in selectors
))
self.assertTrue(any("geometry" not in selector for selector in selectors))
with tempfile.TemporaryDirectory() as directory:
rebuilt = Path(directory) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_unsupported during incremental replay")
self.assertTrue(rebuilt.exists())
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1")
def test_direct_translation_transform_updates_the_source_revolve(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
@@ -1512,6 +1853,121 @@ class LoweringTests(unittest.TestCase):
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
self.assertFalse(diagnostics)
def test_curve_point_plane_uses_an_explicit_bspline_endpoint_tangent(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0082/00827798.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00827798"), {})
plane = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
self.assertEqual(plane["atomic_id"], "reference_plane")
self.assertFalse(any(
item.get("operation") == "cPlane" and item.get("feature_id") == "F1"
for item in result.diagnostics
))
expected = [107.86, 0.0, -176.04]
magnitude = math.sqrt(sum(value * value for value in expected))
for actual, value in zip(plane["params"]["plane"]["normal"], expected):
self.assertAlmostEqual(actual, value / magnitude)
second_feature = root / "featurescript_rp/0084/00845891.txt"
if second_feature.exists():
second = lower_model(parse_featurescript(second_feature.read_text(), "00845891"), {})
self.assertEqual(
next(item for item in second.cdsl["features"] if item["id"] == "f_F3")["atomic_id"],
"reference_plane",
)
self.assertFalse(any(
item.get("operation") == "cPlane" and item.get("feature_id") == "F3"
for item in second.diagnostics
))
without_end_derivative = feature.read_text().replace(
', "endDerivative": vector(107.86, -176.04) * mm', "", 1,
)
rejected = lower_model(parse_featurescript(without_end_derivative, "00827798-no-end-derivative"), {})
self.assertIn({
"code": "unsupported_engine_capability",
"capability": "reference_plane:curve_point",
"feature_id": "F1",
"operation": "cPlane",
"message": "CURVE_POINT B-spline endpoint has no exported tangent",
}, rejected.diagnostics)
def test_curve_endpoint_tangent_uses_arc_orientation_without_a_chord_fallback(self):
arc = {
"type": "arc", "start": [1.0, 0.0], "end": [0.0, 1.0],
"center": [0.0, 0.0], "clockwise": False,
}
self.assertEqual(_curve_endpoint_tangent(arc, "start"), [0.0, 1.0])
self.assertEqual(_curve_endpoint_tangent(arc, "end"), [-1.0, 0.0])
with self.assertRaisesRegex(UnsupportedCapability, "direct source-curve endpoint"):
_curve_endpoint_tangent(arc, "internal")
def test_curve_point_plane_uses_exact_bspline_interpolation_vertex_tangents(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = {
"00456498": ("f_F1", "F1", [141.23, 0.0, -13.99]),
"00812557": ("f_F2", "F2", [0.0, 191.15, -139.74]),
}
for sample_id, (cdsl_feature_id, source_feature_id, expected) in cases.items():
with self.subTest(sample_id=sample_id):
source = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
if not source.exists():
self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(source.read_text(), sample_id), {})
plane = next(item for item in result.cdsl["features"] if item["id"] == cdsl_feature_id)
self.assertEqual(plane["atomic_id"], "reference_plane")
magnitude = math.sqrt(sum(value * value for value in expected))
for actual, value in zip(plane["params"]["plane"]["normal"], expected):
self.assertAlmostEqual(actual, value / magnitude, places=8)
self.assertFalse(any(
item.get("operation") == "cPlane" and item.get("feature_id") == source_feature_id
for item in result.diagnostics
))
no_derivatives = (root / "featurescript_rp/0045/00456498.txt").read_text().replace(
', "startDerivative": vector(0, 64.52) * mm, "endDerivative": vector(141.23, -13.99) * mm', "", 1,
)
rejected = lower_model(parse_featurescript(no_derivatives, "00456498-no-derivatives"), {})
self.assertIn({
"code": "unsupported_engine_capability",
"capability": "reference_plane:curve_point",
"feature_id": "F1",
"operation": "cPlane",
"message": "CURVE_POINT B-spline interpolation point has no complete source interpolation data",
}, rejected.diagnostics)
derived = (root / "featurescript_rp/0045/00456498.txt").read_text().replace(
'"E0.2.internal"', '"E0.2.endSnap0"', 1,
)
rejected_derived = lower_model(parse_featurescript(derived, "00456498-derived-vertex"), {})
self.assertIn({
"code": "unsupported_engine_capability",
"capability": "reference_plane:curve_point",
"feature_id": "F1",
"operation": "cPlane",
"message": "CURVE_POINT point must name a direct source-curve endpoint or B-spline interpolation vertex",
}, rejected_derived.diagnostics)
def test_curve_point_plane_uses_a_direct_arc_endpoint_tangent(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0005/00057273.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
source = feature.read_text().replace(
'EDGE,"E1"', 'EDGE,"E0"', 1,
).replace(
'VERTEX,"E1.end"', 'VERTEX,"E0.end"', 1,
)
result = lower_model(parse_featurescript(source, "00057273-arc-curve-point"), {})
plane = next(item for item in result.cdsl["features"] if item["id"] == "f_F1")
self.assertEqual(plane["atomic_id"], "reference_plane")
self.assertEqual(plane["params"]["plane"]["origin_mm"], [-24.6, 0.0, 0.0])
self.assertEqual(plane["params"]["plane"]["normal"], [0.0, -1.0, 0.0])
self.assertFalse(any(
item.get("operation") == "cPlane" and item.get("feature_id") == "F1"
for item in result.diagnostics
))
def test_offset_reference_plane_honors_opposite_direction_without_reversing_its_frame(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0077/00777619.txt"
@@ -1571,7 +2027,7 @@ class LoweringTests(unittest.TestCase):
))
self.assertNotIn("F8", {item.get("feature_id") for item in result.diagnostics})
def test_shell_lowers_and_executes_direct_linear_extrude_swept_faces(self):
def test_shell_resolves_direct_prism_swept_faces_through_kernel_lineage(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0059/00594348.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -1582,18 +2038,105 @@ class LoweringTests(unittest.TestCase):
shell = result.cdsl["features"][shell_index]
self.assertEqual(shell["atomic_id"], "shell")
self.assertEqual([item["owner_feature_id"] for item in shell["selectors"]], ["f_F1", "f_F1"])
self.assertTrue(all("normal" in item["geometry"] for item in shell["selectors"]))
prefix = deepcopy(result.cdsl)
prefix["features"] = prefix["features"][:shell_index + 1]
self.assertTrue(all("geometry" not in item and "stable_id" not in item for item in shell["selectors"]))
self.assertTrue(all(item["selector_intent"]["query_family"] == "SWEPT_FACE" for item in shell["selectors"]))
self.assertTrue(all(item["selector_intent"]["evidence"] == "kernel_history" for item in shell["selectors"]))
self.assertTrue(all(
item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"}
for item in shell["selectors"]
))
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(prefix, Path(directory) / "swept-face-shell.step")
outcome = rebuild_candidate(result.cdsl, Path(directory) / "swept-face-shell.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(
[item["status"] for item in outcome["result"]["feature_results"]],
["executed", "executed"],
resolutions = [
item for item in outcome["result"]["selector_resolution"]
if item["feature_id"] == "f_F2"
]
self.assertEqual(len(resolutions), 2)
self.assertTrue(all(item["resolution_mode"] == "kernel_lineage" for item in resolutions))
self.assertTrue(all(
item["evidence"]["relations"][0]["source_kind"] == "edge"
and item["evidence"]["relations"][0]["result_kind"] == "face"
for item in resolutions
))
def test_shell_resolves_immediate_direct_prism_cap_faces_through_output_roles(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
cases = (("00212904", "extrude.start"), ("00789939", "extrude.end"))
for sample_id, output_role in cases:
with self.subTest(sample_id=sample_id):
feature = root / "featurescript_rp" / sample_id[:4] / f"{sample_id}.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(encoding="utf-8"), sample_id), {})
self.assertEqual(result.status, "converted_complete")
shell_index = next(index for index, item in enumerate(result.cdsl["features"]) if item["id"] == "f_F2")
shell = result.cdsl["features"][shell_index]
self.assertEqual(shell["atomic_id"], "shell")
self.assertEqual(len(shell["selectors"]), 1)
selector = shell["selectors"][0]
self.assertEqual(selector["owner_feature_id"], "f_F1")
self.assertEqual(selector["output_role"], output_role)
self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE")
self.assertEqual(selector["selector_intent"]["evidence"], "operation_role")
self.assertNotIn("stable_id", selector)
self.assertNotIn("binding_feature_id", selector)
self.assertNotIn("geometry", selector)
prefix = deepcopy(result.cdsl)
prefix["features"] = prefix["features"][:shell_index + 1]
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(prefix, Path(directory) / f"{sample_id}-cap-face-shell.step")
self.assertEqual(outcome["status"], "rebuilt")
resolution = next(
item for item in outcome["result"]["selector_resolution"]
if item["feature_id"] == "f_F2"
)
self.assertEqual(resolution["status"], "resolved")
self.assertEqual(resolution["resolution_mode"], "operation_role")
self.assertEqual(resolution["selected"]["output_roles"], [output_role])
def test_shell_direct_prism_swept_face_rejects_unregistered_source_version(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0059/00594348.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
source = feature.read_text(encoding="utf-8").replace("FeatureScript 1511;", "FeatureScript 1793;", 1)
result = lower_model(parse_featurescript(source, "00594348-version-1793"), {})
shell = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertTrue(all(
item["selector_intent"]["evidence"] == "feature_script_query"
and item["selector_intent"]["derivation_policy"]["multiplicity"] == "none"
for item in shell["selectors"]
))
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(result.cdsl, Path(directory) / "swept-face-shell-1793.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1")
def test_shell_direct_prism_swept_face_rejects_unregistered_standard_library_revision(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0059/00594348.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
source = feature.read_text(encoding="utf-8").replace(
'import(path : "onshape/std/geometry.fs", version : "1511.0");',
'import(path : "onshape/std/geometry.fs", version : "1512.0");',
1,
)
self.assertFalse(outcome["result"].get("diagnostics", []))
result = lower_model(parse_featurescript(source, "00594348-library-1512"), {})
shell = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertTrue(all(
item["selector_intent"]["source_query"]["featurescript_version"] == "1511"
and item["selector_intent"]["source_query"]["standard_library"] == "onshape/std/geometry.fs"
and item["selector_intent"]["source_query"]["standard_library_version"] == "1512.0"
for item in shell["selectors"]
))
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(result.cdsl, Path(directory) / "swept-face-shell-library-1512.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F2: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F1")
def test_shell_offset_face_requires_true_dependency_cap_evidence(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -1623,13 +2166,14 @@ class LoweringTests(unittest.TestCase):
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertTrue(rebuilt.exists())
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError")
self.assertEqual(outcome["error"]["type"], "ValueError")
self.assertEqual(outcome["error"]["message"], "f_F5: selector_query_unsupported during incremental replay")
self.assertIn("bound_cdsl", outcome)
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F5")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F4")
self.assertEqual(outcome["last_executable_prefix"]["result"]["solid_count"], 1)
def test_pattern_copy_selector_binding_rebuilds_the_full_history(self):
def test_pattern_copy_intersection_query_preserves_its_prefix_without_instance_geometry_binding(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -1639,14 +2183,15 @@ class LoweringTests(unittest.TestCase):
rebuilt = Path(directory) / "selector-prefix.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertTrue(rebuilt.exists())
self.assertEqual(outcome["status"], "rebuilt")
resolved = [
item for item in outcome["result"]["selector_resolution"]
if item["feature_id"] == "f_F7"
and item["selector"].get("owner_feature_id") == "f_F4.c4.f_F1"
]
self.assertEqual(len(resolved), 2)
self.assertTrue(all(item["status"] == "resolved" for item in resolved))
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6")
reference = next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT")
self.assertEqual(
[item["selector_intent"]["query_family"] for item in reference["intersection_of"][:2]],
["COPY", "COPY"],
)
def test_shell_rejects_swept_faces_without_direct_linear_extrude_ownership(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -1698,6 +2243,10 @@ class LoweringTests(unittest.TestCase):
def test_cap_face_selector_retains_versioned_provenance_intent(self):
source = SOURCE.replace(
"FeatureScript 1511;",
'FeatureScript 1511;\nimport(path : "onshape/std/geometry.fs", version : "1511.0");',
1,
).replace(
'\n});\n',
'\n extrude(context, id + "F2", {"entities":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, {"isStart":false}), "depth":10 * mm});\n});\n',
)
@@ -1705,12 +2254,127 @@ class LoweringTests(unittest.TestCase):
self.assertEqual(result.status, "converted_complete")
selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")["selectors"][0]
self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE")
self.assertEqual(selector["selector_intent_version"], "1.0")
self.assertNotIn("selector_intent_version", selector)
self.assertEqual(selector["selector_intent"]["source_query"]["featurescript_version"], "1511")
self.assertEqual(selector["selector_intent"]["source_query"]["standard_library"], "onshape/std/geometry.fs")
self.assertEqual(selector["selector_intent"]["source_query"]["standard_library_version"], "1511.0")
self.assertEqual(selector["selector_intent"]["evidence"], "operation_role")
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
def test_direct_boolean_intersection_lowering_keeps_two_source_contract(self):
base = lower_model(parse_featurescript(SOURCE, "intersection-intent"), {}).cdsl
sketch = base["geometry"]["sketches"][0]
direct = {**base["features"][0], "id": "f_F2", "name": "F2"}
feature_by_id = {
"f_F1": base["features"][0],
"f_F2": direct,
"f_F3": {
"id": "f_F3", "atomic_id": "boolean_bodies",
"params": {
"operation": "subtract", "target_feature_ids": ["f_F1"],
"tool_feature_ids": ["f_F2"], "keep_tools": False,
},
},
}
cap = Call("makeQuery", ["F1.opExtrude", "CAP_FACE", "EntityType.FACE", {"isStart": False}])
source_edge = Call("sQuery", ["F0.wireOp", "EntityType.EDGE", "E0"])
swept = Call("makeQuery", [
"F2.opExtrude", "SWEPT_FACE", "EntityType.FACE",
{"disambiguationData": [Call("OSD", [[source_edge]])]},
])
query = Call("makeQuery", [
"F3.boolean.opBoolean", "INTERSECT", "EntityType.EDGE", {"derivedFrom": [cap, swept]},
])
selector = _direct_boolean_intersection_selector(
query,
feature_by_id=feature_by_id,
feature_frames={"F2": {"profile_source": "F0"}},
sketch_by_source={"F0": sketch},
sketches_by_id={sketch["id"]: sketch},
entity_by_sketch={"F0": {"E0": {"type": "circle", "center": [0, 0], "radius_mm": 9.53}}},
featurescript_version="1511",
)
self.assertIsNotNone(selector)
self.assertNotIn("stable_id", selector)
intent = selector["selector_intent"]
self.assertEqual(intent["derivation_policy"], {"allowed": ["intersection"], "multiplicity": "one"})
self.assertEqual(intent["intersection_sources"], [
{"query_family": "CAP_FACE", "owner_feature_id": "f_F1", "output_role": "extrude.end"},
{"query_family": "SWEPT_FACE", "owner_feature_id": "f_F2", "source_entity": {"sketch_id": "F0", "entity_id": "E0"}},
])
def test_direct_primary_cut_intersection_requires_no_unverified_disambiguation(self):
base = lower_model(parse_featurescript(SOURCE, "primary-intersection-intent"), {}).cdsl
target = base["features"][0]
target_sketch = base["geometry"]["sketches"][0]
tool_sketch = deepcopy(target_sketch)
tool_sketch.update({"id": "sketch_F2", "source_sketch_id": "F2"})
if tool_sketch["profile"].get("type") == "circle":
tool_sketch["profile"]["source_entity_id"] = "E1"
else:
for contour in tool_sketch["profile"]["contours"]:
for segment in contour["segments"]:
segment["source_entity_id"] = "E1"
tool = {
"id": "f_F3", "atomic_id": "extrude_cut_blind", "sketch_id": "sketch_F2",
"params": {"distance_mm": 10, "end_condition": {"type": "blind"}},
}
cap = Call("makeQuery", ["F1.opExtrude", "CAP_FACE", "EntityType.FACE", {"isStart": False}])
tool_edge = Call("sQuery", ["F2.wireOp", "EntityType.EDGE", "E1"])
swept = Call("makeQuery", [
"F3.opExtrude", "SWEPT_FACE", "EntityType.FACE",
{"disambiguationData": [Call("OSD", [[tool_edge]])]},
])
query = Call("makeQuery", [
"F3.boolean.opBoolean", "INTERSECT", "EntityType.EDGE", {"derivedFrom": [cap, swept]},
])
kwargs = {
"feature_by_id": {"f_F1": target, "f_F3": tool},
"feature_frames": {"F3": {"profile_source": "F2"}},
"sketch_by_source": {"F2": tool_sketch},
"sketches_by_id": {tool_sketch["id"]: tool_sketch, target_sketch["id"]: target_sketch},
"entity_by_sketch": {"F2": {"E1": {"type": "circle", "center": [0, 0], "radius_mm": 9.53}}},
"previous": ["f_F1", "f_F3"],
"featurescript_version": "1511",
}
selector = _direct_primary_cut_intersection_selector(query, **kwargs)
self.assertIsNotNone(selector)
self.assertEqual(selector["owner_feature_id"], "f_F3")
self.assertEqual(selector["selector_intent"]["intersection_sources"][1], {
"query_family": "SWEPT_FACE", "owner_feature_id": "f_F3",
"source_entity": {"sketch_id": "F2", "entity_id": "E1"},
})
deferred = Call("makeQuery", [
"F3.boolean.opBoolean", "INTERSECT", "EntityType.EDGE",
{"derivedFrom": [cap, swept], "disambiguationData": [Call("OD", [0.0])]},
])
self.assertIsNone(_direct_primary_cut_intersection_selector(deferred, **kwargs))
def test_deferred_primary_intersect_keeps_the_convertible_f3_checkpoint(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0002/00020311.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00020311"), {})
f4 = next(item for item in result.cdsl["features"] if item["id"] == "f_F4")
self.assertTrue(all(
selector["kind"] == "edge"
and selector["selector_intent"]["query_family"] == "INTERSECT"
and selector["selector_intent"]["derivation_policy"]["multiplicity"] == "none"
for selector in f4["selectors"]
))
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(result.cdsl, Path(directory) / "00020311.step")
self.assertTrue((Path(directory) / "00020311.step").exists())
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3")
def test_conversion_writes_status_and_sidecars(self):
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
+6
View File
@@ -58,10 +58,16 @@ class ParserTests(unittest.TestCase):
def test_source_version_and_standard_library_are_retained(self):
source = '''FeatureScript 1511;
import(path : "onshape/std/geometry.fs", version : "1511.0");
import(version : "1511.0", path : "onshape/std/common.fs");
export const f = defineFeature(function(context, id, definition) {});'''
model = parse_featurescript(source, "versioned")
self.assertEqual(model.featurescript_version, "1511")
self.assertEqual(model.standard_library, "onshape/std/geometry.fs")
self.assertEqual(model.standard_library_version, "1511.0")
self.assertEqual(model.standard_library_imports, [
{"path": "onshape/std/geometry.fs", "version": "1511.0"},
{"path": "onshape/std/common.fs", "version": "1511.0"},
])
def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self):
source = r'''
+39 -29
View File
@@ -138,27 +138,45 @@ class SelectorBindingTests(unittest.TestCase):
self.assertEqual(len(binding["resolved"]), 1)
self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"])
def test_shell_offset_face_role_binds_only_its_true_dependency_source(self) -> None:
def test_rebuild_candidate_uses_one_incremental_replay(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0001/00016195.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00016195"), {})
# The production path must bind and export from one live session. If
# it called the old compatibility binder, this patched entry point
# would fail while rebuilding selector-bearing prefixes.
with tempfile.TemporaryDirectory() as directory:
with patch(
"engine.cdsl_engine.runtime.rebuild_cdsl",
side_effect=AssertionError("production rebuild invoked legacy prefix replay"),
):
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00016195.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(
[item["feature_id"] for item in outcome["result"]["feature_results"]],
[item["id"] for item in candidate.cdsl["features"]],
)
self.assertTrue(outcome["selector_binding"])
def test_shell_offset_face_preserves_true_dependency_but_rejects_unproven_cap_selector(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0010/00107631.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {})
self.assertEqual(candidate.status, "converted_complete")
# F2 retains F1's end-cap provenance, but F3 also selects the distinct
# start-cap plane. Its direct owner has no geometry-qualified active
# candidate, so binding must consider the unique active F2 face without
# weakening the exact qualified OFFSET_FACE role.
# F2 consumes its immediate direct CAP_FACE output role. F3 retains
# its exact OFFSET_FACE true-dependency intent, but its separate
# start-cap removal selector is deferred and must not rebind
# geometrically.
prefix = deepcopy(candidate.cdsl)
prefix["features"] = prefix["features"][:3]
bound, evidence = bind_candidate_selectors(prefix)
first, offset = bound["features"][-1]["selectors"]
self.assertEqual(first["owner_feature_id"], "f_F2")
self.assertEqual(first["stable_id"], "body:f_F2:face:1")
self.assertEqual(first["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(first["source"], "runtime_snapshot")
self.assertEqual(first["confidence"], 1.0)
first, offset = prefix["features"][-1]["selectors"]
self.assertEqual(first["selector_intent"]["query_family"], "CAP_FACE")
self.assertEqual(first["selector_intent"]["derivation_policy"]["multiplicity"], "none")
self.assertEqual({key: offset[key] for key in (
"kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence",
)}, {
@@ -168,33 +186,25 @@ class SelectorBindingTests(unittest.TestCase):
})
self.assertEqual(offset["selector_intent"]["query_family"], "OFFSET_FACE")
self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency")
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5")
with self.assertRaisesRegex(ValueError, "f_F3: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(prefix)
# Binding must not hide the OCC feasibility boundary. The requested
# second shell currently produces an invalid shape, so preserve the
# F2 prefix instead of changing thickness or removal faces.
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError")
self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape")
self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2")
def test_intersection_vertex_binds_a_pattern_copy_with_exact_instance_owner_evidence(self) -> None:
def test_intersection_vertex_preserves_outer_copy_provenance_without_geometry_binding(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
reference = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2]))
bound, evidence = bind_candidate_selectors(candidate.cdsl)
reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT")
copy_components = reference["intersection_of"][:2]
self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components))
self.assertTrue(all(item.get("snapshot_id") for item in copy_components))
binding = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertGreaterEqual(len(binding["resolved"]), 2)
self.assertEqual([item["selector_intent"]["query_family"] for item in copy_components], ["COPY", "COPY"])
with self.assertRaisesRegex(ValueError, "f_F7: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(candidate.cdsl)
@@ -0,0 +1,71 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from cadfs_to_cdsl.dataset import Sample
from cadfs_to_cdsl.reports import read_json
from cadfs_to_cdsl.selector_candidate_demo import _search_candidate_records, run_geometry_probe, strip_provenance_intents
def _contains_selector_intent(value: object) -> bool:
if isinstance(value, dict):
return "selector_intent" in value or any(_contains_selector_intent(child) for child in value.values())
if isinstance(value, list):
return any(_contains_selector_intent(child) for child in value)
return False
class SelectorCandidateDemoTests(unittest.TestCase):
def test_search_candidates_excludes_records_below_normal_resolution_threshold(self) -> None:
resolution = type("Resolution", (), {
"candidates": (
{"record_id": "low", "score": 0.5},
{"record_id": "high-b", "score": 1.0},
{"record_id": "high-a", "score": 1.0},
{"record_id": "zero", "score": 0.0},
),
})()
candidates, count = _search_candidate_records(resolution, maximum=8)
self.assertEqual(count, 2)
self.assertEqual([item["record_id"] for item in candidates], ["high-a", "high-b"])
def test_strip_provenance_intents_removes_nested_selector_metadata(self) -> None:
value = {
"selector_intent": {"version": "1.0"},
"params": {"reference": {"selector_intent_version": "1.0"}},
}
self.assertEqual(strip_provenance_intents(value), 2)
self.assertEqual(value, {"params": {"reference": {}}})
def test_geometry_probe_keeps_heuristic_success_separate_from_provenance(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt"
step = root / "step_abc/0000/00002243.step"
if not feature.exists() or not step.exists():
self.skipTest("CADFS sample is not installed")
sample = Sample("00002243", {"featurescript": str(feature), "step": str(step)})
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
report = run_geometry_probe(sample, output)
record = read_json(output / "samples" / sample.sample_id / "selector-record.json")
compiled = read_json(output / "samples" / sample.sample_id / "selector_record.compiled.cdsl.json")
self.assertEqual(report["provenance"]["rebuild_status"], "rebuild_failed")
self.assertEqual(report["geometry_probe"]["rebuild_status"], "rebuilt")
self.assertEqual(report["geometry_probe"]["comparison"], {
"decision": "strict_pass",
"strict_passed": True,
"rp_passed": True,
})
self.assertEqual(report["strategy"]["classification"], "heuristic_only")
self.assertEqual(report["selector_record"]["status"], "strict_replayed")
self.assertEqual(report["selector_record"]["record_count"], 2)
self.assertEqual(record["verification"], {
"geometry_probe_strict": True,
"compiled_cdsl_strict": True,
"compiled_cdsl": report["selector_record"]["compiled_cdsl"],
})
self.assertEqual([entry["mapping"] for entry in record["records"]], ["one_to_one", "one_to_one"])
self.assertTrue(all(entry["source_selector"]["selector_intent"]["query_family"] == "CAP_EDGE" for entry in record["records"]))
self.assertFalse(_contains_selector_intent(compiled))
@@ -0,0 +1,73 @@
# CADFS 四次全量输出对比
## 统计口径
- 四次运行均使用同一批 9,347 个 CADFS 样本;日期使用各 `full_run_report.md` 的实际生成时间。
- `生成可执行 STEP``rebuild.step` 工件存在计数。它与最终 runtime 状态为 `rebuilt` 不同:后者失败时,系统仍可能保留最后可执行的 STEP 前缀。
- 能力缺口为受影响样本数,同一样本可能命中多个能力项,因此不能横向相加为失败样本总数。
- executor 原子操作新增,表示引擎可调度该操作;不代表所有 FeatureScript 参数、selector、Body 生命周期和拓扑来源语义都已完整支持。
## 输出成功数量
| 指标 | 2026-09-02 | 2026-09-07 | 2026-09-08 | 2026-09-09 | 首次到最新变化 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 生成候选 CDSL | 8,108 (86.74%) | 7,112 (76.09%) | 8,633 (92.36%) | 8,942 (95.67%) | +834 (+8.93pp) |
| 绑定 CDSL | 1,773 (18.97%) | 5,554 (59.42%) | 6,134 (65.62%) | 8,364 (89.48%) | +6,591 (+70.51pp) |
| 生成可执行 STEP | 1,812 (19.39%) | 5,554 (59.42%) | 6,134 (65.62%) | 8,176 (87.47%) | +6,364 (+68.08pp) |
| 最终 runtime 为 `rebuilt` | 1,811 (19.38%) | 5,554 (59.42%) | 6,134 (65.62%) | 6,055 (64.78%) | +4,244 (+45.40pp) |
| 完成几何比较 | 1,719 (18.39%) | 5,250 (56.17%) | 6,090 (65.16%) | 6,007 (64.27%) | +4,288 (+45.88pp) |
| RP 通过 | 1,159 (12.40%) | 1,358 (14.53%) | 2,031 (21.73%) | 2,253 (24.10%) | +1,094 (+11.70pp) |
| 严格通过 | 578 (6.18%) | 654 (7.00%) | 905 (9.68%) | 989 (10.58%) | +411 (+4.40pp) |
结论:候选 CDSL 在第二次运行短暂下降后恢复至 95.67%。可执行 STEP 从 1,812 增至 8,176,是最明显的产出改善。9 月 9 日的最终 `rebuilt` 数比上次少 79,但可执行 STEP 多 2,042,表明工件存在数与终态重建成功数在该轮明显分离,不能将两者混为同一成功指标。几何比较数较 9 月 8 日少 83,RP 和严格通过数则继续增长。
## 新增执行能力
| 运行阶段 | 新增 executor 原子操作 | 能力结论 |
| --- | --- | --- |
| 2026-09-02 | 基线:盲拉伸/双侧拉伸/切除、圆角、倒角、孔、镜像/线性阵列、参考轴/面、回转等 | 基础建模链路已可执行,但 `loft``sweep``shell` 等仍在已知不支持清单中。 |
| 2026-09-07 | `box_add``cylinder_add``extrude_cut_through``extrude_cut_two_sided``loft_add``pattern_circular``thread_add``thread_cut` | `loft` 不再列入已知不支持 FeatureScript 操作;圆周阵列、螺纹和更多拉伸方式进入执行器。 |
| 2026-09-08 | `bend_add``boolean_bodies``extrude_add_blind_with_hole``extrude_surface``loft_add_with_cap_face``revolve_surface``shell``sweep_add` | 增加多 Body 布尔、壳、扫掠、曲面回转和带孔拉伸执行路径;但 `shell``sweep` 仍有大量 source-lowering/selector 缺口。 |
| 2026-09-09 | `delete_bodies``extrude_from_face``gear_add``rack_add``transform_bodies` | Body 删除、从面拉伸、齿轮/齿条和 Body 变换进入执行器;对应的 query、copy、pattern 等变体仍需按 contract 和拓扑来源继续覆盖。 |
## 已优化能力
下表使用每次 `summary.json` 的能力缺口计数。`减少` 表示该能力在运行中被记录为缺口的样本减少,不表示该特征已经完全支持。
| 能力缺口 | 09-02 | 09-07 | 09-08 | 09-09 | 首次到最新变化 | 结论 |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| `extrude` | 3,861 | 3,600 | 1,197 | 899 | -2,962 | 最大覆盖改善;仍需处理 profile/extent/selector 变体。 |
| `fillet` | 1,914 | 1,904 | 1,417 | 1,084 | -830 | 明显下降,但仍是最新运行的最大单项缺口。 |
| `shell` | 696 | 696 | 57 | 43 | -653 | 执行器加入壳操作后主缺口大幅下降;面选择器问题仍存在。 |
| `revolve` | 741 | 662 | 344 | 200 | -541 | 回转主路径覆盖持续改善。 |
| `extrude_profile_topology:intersect` | 382 | 383 | 362 | 33 | -349 | 末轮拓扑交集选择器覆盖有显著改善。 |
| `chamfer` | 589 | 588 | 381 | 265 | -324 | 降幅明确,运行时构造失败仍是主要剩余问题。 |
| `hole` | 623 | 582 | 386 | 369 | -254 | 孔特征覆盖提升,但仍影响 369 个样本。 |
| `loft` | 308 | 107 | 68 | 66 | -242 | `loft_add` 后快速下降,cap-face 等拓扑变体仍未完成。 |
| `sweep` | 326 | 326 | 133 | 122 | -204 | `sweep_add` 后主缺口下降,path 选择器仍是限制。 |
| `circularPattern` | 228 | 139 | 74 | 51 | -177 | 圆周阵列持续改善。 |
| `booleanBodies` | 187 | 187 | 92 | 35 | -152 | 多 Body 布尔执行路径加入后下降;target 选择仍有独立缺口。 |
| `mirror` | 262 | 241 | 158 | 140 | -122 | 镜像覆盖逐步提升。 |
| `cPlane` | 147 | 168 | 84 | 69 | -78 | 基准面覆盖提升,但不同行/点/曲线来源仍需处理。 |
| `extrude_profile_topology:cap_face` | 200 | 201 | 183 | 112 | -88 | cap-face 来源仍是拉伸后续特征的重要阻塞点。 |
| `extrude_profile_topology:cap_edge` | 133 | 134 | 91 | 88 | -45 | cap-edge 改善较慢,仍需稳定的边来源语义。 |
| `extrude_profile_topology:swept_face` | 99 | 99 | 94 | 94 | -5 | 几乎没有改善,是后续优先的 selector/provenance 问题。 |
此外,`revolve_surface`367/369)和 `extrude_extent:up_to_surface`(95/100)仅在前两次运行列为能力缺口,后两次未再报告;这与新增 `revolve_surface`、扩展拉伸执行路径的时间点一致,但仅能证明该缺口不再被当前报告记录,不能替代逐参数、逐拓扑来源的全量验收。
## 最新仍需处理的能力
| 最新能力缺口 | 受影响样本 | 观察 |
| --- | ---: | --- |
| `fillet` | 1,084 | 最大单项缺口;同时存在内核半径构造失败。 |
| `extrude` | 899 | 主操作已大幅改善,但 profile 和 extent 变体仍广泛存在。 |
| `hole` | 369 | 仍是前三大操作缺口。 |
| `chamfer` | 265 | 除覆盖缺口外,运行时 `Failed creating a chamfer` 仍高频。 |
| `revolve` | 200 | 主路径已改善,曲面/选择器变体需继续验证。 |
| `shell_face_selector` | 158 | 壳体删除面的 selector/provenance 仍是主要限制。 |
| `mirror` / `sweep` | 140 / 122 | 基础路径可用,但来源与路径选择语义尚未完整覆盖。 |
| `extrude_profile_topology:*` | cap-face 112、swept-face 94、cap-edge 88 | 拉伸后面/边拓扑来源是后续特征链断点。 |
## 结论
四次运行的主要进展是从“可转换但大量 deferred”转向“多数样本可产出 CDSL、绑定 CDSL 和可执行 STEP”。最新运行中,95.67% 有候选 CDSL、89.48% 有绑定 CDSL、87.47% 保留可执行 STEP;但 RP 通过仅为 24.10%,严格通过为 10.58%,说明后续重点应从扩大可执行工件覆盖转向提高几何保真与拓扑选择器稳定性。特别是 `fillet``extrude` 及其 cap/swept face/edge 来源、壳体面选择器和倒角内核构造,仍应以通用 contract、body graph 和 selector provenance 方式推进,而不是按单一样本修补。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long