feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector - 对候选分支执行有界重建与严格 STEP 比较 - 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录 - 增加 selector 候选搜索、预算限制和记录生成的测试 - 保持生产 selector resolver 不受 Demo 逻辑影响 - 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
This commit is contained in:
@@ -13,24 +13,30 @@ snapshot, owner identifier, selector token, or `host_face`/`mirror_plane`
|
|||||||
inside `params`. The server allocates identities and injects selector values
|
inside `params`. The server allocates identities and injects selector values
|
||||||
into the destination stated by the operation contract.
|
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
|
Use millimetres and a right-handed coordinate system unless the request says
|
||||||
otherwise. Put every reasonable but unstated design choice in `assumptions`.
|
otherwise. Put every reasonable but unstated design choice in `assumptions`.
|
||||||
Do not turn an assumed dimension into a deterministic acceptance target.
|
Do not turn an assumed dimension into a deterministic acceptance target.
|
||||||
|
|
||||||
## Modeling Brief
|
## Modeling Brief
|
||||||
|
|
||||||
Before authoring, derive this internal brief from the requirements:
|
Identify explicit dimensions, datums, directions, primary volumes, removals,
|
||||||
|
repetitions, finish features, targets, and noncritical assumptions. Explicit
|
||||||
- part or multi-body intent; explicit dimensions and units;
|
dimensions take precedence over inferred proportions. Clarify only when an
|
||||||
- functional datums, origin, base plane, and positive directions;
|
omitted interface, scale, safety, or compliance value prevents construction;
|
||||||
- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing;
|
otherwise record a practical default in `assumptions`.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Construction Order
|
## 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.
|
cross the material; never rely on coincident faces or a guessed nearby face.
|
||||||
Delay dress-up operations because they can alter downstream topology.
|
Delay dress-up operations because they can alter downstream topology.
|
||||||
|
|
||||||
The selector-source rule is an intentional exception to a generic “all adds,
|
## Operation Semantics
|
||||||
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.
|
|
||||||
|
|
||||||
Use only the supplied operation list. Follow each operation's parameter schema
|
Apply the following rules from the supplied operation contracts. They describe
|
||||||
exactly, including required sketch state. Do not invent unsupported operation
|
the CDSL language itself and do not prescribe a particular part design.
|
||||||
parameters, implicit booleans, or a substitute operation after a capability
|
|
||||||
error.
|
### 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
|
## 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,
|
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.
|
and height directly express the requested geometry and no sketch is needed.
|
||||||
|
|
||||||
Name features for their manufacturing role, for example `base_plate`,
|
Name features for their geometric role, for example `primary_add`,
|
||||||
`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and
|
`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and
|
||||||
repair diagnostics readable; they are not server identities.
|
repair diagnostics readable; they are not server identities.
|
||||||
|
|
||||||
## Selectors
|
## Selectors
|
||||||
|
|
||||||
The operation metadata states whether `selectors` are required, their kind,
|
Operation metadata defines selector need, kind, cardinality, and server-side
|
||||||
cardinality, and server-side destination. Put only declarative selectors in a
|
destination. Put only declarative selectors in `selectors`, never the
|
||||||
feature's `selectors` array. Never write the destination field itself inside
|
destination field in `params`.
|
||||||
`params`.
|
|
||||||
|
|
||||||
For an output face, use an exact local role embedded in `source` and state a
|
For an output face, use an exact local role embedded in `source` and state a
|
||||||
unique match:
|
unique match:
|
||||||
@@ -112,54 +175,59 @@ unique match:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"kind": "face",
|
"kind": "face",
|
||||||
"source": "front_hub_boss.top_planar_face",
|
"source": "source_feature.top_planar_face",
|
||||||
"match": "unique"
|
"match": "unique"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not add `role`, `query`, `host_face`, a face index, a coordinate selector,
|
Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or
|
||||||
or a Runtime selector token. The `source` value is the complete local intent.
|
Runtime token. The compiler adds the selector source dependency; declare other
|
||||||
The compiler adds its source feature as an auditable graph dependency; include
|
true construction dependencies yourself.
|
||||||
other true construction dependencies in `depends_on` yourself.
|
|
||||||
|
|
||||||
`top_planar_face` and `end_face` mean the positive-direction cap of a supported
|
`top_planar_face`/`end_face` mean a positive-direction cap;
|
||||||
extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean
|
`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known
|
||||||
the opposite cap. Select the most recent feature whose output is known to be
|
host, never a similar face by location, index, or proximity.
|
||||||
the required host; do not select a similar face by location, face index, or
|
|
||||||
proximity.
|
|
||||||
|
|
||||||
Translate a request's descriptive `max_z`/`min_z` wording into these output
|
For a cap, `source` MUST match
|
||||||
roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`.
|
`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`.
|
||||||
For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom
|
No other role spelling exists in Authoring CDSL. If the producer does not
|
||||||
cap is `bottom_planar_face`; reverse-direction features swap their world-Z
|
advertise one of these roles, it cannot be the selector source.
|
||||||
position but retain their own start/end roles.
|
|
||||||
|
|
||||||
`hole_wizard` requires exactly one `face` selector. It uses that selector as
|
Translate descriptive maximum/minimum placement to an appropriate cap role;
|
||||||
its host face, so a central bore on a hub should select the hub cap, while a
|
never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its
|
||||||
bolt circle on an exposed flange should select the flange cap. A selector must
|
top cap and `bottom_planar_face` for its bottom cap.
|
||||||
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.
|
|
||||||
|
|
||||||
Before creating a hosted hole, calculate each position against the actual host
|
`hole_wizard` requires one `face` selector. It must be unique, planar, active,
|
||||||
face. A local boss can be the global highest face while being too small to host
|
and contain every position. Execute it before its source is replaced; otherwise
|
||||||
a larger bolt circle. In that case use the exposed flange cap at the bolt
|
use a supported self-contained construction or report the limitation.
|
||||||
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.
|
|
||||||
|
|
||||||
## Acceptance And Repair
|
## Acceptance And Repair
|
||||||
|
|
||||||
Describe only user-requested, measurable acceptance targets in
|
Put only user-requested measurable targets in `acceptance_targets`; leave
|
||||||
`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair,
|
inferred dimensions in `assumptions`. On repair return a complete document,
|
||||||
return a complete replacement document. Preserve feature names and every
|
preserve executed features unless diagnosed, and change unexecuted features
|
||||||
feature listed in `executed_feature_ids`, except a feature explicitly named by
|
only as needed. Read diagnostics literally; do not add IDs, weaken requested
|
||||||
the diagnostic. Features that were never executed may be changed freely to
|
values, silently delete a failed feature, or guess a selector.
|
||||||
repair an invalid selector, parameter, dependency, or geometry construction.
|
|
||||||
|
|
||||||
Read structured diagnostics literally. Fix their named cause with the smallest
|
## Pre-Emission Check
|
||||||
document change, then return the entire document. Do not add internal IDs,
|
|
||||||
weaken a requested value, silently delete a failed feature, or replace a
|
Before returning the document, check every feature against these invariants:
|
||||||
failed selector with an arbitrary topology element.
|
|
||||||
|
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.
|
||||||
|
|||||||
@@ -172,7 +172,16 @@ class WorkflowCoordinator:
|
|||||||
for atomic_id in self.runtime.supported_atomic_ids()
|
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."
|
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:
|
try:
|
||||||
raw = await self._tool_call(
|
raw = await self._tool_call(
|
||||||
task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(),
|
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
|
@staticmethod
|
||||||
def _validate_repair_document(
|
def _validate_repair_document(
|
||||||
previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None,
|
previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None,
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Pl
|
|||||||
from OCP.BOPAlgo import BOPAlgo_Splitter
|
from OCP.BOPAlgo import BOPAlgo_Splitter
|
||||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||||
from OCP.BRep import BRep_Tool
|
from OCP.BRep import BRep_Tool
|
||||||
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||||
from OCP.BRepOffset import BRepOffset_Skin
|
from OCP.BRepOffset import BRepOffset_Skin
|
||||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
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.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||||
from OCP.Geom import Geom_SurfaceOfRevolution
|
from OCP.Geom import Geom_SurfaceOfRevolution
|
||||||
from OCP.GeomAbs import GeomAbs_Arc
|
from OCP.GeomAbs import GeomAbs_Arc
|
||||||
from OCP.LocOpe import LocOpe_DPrism
|
from OCP.LocOpe import LocOpe_DPrism
|
||||||
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
|
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.TopExp import TopExp_Explorer
|
||||||
from OCP.TopTools import TopTools_ListOfShape
|
from OCP.TopTools import TopTools_ListOfShape
|
||||||
from OCP.TopoDS import TopoDS
|
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 .parametric_thread import build_thread_solid
|
||||||
from .runtime_types import (
|
from .runtime_types import (
|
||||||
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
|
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
|
||||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3,
|
||||||
canonical_plane_signature,
|
canonical_plane_signature,
|
||||||
)
|
)
|
||||||
from .topology_export import (
|
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]))
|
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:
|
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
|
||||||
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
|
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
|
||||||
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
|
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
|
||||||
@@ -96,7 +165,7 @@ class Build123dGeometryAdapter:
|
|||||||
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
|
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]:
|
||||||
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
|
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
|
||||||
built: list[Edge] = []
|
built: list[Edge] = []
|
||||||
for edge in edges:
|
for edge in edges:
|
||||||
@@ -137,12 +206,12 @@ class Build123dGeometryAdapter:
|
|||||||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
|
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
|
||||||
else:
|
else:
|
||||||
parameter_values = [float(value) for value in parameters] if parameters is not None else None
|
parameter_values = [float(value) for value in parameters] if parameters is not None else None
|
||||||
built.append(Edge.make_spline(
|
built.append(make_interpolated_bspline_edge(
|
||||||
points,
|
[(point.X, point.Y, point.Z) for point in points],
|
||||||
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
|
start_tangent=start_tangent,
|
||||||
|
end_tangent=end_tangent,
|
||||||
periodic=bool(edge.get("periodic")),
|
periodic=bool(edge.get("periodic")),
|
||||||
parameters=parameter_values,
|
parameters=parameter_values,
|
||||||
scale=False,
|
|
||||||
))
|
))
|
||||||
continue
|
continue
|
||||||
if edge.get("type") == "ellipse":
|
if edge.get("type") == "ellipse":
|
||||||
@@ -163,7 +232,203 @@ class Build123dGeometryAdapter:
|
|||||||
else:
|
else:
|
||||||
# 直线边:直接连接首尾。
|
# 直线边:直接连接首尾。
|
||||||
built.append(Edge.make_line(start, end))
|
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:
|
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
|
||||||
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
|
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
|
||||||
@@ -211,6 +476,59 @@ class Build123dGeometryAdapter:
|
|||||||
faces.append(face.make_holes(holes) if holes else face)
|
faces.append(face.make_holes(holes) if holes else face)
|
||||||
return faces
|
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
|
@staticmethod
|
||||||
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
|
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
|
||||||
"""Return OCC split history, keeping an unchanged input as one image."""
|
"""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 {})
|
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||||
return self._faces_from_circles(sketch.get("entities") or [], plane)
|
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
|
@staticmethod
|
||||||
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
|
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
|
||||||
"""Build one planar profile from a sketch outer wire and cap-face holes."""
|
"""Build one planar profile from a sketch outer wire and cap-face holes."""
|
||||||
@@ -582,7 +990,7 @@ class Build123dGeometryAdapter:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
|
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)
|
vector = _vector(direction)
|
||||||
if vector.length <= 1e-9:
|
if vector.length <= 1e-9:
|
||||||
raise ValueError("extrude direction must be non-zero")
|
raise ValueError("extrude direction must be non-zero")
|
||||||
@@ -600,6 +1008,46 @@ class Build123dGeometryAdapter:
|
|||||||
relations.append(TopologyDeltaRelation(
|
relations.append(TopologyDeltaRelation(
|
||||||
"generated", "face", face.wrapped, (output,), output_role=role,
|
"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))
|
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1241,8 +1689,11 @@ class Build123dGeometryAdapter:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
|
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
|
||||||
"""Translate OCC builder history into adapter-neutral opaque relations."""
|
"""Translate OCC builder history into adapter-neutral opaque relations."""
|
||||||
|
source_bodies = tuple(sources)
|
||||||
relations: list[TopologyDeltaRelation] = []
|
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 (
|
for kind, shapes in (
|
||||||
("face", list(source.faces())),
|
("face", list(source.faces())),
|
||||||
("edge", list(source.edges())),
|
("edge", list(source.edges())),
|
||||||
@@ -1274,6 +1725,7 @@ class Build123dGeometryAdapter:
|
|||||||
if generated:
|
if generated:
|
||||||
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
||||||
section_values: tuple[Any, ...] = ()
|
section_values: tuple[Any, ...] = ()
|
||||||
|
section_relations: list[TopologySectionRelation] = []
|
||||||
section_edges = getattr(operation, "SectionEdges", None)
|
section_edges = getattr(operation, "SectionEdges", None)
|
||||||
if callable(section_edges):
|
if callable(section_edges):
|
||||||
try:
|
try:
|
||||||
@@ -1283,10 +1735,37 @@ class Build123dGeometryAdapter:
|
|||||||
section_values = tuple(section_edges())
|
section_values = tuple(section_edges())
|
||||||
except (AttributeError, TypeError, ValueError):
|
except (AttributeError, TypeError, ValueError):
|
||||||
section_values = ()
|
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(
|
return TopologyDelta(
|
||||||
operation=operation_name,
|
operation=operation_name,
|
||||||
relations=tuple(relations),
|
relations=tuple(relations),
|
||||||
section_values=section_values,
|
section_values=unqualified_section_values,
|
||||||
|
section_relations=tuple(section_relations),
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -1830,20 +2309,21 @@ class Build123dGeometryAdapter:
|
|||||||
end_tangent: Vector3 | None = None,
|
end_tangent: Vector3 | None = None,
|
||||||
parameters: list[float] | None = None,
|
parameters: list[float] | None = None,
|
||||||
) -> Edge | Wire:
|
) -> Edge | Wire:
|
||||||
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
|
# 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点
|
||||||
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
|
# 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束,
|
||||||
|
# 缺失时不能伪造,交给内核自动求解。
|
||||||
vertices = [_vector(point) for point in points]
|
vertices = [_vector(point) for point in points]
|
||||||
if len(vertices) < 2:
|
if len(vertices) < 2:
|
||||||
raise ValueError("sweep path needs at least two points")
|
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):
|
if (start_tangent is None) != (end_tangent is None):
|
||||||
raise ValueError("sweep B-spline path requires both endpoint tangents")
|
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
|
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):
|
if parameters is not None and len(parameters) != len(vertices):
|
||||||
raise ValueError("sweep B-spline path parameters must match point count")
|
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]
|
direction = vertices[-1] - vertices[0]
|
||||||
tolerance = 1e-9 * max(1.0, direction.length)
|
tolerance = 1e-9 * max(1.0, direction.length)
|
||||||
collinear_points = direction.length > tolerance and all(
|
collinear_points = direction.length > tolerance and all(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from .runtime_types import (
|
|||||||
pattern_instance_member_id, transform_copy_member_id,
|
pattern_instance_member_id, transform_copy_member_id,
|
||||||
)
|
)
|
||||||
from .operation_contracts import materialized_feature_contracts
|
from .operation_contracts import materialized_feature_contracts
|
||||||
|
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
|
||||||
|
|
||||||
|
|
||||||
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
|
_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)]
|
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]:
|
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||||
"""Return internal body-member keys named by a transform contract."""
|
"""Return internal body-member keys named by a transform contract."""
|
||||||
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
|
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
|
return members, has_active_body
|
||||||
|
|
||||||
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
|
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"
|
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
|
||||||
):
|
):
|
||||||
# Primary cuts execute per explicit member in the runtime so a later
|
# Primary cuts execute per explicit member in the runtime so a later
|
||||||
@@ -448,6 +469,7 @@ class CapabilityAnalyzer:
|
|||||||
completed: set[str] = set()
|
completed: set[str] = set()
|
||||||
body_available = False
|
body_available = False
|
||||||
body_members: set[str] = set()
|
body_members: set[str] = set()
|
||||||
|
previous_node: FeaturePlanNode | None = None
|
||||||
for node in plan:
|
for node in plan:
|
||||||
blockers: list[RuntimeDiagnostic] = []
|
blockers: list[RuntimeDiagnostic] = []
|
||||||
contract = self.contracts.get(node.atomic_id)
|
contract = self.contracts.get(node.atomic_id)
|
||||||
@@ -559,19 +581,38 @@ class CapabilityAnalyzer:
|
|||||||
sketch_id=sketch_id,
|
sketch_id=sketch_id,
|
||||||
))
|
))
|
||||||
contract_selectors = _contract_selectors(node, contract)
|
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):
|
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(
|
blockers.append(self._blocker(
|
||||||
node.feature_id,
|
node.feature_id,
|
||||||
"unsupported_output_role_selector_context",
|
"unsupported_output_role_selector_context",
|
||||||
"Feature output role selector is outside the operation contract slot",
|
"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:
|
if selector.get("output_role") is None:
|
||||||
continue
|
continue
|
||||||
required.append("selector:feature_output_role")
|
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(
|
blockers.append(self._blocker(
|
||||||
node.feature_id,
|
node.feature_id,
|
||||||
"unsupported_output_role_selector",
|
"unsupported_output_role_selector",
|
||||||
@@ -600,6 +641,39 @@ class CapabilityAnalyzer:
|
|||||||
selector_index=selector_index,
|
selector_index=selector_index,
|
||||||
))
|
))
|
||||||
role_source = selector.get("output_role_source")
|
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:
|
if role_source is not None:
|
||||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else 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
|
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||||
@@ -647,11 +721,25 @@ class CapabilityAnalyzer:
|
|||||||
))
|
))
|
||||||
elif kind == "bspline" and (
|
elif kind == "bspline" and (
|
||||||
not isinstance(segment.get("points"), list)
|
not isinstance(segment.get("points"), list)
|
||||||
or len(segment.get("points") or []) < 3
|
or len(segment.get("points") or []) < 2
|
||||||
):
|
):
|
||||||
blockers.append(self._blocker(
|
blockers.append(self._blocker(
|
||||||
node.feature_id, "invalid_sweep_path",
|
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":
|
if node.atomic_id == "boolean_bodies":
|
||||||
target_ids = params.get("target_feature_ids")
|
target_ids = params.get("target_feature_ids")
|
||||||
@@ -762,6 +850,27 @@ class CapabilityAnalyzer:
|
|||||||
"shell target no longer has an independently selectable body output",
|
"shell target no longer has an independently selectable body output",
|
||||||
target_feature_id=target_feature_id,
|
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"}:
|
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
|
||||||
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
|
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
|
||||||
source_ids = params.get(parameter)
|
source_ids = params.get(parameter)
|
||||||
@@ -1086,6 +1195,7 @@ class CapabilityAnalyzer:
|
|||||||
if status == "executable":
|
if status == "executable":
|
||||||
completed.add(node.feature_id)
|
completed.add(node.feature_id)
|
||||||
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
||||||
|
previous_node = node
|
||||||
body_producers = {
|
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_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",
|
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
"geometry": {
|
"geometry": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"selector_intent_version": {"const": "1.0"},
|
|
||||||
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
|
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
|
||||||
},
|
},
|
||||||
"required": ["sketches"],
|
"required": ["sketches"],
|
||||||
@@ -543,6 +542,7 @@
|
|||||||
"end_condition": {"$ref": "#/$defs/endCondition"},
|
"end_condition": {"$ref": "#/$defs/endCondition"},
|
||||||
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
|
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
|
||||||
"host_face": {"$ref": "#/$defs/hostFace"},
|
"host_face": {"$ref": "#/$defs/hostFace"},
|
||||||
|
"scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||||
"thread": {"type": "object"},
|
"thread": {"type": "object"},
|
||||||
"countersink": {"type": "object"},
|
"countersink": {"type": "object"},
|
||||||
"counterbore": {"type": "object"}
|
"counterbore": {"type": "object"}
|
||||||
@@ -570,9 +570,22 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"ast": {},
|
"ast": {},
|
||||||
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
|
"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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"selectorIntent": {
|
"selectorIntent": {
|
||||||
@@ -580,7 +593,7 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"version": {"const": "1.0"},
|
"version": {"const": "1.0"},
|
||||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
"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_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
|
||||||
"source_entity": {
|
"source_entity": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -591,7 +604,47 @@
|
|||||||
"required": ["sketch_id", "entity_id"],
|
"required": ["sketch_id", "entity_id"],
|
||||||
"additionalProperties": false
|
"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"},
|
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||||
|
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||||
|
"body_member_contract": {"enum": ["direct_new_body"]},
|
||||||
"derivation_policy": {
|
"derivation_policy": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -601,7 +654,7 @@
|
|||||||
"required": ["allowed", "multiplicity"],
|
"required": ["allowed", "multiplicity"],
|
||||||
"additionalProperties": false
|
"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"}
|
"disambiguation": {"type": "object"}
|
||||||
},
|
},
|
||||||
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
|
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
|
||||||
@@ -628,7 +681,7 @@
|
|||||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||||
},
|
},
|
||||||
"required": ["kind", "source", "confidence"],
|
"required": ["kind", "source", "confidence"],
|
||||||
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
|
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"analyticSegment": {
|
"analyticSegment": {
|
||||||
@@ -649,6 +702,7 @@
|
|||||||
"clockwise": {"type": "boolean"},
|
"clockwise": {"type": "boolean"},
|
||||||
"start_tangent": {"$ref": "#/$defs/point2"},
|
"start_tangent": {"$ref": "#/$defs/point2"},
|
||||||
"end_tangent": {"$ref": "#/$defs/point2"}
|
"end_tangent": {"$ref": "#/$defs/point2"}
|
||||||
|
,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
|
||||||
},
|
},
|
||||||
"required": ["type"],
|
"required": ["type"],
|
||||||
"allOf": [
|
"allOf": [
|
||||||
@@ -798,7 +852,7 @@
|
|||||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
|
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
|
||||||
"profile": {
|
"profile": {
|
||||||
"oneOf": [
|
"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},
|
{"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/analyticProfile"},
|
||||||
{"$ref": "#/$defs/planarImprintProfile"}
|
{"$ref": "#/$defs/planarImprintProfile"}
|
||||||
@@ -806,7 +860,7 @@
|
|||||||
},
|
},
|
||||||
"sketch": {
|
"sketch": {
|
||||||
"type": "object",
|
"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"],
|
"required": ["id", "workplane", "profile"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,13 +73,50 @@ def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[s
|
|||||||
return members
|
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(
|
def _extruded_tool(
|
||||||
node: FeaturePlanNode,
|
node: FeaturePlanNode,
|
||||||
faces: list[Any],
|
faces: list[Any],
|
||||||
profile_normal: Vector3,
|
profile_normal: Vector3,
|
||||||
session: "ExecutionSession",
|
session: "ExecutionSession",
|
||||||
|
*,
|
||||||
|
record_multiface_prism_history: bool = False,
|
||||||
) -> tuple[Any, TopologyDelta | None]:
|
) -> 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)
|
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||||
draft = node.params.get("draft")
|
draft = node.params.get("draft")
|
||||||
taper_deg = 0.0
|
taper_deg = 0.0
|
||||||
@@ -87,7 +124,7 @@ def _extruded_tool(
|
|||||||
taper_deg = float(draft["angle_deg"])
|
taper_deg = float(draft["angle_deg"])
|
||||||
if not bool(draft["pull_direction"]):
|
if not bool(draft["pull_direction"]):
|
||||||
taper_deg = -taper_deg
|
taper_deg = -taper_deg
|
||||||
topology_delta: TopologyDelta | None = None
|
topology_deltas: list[TopologyDelta] = []
|
||||||
solids: list[Any] = []
|
solids: list[Any] = []
|
||||||
for face in faces:
|
for face in faces:
|
||||||
for extent in extents:
|
for extent in extents:
|
||||||
@@ -96,11 +133,22 @@ def _extruded_tool(
|
|||||||
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
|
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
|
||||||
face, extent.vector, taper_deg,
|
face, extent.vector, taper_deg,
|
||||||
)
|
)
|
||||||
|
if topology_delta is not None:
|
||||||
|
topology_deltas.append(topology_delta)
|
||||||
solids.append(solid)
|
solids.append(solid)
|
||||||
else:
|
else:
|
||||||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
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)
|
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||||
|
topology_deltas.append(topology_delta)
|
||||||
solids.append(solid)
|
solids.append(solid)
|
||||||
elif extent.trim_to is None:
|
elif extent.trim_to is None:
|
||||||
solids.append(session.adapter.extrude(face, extent.vector))
|
solids.append(session.adapter.extrude(face, extent.vector))
|
||||||
@@ -111,7 +159,12 @@ def _extruded_tool(
|
|||||||
tool = session.adapter.fuse(tool, solid)
|
tool = session.adapter.fuse(tool, solid)
|
||||||
if tool is None:
|
if tool is None:
|
||||||
raise ValueError("extrude produced no solid")
|
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(
|
def _apply_primary_tool(
|
||||||
@@ -121,8 +174,10 @@ def _apply_primary_tool(
|
|||||||
*,
|
*,
|
||||||
cutting: bool,
|
cutting: bool,
|
||||||
topology_delta: TopologyDelta | None = None,
|
topology_delta: TopologyDelta | None = None,
|
||||||
|
topology_anchors: list[TopologyRecord] | None = None,
|
||||||
) -> FeatureResult:
|
) -> FeatureResult:
|
||||||
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||||||
|
topology_predecessors: list[TopologyRecord] | None = None
|
||||||
if cutting:
|
if cutting:
|
||||||
if session.body is None:
|
if session.body is None:
|
||||||
raise ValueError("cut feature has no body")
|
raise ValueError("cut feature has no body")
|
||||||
@@ -130,8 +185,46 @@ def _apply_primary_tool(
|
|||||||
if not members:
|
if not members:
|
||||||
session.clear_body()
|
session.clear_body()
|
||||||
return session.result(node)
|
return session.result(node)
|
||||||
body = session.adapter.cut(session.body, tool)
|
tool_delta = topology_delta
|
||||||
topology_delta = None
|
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":
|
elif node.params.get("result_mode") == "new_body":
|
||||||
body = session.adapter.combine(session.body, tool)
|
body = session.adapter.combine(session.body, tool)
|
||||||
members = {**session.body_members, node.feature_id: tool}
|
members = {**session.body_members, node.feature_id: tool}
|
||||||
@@ -142,8 +235,10 @@ def _apply_primary_tool(
|
|||||||
# an unchanged standalone/new-body prism snapshot.
|
# an unchanged standalone/new-body prism snapshot.
|
||||||
if session.body is not None:
|
if session.body is not None:
|
||||||
topology_delta = None
|
topology_delta = None
|
||||||
|
topology_anchors = None
|
||||||
session.register_body(
|
session.register_body(
|
||||||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
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)
|
return session.result(node)
|
||||||
|
|
||||||
@@ -157,7 +252,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
|||||||
if selected_sketch is None:
|
if selected_sketch is None:
|
||||||
raise ValueError("primary feature has no resolved sketch")
|
raise ValueError("primary feature has no resolved sketch")
|
||||||
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
|
# 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:
|
if not faces:
|
||||||
raise ValueError("sketch does not create a closed profile region")
|
raise ValueError("sketch does not create a closed profile region")
|
||||||
if node.atomic_id == "extrude_add_blind_with_hole":
|
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")
|
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
|
||||||
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
|
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
|
||||||
topology_delta: TopologyDelta | None = None
|
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. 按特征类型生成子实体:
|
# 3. 按特征类型生成子实体:
|
||||||
if node.atomic_id.startswith("extrude_"):
|
if node.atomic_id.startswith("extrude_"):
|
||||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||||
@@ -177,7 +282,27 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
|||||||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||||
tool, topology_delta = _extruded_tool(
|
tool, topology_delta = _extruded_tool(
|
||||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
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:
|
else:
|
||||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||||
axis = _revolve_axis(node, session)
|
axis = _revolve_axis(node, session)
|
||||||
@@ -199,6 +324,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
|||||||
raise ValueError("revolve produced no solid")
|
raise ValueError("revolve produced no solid")
|
||||||
return _apply_primary_tool(
|
return _apply_primary_tool(
|
||||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||||
|
topology_anchors=topology_anchors,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,23 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
|||||||
# 1. 校验:孔是切除操作,必须先有主体。
|
# 1. 校验:孔是切除操作,必须先有主体。
|
||||||
if session.body is None:
|
if session.body is None:
|
||||||
raise ValueError("hole feature has no body")
|
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:
|
# 2. 确定宿主面 host_face:
|
||||||
host_selector = node.params.get("host_face")
|
host_selector = node.params.get("host_face")
|
||||||
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
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",
|
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||||||
feature_id=node.feature_id,
|
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)
|
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")
|
params_schema = raw.get("author_params_schema")
|
||||||
injected_paths = raw.get("server_injected_paths")
|
injected_paths = raw.get("server_injected_paths")
|
||||||
selector_policy = raw.get("selector_policy")
|
selector_policy = raw.get("selector_policy")
|
||||||
|
nested_selector_policies = raw.get("nested_selector_policies") or {}
|
||||||
runtime_capability = raw.get("runtime_capability")
|
runtime_capability = raw.get("runtime_capability")
|
||||||
if (
|
if (
|
||||||
not isinstance(shape, dict)
|
not isinstance(shape, dict)
|
||||||
or not isinstance(params_schema, dict)
|
or not isinstance(params_schema, dict)
|
||||||
or not isinstance(injected_paths, list)
|
or not isinstance(injected_paths, list)
|
||||||
or not isinstance(selector_policy, dict)
|
or not isinstance(selector_policy, dict)
|
||||||
|
or not isinstance(nested_selector_policies, dict)
|
||||||
):
|
):
|
||||||
raise ValueError(f"operation contract is incomplete for {atomic_id}")
|
raise ValueError(f"operation contract is incomplete for {atomic_id}")
|
||||||
if not isinstance(runtime_capability, dict) or not all(
|
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}")
|
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):
|
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}")
|
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))
|
materialized_required = list(dict.fromkeys(required))
|
||||||
for path in injected_paths:
|
for path in injected_paths:
|
||||||
if not isinstance(path, str):
|
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",
|
"requires_sketch": shape.get("sketch") == "required",
|
||||||
"selector_slot": selector_policy.get("slot"),
|
"selector_slot": selector_policy.get("slot"),
|
||||||
"selector_token_kind": selector_policy.get("token_kind"),
|
"selector_token_kind": selector_policy.get("token_kind"),
|
||||||
|
"nested_selector_policies": dict(nested_selector_policies),
|
||||||
"runtime_capability": dict(runtime_capability),
|
"runtime_capability": dict(runtime_capability),
|
||||||
}
|
}
|
||||||
return derived
|
return derived
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
"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"],
|
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"],
|
||||||
"operation_contracts": {
|
"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_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_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}},
|
"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}},
|
"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}},
|
"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_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_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}},
|
"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}},
|
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ working.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
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
|
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
|
||||||
# Historical private name still imported by the test suite.
|
# Historical private name still imported by the test suite.
|
||||||
from .executors.primitives import _execute_box # noqa: F401
|
from .executors.primitives import _execute_box # noqa: F401
|
||||||
|
from .executors.common import _selector_edges # noqa: F401
|
||||||
from .extents import (
|
from .extents import (
|
||||||
_extent_reference,
|
_extent_reference,
|
||||||
_extent_vectors,
|
_extent_vectors,
|
||||||
@@ -73,6 +75,9 @@ from .topology import (
|
|||||||
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
|
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Historical private imports used by integration tests and local diagnostics.
|
||||||
|
_execute_node = execute_node
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ALL_ATOMIC_IDS",
|
"ALL_ATOMIC_IDS",
|
||||||
"EXECUTORS",
|
"EXECUTORS",
|
||||||
@@ -85,7 +90,10 @@ __all__ = [
|
|||||||
"GeometryAdapter",
|
"GeometryAdapter",
|
||||||
"RuntimeDiagnostic",
|
"RuntimeDiagnostic",
|
||||||
"RuntimeExecutionError",
|
"RuntimeExecutionError",
|
||||||
|
"IncrementalCdslExecution",
|
||||||
"analyze_cdsl",
|
"analyze_cdsl",
|
||||||
|
"prepare_cdsl_execution",
|
||||||
|
"finalize_cdsl_execution",
|
||||||
"execute_node",
|
"execute_node",
|
||||||
"rebuild_cdsl",
|
"rebuild_cdsl",
|
||||||
]
|
]
|
||||||
@@ -101,8 +109,81 @@ def analyze_cdsl(cdsl: dict[str, Any]):
|
|||||||
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
|
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
|
||||||
|
|
||||||
|
|
||||||
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
|
def _preflight_error(analysis: Any) -> ValueError | None:
|
||||||
"""Rebuild CDSL through session-scoped atomic executors only."""
|
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] = {}
|
sketch_errors: dict[str, str] = {}
|
||||||
resolved = resolve_required_sketches(
|
resolved = resolve_required_sketches(
|
||||||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
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(
|
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
|
||||||
resolved, sketch_errors=sketch_errors,
|
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(
|
session = ExecutionSession(
|
||||||
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
|
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},
|
nodes={node.feature_id: node for node in analysis.plan},
|
||||||
)
|
)
|
||||||
diagnostics: list[RuntimeDiagnostic] = []
|
return IncrementalCdslExecution(resolved, analysis, session)
|
||||||
for node, preflight in zip(analysis.plan, analysis.feature_results):
|
|
||||||
if not preflight.executable:
|
|
||||||
diagnostics.extend(preflight.blockers)
|
def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]:
|
||||||
if strict:
|
"""Export the current executable checkpoint from an incremental replay."""
|
||||||
break
|
session = execution.session
|
||||||
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
|
|
||||||
output = session.body
|
output = session.body
|
||||||
surface_geometry: dict[str, Any] | None = None
|
surface_geometry: dict[str, Any] | None = None
|
||||||
if output is 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_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,
|
"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()],
|
"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_records": [record.public_dict() for record in session.topology.records()],
|
||||||
"topology_deltas": list(session.topology.topology_deltas()),
|
"topology_deltas": list(session.topology.topology_deltas()),
|
||||||
"selector_resolution": session.selector_resolutions,
|
"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,
|
SelectorResolution,
|
||||||
TopologyDelta,
|
TopologyDelta,
|
||||||
TopologyDeltaRelation,
|
TopologyDeltaRelation,
|
||||||
|
TopologySectionRelation,
|
||||||
TopologyLineage,
|
TopologyLineage,
|
||||||
TopologyRecord,
|
TopologyRecord,
|
||||||
TopologyRegistry,
|
TopologyRegistry,
|
||||||
@@ -64,6 +65,7 @@ __all__ = [
|
|||||||
"ThreadSpec",
|
"ThreadSpec",
|
||||||
"TopologyDelta",
|
"TopologyDelta",
|
||||||
"TopologyDeltaRelation",
|
"TopologyDeltaRelation",
|
||||||
|
"TopologySectionRelation",
|
||||||
"TopologyLineage",
|
"TopologyLineage",
|
||||||
"TopologyRecord",
|
"TopologyRecord",
|
||||||
"TopologyRegistry",
|
"TopologyRegistry",
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Verified FeatureScript query semantics available to the selector resolver.
|
||||||
|
|
||||||
|
This is intentionally a small, explicit allow-list. A numeric source version
|
||||||
|
only identifies a FeatureScript release; it does not establish that this
|
||||||
|
runtime has verified a query family's source semantics for that release.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SelectorQueryCapability:
|
||||||
|
query_family: str
|
||||||
|
featurescript_version: str
|
||||||
|
standard_library: str
|
||||||
|
standard_library_version: str
|
||||||
|
source: str
|
||||||
|
contract: str
|
||||||
|
|
||||||
|
|
||||||
|
# These entries correspond to the direct builder contracts exercised by the
|
||||||
|
# CADFS 1511 corpus. New versions and query families must be registered with
|
||||||
|
# their source/API evidence before the runtime accepts them.
|
||||||
|
_CAPABILITIES = {
|
||||||
|
("CAP_FACE", "1511"): SelectorQueryCapability(
|
||||||
|
"CAP_FACE",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history",
|
||||||
|
"direct extrude cap operation role",
|
||||||
|
),
|
||||||
|
("CAP_EDGE", "1511"): SelectorQueryCapability(
|
||||||
|
"CAP_EDGE",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history",
|
||||||
|
"direct prism source edge to qualified start/end cap edge kernel lineage",
|
||||||
|
),
|
||||||
|
("OFFSET_FACE", "1511"): SelectorQueryCapability(
|
||||||
|
"OFFSET_FACE",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history",
|
||||||
|
"shell offset-face operation role with true dependency qualification",
|
||||||
|
),
|
||||||
|
# These entries authorize the deliberately narrow direct-prism lineage
|
||||||
|
# path. The capability matrix keeps the broader query families explicitly
|
||||||
|
# partial; all other generator and lifecycle combinations remain rejected.
|
||||||
|
("SWEPT_FACE", "1511"): SelectorQueryCapability(
|
||||||
|
"SWEPT_FACE",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history",
|
||||||
|
"kernel-lineage resolver contract only",
|
||||||
|
),
|
||||||
|
("SWEPT_EDGE", "1511"): SelectorQueryCapability(
|
||||||
|
"SWEPT_EDGE",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history",
|
||||||
|
"kernel-lineage resolver contract only",
|
||||||
|
),
|
||||||
|
("SWEPT_BODY", "1511"): SelectorQueryCapability(
|
||||||
|
"SWEPT_BODY",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history; one-item qUnion identity",
|
||||||
|
"active direct-new-body member selected by its producing operation",
|
||||||
|
),
|
||||||
|
("INTERSECT", "1511"): SelectorQueryCapability(
|
||||||
|
"INTERSECT",
|
||||||
|
"1511",
|
||||||
|
"onshape/std/geometry.fs",
|
||||||
|
"1511.0",
|
||||||
|
"CADFS FeatureScript 1511 exported query history; BRepAlgoAPI boolean Generated(face) and SectionEdges() exact handles",
|
||||||
|
"two source-qualified boolean input faces to one final section edge kernel lineage",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def selector_query_capability(intent: dict[str, Any]) -> SelectorQueryCapability | None:
|
||||||
|
"""Return an explicitly verified query capability for ``intent`` only."""
|
||||||
|
source_query = intent.get("source_query")
|
||||||
|
if not isinstance(source_query, dict):
|
||||||
|
return None
|
||||||
|
family = intent.get("query_family")
|
||||||
|
version = source_query.get("featurescript_version")
|
||||||
|
if not isinstance(family, str) or not isinstance(version, str):
|
||||||
|
return None
|
||||||
|
capability = _CAPABILITIES.get((family, version))
|
||||||
|
if capability is None:
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
source_query.get("standard_library") != capability.standard_library
|
||||||
|
or source_query.get("standard_library_version") != capability.standard_library_version
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
return capability
|
||||||
|
|
||||||
|
|
||||||
|
def known_selector_query_versions(query_family: str) -> tuple[str, ...]:
|
||||||
|
"""Expose registered versions for deterministic unsupported diagnostics."""
|
||||||
|
return tuple(sorted(version for family, version in _CAPABILITIES if family == query_family))
|
||||||
|
|
||||||
|
|
||||||
|
def known_selector_query_standard_library_versions(query_family: str) -> tuple[tuple[str, str], ...]:
|
||||||
|
"""Expose exact direct imports that back a registered query contract."""
|
||||||
|
return tuple(sorted({
|
||||||
|
(capability.standard_library, capability.standard_library_version)
|
||||||
|
for (family, _version), capability in _CAPABILITIES.items()
|
||||||
|
if family == query_family
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
def is_direct_blind_extrude_cap_output_role(
|
||||||
|
selector: dict[str, Any],
|
||||||
|
producer: dict[str, Any] | None,
|
||||||
|
sketches: dict[str, dict[str, Any]],
|
||||||
|
) -> bool:
|
||||||
|
"""Whether a selector names one cap from the direct prism contract.
|
||||||
|
|
||||||
|
This is the consumer-side mirror of the lowerer's CAP_FACE constructor.
|
||||||
|
It deliberately describes a narrow builder contract, rather than treating
|
||||||
|
an output-role string as general permission to select arbitrary topology.
|
||||||
|
The runtime still has to prove the exact role in its active snapshot.
|
||||||
|
"""
|
||||||
|
if not isinstance(selector, dict) or not isinstance(producer, dict):
|
||||||
|
return False
|
||||||
|
intent = selector.get("selector_intent")
|
||||||
|
if (
|
||||||
|
selector.get("kind") != "face"
|
||||||
|
or selector.get("output_role") not in {"extrude.start", "extrude.end"}
|
||||||
|
or selector.get("source") != "runtime_snapshot"
|
||||||
|
or selector.get("output_role_source") is not None
|
||||||
|
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id"))
|
||||||
|
or not isinstance(intent, dict)
|
||||||
|
or intent.get("query_family") != "CAP_FACE"
|
||||||
|
or intent.get("evidence") != "operation_role"
|
||||||
|
or intent.get("output_role") != selector.get("output_role")
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
policy = intent.get("derivation_policy")
|
||||||
|
if not isinstance(policy, dict) or policy.get("multiplicity") != "one" or set(policy.get("allowed") or ()) != {"boundary", "continuation"}:
|
||||||
|
return False
|
||||||
|
params = producer.get("params") or {}
|
||||||
|
if (
|
||||||
|
producer.get("atomic_id") != "extrude_add_blind"
|
||||||
|
or params.get("result_mode") != "new_body"
|
||||||
|
or (params.get("end_condition") or {}).get("type") != "blind"
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if params.get("draft") is None:
|
||||||
|
return True
|
||||||
|
sketch = sketches.get(str(producer.get("sketch_id") or "")) or {}
|
||||||
|
profile = sketch.get("profile") or {}
|
||||||
|
if profile.get("type") == "circle":
|
||||||
|
return True
|
||||||
|
contours = profile.get("contours")
|
||||||
|
return (
|
||||||
|
profile.get("type") == "analytic_contours"
|
||||||
|
and isinstance(contours, list)
|
||||||
|
and len(contours) == 1
|
||||||
|
and bool((contours[0] or {}).get("closed"))
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ from typing import Any
|
|||||||
from jsonschema import Draft202012Validator
|
from jsonschema import Draft202012Validator
|
||||||
|
|
||||||
from .operation_contracts import materialized_feature_contracts
|
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}$")
|
_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)]
|
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:
|
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."""
|
"""Enforce the provenance boundary before the runtime can bind a selector."""
|
||||||
intent = selector.get("selector_intent")
|
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:
|
if selector.get("selector_intent_version") is not None:
|
||||||
raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent")
|
raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent")
|
||||||
return
|
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":
|
if not isinstance(intent, dict) or intent.get("version") != "1.0":
|
||||||
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent")
|
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")}:
|
if intent.get("kind") not in {None, selector.get("kind")}:
|
||||||
raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind")
|
raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind")
|
||||||
family = intent.get("query_family")
|
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"):
|
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")
|
raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id")
|
||||||
policy = intent.get("derivation_policy") or {}
|
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")
|
raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback")
|
||||||
source_query = intent.get("source_query") or {}
|
source_query = intent.get("source_query") or {}
|
||||||
version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None
|
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):
|
# An absent source version is a valid preservation state. The resolver,
|
||||||
raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version")
|
# 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"):
|
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")
|
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"}
|
forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"}
|
||||||
stack = [intent]
|
stack = [intent]
|
||||||
while stack:
|
while stack:
|
||||||
@@ -166,6 +266,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
feature_ids: set[str] = set()
|
feature_ids: set[str] = set()
|
||||||
preceding_features: dict[str, dict[str, Any]] = {}
|
preceding_features: dict[str, dict[str, Any]] = {}
|
||||||
|
previous_feature_id: str | None = None
|
||||||
deferred: list[str] = []
|
deferred: list[str] = []
|
||||||
unresolved: list[dict[str, Any]] = []
|
unresolved: list[dict[str, Any]] = []
|
||||||
contracts = _operation_contracts()
|
contracts = _operation_contracts()
|
||||||
@@ -185,21 +286,26 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
|||||||
deferred.append(fid)
|
deferred.append(fid)
|
||||||
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
|
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
|
||||||
feature_selectors = _contract_selectors(feature, contract)
|
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 = {
|
selector_intent_ids = {
|
||||||
id(selector.get("selector_intent"))
|
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)
|
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)
|
_validate_selector_intent(selector, fid, index)
|
||||||
|
validated_selector_ids.add(id(selector))
|
||||||
owner = selector.get("owner_feature_id")
|
owner = selector.get("owner_feature_id")
|
||||||
binding_owner = selector.get("binding_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:
|
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")
|
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
|
||||||
if selector.get("output_role") is not None:
|
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:
|
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")
|
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
|
||||||
if selector.get("source") != "runtime_snapshot":
|
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")):
|
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")
|
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
|
||||||
role_source = selector.get("output_role_source")
|
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:
|
if role_source is not None:
|
||||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else 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
|
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:
|
elif selector.get("output_role_source") is not None:
|
||||||
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
|
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
|
||||||
for selector in _mappings(feature):
|
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
|
# ``output_role_source`` is provenance metadata nested inside a
|
||||||
# feature selector, not a selector on its own.
|
# feature selector, not a selector on its own.
|
||||||
if (
|
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")
|
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:
|
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")
|
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":
|
if feature.get("atomic_id") == "transform_bodies":
|
||||||
params = feature.get("params") or {}
|
params = feature.get("params") or {}
|
||||||
references = params.get("pattern_instance_refs") 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"])})
|
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
|
||||||
feature_ids.add(fid)
|
feature_ids.add(fid)
|
||||||
preceding_features[fid] = feature
|
preceding_features[fid] = feature
|
||||||
|
previous_feature_id = fid
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"schema_version": version,
|
"schema_version": version,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from .topology import (
|
|||||||
TopologyDelta,
|
TopologyDelta,
|
||||||
TopologyRecord,
|
TopologyRecord,
|
||||||
TopologyRegistry,
|
TopologyRegistry,
|
||||||
|
validate_selector_provenance_intent,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ class GeometryAdapter(Protocol):
|
|||||||
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
||||||
def surface_geometry(self, surface: 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(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 face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
|
||||||
def loft(self, sketches: list[dict[str, 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]: ...
|
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,
|
body_members: dict[str, Any] | None = None,
|
||||||
topology_delta: TopologyDelta | None = None,
|
topology_delta: TopologyDelta | None = None,
|
||||||
topology_predecessors: list[TopologyRecord] | None = None,
|
topology_predecessors: list[TopologyRecord] | None = None,
|
||||||
|
topology_anchors: list[TopologyRecord] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
|
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
|
||||||
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
||||||
@@ -123,12 +126,20 @@ class ExecutionSession:
|
|||||||
self.body = body
|
self.body = body
|
||||||
self.body_id = f"body:{feature_id}"
|
self.body_id = f"body:{feature_id}"
|
||||||
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
|
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)
|
solids = self.adapter.body_solids(body)
|
||||||
if len(solids) <= 1:
|
if len(solids) <= 1:
|
||||||
self.topology.replace_body_topology(
|
self.topology.replace_body_topology(
|
||||||
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
|
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
|
||||||
topology_delta=topology_delta,
|
topology_delta=topology_delta,
|
||||||
additional_predecessors=topology_predecessors or (),
|
additional_predecessors=predecessors,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
|
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
|
||||||
@@ -141,7 +152,7 @@ class ExecutionSession:
|
|||||||
]
|
]
|
||||||
self.topology.replace_body_topologies(
|
self.topology.replace_body_topologies(
|
||||||
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
|
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(
|
self.topology.register(TopologyRecord(
|
||||||
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
|
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:
|
if replay_node is not None:
|
||||||
self.replay_definitions[feature_id] = replay_node
|
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:
|
def register_surface(self, feature_id: str, surface: Any) -> str:
|
||||||
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
|
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
|
||||||
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
|
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
|
||||||
@@ -236,6 +266,17 @@ class ExecutionSession:
|
|||||||
|
|
||||||
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
|
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
|
||||||
if selector.get("intersection_of") is not None:
|
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))
|
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
|
||||||
owner = str(selector.get("owner_feature_id") or "")
|
owner = str(selector.get("owner_feature_id") or "")
|
||||||
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
|
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
|
||||||
|
|||||||
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
|
|||||||
_TOLERANCE_MM = 1e-5
|
_TOLERANCE_MM = 1e-5
|
||||||
|
|
||||||
|
|
||||||
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
|
def _circle(
|
||||||
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
|
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:
|
def _line(
|
||||||
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
|
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]:
|
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])
|
cx, cy = float(center[0]), float(center[1])
|
||||||
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
|
# 直接圆 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]]:
|
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))
|
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]
|
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]
|
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]:
|
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]:
|
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||||
kind = segment.get("type")
|
kind = segment.get("type")
|
||||||
if kind == "line":
|
if kind == "line":
|
||||||
return [_contour_line(segment["start"], segment["end"])]
|
edges = [_contour_line(segment["start"], segment["end"])]
|
||||||
if kind == "arc":
|
elif kind == "arc":
|
||||||
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
|
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
|
||||||
if kind == "circle":
|
elif kind == "circle":
|
||||||
return _circle_edges(segment)
|
edges = _circle_edges(segment)
|
||||||
if kind == "ellipse":
|
elif kind == "ellipse":
|
||||||
return _ellipse_edges(segment)
|
edges = _ellipse_edges(segment)
|
||||||
if kind == "bspline":
|
elif kind == "bspline":
|
||||||
points = segment.get("points") or []
|
points = segment.get("points") or []
|
||||||
if len(points) < 2:
|
if len(points) < 2:
|
||||||
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
|
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")
|
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
|
||||||
output["start_tangent_mm"] = _point(start_tangent)
|
output["start_tangent_mm"] = _point(start_tangent)
|
||||||
output["end_tangent_mm"] = _point(end_tangent)
|
output["end_tangent_mm"] = _point(end_tangent)
|
||||||
return [output]
|
edges = [output]
|
||||||
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
|
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]:
|
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] = []
|
raw_edges: list[_Ctx] = []
|
||||||
for segment in contour.get("segments") or []:
|
for segment in contour.get("segments") or []:
|
||||||
if segment.get("type") == "line":
|
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":
|
elif segment.get("type") == "circle":
|
||||||
if contour_open:
|
if contour_open:
|
||||||
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
|
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))
|
raw_edges.extend(_segment_edges(segment))
|
||||||
if raw_edges:
|
if raw_edges:
|
||||||
edges = _join(raw_edges, allow_open=contour_open)
|
edges = _join(raw_edges, allow_open=contour_open)
|
||||||
|
|||||||
+1391
-144
File diff suppressed because it is too large
Load Diff
@@ -395,6 +395,23 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6)
|
self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6)
|
||||||
self.assertAlmostEqual(edge.tangent_at(1).X, -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:
|
def test_two_point_bspline_profile_requires_and_preserves_endpoint_tangents(self) -> None:
|
||||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
||||||
|
|
||||||
@@ -930,51 +947,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
source = object()
|
source = object()
|
||||||
exact_result = object()
|
exact_result = object()
|
||||||
geometrically_similar = object()
|
geometrically_similar = object()
|
||||||
registry.replace_body_topology("base", "body:base", [
|
source_anchor = TopologyRecord(
|
||||||
TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
|
"anchor:base:circle", "edge", "base", geometry={}, value=source,
|
||||||
])
|
source_entity=("sketch_base", "circle"),
|
||||||
|
)
|
||||||
|
registry.register(source_anchor)
|
||||||
registry.replace_body_topology("later", "body:later", [
|
registry.replace_body_topology("later", "body:later", [
|
||||||
TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result),
|
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),
|
TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar),
|
||||||
], topology_delta=TopologyDelta("transform", (
|
], topology_delta=TopologyDelta("extrude", (
|
||||||
TopologyDeltaRelation("modified", "face", source, (exact_result,)),
|
TopologyDeltaRelation(
|
||||||
)))
|
"generated", "edge", source, (exact_result,),
|
||||||
|
source_kind="edge", result_kind="face", derivation="boundary",
|
||||||
|
),
|
||||||
|
)), additional_predecessors=[source_anchor])
|
||||||
selector = {
|
selector = {
|
||||||
"kind": "face", "owner_feature_id": "base", "stable_id": "base:face",
|
"kind": "face", "owner_feature_id": "base",
|
||||||
"source": "runtime_snapshot", "confidence": 1.0,
|
"source": "runtime_snapshot", "confidence": 1.0,
|
||||||
"geometry": {"center_mm": [0, 0, 0]},
|
"geometry": {"center_mm": [0, 0, 0]},
|
||||||
"selector_intent": {
|
"selector_intent": {
|
||||||
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
|
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
|
||||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
"source_query": {
|
||||||
"derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"},
|
"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",
|
"evidence": "kernel_history",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
resolution = registry.resolve(selector, active_body_id="body:later")
|
resolution = registry.resolve(selector, active_body_id="body:later")
|
||||||
self.assertEqual(resolution.status, "resolved")
|
self.assertEqual(resolution.status, "resolved")
|
||||||
self.assertEqual(resolution.record.record_id, "later:exact")
|
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:
|
def test_provenance_selector_rejects_non_unique_fragment(self) -> None:
|
||||||
registry = TopologyRegistry()
|
registry = TopologyRegistry()
|
||||||
source = object()
|
source = object()
|
||||||
first = object()
|
first = object()
|
||||||
second = object()
|
second = object()
|
||||||
registry.replace_body_topology("base", "body:base", [
|
source_anchor = TopologyRecord(
|
||||||
TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
|
"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", [
|
registry.replace_body_topology("fillet", "body:fillet", [
|
||||||
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first),
|
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),
|
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second),
|
||||||
], topology_delta=TopologyDelta("fillet", (
|
], 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({
|
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,
|
"source": "runtime_snapshot", "confidence": 1.0,
|
||||||
"selector_intent": {
|
"selector_intent": {
|
||||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
"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"},
|
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"},
|
||||||
"evidence": "kernel_history",
|
"evidence": "kernel_history",
|
||||||
},
|
},
|
||||||
@@ -987,21 +1084,34 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
source = object()
|
source = object()
|
||||||
first = object()
|
first = object()
|
||||||
second = object()
|
second = object()
|
||||||
registry.replace_body_topology("base", "body:base", [
|
source_anchor = TopologyRecord(
|
||||||
TopologyRecord("base:edge", "edge", "base", "body:base", {}, source),
|
"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", [
|
registry.replace_body_topology("fillet", "body:fillet", [
|
||||||
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first),
|
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first),
|
||||||
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second),
|
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second),
|
||||||
], topology_delta=TopologyDelta("fillet", (
|
], 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({
|
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,
|
"source": "runtime_snapshot", "confidence": 1.0,
|
||||||
"selector_intent": {
|
"selector_intent": {
|
||||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
"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"},
|
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"},
|
||||||
"evidence": "kernel_history",
|
"evidence": "kernel_history",
|
||||||
},
|
},
|
||||||
@@ -1010,6 +1120,75 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
self.assertIsNone(resolution.record)
|
self.assertIsNone(resolution.record)
|
||||||
self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"])
|
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:
|
def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None:
|
||||||
registry = TopologyRegistry()
|
registry = TopologyRegistry()
|
||||||
section_edge = object()
|
section_edge = object()
|
||||||
@@ -1022,6 +1201,138 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
self.assertEqual(lineage["result_record_ids"], ["boolean:section"])
|
self.assertEqual(lineage["result_record_ids"], ["boolean:section"])
|
||||||
self.assertTrue(delta["relations"][0]["section_edge"])
|
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:
|
def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None:
|
||||||
registry = TopologyRegistry()
|
registry = TopologyRegistry()
|
||||||
extrude_start = object()
|
extrude_start = object()
|
||||||
@@ -1177,6 +1488,124 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
with self.assertRaisesRegex(ValueError, "requires a preceding body feature"):
|
with self.assertRaisesRegex(ValueError, "requires a preceding body feature"):
|
||||||
validate_semantic_cdsl(invalid_shell_target)
|
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:
|
def test_transformed_owner_selector_executes_a_downstream_fillet(self) -> None:
|
||||||
from cdsl_engine.runtime import rebuild_cdsl
|
from cdsl_engine.runtime import rebuild_cdsl
|
||||||
|
|
||||||
@@ -2303,6 +2732,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
for item in downstream["selector_resolution"]
|
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:
|
def test_sweep_history_falls_back_for_hollow_profiles(self) -> None:
|
||||||
from build123d import Edge, Face, Plane, Vector, Wire
|
from build123d import Edge, Face, Plane, Vector, Wire
|
||||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
||||||
@@ -2572,6 +3035,71 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
|||||||
result = rebuild_cdsl(base, Path(directory) / "local-hole.step")
|
result = rebuild_cdsl(base, Path(directory) / "local-hole.step")
|
||||||
self.assertAlmostEqual(result["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
|
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:
|
def test_counterbore_and_countersink_holes_execute_with_explicit_host_frames(self) -> None:
|
||||||
"""Keep both legacy hole contracts covered by an actual kernel rebuild."""
|
"""Keep both legacy hole contracts covered by an actual kernel rebuild."""
|
||||||
from cdsl_engine.runtime import rebuild_cdsl
|
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
|
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():
|
def test_schema_repair_hint_contains_the_exact_authoring_sketch_form():
|
||||||
assert "diameter_mm" in WorkflowCoordinator._repair_hint(
|
assert "diameter_mm" in WorkflowCoordinator._repair_hint(
|
||||||
"AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile",
|
"AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile",
|
||||||
|
|||||||
@@ -202,6 +202,18 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖
|
strict 仍因 max/p99 surface `0.01 mm` 与体积/面积阈值失败而保留为诊断。单元测试还覆盖
|
||||||
copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member
|
copy、boolean、pattern 与 delete 均清空 lineage;没有能唯一表示的 nested source-member
|
||||||
chain 仍属未完成 contract,继续保留明确诊断。
|
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`
|
2. 在 adapter 层记录每次建模操作的 `preserved`、`modified`、`generated`、`deleted`
|
||||||
topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector
|
topology delta 及 CAP/SWEPT/boolean 等输出角色;runtime 以该记录完成后继 selector
|
||||||
绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体
|
绑定,不以“当前形状中最相近元素”猜测。当前增量已覆盖 OCC 刚体
|
||||||
@@ -232,7 +244,13 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
sweep/loft direct-builder cap role;registry 只会在
|
sweep/loft direct-builder cap role;registry 只会在
|
||||||
relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`,resolver 要求
|
relation 的唯一 result snapshot 上写入 `TopologyRecord.output_roles`,resolver 要求
|
||||||
owner、active body snapshot 与唯一 role 命中,并拒绝 stable ID、geometry、snapshot ID
|
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
|
`LocOpe_DPrism` drafted extrusion 生成该 contract:单一闭合外环、无内环、单向 blind
|
||||||
extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把
|
extent,且拉伸方向与实际端盖法向同向。`extrude_from_face` 直接拉伸该 B-rep 面,不能把
|
||||||
CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨
|
CAP_FACE 还原为原始草图。registry 只会让唯一 OCC `modified`/`preserved` relation 跨
|
||||||
@@ -244,8 +262,99 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差
|
RP 通过(bbox delta `0.0008 mm`、体积相对误差 `0.00020848`、面积相对误差
|
||||||
`0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行
|
`0.00021742`)。`00268467` 的 drafted 内环则明确保留 capability diagnostic 和可执行
|
||||||
前缀,不伪造可绑定 selector。
|
前缀,不伪造可绑定 selector。
|
||||||
3. 建立统一 selector resolver:owner body、source feature、output role、几何签名、
|
`TopologyDeltaRelation` 还必须显式记录 `source_kind` 与 `result_kind`,禁止把 face、edge
|
||||||
snapshot 和唯一性证明。找不到或多解必须稳定诊断。
|
或其它 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 snapshot;resolver 再要求唯一 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 fallback,provenance/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 wires:adapter 将外环和全部内环一次
|
||||||
|
交给 `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 role:lowering
|
||||||
|
只保留一个 direct source-profile edge anchor,resolver 必须逐段验证 `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/17:2 `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/71:7 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、
|
4. 完成草图 region/wire 模型:多轮廓、内环、开口 reference geometry、B-spline/ellipse、
|
||||||
profile query、显式 construction 和退化检测。
|
profile query、显式 construction 和退化检测。
|
||||||
非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且
|
非周期 `skFitSpline` 的两点受限变体现已贯通:仅当端点不同、同时给出两端导数,且
|
||||||
@@ -263,7 +372,62 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
`SWEPT_EDGE`、`OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`,将其物化为可追溯的
|
`SWEPT_EDGE`、`OFFSET_FACE`、`INTERSECT`、`MID_CAP_EDGE`,将其物化为可追溯的
|
||||||
profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、
|
profile/wire,而不是复用原草图或写入样本特例。现有 direct-prism,以及单闭合无内环、
|
||||||
法向同向的 `LocOpe_DPrism` draft CAP_FACE,是受限例外:它们以 builder-proven output
|
法向同向的 `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
|
||||||
|
纵向 contract:source 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/wire:source
|
||||||
|
sketch entity 必须仍对应一个 exact direct-profile edge,query 的 `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 consumer,boundary-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 直接消费一个
|
`OFFSET_FACE` 现有一个独立的受限 profile 物化路径:只有 inward shell 直接消费一个
|
||||||
`new_body` blind additive prism,shell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向
|
`new_body` blind additive prism,shell 恰移除该 prism 的一个 CAP,且 OFFSET query 恰指向
|
||||||
其未修改、单闭合凸线性 profile 中的一条非 construction line,lowering 才根据 source
|
其未修改、单闭合凸线性 profile 中的一条非 construction line,lowering 才根据 source
|
||||||
@@ -302,11 +466,23 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
`INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始
|
`INTERSECT` 的第一条通用受限路径现以 CDSL `planar_imprint` 保存同一草图的原始
|
||||||
analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment side;solver
|
analytic entities、IMPRINT 面侧、可选 INTERSECT vertex order 与 fragment side;solver
|
||||||
仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际
|
仅转换这些精确曲线,adapter 用 OCP `BOPAlgo_Splitter` 切分有界 support face 后选取实际
|
||||||
B-rep region。没有唯一 fragment、选中无界 support-boundary region、construction/source
|
B-rep region。top-level `IMPRINT FACE` 的 multi-face query 只有既有的
|
||||||
alias、split failure 或不完整 query 都稳定拒绝,不把 B-spline/arc 采样成多边形,也不复用
|
`_profile_selection_sketch` 不能将它缩减为单一/圆形/open 专用 profile 时,才进入该 typed
|
||||||
整张草图。该路径覆盖 bounded line/arc/circle/ellipse/B-spline arrangement 的 shared
|
arrangement profile;不能把 nested `INTERSECT` 误当作 outer profile family,也不能抢占
|
||||||
contract,但尚未覆盖不同 sketch、surface/topology producer、trim/copy/pattern 后继或完整
|
existing circle/open/surface IMPRINT contracts。没有唯一 fragment、选中无界 support-boundary
|
||||||
FeatureScript IMPRINT query grammar,因此仍是 P1 部分完成项。工件
|
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 扩展前的历史工件:
|
`output/planar-imprint-intersect-20260909-runtime` 是 CAP_FACE 扩展前的历史工件:
|
||||||
`00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false`,`00071859` 和
|
`00082324` 与当时的 `00835610` 都保留可执行 STEP 但 `rp.passed == false`,`00071859` 和
|
||||||
`00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。
|
`00093912` 分别保留既有 `TopoDS::Solid` 和 revolution-segmentation runtime failure。
|
||||||
@@ -350,6 +526,26 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持
|
作为受限 CDSL output-role selector 供 downstream feature 消费;同一 contract 还支持
|
||||||
independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的
|
independent blind prism CAP_FACE 的 `extrude_from_face`。这些路径仍不能代替完整的
|
||||||
derived-topology selector 语义,CADFS lowering 也尚未以此表示一般 CAP/SWEPT query。
|
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 contract:FeatureScript 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` F2(F1/E2 two-point
|
||||||
|
B-spline)现在 converted/rebuilt 且无 runtime diagnostic,artifact 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
|
2. `shell`:单一 selected solid 的 direct-builder topology delta 与有限 output-role
|
||||||
evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或
|
evidence 已覆盖。CADFS lowering 现额外接受一个受限的 removal selector:直接 blind 或
|
||||||
two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非
|
two-sided linear extrusion 的立即后继 shell,可从同一未修改 source profile 中唯一的、非
|
||||||
@@ -413,7 +609,22 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
都不继承该 contract。该受限路径不覆盖 sweep、surface/partial/fused revolve、
|
都不继承该 contract。该受限路径不覆盖 sweep、surface/partial/fused revolve、
|
||||||
multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一
|
multi-section loft、generated/trimmed/transformed profile 或 boolean/pattern 后继;任一
|
||||||
端点、axis 或 owner 不唯一时必须保留前缀并诊断。内核不能完成时不能伪造较小半径或不同
|
端点、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 contract:lowering 仅接受原始
|
||||||
|
`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 solid;executor 在每次切除后保留原 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 passed,strict 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、实例输出、
|
4. `booleanBodies`、`mirror`、`circularPattern`:显式 source/target bodies、实例输出、
|
||||||
nested pattern、remove/intersect/keep-tools 和 transform 后继。当前 direct circular
|
nested pattern、remove/intersect/keep-tools 和 transform 后继。当前 direct circular
|
||||||
`NEW` COPY -> rigid transform,以及 direct mirror 的 `NEW` source -> `instance 1`
|
`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
|
`output/core-17-cplane-offset-opposite-20260909` 分类保持 3 strict、3 RP、9 rejected、1
|
||||||
rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖
|
rebuild failure、1 comparison timeout。它仍只是 cPlane 的一项通用 frame contract,不覆盖
|
||||||
曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。
|
曲面/曲线 attachment、退化输入或完整 derived-topology plane semantics。
|
||||||
|
`LINE_ANGLE` 现额外覆盖单一 direct `skCircle` edge:FeatureScript 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 feature,pipeline 正确分类为 `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 contract:direct `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 plane;qAdjacent/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 操作
|
### 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
|
2. lowering 仅根据 FeatureScript source 产生该表达,并保留 source feature、selector
|
||||||
和 body provenance。仅有 runtime CDSL contract 而未由 lowering 产生的能力必须标为
|
和 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 evidence;resolver 必须据此证明 derivation policy、operation-wide cardinality
|
||||||
|
和 active member,并输出实际 `resolution_mode` 与 evidence。
|
||||||
4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和
|
4. 能力矩阵记录对应 FeatureScript API/query 的源版本文档或标准库依据、采用的语义和
|
||||||
未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖
|
未覆盖边界;原子语义矩阵包含正向、边界和拒绝测试,且至少多个真实语料样本覆盖
|
||||||
不同几何和生命周期组合。
|
不同几何和生命周期组合。只有 parser/AST 或 resolver unit test 的 query family 只能标为
|
||||||
|
resolver-level/部分完成;必须同时具备 lowering、schema、adapter、consumer 和多个真实样本
|
||||||
|
的端到端证据后才可标为 complete。
|
||||||
5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余
|
5. 受影响核心集、扩展集和全量 shard 有可复现结果,工程相似通过率、失败数和剩余
|
||||||
exception 均更新到本地台账。
|
exception 均更新到本地台账。
|
||||||
6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为
|
6. 代码审查确认没有 sample-specific 分支、gold STEP 参数回填、隐式默认尺寸或为
|
||||||
@@ -518,3 +800,54 @@ instance 生命周期。每项采用或拒绝外部方案的决定、理由和
|
|||||||
全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力”
|
全量目标完成时,报告必须按“工程相似”“严格一致”“source exception”“未实现能力”
|
||||||
和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须
|
和“基础设施失败”分别给出分母、样本 ID、工件和原因。任何仍可执行的模型都必须
|
||||||
保留输出,不能因未满足最终几何目标而丢弃。
|
保留输出,不能因未满足最终几何目标而丢弃。
|
||||||
|
|
||||||
|
## Selector 迁移台账(2026-09-09)
|
||||||
|
|
||||||
|
已完成第一条迁移边界,而非完整 query-family 实现:CDSL schema 允许保留未知的
|
||||||
|
大写 FeatureScript query family;CADFS 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 metadata;COPY/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 1793)F4
|
||||||
|
的四个 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 evidence:parser 逐条保留直接
|
||||||
|
`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 都可 rebuild;combined 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.
|
||||||
@@ -6,6 +6,27 @@ from .featurescript_lexer import Token, lex
|
|||||||
from .ir import Call, FeatureIR, ModelIR, SketchIR
|
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:
|
def _string(value: Any) -> Any:
|
||||||
if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]:
|
if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]:
|
||||||
try:
|
try:
|
||||||
@@ -133,15 +154,15 @@ def _arg_map(call: Call) -> dict[str, Any]:
|
|||||||
def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
|
def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
|
||||||
parser = Parser(source); calls = parser.statements()
|
parser = Parser(source); calls = parser.statements()
|
||||||
version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source)
|
version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source)
|
||||||
standard_library = re.search(
|
standard_library_imports = _standard_library_imports(source)
|
||||||
r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']",
|
primary_standard_library = standard_library_imports[0] if standard_library_imports else {}
|
||||||
source,
|
|
||||||
)
|
|
||||||
model = ModelIR(
|
model = ModelIR(
|
||||||
sample_id,
|
sample_id,
|
||||||
raw_source=source,
|
raw_source=source,
|
||||||
featurescript_version=version.group(1) if version else None,
|
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:
|
for call in calls:
|
||||||
if call.name == "newSketch":
|
if call.name == "newSketch":
|
||||||
|
|||||||
@@ -41,3 +41,5 @@ class ModelIR:
|
|||||||
# selector can be audited against the FeatureScript API contract it used.
|
# selector can be audited against the FeatureScript API contract it used.
|
||||||
featurescript_version: str | None = None
|
featurescript_version: str | None = None
|
||||||
standard_library: 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
File diff suppressed because it is too large
Load Diff
+51
-37
@@ -6,36 +6,38 @@ import re
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def _last_executable_prefix(
|
def _last_executable_prefix(error: Exception, output: Path) -> dict[str, Any] | None:
|
||||||
cdsl: dict[str, Any],
|
"""Export the checkpoint already produced by the failed live replay."""
|
||||||
failed_feature_id: str | None,
|
execution = getattr(error, "incremental_execution", None)
|
||||||
output: Path,
|
bound = getattr(error, "bound_cdsl", None)
|
||||||
) -> dict[str, Any] | None:
|
if execution is None or not isinstance(bound, dict):
|
||||||
"""Bind and export the longest verified prefix before a failed feature."""
|
return None
|
||||||
from .selector_binding import bind_candidate_selectors
|
session = execution.session
|
||||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
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 [])
|
try:
|
||||||
failed_index = next(
|
result = finalize_cdsl_execution(execution, output)
|
||||||
(index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id),
|
except Exception:
|
||||||
len(features),
|
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):
|
if feature_count == 0:
|
||||||
prefix = deepcopy(cdsl)
|
return None
|
||||||
prefix["features"] = features[:feature_count]
|
prefix = deepcopy(bound)
|
||||||
try:
|
prefix["features"] = features[:feature_count]
|
||||||
bound_prefix, _binding = bind_candidate_selectors(prefix)
|
return {
|
||||||
result = rebuild_cdsl(bound_prefix, output, strict=True)
|
"failed_feature_id": getattr(error, "failed_feature_id", None) or _failed_feature_id(error),
|
||||||
except Exception:
|
"feature_count": feature_count,
|
||||||
continue
|
"last_feature_id": last_feature_id,
|
||||||
return {
|
"bound_cdsl": prefix,
|
||||||
"failed_feature_id": failed_feature_id,
|
"result": result,
|
||||||
"feature_count": feature_count,
|
}
|
||||||
"last_feature_id": str(features[feature_count - 1].get("id") or ""),
|
|
||||||
"bound_cdsl": bound_prefix,
|
|
||||||
"result": result,
|
|
||||||
}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _failed_feature_id(error: Exception) -> str | None:
|
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]:
|
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
|
||||||
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
from engine.cdsl_engine.runtime import analyze_cdsl, finalize_cdsl_execution
|
||||||
from .selector_binding import bind_candidate_selectors
|
from .selector_binding import bind_and_execute_candidate_selectors
|
||||||
|
|
||||||
analysis = analyze_cdsl(cdsl)
|
analysis = analyze_cdsl(cdsl)
|
||||||
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
|
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
|
||||||
if not analysis.runtime_eligible:
|
if not analysis.runtime_eligible:
|
||||||
return {"status": "runtime_ineligible", "analysis": analysis_dict}
|
return {"status": "runtime_ineligible", "analysis": analysis_dict}
|
||||||
bound: dict[str, Any] | None = None
|
|
||||||
try:
|
try:
|
||||||
bound, binding = bind_candidate_selectors(cdsl)
|
replay = bind_and_execute_candidate_selectors(cdsl)
|
||||||
result = rebuild_cdsl(bound, output, strict=True)
|
result = finalize_cdsl_execution(replay.execution, output)
|
||||||
return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result}
|
return {
|
||||||
|
"status": "rebuilt",
|
||||||
|
"analysis": analysis_dict,
|
||||||
|
"selector_binding": replay.evidence,
|
||||||
|
"bound_cdsl": replay.bound_cdsl,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
detail = {"type": type(exc).__name__, "message": str(exc)}
|
detail = {"type": type(exc).__name__, "message": str(exc)}
|
||||||
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
|
if hasattr(exc, "selector_resolutions"):
|
||||||
prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output)
|
detail["selector_resolutions"] = exc.selector_resolutions
|
||||||
|
prefix = _last_executable_prefix(exc, output)
|
||||||
result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
|
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
|
if prefix is not None: result["last_executable_prefix"] = prefix
|
||||||
return result
|
return result
|
||||||
|
|||||||
+230
-140
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -95,6 +96,14 @@ def _binding_targets(selector: dict[str, Any]):
|
|||||||
yield selector
|
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]]]:
|
def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
||||||
bound = []
|
bound = []
|
||||||
for record in records:
|
for record in records:
|
||||||
@@ -109,160 +118,241 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]])
|
|||||||
return bound[0], bound
|
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]]]:
|
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
|
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:
|
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
|
||||||
for index, feature in enumerate(bound.get("features") or []):
|
for index, feature in enumerate(bound.get("features") or []):
|
||||||
roots = list(feature.get("selectors") or [])
|
targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)]
|
||||||
for name in ("end_condition", "reverse_end_condition"):
|
if not targets:
|
||||||
condition = (feature.get("params") or {}).get(name)
|
continue
|
||||||
reference = condition.get("reference") if isinstance(condition, dict) else None
|
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]] = {}
|
||||||
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]] = {}
|
|
||||||
|
|
||||||
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
|
prefix_count = index
|
||||||
if binding_feature_id is not None:
|
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)
|
binding_index = next(
|
||||||
if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
|
(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
|
prefix_count = binding_index + 1
|
||||||
if prefix_count not in prefix_cache:
|
if prefix_count not in prefix_cache:
|
||||||
prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count]
|
prefix = deepcopy(bound)
|
||||||
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
|
prefix["features"] = bound["features"][:prefix_count]
|
||||||
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True)
|
if not prefix["features"]:
|
||||||
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
|
raise ValueError(f"{feature['id']}: selector has no executable prefix")
|
||||||
prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id)
|
report = rebuild_cdsl(
|
||||||
all_records, body_id = prefix_cache[prefix_count]
|
prefix,
|
||||||
return [
|
Path(temporary) / f"prefix-{index}-{prefix_count}.step",
|
||||||
item for item in all_records
|
strict=True,
|
||||||
# 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"
|
|
||||||
)
|
)
|
||||||
if output_role:
|
body_id = next(
|
||||||
# Builder output roles are not geometry placeholders. They
|
(item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")),
|
||||||
# remain in the bound CDSL so runtime can resolve the
|
None,
|
||||||
# current active B-rep face through exact kernel history.
|
)
|
||||||
# Replacing one with stable_id/geometry would mix evidence
|
prefix_cache[prefix_count] = (
|
||||||
# and make a stale snapshot appear durable.
|
list(report.get("topology_records") or []),
|
||||||
owner = placeholder.get("owner_feature_id")
|
body_id,
|
||||||
role_source = placeholder.get("output_role_source")
|
list(report.get("topology_deltas") or []),
|
||||||
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
|
return prefix_cache[prefix_count]
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
unique = {selector_key(selector): selector for selector in feature_selectors}
|
resolved: list[dict[str, Any]] = []
|
||||||
feature["selectors"] = list(unique.values())
|
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":
|
if feature.get("atomic_id") == "pattern_mirror":
|
||||||
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
|
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
|
||||||
if len(planes) != 1:
|
if len(planes) != 1:
|
||||||
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
|
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
|
||||||
feature.setdefault("params", {})["mirror_plane"] = planes[0]
|
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
|
return bound, evidence
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -10,7 +10,7 @@ from cadfs_to_cdsl.rebuild import rebuild_candidate
|
|||||||
|
|
||||||
|
|
||||||
class IntegrationTests(unittest.TestCase):
|
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
|
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")
|
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:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
rebuilt = Path(tmp) / "rebuild.step"
|
rebuilt = Path(tmp) / "rebuild.step"
|
||||||
result = rebuild_candidate(cdsl, rebuilt)
|
result = rebuild_candidate(cdsl, rebuilt)
|
||||||
self.assertEqual(result["status"], "rebuilt")
|
self.assertEqual(result["status"], "rebuild_failed")
|
||||||
comparison = compare_steps(gold, rebuilt)
|
self.assertEqual(result["error"]["message"], "f_F2: selector_query_unsupported during incremental replay")
|
||||||
self.assertFalse(comparison["strict"]["passed"])
|
self.assertEqual(result["last_executable_prefix"]["last_feature_id"], "f_F1")
|
||||||
self.assertTrue(comparison["rp"]["passed"])
|
|
||||||
self.assertEqual(comparison["decision"], "approximate_pass")
|
|
||||||
|
|
||||||
def test_standard_tapped_through_counterbore_00002243(self):
|
def test_standard_tapped_through_counterbore_00002243(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
@@ -43,9 +41,30 @@ class IntegrationTests(unittest.TestCase):
|
|||||||
|
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
rebuilt = Path(tmp) / "rebuild.step"
|
rebuilt = Path(tmp) / "rebuild.step"
|
||||||
self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt")
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt)
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
self.assertTrue(comparison["strict"]["passed"])
|
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):
|
def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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")
|
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
|
||||||
self.assertEqual(fillet["atomic_id"], "fillet")
|
self.assertEqual(fillet["atomic_id"], "fillet")
|
||||||
self.assertEqual(fillet["selectors"][0]["kind"], "face")
|
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(
|
self.assertEqual(
|
||||||
fillet["selectors"][0]["geometry"],
|
fillet["selectors"][0]["selector_intent"]["source_entity"],
|
||||||
{"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66},
|
{"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")
|
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.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")
|
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:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
rebuilt = Path(tmp) / "rebuild.step"
|
rebuilt = Path(tmp) / "rebuild.step"
|
||||||
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
runtime_box = outcome["result"]["bbox_mm"]
|
self.assertEqual(outcome["error"]["message"], "f_F5: selector_query_unsupported during incremental replay")
|
||||||
imported_box = import_step(str(rebuilt)).bounding_box()
|
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F4")
|
||||||
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)
|
|
||||||
|
|
||||||
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0078/00789939.txt"
|
feature = root / "featurescript_rp/0078/00789939.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||||
@@ -128,8 +318,10 @@ class IntegrationTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
rebuilt = Path(tmp) / "rebuild.step"
|
rebuilt = Path(tmp) / "rebuild.step"
|
||||||
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
self.assertEqual(outcome["result"]["solid_count"], 2)
|
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):
|
def test_outward_cap_shells_lower_and_rebuild_prefixes(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
@@ -155,7 +347,10 @@ class IntegrationTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step")
|
outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step")
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
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):
|
def test_sweep_00542223_preserves_its_open_bspline_path(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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("F2", [item.get("feature_id") for item in result.diagnostics])
|
||||||
self.assertNotIn("F5", [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):
|
def test_circular_pattern_00542223_preserves_all_sweep_arms(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0054/00542223.txt"
|
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"][0], 52.14101625137762)
|
||||||
self.assertAlmostEqual(bbox["max"][1], 37.5000001000001)
|
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0054/00542223.txt"
|
feature = root / "featurescript_rp/0054/00542223.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||||
@@ -202,53 +433,20 @@ class IntegrationTests(unittest.TestCase):
|
|||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
|
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
|
||||||
|
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
result = outcome["result"]
|
self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
|
||||||
self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]])
|
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6")
|
||||||
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))
|
|
||||||
|
|
||||||
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0011/00111611.txt"
|
feature = root / "featurescript_rp/0011/00111611.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
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
|
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
|
||||||
|
|
||||||
candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl
|
candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl
|
||||||
bound, _ = bind_candidate_selectors(candidate)
|
with self.assertRaisesRegex(ValueError, "f_F5: selector_query_unsupported after prefix rebuild"):
|
||||||
resolved = resolve_required_sketches(
|
bind_candidate_selectors(candidate)
|
||||||
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))
|
|
||||||
|
|
||||||
def test_circular_remove_pattern_replays_cut_sources_00159804(self):
|
def test_circular_remove_pattern_replays_cut_sources_00159804(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ from pathlib import Path
|
|||||||
from cadfs_to_cdsl.compare import compare_steps
|
from cadfs_to_cdsl.compare import compare_steps
|
||||||
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
|
||||||
from cadfs_to_cdsl.ir import Call
|
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.pipeline import compare_one, convert_one, rebuild_one
|
||||||
from cadfs_to_cdsl.rebuild import rebuild_candidate
|
from cadfs_to_cdsl.rebuild import rebuild_candidate
|
||||||
from cadfs_to_cdsl.dataset import Sample
|
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
|
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):
|
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):
|
def test_strict_comparison_status_is_not_labeled_approximate(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
output = Path(tmp); directory = output / "samples" / "strict-status"; directory.mkdir(parents=True)
|
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["center"][1], 0.5)
|
||||||
self.assertAlmostEqual(arc["radius_mm"], math.hypot(2.5, 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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
cases = {
|
cases = {
|
||||||
"00000715": ("f_F2", 2, True),
|
"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)
|
fillet = next(item for item in result.cdsl["features"] if item["id"] == fillet_id)
|
||||||
self.assertEqual(len(fillet["selectors"]), selector_count)
|
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(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"]))
|
if sample_id == "00000715":
|
||||||
self.assertEqual(["curve_type" in item["geometry"] for item in fillet["selectors"]], [line_type] * selector_count)
|
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:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
rebuilt = Path(directory) / "rebuild.step"
|
rebuilt = Path(directory) / "rebuild.step"
|
||||||
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
if sample_id == "00005267":
|
||||||
comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt)
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
self.assertTrue(comparison["strict"]["passed"])
|
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):
|
def test_swept_edge_requires_one_direct_shared_source_endpoint(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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",
|
"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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
for sample_id, dressup_id, selector_count in (
|
for sample_id, dressup_id, selector_count in (
|
||||||
("00048326", "f_F3", 2),
|
("00048326", "f_F3", 2),
|
||||||
@@ -109,14 +222,17 @@ class LoweringTests(unittest.TestCase):
|
|||||||
selector["geometry"].get("curve_type") == "circle"
|
selector["geometry"].get("curve_type") == "circle"
|
||||||
and len(selector["geometry"].get("circle_center_mm") or []) == 3
|
and len(selector["geometry"].get("circle_center_mm") or []) == 3
|
||||||
and selector["geometry"].get("radius_mm", 0) > 0
|
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"]
|
for selector in dressup["selectors"]
|
||||||
))
|
))
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
rebuilt = Path(directory) / "rebuild.step"
|
rebuilt = Path(directory) / "rebuild.step"
|
||||||
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
comparison = compare_steps(root / "step_abc" / sample_id[:4] / f"{sample_id}.step", rebuilt)
|
self.assertEqual(outcome["error"]["message"], f"{dressup_id}: selector_query_unsupported during incremental replay")
|
||||||
self.assertTrue(comparison["strict"]["passed"])
|
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], dressup_id)
|
||||||
|
|
||||||
def test_full_revolve_swept_circle_requires_one_direct_shared_source_endpoint(self):
|
def test_full_revolve_swept_circle_requires_one_direct_shared_source_endpoint(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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",
|
"message": "swept edge source endpoint provenance is unsupported",
|
||||||
}, result.diagnostics)
|
}, 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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
source = root / "featurescript_rp/0011/00112257.txt"
|
source = root / "featurescript_rp/0011/00112257.txt"
|
||||||
result = lower_model(parse_featurescript(source.read_text(), "00112257"), {})
|
result = lower_model(parse_featurescript(source.read_text(), "00112257"), {})
|
||||||
@@ -144,17 +260,16 @@ class LoweringTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(fillet["selectors"]), 5)
|
self.assertEqual(len(fillet["selectors"]), 5)
|
||||||
self.assertTrue(all(
|
self.assertTrue(all(
|
||||||
selector["geometry"].get("curve_type") == "circle"
|
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"]
|
for selector in fillet["selectors"]
|
||||||
))
|
))
|
||||||
|
|
||||||
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
|
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
|
||||||
prefix = dict(result.cdsl)
|
prefix = dict(result.cdsl)
|
||||||
prefix["features"] = result.cdsl["features"][:2]
|
prefix["features"] = result.cdsl["features"][:2]
|
||||||
bound, evidence = bind_candidate_selectors(prefix)
|
with self.assertRaisesRegex(ValueError, "f_F2: selector_query_unsupported after prefix rebuild"):
|
||||||
selectors = next(item for item in bound["features"] if item["id"] == "f_F2")["selectors"]
|
bind_candidate_selectors(prefix)
|
||||||
self.assertEqual(len(evidence), 1)
|
|
||||||
self.assertEqual(len(selectors), 5)
|
|
||||||
self.assertTrue(all(selector.get("snapshot_id") for selector in selectors))
|
|
||||||
|
|
||||||
def test_changed_imprint_profile_does_not_inherit_full_revolve_source_edges(self):
|
def test_changed_imprint_profile_does_not_inherit_full_revolve_source_edges(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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["offset_mm"], 3.0)
|
||||||
self.assertEqual(end["reference"]["kind"], "body")
|
self.assertEqual(end["reference"]["kind"], "body")
|
||||||
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
|
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):
|
def test_up_to_surface_extrude_captures_the_cap_face(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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["type"], "up_to_surface")
|
||||||
self.assertEqual(end["reference"]["kind"], "face")
|
self.assertEqual(end["reference"]["kind"], "face")
|
||||||
self.assertEqual(end["reference"]["owner_feature_id"], "f_F1")
|
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")
|
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")
|
mirror = next(item for item in result.cdsl["features"] if item["id"] == "f_F18")
|
||||||
self.assertTrue(mirror["params"]["mirror_current_body"])
|
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"][0], -12.15)
|
||||||
self.assertAlmostEqual(plane["origin_mm"][1], -24.3 * math.sqrt(3) / 2)
|
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):
|
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)
|
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"), {})
|
result = lower_model(parse_featurescript(source, "open-profile"), {})
|
||||||
@@ -931,7 +1239,7 @@ class LoweringTests(unittest.TestCase):
|
|||||||
[("outer", 25.5), ("inner", 19.0)],
|
[("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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0071/00710855.txt"
|
feature = root / "featurescript_rp/0071/00710855.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
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
|
from cadfs_to_cdsl.rebuild import rebuild_candidate
|
||||||
rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step")
|
rebuilt = rebuild_candidate(result.cdsl, Path(tmp) / "rebuilt.step")
|
||||||
|
|
||||||
self.assertEqual(rebuilt["status"], "rebuilt")
|
self.assertEqual(rebuilt["status"], "rebuild_failed")
|
||||||
chamfer = next(item for item in rebuilt["result"]["feature_results"] if item["feature_id"] == "f_F10")
|
self.assertEqual(rebuilt["error"]["message"], "f_F10: selector_query_unsupported during incremental replay")
|
||||||
self.assertIn("chamfer_surface_limited", [item["code"] for item in chamfer["diagnostics"]])
|
self.assertEqual(rebuilt["last_executable_prefix"]["failed_feature_id"], "f_F10")
|
||||||
self.assertAlmostEqual(rebuilt["result"]["volume_mm3"], 72577.33528261917, places=6)
|
self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F9")
|
||||||
|
|
||||||
def test_fit_spline_loft_lowers_to_executable_loft_add(self):
|
def test_fit_spline_loft_lowers_to_executable_loft_add(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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",
|
"kind": "face", "owner_feature_id": "f_F3", "output_role": "extrude.start",
|
||||||
"source": "runtime_snapshot", "confidence": 1.0,
|
"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")
|
self.assertEqual(selector["selector_intent"]["query_family"], "CAP_FACE")
|
||||||
|
|
||||||
from engine.cdsl_engine.runtime import rebuild_cdsl
|
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
|
# F9 rotates F4 only after F5/F7 have consumed it. It cannot be baked
|
||||||
# into the earlier F4 sketch; the unsupported body continuation stays
|
# into the earlier F4 sketch. The CAP_EDGE geometry is preserved for
|
||||||
# explicit and F1--F7 remains an executable prefix.
|
# diagnostics, but no source-qualified cap-edge lineage exists for
|
||||||
|
# this two-sided profile contract.
|
||||||
prefix = deepcopy(result.cdsl)
|
prefix = deepcopy(result.cdsl)
|
||||||
prefix["features"] = [
|
prefix["features"] = [
|
||||||
item for item in prefix["features"]
|
item for item in prefix["features"]
|
||||||
if item["id"] in {"f_F1", "f_F2", "f_F4", "f_F5", "f_F7"}
|
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:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
rebuilt = rebuild_cdsl(prefix, Path(directory) / "two-sided-cap-prefix.step")
|
rebuilt = rebuild_candidate(prefix, Path(directory) / "two-sided-cap-prefix.step")
|
||||||
self.assertFalse(rebuilt["runtime_diagnostics"])
|
self.assertEqual(rebuilt["status"], "rebuild_failed")
|
||||||
self.assertEqual(
|
self.assertEqual(rebuilt["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
|
||||||
[item["feature_id"] for item in rebuilt["feature_results"]],
|
self.assertEqual(rebuilt["last_executable_prefix"]["last_feature_id"], "f_F5")
|
||||||
["f_F1", "f_F2", "f_F4", "f_F5", "f_F7"],
|
|
||||||
)
|
|
||||||
|
|
||||||
from engine.cdsl_engine.runtime import analyze_cdsl
|
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")
|
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"],
|
["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):
|
def test_direct_translation_transform_updates_the_source_revolve(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0011/00111611.txt"
|
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"]
|
diagnostics = [item for item in result.diagnostics if item.get("operation") == "cPlane"]
|
||||||
self.assertFalse(diagnostics)
|
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):
|
def test_offset_reference_plane_honors_opposite_direction_without_reversing_its_frame(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0077/00777619.txt"
|
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})
|
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0059/00594348.txt"
|
feature = root / "featurescript_rp/0059/00594348.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
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]
|
shell = result.cdsl["features"][shell_index]
|
||||||
self.assertEqual(shell["atomic_id"], "shell")
|
self.assertEqual(shell["atomic_id"], "shell")
|
||||||
self.assertEqual([item["owner_feature_id"] for item in shell["selectors"]], ["f_F1", "f_F1"])
|
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"]))
|
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"]))
|
||||||
prefix = deepcopy(result.cdsl)
|
self.assertTrue(all(item["selector_intent"]["evidence"] == "kernel_history" for item in shell["selectors"]))
|
||||||
prefix["features"] = prefix["features"][:shell_index + 1]
|
self.assertTrue(all(
|
||||||
|
item["selector_intent"]["derivation_policy"] == {"allowed": ["boundary"], "multiplicity": "one"}
|
||||||
|
for item in shell["selectors"]
|
||||||
|
))
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
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(outcome["status"], "rebuilt")
|
||||||
self.assertEqual(
|
resolutions = [
|
||||||
[item["status"] for item in outcome["result"]["feature_results"]],
|
item for item in outcome["result"]["selector_resolution"]
|
||||||
["executed", "executed"],
|
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):
|
def test_shell_offset_face_requires_true_dependency_cap_evidence(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
@@ -1623,13 +2166,14 @@ class LoweringTests(unittest.TestCase):
|
|||||||
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
self.assertTrue(rebuilt.exists())
|
self.assertTrue(rebuilt.exists())
|
||||||
self.assertEqual(outcome["status"], "rebuild_failed")
|
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.assertIn("bound_cdsl", outcome)
|
||||||
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F5")
|
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"]["last_feature_id"], "f_F4")
|
||||||
self.assertEqual(outcome["last_executable_prefix"]["result"]["solid_count"], 1)
|
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0042/00423838.txt"
|
feature = root / "featurescript_rp/0042/00423838.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
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"
|
rebuilt = Path(directory) / "selector-prefix.step"
|
||||||
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
outcome = rebuild_candidate(result.cdsl, rebuilt)
|
||||||
self.assertTrue(rebuilt.exists())
|
self.assertTrue(rebuilt.exists())
|
||||||
self.assertEqual(outcome["status"], "rebuilt")
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
resolved = [
|
self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
|
||||||
item for item in outcome["result"]["selector_resolution"]
|
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6")
|
||||||
if item["feature_id"] == "f_F7"
|
reference = next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
|
||||||
and item["selector"].get("owner_feature_id") == "f_F4.c4.f_F1"
|
self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT")
|
||||||
]
|
self.assertEqual(
|
||||||
self.assertEqual(len(resolved), 2)
|
[item["selector_intent"]["query_family"] for item in reference["intersection_of"][:2]],
|
||||||
self.assertTrue(all(item["status"] == "resolved" for item in resolved))
|
["COPY", "COPY"],
|
||||||
|
)
|
||||||
|
|
||||||
def test_shell_rejects_swept_faces_without_direct_linear_extrude_ownership(self):
|
def test_shell_rejects_swept_faces_without_direct_linear_extrude_ownership(self):
|
||||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
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):
|
def test_cap_face_selector_retains_versioned_provenance_intent(self):
|
||||||
source = SOURCE.replace(
|
source = SOURCE.replace(
|
||||||
|
"FeatureScript 1511;",
|
||||||
|
'FeatureScript 1511;\nimport(path : "onshape/std/geometry.fs", version : "1511.0");',
|
||||||
|
1,
|
||||||
|
).replace(
|
||||||
'\n});\n',
|
'\n});\n',
|
||||||
'\n extrude(context, id + "F2", {"entities":makeQuery(id + "F1.opExtrude", "CAP_FACE", FACE, {"isStart":false}), "depth":10 * mm});\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")
|
self.assertEqual(result.status, "converted_complete")
|
||||||
selector = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")["selectors"][0]
|
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"]["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"]["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")
|
self.assertEqual(selector["selector_intent"]["evidence"], "operation_role")
|
||||||
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
|
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
|
||||||
self.assertTrue(validate_semantic_cdsl(result.cdsl)["future_rebuild_ready"])
|
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):
|
def test_conversion_writes_status_and_sidecars(self):
|
||||||
with tempfile.TemporaryDirectory() as tmp:
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
|
root = Path(tmp); source = root / "00000173.txt"; source.write_text(SOURCE)
|
||||||
|
|||||||
@@ -58,10 +58,16 @@ class ParserTests(unittest.TestCase):
|
|||||||
def test_source_version_and_standard_library_are_retained(self):
|
def test_source_version_and_standard_library_are_retained(self):
|
||||||
source = '''FeatureScript 1511;
|
source = '''FeatureScript 1511;
|
||||||
import(path : "onshape/std/geometry.fs", version : "1511.0");
|
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) {});'''
|
export const f = defineFeature(function(context, id, definition) {});'''
|
||||||
model = parse_featurescript(source, "versioned")
|
model = parse_featurescript(source, "versioned")
|
||||||
self.assertEqual(model.featurescript_version, "1511")
|
self.assertEqual(model.featurescript_version, "1511")
|
||||||
self.assertEqual(model.standard_library, "onshape/std/geometry.fs")
|
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):
|
def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self):
|
||||||
source = r'''
|
source = r'''
|
||||||
|
|||||||
@@ -138,27 +138,45 @@ class SelectorBindingTests(unittest.TestCase):
|
|||||||
self.assertEqual(len(binding["resolved"]), 1)
|
self.assertEqual(len(binding["resolved"]), 1)
|
||||||
self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"])
|
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0010/00107631.txt"
|
feature = root / "featurescript_rp/0010/00107631.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||||
candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {})
|
candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {})
|
||||||
self.assertEqual(candidate.status, "converted_complete")
|
self.assertEqual(candidate.status, "converted_complete")
|
||||||
|
|
||||||
# F2 retains F1's end-cap provenance, but F3 also selects the distinct
|
# F2 consumes its immediate direct CAP_FACE output role. F3 retains
|
||||||
# start-cap plane. Its direct owner has no geometry-qualified active
|
# its exact OFFSET_FACE true-dependency intent, but its separate
|
||||||
# candidate, so binding must consider the unique active F2 face without
|
# start-cap removal selector is deferred and must not rebind
|
||||||
# weakening the exact qualified OFFSET_FACE role.
|
# geometrically.
|
||||||
prefix = deepcopy(candidate.cdsl)
|
prefix = deepcopy(candidate.cdsl)
|
||||||
prefix["features"] = prefix["features"][:3]
|
prefix["features"] = prefix["features"][:3]
|
||||||
bound, evidence = bind_candidate_selectors(prefix)
|
first, offset = prefix["features"][-1]["selectors"]
|
||||||
|
self.assertEqual(first["selector_intent"]["query_family"], "CAP_FACE")
|
||||||
first, offset = bound["features"][-1]["selectors"]
|
self.assertEqual(first["selector_intent"]["derivation_policy"]["multiplicity"], "none")
|
||||||
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)
|
|
||||||
self.assertEqual({key: offset[key] for key in (
|
self.assertEqual({key: offset[key] for key in (
|
||||||
"kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence",
|
"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"]["query_family"], "OFFSET_FACE")
|
||||||
self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency")
|
self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency")
|
||||||
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
|
with self.assertRaisesRegex(ValueError, "f_F3: selector_query_unsupported after prefix rebuild"):
|
||||||
self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1")
|
bind_candidate_selectors(prefix)
|
||||||
self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5")
|
|
||||||
|
|
||||||
# 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:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step")
|
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step")
|
||||||
self.assertEqual(outcome["status"], "rebuild_failed")
|
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||||
self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError")
|
self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay")
|
||||||
self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape")
|
|
||||||
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3")
|
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3")
|
||||||
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2")
|
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"
|
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||||
feature = root / "featurescript_rp/0042/00423838.txt"
|
feature = root / "featurescript_rp/0042/00423838.txt"
|
||||||
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
if not feature.exists(): self.skipTest("CADFS sample is not installed")
|
||||||
candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
|
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"]
|
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]))
|
self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2]))
|
||||||
|
self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT")
|
||||||
bound, evidence = bind_candidate_selectors(candidate.cdsl)
|
|
||||||
reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
|
|
||||||
copy_components = reference["intersection_of"][:2]
|
copy_components = reference["intersection_of"][:2]
|
||||||
self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components))
|
self.assertEqual([item["selector_intent"]["query_family"] for item in copy_components], ["COPY", "COPY"])
|
||||||
self.assertTrue(all(item.get("snapshot_id") for item in copy_components))
|
with self.assertRaisesRegex(ValueError, "f_F7: selector_query_unsupported after prefix rebuild"):
|
||||||
binding = next(item for item in evidence if item["feature_id"] == "f_F7")
|
bind_candidate_selectors(candidate.cdsl)
|
||||||
self.assertGreaterEqual(len(binding["resolved"]), 2)
|
|
||||||
|
|||||||
@@ -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 方式推进,而不是按单一样本修补。
|
||||||
Binary file not shown.
+471
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user