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
|
||||
into the destination stated by the operation contract.
|
||||
|
||||
Literal request language such as `max_z`, `min_z`, `host face`, or a face name
|
||||
is design intent, never a CDSL field or output role. Never emit `host_face`,
|
||||
`max_z`, `min_z`, `max_z_face`, `min_z_face`, `top_face`, or `bottom_face` in
|
||||
any JSON value, including `selector.source`. A selector is only `{kind,
|
||||
source, match}`, and `source` must use an exact role advertised by the producer
|
||||
operation.
|
||||
|
||||
Treat `requirements`, prior documents, and diagnostics as semantic input, not
|
||||
CDSL templates. Their keys are descriptive labels. Copy geometry, values, and
|
||||
relations only into fields allowed by the active schema and operation contract.
|
||||
Thus `host_face: "max_z"` conveys placement intent, never output strings;
|
||||
derive a selector from advertised roles or use explicit coordinates.
|
||||
|
||||
Use millimetres and a right-handed coordinate system unless the request says
|
||||
otherwise. Put every reasonable but unstated design choice in `assumptions`.
|
||||
Do not turn an assumed dimension into a deterministic acceptance target.
|
||||
|
||||
## Modeling Brief
|
||||
|
||||
Before authoring, derive this internal brief from the requirements:
|
||||
|
||||
- part or multi-body intent; explicit dimensions and units;
|
||||
- functional datums, origin, base plane, and positive directions;
|
||||
- primary volumes, holes, pockets, bosses, ribs, patterns, and finishing;
|
||||
- explicitly verifiable targets versus manual targets;
|
||||
- assumptions that do not affect fit, safety, or compliance.
|
||||
|
||||
Dimensioned request facts take precedence over proportions inferred from an
|
||||
image. Ask for clarification only when a missing interface, scale, safety, or
|
||||
compliance value makes construction impossible. Otherwise choose a practical
|
||||
engineering default and record it as an assumption.
|
||||
Identify explicit dimensions, datums, directions, primary volumes, removals,
|
||||
repetitions, finish features, targets, and noncritical assumptions. Explicit
|
||||
dimensions take precedence over inferred proportions. Clarify only when an
|
||||
omitted interface, scale, safety, or compliance value prevents construction;
|
||||
otherwise record a practical default in `assumptions`.
|
||||
|
||||
## Construction Order
|
||||
|
||||
@@ -52,16 +58,74 @@ For through cuts, choose the operation's through extent and make the tool
|
||||
cross the material; never rely on coincident faces or a guessed nearby face.
|
||||
Delay dress-up operations because they can alter downstream topology.
|
||||
|
||||
The selector-source rule is an intentional exception to a generic “all adds,
|
||||
then all cuts” sequence. For example, a bolt circle hosted on an original
|
||||
flange cap must be placed immediately after the base when later additive
|
||||
fusions replace that exact cap. A required final through-cut can still follow
|
||||
all additive features and the earlier bolt operation.
|
||||
## Operation Semantics
|
||||
|
||||
Use only the supplied operation list. Follow each operation's parameter schema
|
||||
exactly, including required sketch state. Do not invent unsupported operation
|
||||
parameters, implicit booleans, or a substitute operation after a capability
|
||||
error.
|
||||
Apply the following rules from the supplied operation contracts. They describe
|
||||
the CDSL language itself and do not prescribe a particular part design.
|
||||
|
||||
### Hard Emission Boundaries
|
||||
|
||||
Do not emit `boolean_bodies` in `cad.author.v1`. Its body operand lifecycle is
|
||||
not expressed by document-local names, so it cannot provide a reliable
|
||||
model-facing construction path. Express additions with an additive operation
|
||||
whose declared result mode supports the intended connected result, and express
|
||||
removals with a cut operation and a sketch.
|
||||
|
||||
Prefer self-contained operations. An unavailable selector makes a hosted
|
||||
operation unavailable even if requested; use a selector-free construction that
|
||||
preserves geometry and record the method limitation. Every selector must pass
|
||||
the exact-role and active-source checks.
|
||||
|
||||
- A primitive axis starts at `origin_mm` and grows in the supplied positive
|
||||
`direction`. Heights and distances are positive magnitudes. A direction of
|
||||
`[0,0,-1]` therefore models travel toward `-Z` without a negative height.
|
||||
- A sketch has exactly one `profile`. A circle is one closed contour and a
|
||||
polygon is one simple, non-self-intersecting closed loop. Never concatenate
|
||||
disconnected circles or multiple polygon loops into one profile, and never
|
||||
repeat a loop's start point to begin another loop.
|
||||
- Model repeats as one valid seed plus `pattern_circular` or `pattern_linear`;
|
||||
`source_feature_ids` contains local names and the count includes the seed.
|
||||
If a repeated removal has no live host selector, use a selector-free cut seed
|
||||
plus circular `operation_mode: "remove"`. Otherwise use one valid feature
|
||||
per unrelated instance with its true dependency.
|
||||
- Treat every operation schema as independent. Do not copy a parameter name,
|
||||
enum value, or field shape from a similar operation into another operation;
|
||||
emit only fields present in that feature's supplied `params_schema`.
|
||||
- To extend an already connected solid, use an additive operation with the
|
||||
explicit `result_mode: "fuse"` when its contract provides that mode. The new
|
||||
profile must intersect the prior solid, and the feature depends on the prior
|
||||
feature. Do not turn touching or overlapping primitive additions into
|
||||
independent bodies followed by a boolean union.
|
||||
- A primitive operation whose authoring schema has no `result_mode: "fuse"`
|
||||
must be treated as a body-producing operation, not as a guaranteed extension
|
||||
of an existing connected result. Use such an operation as an initial or
|
||||
explicitly independent construction only. When the requested result must be
|
||||
one connected body, continue it through an additive operation that explicitly
|
||||
supports fusion rather than joining primitive outputs later with a boolean.
|
||||
- Use a through-cut operation with a profile that intersects the material for
|
||||
a through removal. Build all intended material before a removal that must
|
||||
cross it. Do not make a temporary tool body and call `boolean_bodies` for a
|
||||
removal expressible as a cut operation.
|
||||
- Do not introduce an auxiliary feature solely to construct or delete a tool
|
||||
for an operation that the contract already represents directly. A deletion
|
||||
is a state-changing operation, never a no-op cleanup step, and downstream
|
||||
dependencies must not rely on deleted geometry.
|
||||
- Use a selector-hosted operation only when its required source output role is
|
||||
available and the target geometry unambiguously lies on that output. Supply
|
||||
every required selector and parameter from the operation contract; otherwise
|
||||
choose a self-contained operation whose coordinates express the intent.
|
||||
- A face-hosted extrusion reuses its host boundary. For any different boundary,
|
||||
use a sketch-profile operation at an explicit workplane.
|
||||
- Selector provenance is live. Do not select a source changed by an intervening
|
||||
fuse, cut, shell, pattern, or finish. A through-cut has no cap role for a
|
||||
later host; schedule host-dependent features before it or use coordinates.
|
||||
|
||||
For `max_z`/`min_z` placement intent, use an owner role such as
|
||||
`source_feature.top_planar_face` or `source_feature.bottom_planar_face`, never
|
||||
`max_z_face` or `min_z_face`; prefer explicit coordinates when available.
|
||||
|
||||
Use only supplied operations and their exact parameter schemas. Do not invent
|
||||
parameters, implicit booleans, or substitutes after a capability error.
|
||||
|
||||
## Sketches And Coordinates
|
||||
|
||||
@@ -95,16 +159,15 @@ other key inside an Authoring sketch. The compiler derives Runtime radius and
|
||||
sketch identity. Use a primitive such as `cylinder_add` when its axis, radius,
|
||||
and height directly express the requested geometry and no sketch is needed.
|
||||
|
||||
Name features for their manufacturing role, for example `base_plate`,
|
||||
`front_hub_boss`, `center_bore`, and `bolt_holes`. Names make dependencies and
|
||||
Name features for their geometric role, for example `primary_add`,
|
||||
`secondary_add`, `through_cut`, and `repeated_cut`. Names make dependencies and
|
||||
repair diagnostics readable; they are not server identities.
|
||||
|
||||
## Selectors
|
||||
|
||||
The operation metadata states whether `selectors` are required, their kind,
|
||||
cardinality, and server-side destination. Put only declarative selectors in a
|
||||
feature's `selectors` array. Never write the destination field itself inside
|
||||
`params`.
|
||||
Operation metadata defines selector need, kind, cardinality, and server-side
|
||||
destination. Put only declarative selectors in `selectors`, never the
|
||||
destination field in `params`.
|
||||
|
||||
For an output face, use an exact local role embedded in `source` and state a
|
||||
unique match:
|
||||
@@ -112,54 +175,59 @@ unique match:
|
||||
```json
|
||||
{
|
||||
"kind": "face",
|
||||
"source": "front_hub_boss.top_planar_face",
|
||||
"source": "source_feature.top_planar_face",
|
||||
"match": "unique"
|
||||
}
|
||||
```
|
||||
|
||||
Do not add `role`, `query`, `host_face`, a face index, a coordinate selector,
|
||||
or a Runtime selector token. The `source` value is the complete local intent.
|
||||
The compiler adds its source feature as an auditable graph dependency; include
|
||||
other true construction dependencies in `depends_on` yourself.
|
||||
Do not add `role`, `query`, `host_face`, a face index, coordinate selector, or
|
||||
Runtime token. The compiler adds the selector source dependency; declare other
|
||||
true construction dependencies yourself.
|
||||
|
||||
`top_planar_face` and `end_face` mean the positive-direction cap of a supported
|
||||
extrude, sweep, loft, or cylinder. `bottom_planar_face` and `start_face` mean
|
||||
the opposite cap. Select the most recent feature whose output is known to be
|
||||
the required host; do not select a similar face by location, face index, or
|
||||
proximity.
|
||||
`top_planar_face`/`end_face` mean a positive-direction cap;
|
||||
`bottom_planar_face`/`start_face` mean the opposite cap. Select an exact known
|
||||
host, never a similar face by location, index, or proximity.
|
||||
|
||||
Translate a request's descriptive `max_z`/`min_z` wording into these output
|
||||
roles before writing CDSL. Never emit `base.max_z_face` or `base.min_z_face`.
|
||||
For a cylinder built in `+Z`, the top cap is `top_planar_face` and the bottom
|
||||
cap is `bottom_planar_face`; reverse-direction features swap their world-Z
|
||||
position but retain their own start/end roles.
|
||||
For a cap, `source` MUST match
|
||||
`^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$`.
|
||||
No other role spelling exists in Authoring CDSL. If the producer does not
|
||||
advertise one of these roles, it cannot be the selector source.
|
||||
|
||||
`hole_wizard` requires exactly one `face` selector. It uses that selector as
|
||||
its host face, so a central bore on a hub should select the hub cap, while a
|
||||
bolt circle on an exposed flange should select the flange cap. A selector must
|
||||
name a host that contains every requested hole position. If that cannot be
|
||||
made unique, redesign the feature sequence or omit the unsupported feature;
|
||||
never guess a face.
|
||||
Translate descriptive maximum/minimum placement to an appropriate cap role;
|
||||
never copy it into `source`. A `+Z` cylinder uses `top_planar_face` for its
|
||||
top cap and `bottom_planar_face` for its bottom cap.
|
||||
|
||||
Before creating a hosted hole, calculate each position against the actual host
|
||||
face. A local boss can be the global highest face while being too small to host
|
||||
a larger bolt circle. In that case use the exposed flange cap at the bolt
|
||||
radius, with its own plane height, rather than the global maximum-Z cap. Place
|
||||
the hosted holes while that source cap's exact provenance is still active:
|
||||
before a later fusion or cut would split, remove, or replace it. A final
|
||||
through-cut may still follow all additive features, so a bolt circle can be
|
||||
hosted before an unrelated final boss and before that final cut.
|
||||
`hole_wizard` requires one `face` selector. It must be unique, planar, active,
|
||||
and contain every position. Execute it before its source is replaced; otherwise
|
||||
use a supported self-contained construction or report the limitation.
|
||||
|
||||
## Acceptance And Repair
|
||||
|
||||
Describe only user-requested, measurable acceptance targets in
|
||||
`acceptance_targets`; leave inferred dimensions in `assumptions`. On repair,
|
||||
return a complete replacement document. Preserve feature names and every
|
||||
feature listed in `executed_feature_ids`, except a feature explicitly named by
|
||||
the diagnostic. Features that were never executed may be changed freely to
|
||||
repair an invalid selector, parameter, dependency, or geometry construction.
|
||||
Put only user-requested measurable targets in `acceptance_targets`; leave
|
||||
inferred dimensions in `assumptions`. On repair return a complete document,
|
||||
preserve executed features unless diagnosed, and change unexecuted features
|
||||
only as needed. Read diagnostics literally; do not add IDs, weaken requested
|
||||
values, silently delete a failed feature, or guess a selector.
|
||||
|
||||
Read structured diagnostics literally. Fix their named cause with the smallest
|
||||
document change, then return the entire document. Do not add internal IDs,
|
||||
weaken a requested value, silently delete a failed feature, or replace a
|
||||
failed selector with an arbitrary topology element.
|
||||
## Pre-Emission Check
|
||||
|
||||
Before returning the document, check every feature against these invariants:
|
||||
|
||||
1. The operation is in the supplied whitelist, its params use only the
|
||||
supplied closed schema, and its sketch/selector presence matches the
|
||||
operation contract. The document contains no `boolean_bodies` feature.
|
||||
2. Each reference is a local name, each dependency is necessary, and no
|
||||
operation relies on a deleted, transient, or merely assumed body.
|
||||
3. Each sketch contains one valid profile and a complete right-handed
|
||||
workplane; each dimension is positive where the contract requires a
|
||||
magnitude.
|
||||
4. Each selector source has the exact `<feature>.<output_role>` form. Its role
|
||||
is one advertised by that producer operation, not a descriptive synonym,
|
||||
coordinate extreme, or inferred face name. For a cap, verify the required
|
||||
four-role source regex character-for-character.
|
||||
5. Every requested removal intersects its intended material, every repeated
|
||||
feature has a valid seed reference, and every connected addition uses an
|
||||
operation whose contract explicitly supports the chosen result mode.
|
||||
|
||||
If any invariant is false, revise the construction before emitting the single
|
||||
complete `cad.author.v1` document.
|
||||
|
||||
@@ -172,7 +172,16 @@ class WorkflowCoordinator:
|
||||
for atomic_id in self.runtime.supported_atomic_ids()
|
||||
}
|
||||
repair_instruction = "" if state.repair_count == 0 else "Return a complete replacement document. Preserve only features confirmed in executed_feature_ids unless the diagnostic identifies that feature. Features that did not execute may be corrected. Keep local names unless the diagnostic identifies a name conflict. Never add IDs, stable selectors, snapshots, or tokens."
|
||||
content = json.dumps({"requirements": requirements, "supported_operations": operation_schemas, "previous_authoring": previous, "diagnostics": diagnostics}, ensure_ascii=False)
|
||||
content = json.dumps({
|
||||
"requirements": self._authoring_requirements_context(requirements),
|
||||
"supported_operations": operation_schemas,
|
||||
"authoring_selector_contract": {
|
||||
"face_cap_source_pattern": r"^[a-z][a-z0-9_]{0,63}\.(top_planar_face|bottom_planar_face|end_face|start_face)$",
|
||||
"face_cap_roles": ["top_planar_face", "bottom_planar_face", "end_face", "start_face"],
|
||||
},
|
||||
"previous_authoring": previous,
|
||||
"diagnostics": diagnostics,
|
||||
}, ensure_ascii=False)
|
||||
try:
|
||||
raw = await self._tool_call(
|
||||
task_id, author, "write_authoring_cdsl", AuthoringDocument.model_json_schema(),
|
||||
@@ -343,6 +352,20 @@ class WorkflowCoordinator:
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _authoring_requirements_context(requirements: dict[str, Any]) -> dict[str, list[str]]:
|
||||
"""Pass requirement meaning to the author without leaking analysis field names.
|
||||
|
||||
``acceptance_targets.expected`` is intentionally an open-ended
|
||||
reporting record. It may contain descriptive keys from a user request,
|
||||
while Authoring CDSL has a closed protocol. Passing it through verbatim
|
||||
invites a model to treat analysis labels as output fields.
|
||||
"""
|
||||
return {
|
||||
key: [item for item in requirements.get(key, []) if isinstance(item, str)]
|
||||
for key in ("explicit_requirements", "assumptions", "manual_targets")
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_repair_document(
|
||||
previous: dict[str, Any], replacement: dict[str, Any], diagnostics: dict[str, Any] | None,
|
||||
|
||||
@@ -9,17 +9,18 @@ from build123d import AngularDirection, Axis, Compound, Edge, Face, Location, Pl
|
||||
from OCP.BOPAlgo import BOPAlgo_Splitter
|
||||
from OCP.BRepAlgoAPI import BRepAlgoAPI_Common, BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.BRepExtrema import BRepExtrema_DistShapeShape
|
||||
from OCP.BRepFilletAPI import BRepFilletAPI_MakeChamfer, BRepFilletAPI_MakeFillet
|
||||
from OCP.BRepOffset import BRepOffset_Skin
|
||||
from OCP.BRepOffsetAPI import BRepOffsetAPI_MakePipeShell, BRepOffsetAPI_MakeThickSolid, BRepOffsetAPI_ThruSections
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_Transform
|
||||
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_MakeWire, BRepBuilderAPI_Transform
|
||||
from OCP.BRepPrimAPI import BRepPrimAPI_MakeCylinder, BRepPrimAPI_MakePrism, BRepPrimAPI_MakeRevol
|
||||
from OCP.Geom import Geom_SurfaceOfRevolution
|
||||
from OCP.GeomAbs import GeomAbs_Arc
|
||||
from OCP.LocOpe import LocOpe_DPrism
|
||||
from OCP.ShapeUpgrade import ShapeUpgrade_ShapeDivideAngle
|
||||
from OCP.TopAbs import TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopAbs import TopAbs_EDGE, TopAbs_FACE, TopAbs_SHELL
|
||||
from OCP.TopExp import TopExp_Explorer
|
||||
from OCP.TopTools import TopTools_ListOfShape
|
||||
from OCP.TopoDS import TopoDS
|
||||
@@ -30,7 +31,7 @@ from .parametric_gears import build_gear_solid, build_rack_solid
|
||||
from .parametric_thread import build_thread_solid
|
||||
from .runtime_types import (
|
||||
AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec,
|
||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, Vector3,
|
||||
TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologySectionRelation, Vector3,
|
||||
canonical_plane_signature,
|
||||
)
|
||||
from .topology_export import (
|
||||
@@ -45,6 +46,74 @@ def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
|
||||
return Vector(float(value[0]), float(value[1]), float(value[2]))
|
||||
|
||||
|
||||
def make_interpolated_bspline_edge(
|
||||
points: list[Vector3],
|
||||
*,
|
||||
start_tangent: Vector3 | None = None,
|
||||
end_tangent: Vector3 | None = None,
|
||||
periodic: bool = False,
|
||||
parameters: list[float] | None = None,
|
||||
) -> Edge:
|
||||
"""Build the exact non-scaling interpolator used by CDSL sketch edges.
|
||||
|
||||
FeatureScript ``skFitSpline`` lowering retains its centripetal parameters
|
||||
and endpoint derivatives. Keep this construction in one kernel helper so
|
||||
geometry execution and consumers that need a source-curve differential do
|
||||
not accidentally use a chord or a separately parameterized interpolator.
|
||||
Callers remain responsible for their own CDSL contract validation.
|
||||
"""
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("interpolated B-spline requires both endpoint tangents")
|
||||
return Edge.make_spline(
|
||||
[_vector(point) for point in points],
|
||||
tangents=(
|
||||
[_vector(start_tangent), _vector(end_tangent)]
|
||||
if start_tangent is not None else None
|
||||
),
|
||||
periodic=periodic,
|
||||
parameters=parameters,
|
||||
scale=False,
|
||||
)
|
||||
|
||||
|
||||
def interpolated_bspline_point_and_tangent(
|
||||
points: list[Vector3],
|
||||
*,
|
||||
start_tangent: Vector3,
|
||||
end_tangent: Vector3,
|
||||
parameters: list[float],
|
||||
interpolation_index: int,
|
||||
) -> tuple[Vector3, Vector3]:
|
||||
"""Evaluate a CDSL interpolation point with the runtime's OCC curve.
|
||||
|
||||
``Edge.tangent_at`` accepts normalized edge positions, which need not be
|
||||
the explicit interpolation parameters. Use ``BRepAdaptor_Curve.D1`` at
|
||||
the source parameter and return the actual point as well, allowing the
|
||||
caller to prove that OCC still interpolated the named source vertex.
|
||||
"""
|
||||
if interpolation_index < 0 or interpolation_index >= len(points):
|
||||
raise ValueError("B-spline interpolation index is out of range")
|
||||
if len(parameters) != len(points):
|
||||
raise ValueError("B-spline interpolation parameters must match points")
|
||||
if not all(math.isfinite(float(value)) for value in parameters):
|
||||
raise ValueError("B-spline interpolation parameters must be finite")
|
||||
if any(float(right) <= float(left) for left, right in zip(parameters, parameters[1:])):
|
||||
raise ValueError("B-spline interpolation parameters must be strictly increasing")
|
||||
edge = make_interpolated_bspline_edge(
|
||||
points,
|
||||
start_tangent=start_tangent,
|
||||
end_tangent=end_tangent,
|
||||
parameters=parameters,
|
||||
)
|
||||
point = gp_Pnt()
|
||||
tangent = gp_Vec()
|
||||
BRepAdaptor_Curve(edge.wrapped).D1(float(parameters[interpolation_index]), point, tangent)
|
||||
return (
|
||||
(float(point.X()), float(point.Y()), float(point.Z())),
|
||||
(float(tangent.X()), float(tangent.Y()), float(tangent.Z())),
|
||||
)
|
||||
|
||||
|
||||
def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
|
||||
# 计算圆弧中点(配合 Edge.make_three_point_arc 三点画弧),支持显式法向与顺时针/逆时针方向。
|
||||
# 1. 半径:优先取 edge.radius_mm,缺省时由圆心到起点的距离推算。
|
||||
@@ -96,7 +165,7 @@ class Build123dGeometryAdapter:
|
||||
return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
|
||||
|
||||
@staticmethod
|
||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||||
def _wire_edges(edges: list[dict[str, Any]]) -> list[Edge]:
|
||||
# 将边字典列表(直线/圆弧/椭圆/插值 B 样条)组装成 build123d 的 Wire 线框。
|
||||
built: list[Edge] = []
|
||||
for edge in edges:
|
||||
@@ -137,12 +206,12 @@ class Build123dGeometryAdapter:
|
||||
raise ValueError("two-point bspline contour edge parameters must be finite and strictly increasing")
|
||||
else:
|
||||
parameter_values = [float(value) for value in parameters] if parameters is not None else None
|
||||
built.append(Edge.make_spline(
|
||||
points,
|
||||
tangents=[_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None,
|
||||
built.append(make_interpolated_bspline_edge(
|
||||
[(point.X, point.Y, point.Z) for point in points],
|
||||
start_tangent=start_tangent,
|
||||
end_tangent=end_tangent,
|
||||
periodic=bool(edge.get("periodic")),
|
||||
parameters=parameter_values,
|
||||
scale=False,
|
||||
))
|
||||
continue
|
||||
if edge.get("type") == "ellipse":
|
||||
@@ -163,7 +232,203 @@ class Build123dGeometryAdapter:
|
||||
else:
|
||||
# 直线边:直接连接首尾。
|
||||
built.append(Edge.make_line(start, end))
|
||||
return Wire(built)
|
||||
return built
|
||||
|
||||
@staticmethod
|
||||
def _wire(edges: list[dict[str, Any]]) -> Wire:
|
||||
return Wire(Build123dGeometryAdapter._wire_edges(edges))
|
||||
|
||||
@staticmethod
|
||||
def _wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Wire, list[tuple[str, Edge]]]:
|
||||
"""Construct one wire and retain only explicit one-to-one source edges."""
|
||||
built = Build123dGeometryAdapter._wire_edges(edges)
|
||||
return Wire(built), [
|
||||
(str(edge["source_entity_id"]), built[index])
|
||||
for index, edge in enumerate(edges)
|
||||
if isinstance(edge.get("source_entity_id"), str) and edge["source_entity_id"]
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _direct_wire_with_source_edges(edges: list[dict[str, Any]]) -> tuple[Any, list[tuple[str, Edge]]]:
|
||||
"""Build one direct wire and retain the builder's exact source edges.
|
||||
|
||||
``Wire(built)`` may repair shared vertices by replacing individual
|
||||
edges. ``BRepBuilderAPI_MakeWire.Edge()`` exposes each repaired edge at
|
||||
insertion time. The caller must still prove those handles are members
|
||||
of the final face before registering a source anchor.
|
||||
"""
|
||||
wire_builder = BRepBuilderAPI_MakeWire()
|
||||
source_edges: list[tuple[str, Edge]] = []
|
||||
for definition, edge in zip(edges, Build123dGeometryAdapter._wire_edges(edges)):
|
||||
wire_builder.Add(edge.wrapped)
|
||||
if not wire_builder.IsDone():
|
||||
raise ValueError("analytic contour wire construction failed")
|
||||
source_entity_id = definition.get("source_entity_id")
|
||||
if isinstance(source_entity_id, str) and source_entity_id:
|
||||
source_edges.append((source_entity_id, Edge.cast(wire_builder.Edge())))
|
||||
return wire_builder.Wire(), source_edges
|
||||
|
||||
def _face_from_direct_wires(
|
||||
self,
|
||||
outer: list[dict[str, Any]],
|
||||
holes: Iterable[list[dict[str, Any]]] = (),
|
||||
*,
|
||||
logical_circle_sources: dict[str, dict[str, Any]] | None = None,
|
||||
plane_spec: PlaneSpec | None = None,
|
||||
) -> tuple[Face, list[tuple[str, Edge]]]:
|
||||
"""Build one direct profile face with exact outer and hole wire handles.
|
||||
|
||||
Constructing a face and then calling ``Face.make_holes`` can replace
|
||||
linear outer-wire subshapes. A direct ``BRepBuilderAPI_MakeFace``
|
||||
instead gives the face builder every source wire, so a later ``IsSame``
|
||||
membership check can establish provenance without geometry matching.
|
||||
"""
|
||||
outer_wire, source_edges = self._direct_wire_with_source_edges(outer)
|
||||
face_builder = BRepBuilderAPI_MakeFace(outer_wire, True)
|
||||
for hole in holes:
|
||||
hole_wire, hole_source_edges = self._direct_hole_wire_with_source_edges(
|
||||
hole,
|
||||
logical_circle_sources=logical_circle_sources,
|
||||
plane_spec=plane_spec,
|
||||
)
|
||||
# OCC requires an inner wire to have the opposite orientation to
|
||||
# its outer boundary. ``Wire.Reversed`` returns a generic shape,
|
||||
# so cast it back to TopoDS_Wire for the face-builder API.
|
||||
face_builder.Add(TopoDS.Wire_s(hole_wire.Reversed()))
|
||||
source_edges.extend(hole_source_edges)
|
||||
if not face_builder.IsDone():
|
||||
raise ValueError("analytic contour face construction failed")
|
||||
return Face.cast(face_builder.Face()), source_edges
|
||||
|
||||
def _face_from_direct_wire(self, edges: list[dict[str, Any]]) -> tuple[Face, list[tuple[str, Edge]]]:
|
||||
"""Build a direct face with no inner wires for existing callers."""
|
||||
return self._face_from_direct_wires(edges)
|
||||
|
||||
def _direct_hole_wire_with_source_edges(
|
||||
self,
|
||||
edges: list[dict[str, Any]],
|
||||
*,
|
||||
logical_circle_sources: dict[str, dict[str, Any]] | None,
|
||||
plane_spec: PlaneSpec | None,
|
||||
) -> tuple[Any, list[tuple[str, Edge]]]:
|
||||
"""Restore one solver-split source circle only from explicit provenance.
|
||||
|
||||
``analytic_contours`` decomposes a circle into four arcs to classify
|
||||
regions. Those arcs are not source edges. The solver records one
|
||||
logical-circle source marker on every generated arc, so a complete
|
||||
marked loop may be rebuilt as one native circle wire. Any missing,
|
||||
mixed, or unregistered marker uses the ordinary direct wire path and
|
||||
therefore retains no invented source edge.
|
||||
"""
|
||||
logical_ids = {
|
||||
edge.get("logical_circle_source_entity_id")
|
||||
for edge in edges
|
||||
if isinstance(edge.get("logical_circle_source_entity_id"), str)
|
||||
and edge["logical_circle_source_entity_id"]
|
||||
}
|
||||
if (
|
||||
len(edges) == 4
|
||||
and len(logical_ids) == 1
|
||||
and all(edge.get("logical_circle_source_entity_id") in logical_ids for edge in edges)
|
||||
and logical_circle_sources is not None
|
||||
and plane_spec is not None
|
||||
):
|
||||
source_entity_id = next(iter(logical_ids))
|
||||
source = logical_circle_sources.get(source_entity_id)
|
||||
center = source.get("center") if isinstance(source, dict) else None
|
||||
radius = source.get("radius_mm") if isinstance(source, dict) else None
|
||||
if (
|
||||
isinstance(center, list)
|
||||
and len(center) >= 2
|
||||
and isinstance(radius, (int, float))
|
||||
and math.isfinite(float(radius))
|
||||
and float(radius) > 0
|
||||
):
|
||||
wire = self._circle_wire([float(center[0]), float(center[1])], float(radius), plane_spec)
|
||||
return wire.wrapped, [(source_entity_id, edge) for edge in wire.edges()]
|
||||
return self._direct_wire_with_source_edges(edges)
|
||||
|
||||
@staticmethod
|
||||
def _logical_circle_sources(profile: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Index uniquely named, unsplit circle source entities from one profile."""
|
||||
sources: dict[str, dict[str, Any]] = {}
|
||||
duplicates: set[str] = set()
|
||||
for contour in profile.get("contours") or []:
|
||||
if not isinstance(contour, dict):
|
||||
continue
|
||||
for segment in contour.get("segments") or []:
|
||||
if not isinstance(segment, dict) or segment.get("type") != "circle":
|
||||
continue
|
||||
source_entity_id = segment.get("source_entity_id")
|
||||
if not isinstance(source_entity_id, str) or not source_entity_id:
|
||||
continue
|
||||
if source_entity_id in sources:
|
||||
duplicates.add(source_entity_id)
|
||||
else:
|
||||
sources[source_entity_id] = segment
|
||||
for source_entity_id in duplicates:
|
||||
sources.pop(source_entity_id, None)
|
||||
return sources
|
||||
|
||||
@staticmethod
|
||||
def _face_source_anchor_specs(
|
||||
face: Face,
|
||||
source_edges: list[tuple[str, Edge]],
|
||||
source_sketch_id: str | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Bind direct source labels to the exact face boundary subshapes.
|
||||
|
||||
The caller retains the wire edges created for this profile. A source
|
||||
label is emitted only when it has one exact final face edge; there is
|
||||
no geometry-based reconstruction when splitting, trimming, or wire
|
||||
construction changes that one-to-one relationship.
|
||||
"""
|
||||
if not isinstance(source_sketch_id, str) or not source_sketch_id:
|
||||
return []
|
||||
actual_edges = list(face.edges())
|
||||
mapped: list[tuple[str, Edge]] = []
|
||||
source_counts: dict[str, int] = {}
|
||||
for source_entity_id, _edge in source_edges:
|
||||
source_counts[source_entity_id] = source_counts.get(source_entity_id, 0) + 1
|
||||
for source_entity_id, source_edge in source_edges:
|
||||
if source_counts[source_entity_id] != 1:
|
||||
continue
|
||||
matches = [
|
||||
edge for edge in actual_edges
|
||||
if edge.wrapped.IsSame(source_edge.wrapped)
|
||||
]
|
||||
if len(matches) == 1:
|
||||
mapped.append((source_entity_id, matches[0]))
|
||||
specs: list[dict[str, Any]] = [
|
||||
{
|
||||
"kind": "edge",
|
||||
"value": edge,
|
||||
"source_entity": (source_sketch_id, source_entity_id),
|
||||
}
|
||||
for source_entity_id, edge in mapped
|
||||
]
|
||||
vertex_groups: list[tuple[Any, set[str]]] = []
|
||||
for source_entity_id, edge in mapped:
|
||||
for vertex in edge.vertices():
|
||||
group = next(
|
||||
(candidate for candidate in vertex_groups if candidate[0].wrapped.IsSame(vertex.wrapped)),
|
||||
None,
|
||||
)
|
||||
if group is None:
|
||||
vertex_groups.append((vertex, {source_entity_id}))
|
||||
else:
|
||||
group[1].add(source_entity_id)
|
||||
for vertex, entity_ids in vertex_groups:
|
||||
if len(entity_ids) < 2:
|
||||
continue
|
||||
specs.append({
|
||||
"kind": "vertex",
|
||||
"value": vertex,
|
||||
"source_entities": tuple(
|
||||
(source_sketch_id, entity_id) for entity_id in sorted(entity_ids)
|
||||
),
|
||||
})
|
||||
return specs
|
||||
|
||||
def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
|
||||
# 在草图工作平面上,按局部二维圆心与半径生成整圆 Wire(圆心由工作平面原点 + x/y 方向线性组合得到)。
|
||||
@@ -211,6 +476,59 @@ class Build123dGeometryAdapter:
|
||||
faces.append(face.make_holes(holes) if holes else face)
|
||||
return faces
|
||||
|
||||
def _faces_from_circles_with_source_anchors(
|
||||
self,
|
||||
entities: list[dict[str, Any]],
|
||||
plane_spec: PlaneSpec,
|
||||
source_sketch_id: str | None,
|
||||
) -> tuple[list[Face], list[dict[str, Any]]]:
|
||||
"""Build direct circular profile faces while retaining their wire edges."""
|
||||
circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
|
||||
if not circles:
|
||||
return [], []
|
||||
entries = []
|
||||
for item in circles:
|
||||
radius = float(item.get("radius_mm") or 0)
|
||||
if radius <= 0:
|
||||
continue
|
||||
center = [float(value) for value in item.get("center") or [0, 0]]
|
||||
entries.append({
|
||||
"center": center,
|
||||
"radius": radius,
|
||||
"wire": self._circle_wire(center, radius, plane_spec),
|
||||
"source_entity_id": item.get("source_entity_id"),
|
||||
})
|
||||
faces: list[Face] = []
|
||||
anchors: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
containing = sum(
|
||||
math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
|
||||
for other in entries
|
||||
if other is not entry
|
||||
)
|
||||
if containing % 2:
|
||||
continue
|
||||
holes = [
|
||||
other for other in entries
|
||||
if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
|
||||
and sum(
|
||||
math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
|
||||
for candidate in entries
|
||||
if candidate is not other
|
||||
) == containing + 1
|
||||
]
|
||||
face = Face(entry["wire"])
|
||||
result = face.make_holes([item["wire"] for item in holes]) if holes else face
|
||||
source_edges = [
|
||||
(str(item["source_entity_id"]), edge)
|
||||
for item in [entry, *holes]
|
||||
if isinstance(item.get("source_entity_id"), str) and item["source_entity_id"]
|
||||
for edge in item["wire"].edges()
|
||||
]
|
||||
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
|
||||
faces.append(result)
|
||||
return faces, anchors
|
||||
|
||||
@staticmethod
|
||||
def _split_images(splitter: BOPAlgo_Splitter, edge: Edge) -> list[Edge]:
|
||||
"""Return OCC split history, keeping an unchanged input as one image."""
|
||||
@@ -476,6 +794,96 @@ class Build123dGeometryAdapter:
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
return self._faces_from_circles(sketch.get("entities") or [], plane)
|
||||
|
||||
def faces_for_sketch_with_source_anchors(
|
||||
self,
|
||||
sketch: dict[str, Any],
|
||||
) -> tuple[list[Face], list[dict[str, Any]]]:
|
||||
"""Return profile faces plus direct source anchors for prism history.
|
||||
|
||||
This intentionally covers only the direct analytic profile builders.
|
||||
IMPRINT, split, generated, or otherwise transformed profile paths keep
|
||||
their normal geometry but expose no semantic source anchor.
|
||||
"""
|
||||
profile = sketch.get("profile") or {}
|
||||
source_sketch_id = sketch.get("source_sketch_id")
|
||||
if profile.get("type") == "planar_imprint" or not isinstance(source_sketch_id, str):
|
||||
return self.faces_for_sketch(sketch), []
|
||||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||||
if isinstance(contours, list) and contours and all(
|
||||
isinstance(contour, dict)
|
||||
and bool(contour.get("closed"))
|
||||
and len(contour.get("segments") or []) == 1
|
||||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||||
for contour in contours
|
||||
):
|
||||
# Keep each original circular source edge before the sketch solver
|
||||
# expands it into analytic arc fragments for region bookkeeping.
|
||||
# ``_faces_from_circles_with_source_anchors`` verifies each wire
|
||||
# against the resulting face by exact ``IsSame``; a missing source
|
||||
# label remains unavailable rather than being inferred from its
|
||||
# radius or centre. This also covers a direct annulus or multiple
|
||||
# independent circular regions built by the same face constructor.
|
||||
circles = [
|
||||
{
|
||||
"type": "circle",
|
||||
"center": segment.get("center"),
|
||||
"radius_mm": segment.get("radius_mm"),
|
||||
**(
|
||||
{"source_entity_id": segment["source_entity_id"]}
|
||||
if isinstance(segment.get("source_entity_id"), str) and segment["source_entity_id"]
|
||||
else {}
|
||||
),
|
||||
}
|
||||
for contour in contours
|
||||
for segment in contour.get("segments") or []
|
||||
]
|
||||
return self._faces_from_circles_with_source_anchors(
|
||||
circles, PlaneSpec.from_mapping(sketch.get("workplane") or {}), source_sketch_id,
|
||||
)
|
||||
regions = sketch.get("contour_regions_mm") or []
|
||||
if regions:
|
||||
# ``Face.make_holes`` remains the authoritative geometry path for
|
||||
# a mixed multi-region profile. OCC can give a valid direct face
|
||||
# for each individual region while changing how those regions
|
||||
# interact when their prisms are combined. The direct builder is
|
||||
# therefore limited to one hole-bearing region; keep the existing
|
||||
# executable profile and withhold anchors for the broader case.
|
||||
if len(regions) > 1 and any(region.get("holes") for region in regions):
|
||||
return self.faces_for_sketch(sketch), []
|
||||
logical_circle_sources = self._logical_circle_sources(profile)
|
||||
plane_spec = PlaneSpec.from_mapping(sketch.get("workplane") or {}) if logical_circle_sources else None
|
||||
faces: list[Face] = []
|
||||
anchors: list[dict[str, Any]] = []
|
||||
for region in regions:
|
||||
outer = region.get("outer") or []
|
||||
if len(outer) < 1:
|
||||
continue
|
||||
# Build all direct boundary wires in one face builder. Each
|
||||
# prospective source edge is verified below against the final
|
||||
# face, so an OCC wire repair or an unsupported hole topology
|
||||
# removes evidence rather than creating a geometric fallback.
|
||||
try:
|
||||
result, source_edges = self._face_from_direct_wires(
|
||||
outer,
|
||||
region.get("holes") or (),
|
||||
logical_circle_sources=logical_circle_sources,
|
||||
plane_spec=plane_spec,
|
||||
)
|
||||
except ValueError:
|
||||
# Keep the established executable profile path when the
|
||||
# direct builder cannot represent this analytic region.
|
||||
# It deliberately carries no provenance anchor.
|
||||
return self.faces_for_sketch(sketch), []
|
||||
anchors.extend(self._face_source_anchor_specs(result, source_edges, source_sketch_id))
|
||||
faces.append(result)
|
||||
return faces, anchors
|
||||
if profile.get("type") == "circle":
|
||||
plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
|
||||
return self._faces_from_circles_with_source_anchors(
|
||||
sketch.get("entities") or [], plane, source_sketch_id,
|
||||
)
|
||||
return self.faces_for_sketch(sketch), []
|
||||
|
||||
@staticmethod
|
||||
def face_with_holes(outer: Face, holes: Iterable[Face]) -> Face:
|
||||
"""Build one planar profile from a sketch outer wire and cap-face holes."""
|
||||
@@ -582,7 +990,7 @@ class Build123dGeometryAdapter:
|
||||
|
||||
@staticmethod
|
||||
def extrude_with_topology_delta(face: Face, direction: Vector3) -> tuple[Solid, TopologyDelta]:
|
||||
"""Extrude one B-rep face and retain its two builder-proven cap faces."""
|
||||
"""Extrude one face with exact cap, side-wall, and swept-edge history."""
|
||||
vector = _vector(direction)
|
||||
if vector.length <= 1e-9:
|
||||
raise ValueError("extrude direction must be non-zero")
|
||||
@@ -600,6 +1008,46 @@ class Build123dGeometryAdapter:
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "face", face.wrapped, (output,), output_role=role,
|
||||
))
|
||||
for source_edge in face.edges():
|
||||
# ``Generated(edge)`` proves the lateral face only. Prism's
|
||||
# source-edge overloads preserve the distinct start/end cap-edge
|
||||
# mapping, verified against the final result snapshot here.
|
||||
for role, cap_edge in (
|
||||
("extrude.start", builder.FirstShape(source_edge.wrapped)),
|
||||
("extrude.end", builder.LastShape(source_edge.wrapped)),
|
||||
):
|
||||
is_final_edge = (
|
||||
not cap_edge.IsNull()
|
||||
and cap_edge.ShapeType() == TopAbs_EDGE
|
||||
and any(cap_edge.IsSame(edge.wrapped) for edge in result.edges())
|
||||
)
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "edge", source_edge.wrapped,
|
||||
(cap_edge,) if is_final_edge else (),
|
||||
output_role=role,
|
||||
source_kind="edge",
|
||||
result_kind="edge",
|
||||
derivation="boundary",
|
||||
coverage="complete" if is_final_edge else "partial",
|
||||
status="proven" if is_final_edge else "unknown",
|
||||
))
|
||||
generated = tuple(builder.Generated(source_edge.wrapped))
|
||||
side_faces = tuple(shape for shape in generated if shape.ShapeType() == TopAbs_FACE)
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "edge", source_edge.wrapped, side_faces,
|
||||
source_kind="edge", result_kind="face", derivation="boundary",
|
||||
coverage="complete" if len(side_faces) == len(generated) and side_faces else "partial",
|
||||
status="proven" if len(side_faces) == len(generated) and side_faces else "unknown",
|
||||
))
|
||||
for source_vertex in source_edge.vertices():
|
||||
generated_edges = tuple(builder.Generated(source_vertex.wrapped))
|
||||
swept_edges = tuple(shape for shape in generated_edges if shape.ShapeType() == TopAbs_EDGE)
|
||||
relations.append(TopologyDeltaRelation(
|
||||
"generated", "vertex", source_vertex.wrapped, swept_edges,
|
||||
source_kind="vertex", result_kind="edge", derivation="boundary",
|
||||
coverage="complete" if len(swept_edges) == len(generated_edges) and swept_edges else "partial",
|
||||
status="proven" if len(swept_edges) == len(generated_edges) and swept_edges else "unknown",
|
||||
))
|
||||
return result, TopologyDelta(operation="extrude", relations=tuple(relations))
|
||||
|
||||
@staticmethod
|
||||
@@ -1241,8 +1689,11 @@ class Build123dGeometryAdapter:
|
||||
@staticmethod
|
||||
def _builder_topology_delta(operation: Any, sources: Iterable[Any], operation_name: str) -> TopologyDelta:
|
||||
"""Translate OCC builder history into adapter-neutral opaque relations."""
|
||||
source_bodies = tuple(sources)
|
||||
relations: list[TopologyDeltaRelation] = []
|
||||
for source in sources:
|
||||
source_faces: list[tuple[int, Any]] = []
|
||||
for source_index, source in enumerate(source_bodies):
|
||||
source_faces.extend((source_index, face.wrapped) for face in source.faces())
|
||||
for kind, shapes in (
|
||||
("face", list(source.faces())),
|
||||
("edge", list(source.edges())),
|
||||
@@ -1274,6 +1725,7 @@ class Build123dGeometryAdapter:
|
||||
if generated:
|
||||
relations.append(TopologyDeltaRelation("generated", kind, source_value, generated))
|
||||
section_values: tuple[Any, ...] = ()
|
||||
section_relations: list[TopologySectionRelation] = []
|
||||
section_edges = getattr(operation, "SectionEdges", None)
|
||||
if callable(section_edges):
|
||||
try:
|
||||
@@ -1283,10 +1735,37 @@ class Build123dGeometryAdapter:
|
||||
section_values = tuple(section_edges())
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
section_values = ()
|
||||
if section_values and len(source_bodies) >= 2:
|
||||
# A section edge becomes source-qualified only when OCC returns
|
||||
# that exact edge from Generated(face) for one face from each
|
||||
# boolean input. Do not inspect BOPDS internals here: those
|
||||
# Python bindings are unsafe for this traversal and ordinary
|
||||
# SectionEdges remain useful unqualified diagnostics.
|
||||
for section_edge in section_values:
|
||||
generators: list[tuple[int, Any]] = []
|
||||
for source_index, source_face in source_faces:
|
||||
try:
|
||||
generated = tuple(operation.Generated(source_face))
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
generated = ()
|
||||
if any(candidate.IsSame(section_edge) for candidate in generated):
|
||||
generators.append((source_index, source_face))
|
||||
if len(generators) != 2 or {item[0] for item in generators} != {0, 1}:
|
||||
continue
|
||||
section_relations.append(TopologySectionRelation(
|
||||
source_values=(generators[0][1], generators[1][1]),
|
||||
result_value=section_edge,
|
||||
))
|
||||
qualified_section_values = tuple(item.result_value for item in section_relations)
|
||||
unqualified_section_values = tuple(
|
||||
value for value in section_values
|
||||
if not any(value.IsSame(qualified) for qualified in qualified_section_values)
|
||||
)
|
||||
return TopologyDelta(
|
||||
operation=operation_name,
|
||||
relations=tuple(relations),
|
||||
section_values=section_values,
|
||||
section_values=unqualified_section_values,
|
||||
section_relations=tuple(section_relations),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1830,20 +2309,21 @@ class Build123dGeometryAdapter:
|
||||
end_tangent: Vector3 | None = None,
|
||||
parameters: list[float] | None = None,
|
||||
) -> Edge | Wire:
|
||||
# 两点路径保持直线;三个及以上插值点构造单段 B-spline。端切线是
|
||||
# FeatureScript skFitSpline 的约束,缺失时不能伪造,交给内核自动求解。
|
||||
# 两点无导数路径保持直线;两个点加两个端切线以及三个及以上插值点
|
||||
# 构造单段 B-spline。端切线是 FeatureScript skFitSpline 的约束,
|
||||
# 缺失时不能伪造,交给内核自动求解。
|
||||
vertices = [_vector(point) for point in points]
|
||||
if len(vertices) < 2:
|
||||
raise ValueError("sweep path needs at least two points")
|
||||
if len(vertices) == 2:
|
||||
if start_tangent is not None or end_tangent is not None or parameters is not None:
|
||||
raise ValueError("line sweep path does not accept B-spline tangents")
|
||||
return Edge.make_line(vertices[0], vertices[1])
|
||||
if (start_tangent is None) != (end_tangent is None):
|
||||
raise ValueError("sweep B-spline path requires both endpoint tangents")
|
||||
tangents = [_vector(start_tangent), _vector(end_tangent)] if start_tangent is not None else None
|
||||
if parameters is not None and len(parameters) != len(vertices):
|
||||
raise ValueError("sweep B-spline path parameters must match point count")
|
||||
if len(vertices) == 2 and tangents is None:
|
||||
if parameters is not None:
|
||||
raise ValueError("line sweep path does not accept B-spline parameters")
|
||||
return Edge.make_line(vertices[0], vertices[1])
|
||||
direction = vertices[-1] - vertices[0]
|
||||
tolerance = 1e-9 * max(1.0, direction.length)
|
||||
collinear_points = direction.length > tolerance and all(
|
||||
|
||||
@@ -17,6 +17,7 @@ from .runtime_types import (
|
||||
pattern_instance_member_id, transform_copy_member_id,
|
||||
)
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
|
||||
|
||||
|
||||
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
|
||||
@@ -97,6 +98,24 @@ def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None)
|
||||
return [value for value in values if isinstance(value, dict)]
|
||||
|
||||
|
||||
def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""Return the one nested output-role slot supported by extrusion extents."""
|
||||
policy = ((contract or {}).get("nested_selector_policies") or {}).get("params.end_condition.reference")
|
||||
if (
|
||||
not isinstance(policy, dict)
|
||||
or policy.get("end_condition_type") != "up_to_surface"
|
||||
or policy.get("token_kind") != "face"
|
||||
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
|
||||
or policy.get("requires_immediate_owner") is not True
|
||||
):
|
||||
return None
|
||||
end_condition = node.params.get("end_condition") or {}
|
||||
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
|
||||
if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None:
|
||||
return reference
|
||||
return None
|
||||
|
||||
|
||||
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
"""Return internal body-member keys named by a transform contract."""
|
||||
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
|
||||
@@ -232,6 +251,8 @@ def _next_body_graph(
|
||||
return members, has_active_body
|
||||
|
||||
if atomic_id in {"extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "revolve_cut"} or (
|
||||
atomic_id in _HOLE_ATOMICS and node.params.get("scope_feature_id") is not None
|
||||
) or (
|
||||
atomic_id == "extrude_from_face" and node.params.get("operation") == "cut"
|
||||
):
|
||||
# Primary cuts execute per explicit member in the runtime so a later
|
||||
@@ -448,6 +469,7 @@ class CapabilityAnalyzer:
|
||||
completed: set[str] = set()
|
||||
body_available = False
|
||||
body_members: set[str] = set()
|
||||
previous_node: FeaturePlanNode | None = None
|
||||
for node in plan:
|
||||
blockers: list[RuntimeDiagnostic] = []
|
||||
contract = self.contracts.get(node.atomic_id)
|
||||
@@ -559,19 +581,38 @@ class CapabilityAnalyzer:
|
||||
sketch_id=sketch_id,
|
||||
))
|
||||
contract_selectors = _contract_selectors(node, contract)
|
||||
contract_selector_ids = {id(selector) for selector in contract_selectors}
|
||||
extent_selector = _up_to_surface_output_role_reference(node, contract)
|
||||
output_role_selectors = [
|
||||
*contract_selectors,
|
||||
*([extent_selector] if extent_selector is not None else []),
|
||||
]
|
||||
contract_selector_ids = {id(selector) for selector in output_role_selectors}
|
||||
selector_intent_ids = {
|
||||
id(selector.get("selector_intent"))
|
||||
for selector in output_role_selectors
|
||||
if isinstance(selector.get("selector_intent"), dict)
|
||||
}
|
||||
for selector in _mappings(params):
|
||||
if selector.get("output_role") is not None and id(selector) not in contract_selector_ids:
|
||||
if (
|
||||
selector.get("output_role") is not None
|
||||
and selector.get("kind") is not None
|
||||
and id(selector) not in contract_selector_ids
|
||||
and id(selector) not in selector_intent_ids
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector_context",
|
||||
"Feature output role selector is outside the operation contract slot",
|
||||
))
|
||||
for selector_index, selector in enumerate(contract_selectors):
|
||||
for selector_index, selector in enumerate(output_role_selectors):
|
||||
if selector.get("output_role") is None:
|
||||
continue
|
||||
required.append("selector:feature_output_role")
|
||||
if contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
|
||||
is_extent_reference = selector is extent_selector
|
||||
if (
|
||||
not is_extent_reference
|
||||
and (contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face")
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector",
|
||||
@@ -600,6 +641,39 @@ class CapabilityAnalyzer:
|
||||
selector_index=selector_index,
|
||||
))
|
||||
role_source = selector.get("output_role_source")
|
||||
selector_intent = selector.get("selector_intent")
|
||||
is_shell_cap_face_output_role = (
|
||||
node.atomic_id == "shell"
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
)
|
||||
if is_extent_reference and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_extent_output_role_selector",
|
||||
"up_to_surface output roles require the immediately preceding direct new_body blind extrusion cap",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_shell_cap_face_output_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_cap_face_output_role_selector",
|
||||
"CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if role_source is not None:
|
||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||
@@ -647,11 +721,25 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
elif kind == "bspline" and (
|
||||
not isinstance(segment.get("points"), list)
|
||||
or len(segment.get("points") or []) < 3
|
||||
or len(segment.get("points") or []) < 2
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep B-spline path requires at least three interpolation points",
|
||||
"Sweep B-spline path requires at least two interpolation points",
|
||||
))
|
||||
elif (
|
||||
kind == "bspline"
|
||||
and len(segment.get("points") or []) == 2
|
||||
and not (
|
||||
isinstance(segment.get("start_tangent"), list)
|
||||
and len(segment["start_tangent"]) == 2
|
||||
and isinstance(segment.get("end_tangent"), list)
|
||||
and len(segment["end_tangent"]) == 2
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"A two-point B-spline sweep path requires both endpoint tangents",
|
||||
))
|
||||
if node.atomic_id == "boolean_bodies":
|
||||
target_ids = params.get("target_feature_ids")
|
||||
@@ -762,6 +850,27 @@ class CapabilityAnalyzer:
|
||||
"shell target no longer has an independently selectable body output",
|
||||
target_feature_id=target_feature_id,
|
||||
))
|
||||
if node.atomic_id in _HOLE_ATOMICS and params.get("scope_feature_id") is not None:
|
||||
scope_feature_id = params.get("scope_feature_id")
|
||||
required.append("hole:explicit_scope_body")
|
||||
scope = nodes_by_id.get(str(scope_feature_id or ""))
|
||||
if not isinstance(scope_feature_id, str) or not scope_feature_id:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_hole_scope_body",
|
||||
"hole scope_feature_id must name one preceding body member",
|
||||
))
|
||||
elif scope is None or scope.feature_id not in completed or scope.feature_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "hole_scope_body_unavailable",
|
||||
"hole scope body is no longer an independently selectable body output",
|
||||
scope_feature_id=scope_feature_id,
|
||||
))
|
||||
elif len(body_members) != 1:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "hole_scope_body_ambiguous",
|
||||
"hole scope body must be the sole active member",
|
||||
scope_feature_id=scope_feature_id,
|
||||
))
|
||||
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
|
||||
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
|
||||
source_ids = params.get(parameter)
|
||||
@@ -1086,6 +1195,7 @@ class CapabilityAnalyzer:
|
||||
if status == "executable":
|
||||
completed.add(node.feature_id)
|
||||
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
||||
previous_node = node
|
||||
body_producers = {
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"geometry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"selector_intent_version": {"const": "1.0"},
|
||||
"sketches": {"type": "array", "items": {"$ref": "#/$defs/sketch"}}
|
||||
},
|
||||
"required": ["sketches"],
|
||||
@@ -543,6 +542,7 @@
|
||||
"end_condition": {"$ref": "#/$defs/endCondition"},
|
||||
"positions": {"type": "array", "items": {"$ref": "#/$defs/holePosition"}},
|
||||
"host_face": {"$ref": "#/$defs/hostFace"},
|
||||
"scope_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
|
||||
"thread": {"type": "object"},
|
||||
"countersink": {"type": "object"},
|
||||
"counterbore": {"type": "object"}
|
||||
@@ -570,9 +570,22 @@
|
||||
"properties": {
|
||||
"ast": {},
|
||||
"featurescript_version": {"type": "string", "pattern": "^[0-9]+(?:\\.[0-9]+)*$"},
|
||||
"standard_library": {"type": "string", "minLength": 1}
|
||||
"standard_library": {"type": "string", "minLength": 1},
|
||||
"standard_library_version": {"type": "string", "minLength": 1},
|
||||
"standard_library_imports": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {"type": "string", "minLength": 1},
|
||||
"version": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["path"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["ast", "featurescript_version"],
|
||||
"required": ["ast"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"selectorIntent": {
|
||||
@@ -580,7 +593,7 @@
|
||||
"properties": {
|
||||
"version": {"const": "1.0"},
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"query_family": {"enum": ["CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY", "GEOMETRIC"]},
|
||||
"query_family": {"type": "string", "pattern": "^[A-Z][A-Z0-9_]{0,79}$"},
|
||||
"source_query": {"$ref": "#/$defs/selectorIntentSourceQuery"},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
@@ -591,7 +604,47 @@
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"source_entities": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"intersection_sources": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"maxItems": 2,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query_family": {"enum": ["CAP_FACE", "SWEPT_FACE"]},
|
||||
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
|
||||
"output_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"source_entity": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sketch_id": {"type": "string", "minLength": 1},
|
||||
"entity_id": {"type": "string", "minLength": 1}
|
||||
},
|
||||
"required": ["sketch_id", "entity_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["query_family", "owner_feature_id"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"output_role": {"$ref": "#/$defs/featureOutputRole"},
|
||||
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
|
||||
"body_member_contract": {"enum": ["direct_new_body"]},
|
||||
"derivation_policy": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -601,7 +654,7 @@
|
||||
"required": ["allowed", "multiplicity"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"evidence": {"enum": ["kernel_history", "operation_role", "feature_script_query", "explicit_datum", "geometry_hint"]},
|
||||
"evidence": {"enum": ["kernel_history", "operation_role", "active_body_member", "feature_script_query", "explicit_datum", "geometry_hint"]},
|
||||
"disambiguation": {"type": "object"}
|
||||
},
|
||||
"required": ["version", "query_family", "source_query", "derivation_policy", "evidence"],
|
||||
@@ -628,7 +681,7 @@
|
||||
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
|
||||
},
|
||||
"required": ["kind", "source", "confidence"],
|
||||
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}],
|
||||
"anyOf": [{"required": ["stable_id"]}, {"required": ["output_role"]}, {"required": ["selector_intent"]}],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"analyticSegment": {
|
||||
@@ -649,6 +702,7 @@
|
||||
"clockwise": {"type": "boolean"},
|
||||
"start_tangent": {"$ref": "#/$defs/point2"},
|
||||
"end_tangent": {"$ref": "#/$defs/point2"}
|
||||
,"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
|
||||
},
|
||||
"required": ["type"],
|
||||
"allOf": [
|
||||
@@ -798,7 +852,7 @@
|
||||
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
|
||||
"profile": {
|
||||
"oneOf": [
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "circle"}, "center": {"$ref": "#/$defs/point2"}, "radius_mm": {"$ref": "#/$defs/positive"}, "source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}}, "required": ["type", "radius_mm"], "additionalProperties": false},
|
||||
{"type": "object", "properties": {"type": {"const": "polygon"}, "vertices": {"type": "array", "minItems": 3, "items": {"$ref": "#/$defs/point2"}}}, "required": ["type", "vertices"], "additionalProperties": false},
|
||||
{"$ref": "#/$defs/analyticProfile"},
|
||||
{"$ref": "#/$defs/planarImprintProfile"}
|
||||
@@ -806,7 +860,7 @@
|
||||
},
|
||||
"sketch": {
|
||||
"type": "object",
|
||||
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"properties": {"id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}, "name": {"type": "string"}, "source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160}, "workplane": {"$ref": "#/$defs/workplane"}, "profile": {"$ref": "#/$defs/profile"}, "role": {"enum": ["profile", "reference"]}, "attachment": {"$ref": "#/$defs/selectorRef"}, "profile_from": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
|
||||
"required": ["id", "workplane", "profile"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -73,13 +73,50 @@ def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[s
|
||||
return members
|
||||
|
||||
|
||||
def _can_register_primary_cut_tool_history(
|
||||
session: "ExecutionSession",
|
||||
tool: Any,
|
||||
topology_delta: TopologyDelta | None,
|
||||
topology_anchors: list[TopologyRecord] | None,
|
||||
) -> bool:
|
||||
"""Return whether a primary REMOVE can retain a transient tool snapshot.
|
||||
|
||||
The implicit CADFS primary boolean normally has no independently active
|
||||
tool body. It may contribute selector provenance only when the target,
|
||||
tool, profile anchors, and prism builder history are all singular and
|
||||
exact. This gate applies only to the tool-side transient snapshot used by
|
||||
source-qualified section queries. The cut builder can independently
|
||||
prove a target-side continuation even when a trimmed or fallback tool has
|
||||
no direct-prism history.
|
||||
"""
|
||||
if (
|
||||
session.body is None
|
||||
or topology_delta is None
|
||||
or topology_delta.operation != "extrude"
|
||||
or topology_delta.history_status != "proven"
|
||||
or not topology_delta.relations
|
||||
or not topology_anchors
|
||||
or len(session.body_members) != 1
|
||||
or len(session.adapter.body_solids(session.body)) != 1
|
||||
or len(session.adapter.body_solids(tool)) != 1
|
||||
):
|
||||
return False
|
||||
return all(
|
||||
anchor.kind in {"edge", "vertex"}
|
||||
and (anchor.source_entity is not None or anchor.source_entities)
|
||||
for anchor in topology_anchors
|
||||
)
|
||||
|
||||
|
||||
def _extruded_tool(
|
||||
node: FeaturePlanNode,
|
||||
faces: list[Any],
|
||||
profile_normal: Vector3,
|
||||
session: "ExecutionSession",
|
||||
*,
|
||||
record_multiface_prism_history: bool = False,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Build one extrude tool, retaining caps only from one exact builder result."""
|
||||
"""Build an extrude tool, retaining complete direct builder history."""
|
||||
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||
draft = node.params.get("draft")
|
||||
taper_deg = 0.0
|
||||
@@ -87,7 +124,7 @@ def _extruded_tool(
|
||||
taper_deg = float(draft["angle_deg"])
|
||||
if not bool(draft["pull_direction"]):
|
||||
taper_deg = -taper_deg
|
||||
topology_delta: TopologyDelta | None = None
|
||||
topology_deltas: list[TopologyDelta] = []
|
||||
solids: list[Any] = []
|
||||
for face in faces:
|
||||
for extent in extents:
|
||||
@@ -96,11 +133,22 @@ def _extruded_tool(
|
||||
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
|
||||
face, extent.vector, taper_deg,
|
||||
)
|
||||
if topology_delta is not None:
|
||||
topology_deltas.append(topology_delta)
|
||||
solids.append(solid)
|
||||
else:
|
||||
solids.append(session.adapter.extrude_taper(face, extent.vector, taper_deg))
|
||||
elif extent.trim_to is None and len(faces) == 1 and len(extents) == 1:
|
||||
elif extent.trim_to is None and len(extents) == 1 and (
|
||||
len(faces) == 1 or record_multiface_prism_history
|
||||
):
|
||||
# Each independently constructed profile face has its own OCC
|
||||
# prism history only when the adapter retained direct source
|
||||
# anchors for every participating profile path. Other complex
|
||||
# multi-face profiles keep the established general-extrude
|
||||
# path; forcing them through MakePrism can make a previously
|
||||
# executable profile invalid without adding usable evidence.
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
topology_deltas.append(topology_delta)
|
||||
solids.append(solid)
|
||||
elif extent.trim_to is None:
|
||||
solids.append(session.adapter.extrude(face, extent.vector))
|
||||
@@ -111,7 +159,12 @@ def _extruded_tool(
|
||||
tool = session.adapter.fuse(tool, solid)
|
||||
if tool is None:
|
||||
raise ValueError("extrude produced no solid")
|
||||
return tool, topology_delta
|
||||
if len(topology_deltas) != len(solids):
|
||||
return tool, None
|
||||
return tool, TopologyDelta(
|
||||
operation="extrude",
|
||||
relations=tuple(relation for delta in topology_deltas for relation in delta.relations),
|
||||
)
|
||||
|
||||
|
||||
def _apply_primary_tool(
|
||||
@@ -121,8 +174,10 @@ def _apply_primary_tool(
|
||||
*,
|
||||
cutting: bool,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
topology_anchors: list[TopologyRecord] | None = None,
|
||||
) -> FeatureResult:
|
||||
"""Apply a profile-derived tool while preserving only final-snapshot topology evidence."""
|
||||
topology_predecessors: list[TopologyRecord] | None = None
|
||||
if cutting:
|
||||
if session.body is None:
|
||||
raise ValueError("cut feature has no body")
|
||||
@@ -130,8 +185,46 @@ def _apply_primary_tool(
|
||||
if not members:
|
||||
session.clear_body()
|
||||
return session.result(node)
|
||||
body = session.adapter.cut(session.body, tool)
|
||||
topology_delta = None
|
||||
tool_delta = topology_delta
|
||||
tool_anchors = list(topology_anchors or ())
|
||||
retain_transient_tool = _can_register_primary_cut_tool_history(
|
||||
session, tool, tool_delta, topology_anchors,
|
||||
)
|
||||
if (
|
||||
len(session.body_members) == 1
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
and len(session.adapter.body_solids(tool)) == 1
|
||||
):
|
||||
body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool)
|
||||
# The target-side boolean history is independent of the source
|
||||
# tool's construction history. A trimmed tool cannot support a
|
||||
# source-qualified section query, but its exact BRepAlgoAPI_Cut
|
||||
# Modified/Preserved facts can still prove a unique continuation
|
||||
# of the active target face or edge.
|
||||
topology_anchors = None
|
||||
if cut_delta is not None:
|
||||
if retain_transient_tool:
|
||||
try:
|
||||
topology_predecessors = session.register_transient_prism_tool(
|
||||
node.feature_id,
|
||||
tool,
|
||||
topology_delta=tool_delta,
|
||||
topology_anchors=tool_anchors,
|
||||
)
|
||||
except ValueError:
|
||||
# Retain cut history as partial diagnostic evidence. The
|
||||
# absent transient source records prevent it from proving
|
||||
# a source-qualified section edge.
|
||||
topology_predecessors = None
|
||||
topology_delta = cut_delta
|
||||
else:
|
||||
topology_delta = None
|
||||
member_id = next(iter(session.body_members))
|
||||
members = {member_id: body}
|
||||
else:
|
||||
body = session.adapter.cut(session.body, tool)
|
||||
topology_delta = None
|
||||
topology_anchors = None
|
||||
elif node.params.get("result_mode") == "new_body":
|
||||
body = session.adapter.combine(session.body, tool)
|
||||
members = {**session.body_members, node.feature_id: tool}
|
||||
@@ -142,8 +235,10 @@ def _apply_primary_tool(
|
||||
# an unchanged standalone/new-body prism snapshot.
|
||||
if session.body is not None:
|
||||
topology_delta = None
|
||||
topology_anchors = None
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||||
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
|
||||
)
|
||||
return session.result(node)
|
||||
|
||||
@@ -157,7 +252,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
if selected_sketch is None:
|
||||
raise ValueError("primary feature has no resolved sketch")
|
||||
# 2. 从草图解析闭合轮廓区域(faces),没有闭合区域就无法生成实体。
|
||||
faces = session.adapter.faces_for_sketch(selected_sketch)
|
||||
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(selected_sketch)
|
||||
if not faces:
|
||||
raise ValueError("sketch does not create a closed profile region")
|
||||
if node.atomic_id == "extrude_add_blind_with_hole":
|
||||
@@ -169,6 +264,16 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
raise ValueError("profile hole extrusion requires exactly one outer sketch region")
|
||||
faces = [session.adapter.face_with_holes(faces[0], [resolved[0].record.value])]
|
||||
topology_delta: TopologyDelta | None = None
|
||||
topology_anchors: list[TopologyRecord] = []
|
||||
profile = selected_sketch.get("profile") or {}
|
||||
contours = profile.get("contours") if profile.get("type") == "analytic_contours" else None
|
||||
direct_all_circle_profile = isinstance(contours, list) and bool(contours) and all(
|
||||
isinstance(contour, dict)
|
||||
and bool(contour.get("closed"))
|
||||
and len(contour.get("segments") or []) == 1
|
||||
and (contour.get("segments") or [{}])[0].get("type") == "circle"
|
||||
for contour in contours
|
||||
)
|
||||
# 3. 按特征类型生成子实体:
|
||||
if node.atomic_id.startswith("extrude_"):
|
||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||
@@ -177,7 +282,27 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||
tool, topology_delta = _extruded_tool(
|
||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||
record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces),
|
||||
)
|
||||
if topology_delta is not None:
|
||||
for index, spec in enumerate(source_anchor_specs):
|
||||
kind = spec.get("kind")
|
||||
value = spec.get("value")
|
||||
if kind not in {"edge", "vertex"} or value is None:
|
||||
continue
|
||||
source_entity = spec.get("source_entity")
|
||||
source_entities = tuple(spec.get("source_entities") or ())
|
||||
if source_entity is None and not source_entities:
|
||||
continue
|
||||
topology_anchors.append(TopologyRecord(
|
||||
record_id=f"anchor:{node.feature_id}:{kind}:{index}",
|
||||
kind=kind,
|
||||
feature_id=node.feature_id,
|
||||
geometry={},
|
||||
value=value,
|
||||
source_entity=source_entity if isinstance(source_entity, tuple) else None,
|
||||
source_entities=source_entities,
|
||||
))
|
||||
else:
|
||||
# 旋转:解析旋转轴并校验旋转角,然后绕轴旋转每个面得到实体列表。
|
||||
axis = _revolve_axis(node, session)
|
||||
@@ -199,6 +324,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
raise ValueError("revolve produced no solid")
|
||||
return _apply_primary_tool(
|
||||
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
|
||||
topology_anchors=topology_anchors,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -31,6 +31,23 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
# 1. 校验:孔是切除操作,必须先有主体。
|
||||
if session.body is None:
|
||||
raise ValueError("hole feature has no body")
|
||||
scope_feature_id = node.params.get("scope_feature_id")
|
||||
if scope_feature_id is not None:
|
||||
if not isinstance(scope_feature_id, str) or not scope_feature_id:
|
||||
raise ValueError("hole scope_feature_id is invalid")
|
||||
if len(session.body_members) != 1:
|
||||
raise ValueError("hole scope body is no longer the sole active member")
|
||||
scoped_body = session.body_members.get(scope_feature_id)
|
||||
if scoped_body is None:
|
||||
raise ValueError("hole scope body is no longer an independently selectable member")
|
||||
scoped_solids = session.adapter.body_solids(scoped_body)
|
||||
active_solids = session.adapter.body_solids(session.body)
|
||||
if (
|
||||
len(scoped_solids) != 1
|
||||
or len(active_solids) != 1
|
||||
or not scoped_solids[0].is_same(active_solids[0])
|
||||
):
|
||||
raise ValueError("hole scope body does not match the active body")
|
||||
# 2. 确定宿主面 host_face:
|
||||
host_selector = node.params.get("host_face")
|
||||
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
|
||||
@@ -74,5 +91,9 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
message="Thread decoration is not modeled; the hole falls back to a plain cylindrical bore",
|
||||
feature_id=node.feature_id,
|
||||
))
|
||||
session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
|
||||
result_body = session.adapter.cut(session.body, tool)
|
||||
members = {scope_feature_id: result_body} if scope_feature_id is not None else None
|
||||
session.register_body(
|
||||
node.feature_id, result_body, replay_node=node, body_members=members,
|
||||
)
|
||||
return session.result(node, diagnostics=diagnostics)
|
||||
|
||||
@@ -31,12 +31,14 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
params_schema = raw.get("author_params_schema")
|
||||
injected_paths = raw.get("server_injected_paths")
|
||||
selector_policy = raw.get("selector_policy")
|
||||
nested_selector_policies = raw.get("nested_selector_policies") or {}
|
||||
runtime_capability = raw.get("runtime_capability")
|
||||
if (
|
||||
not isinstance(shape, dict)
|
||||
or not isinstance(params_schema, dict)
|
||||
or not isinstance(injected_paths, list)
|
||||
or not isinstance(selector_policy, dict)
|
||||
or not isinstance(nested_selector_policies, dict)
|
||||
):
|
||||
raise ValueError(f"operation contract is incomplete for {atomic_id}")
|
||||
if not isinstance(runtime_capability, dict) or not all(
|
||||
@@ -52,6 +54,8 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
raise ValueError(f"operation author params are invalid for {atomic_id}")
|
||||
if not all(isinstance(name, str) and name in properties for name in required):
|
||||
raise ValueError(f"operation required params are invalid for {atomic_id}")
|
||||
if not all(isinstance(path, str) and isinstance(policy, dict) for path, policy in nested_selector_policies.items()):
|
||||
raise ValueError(f"operation nested selector policies are invalid for {atomic_id}")
|
||||
materialized_required = list(dict.fromkeys(required))
|
||||
for path in injected_paths:
|
||||
if not isinstance(path, str):
|
||||
@@ -67,6 +71,7 @@ def materialized_feature_contracts(profile: dict[str, Any]) -> dict[str, dict[st
|
||||
"requires_sketch": shape.get("sketch") == "required",
|
||||
"selector_slot": selector_policy.get("slot"),
|
||||
"selector_token_kind": selector_policy.get("token_kind"),
|
||||
"nested_selector_policies": dict(nested_selector_policies),
|
||||
"runtime_capability": dict(runtime_capability),
|
||||
}
|
||||
return derived
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
|
||||
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"],
|
||||
"operation_contracts": {
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_add_blind": {"atomic_id":"extrude_add_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_from_face": {"atomic_id":"extrude_from_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"operation":{"enum":["add","cut"]},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"two_sided":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"draft":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":90},"pull_direction":{"type":"boolean"}},"required":["angle_deg","pull_direction"],"additionalProperties":false},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm","operation"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["derived_profile_face","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_add_blind_with_hole": {"atomic_id":"extrude_add_blind_with_hole","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","profile_hole_face"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
|
||||
@@ -14,7 +14,7 @@
|
||||
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
|
||||
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_add_two_sided": {"atomic_id":"extrude_add_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_blind": {"atomic_id":"extrude_cut_blind","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_through": {"atomic_id":"extrude_cut_through","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["end_condition"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"extrude_cut_two_sided": {"atomic_id":"extrude_cut_two_sided","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"reverse_distance_mm":{"type":"number","exclusiveMinimum":0},"reverse":{"type":"boolean"},"end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false},"reverse_end_condition":{"type":"object","properties":{"type":{"type":"string","minLength":1,"maxLength":48},"solidworks_code":{"type":"integer"}},"required":["type","solidworks_code"],"additionalProperties":false}},"required":["distance_mm","reverse_distance_mm"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"revolve_add": {"atomic_id":"revolve_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"angle_deg":{"type":"number","exclusiveMinimum":0,"maximum":360},"axis":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"direction":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","direction"],"additionalProperties":false},"reverse":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["angle_deg","axis"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","revolve_axis_on_sketch"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
|
||||
@@ -20,6 +20,7 @@ working.
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -27,6 +28,7 @@ from . import executors # noqa: F401 (importing performs executor registration
|
||||
from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
|
||||
# Historical private name still imported by the test suite.
|
||||
from .executors.primitives import _execute_box # noqa: F401
|
||||
from .executors.common import _selector_edges # noqa: F401
|
||||
from .extents import (
|
||||
_extent_reference,
|
||||
_extent_vectors,
|
||||
@@ -73,6 +75,9 @@ from .topology import (
|
||||
SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord, TopologyRegistry,
|
||||
)
|
||||
|
||||
# Historical private imports used by integration tests and local diagnostics.
|
||||
_execute_node = execute_node
|
||||
|
||||
__all__ = [
|
||||
"ALL_ATOMIC_IDS",
|
||||
"EXECUTORS",
|
||||
@@ -85,7 +90,10 @@ __all__ = [
|
||||
"GeometryAdapter",
|
||||
"RuntimeDiagnostic",
|
||||
"RuntimeExecutionError",
|
||||
"IncrementalCdslExecution",
|
||||
"analyze_cdsl",
|
||||
"prepare_cdsl_execution",
|
||||
"finalize_cdsl_execution",
|
||||
"execute_node",
|
||||
"rebuild_cdsl",
|
||||
]
|
||||
@@ -101,8 +109,81 @@ def analyze_cdsl(cdsl: dict[str, Any]):
|
||||
return analyzer.analyze(resolved, sketch_errors=sketch_errors)
|
||||
|
||||
|
||||
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
|
||||
"""Rebuild CDSL through session-scoped atomic executors only."""
|
||||
def _preflight_error(analysis: Any) -> ValueError | None:
|
||||
if analysis.runtime_eligible:
|
||||
return None
|
||||
first = next((result for result in analysis.feature_results if not result.executable), None)
|
||||
if first is None:
|
||||
return ValueError(analysis.document_blockers[0].code)
|
||||
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
|
||||
return ValueError(f"unsupported atomic_id: {first.atomic_id}")
|
||||
detail = "; ".join(blocker.code for blocker in first.blockers)
|
||||
return ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
|
||||
|
||||
|
||||
def _execution_diagnostic(
|
||||
error: Exception,
|
||||
node: FeaturePlanNode,
|
||||
session: ExecutionSession,
|
||||
) -> RuntimeDiagnostic:
|
||||
failed_resolution = next(
|
||||
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
|
||||
)
|
||||
if isinstance(error, FeatureExecutionError):
|
||||
return RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
|
||||
if failed_resolution and failed_resolution.get("diagnostic"):
|
||||
diagnostic = failed_resolution["diagnostic"]
|
||||
return RuntimeDiagnostic(
|
||||
diagnostic["code"], diagnostic["message"], feature_id=node.feature_id,
|
||||
detail=diagnostic.get("detail") or {},
|
||||
)
|
||||
return RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class IncrementalCdslExecution:
|
||||
"""One prepared CDSL replay with feature-at-a-time execution.
|
||||
|
||||
CADFS selector binding consumes this object before each feature executes.
|
||||
It therefore observes the exact session topology snapshots generated by
|
||||
the one real kernel replay rather than rebuilding a growing prefix for
|
||||
every selector. The generic runtime also uses it through ``rebuild_cdsl``.
|
||||
"""
|
||||
|
||||
resolved_cdsl: dict[str, Any]
|
||||
analysis: Any
|
||||
session: ExecutionSession
|
||||
diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
|
||||
next_index: int = 0
|
||||
|
||||
def execute_next(self, *, strict: bool = True) -> FeatureResult | None:
|
||||
if self.next_index >= len(self.analysis.plan):
|
||||
raise ValueError("CDSL execution plan is already complete")
|
||||
node = self.analysis.plan[self.next_index]
|
||||
preflight = self.analysis.feature_results[self.next_index]
|
||||
self.next_index += 1
|
||||
if not preflight.executable:
|
||||
self.diagnostics.extend(preflight.blockers)
|
||||
if strict:
|
||||
detail = "; ".join(blocker.code for blocker in preflight.blockers)
|
||||
raise ValueError(f"Feature {node.feature_id} is not runtime eligible: {detail}")
|
||||
return None
|
||||
try:
|
||||
return execute_node(node, self.session)
|
||||
except Exception as error:
|
||||
diagnostic = _execution_diagnostic(error, node, self.session)
|
||||
self.diagnostics.append(diagnostic)
|
||||
if strict:
|
||||
raise RuntimeExecutionError(diagnostic, list(self.session.selector_resolutions)) from error
|
||||
return None
|
||||
|
||||
def execute_all(self, *, strict: bool = True) -> None:
|
||||
while self.next_index < len(self.analysis.plan):
|
||||
self.execute_next(strict=strict)
|
||||
|
||||
|
||||
def prepare_cdsl_execution(cdsl: dict[str, Any]) -> IncrementalCdslExecution:
|
||||
"""Resolve CDSL once and return a reusable sequential execution session."""
|
||||
sketch_errors: dict[str, str] = {}
|
||||
resolved = resolve_required_sketches(
|
||||
deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
|
||||
@@ -110,45 +191,16 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS).analyze(
|
||||
resolved, sketch_errors=sketch_errors,
|
||||
)
|
||||
if strict and not analysis.runtime_eligible:
|
||||
first = next((result for result in analysis.feature_results if not result.executable), None)
|
||||
if first is None:
|
||||
raise ValueError(analysis.document_blockers[0].code)
|
||||
if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
|
||||
raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
|
||||
detail = "; ".join(blocker.code for blocker in first.blockers)
|
||||
raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
|
||||
session = ExecutionSession(
|
||||
sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
|
||||
nodes={node.feature_id: node for node in analysis.plan},
|
||||
)
|
||||
diagnostics: list[RuntimeDiagnostic] = []
|
||||
for node, preflight in zip(analysis.plan, analysis.feature_results):
|
||||
if not preflight.executable:
|
||||
diagnostics.extend(preflight.blockers)
|
||||
if strict:
|
||||
break
|
||||
continue
|
||||
try:
|
||||
execute_node(node, session)
|
||||
except Exception as error:
|
||||
failed_resolution = next(
|
||||
(item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
|
||||
)
|
||||
diagnostic = (
|
||||
RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
|
||||
if isinstance(error, FeatureExecutionError)
|
||||
else
|
||||
RuntimeDiagnostic(
|
||||
failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
|
||||
feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
|
||||
)
|
||||
if failed_resolution and failed_resolution.get("diagnostic")
|
||||
else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
|
||||
)
|
||||
diagnostics.append(diagnostic)
|
||||
if strict:
|
||||
raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
|
||||
return IncrementalCdslExecution(resolved, analysis, session)
|
||||
|
||||
|
||||
def finalize_cdsl_execution(execution: IncrementalCdslExecution, out_step: Path) -> dict[str, Any]:
|
||||
"""Export the current executable checkpoint from an incremental replay."""
|
||||
session = execution.session
|
||||
output = session.body
|
||||
surface_geometry: dict[str, Any] | None = None
|
||||
if output is None:
|
||||
@@ -177,8 +229,19 @@ def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -
|
||||
"surface_face_count": int(surface_geometry["face_count"]) if surface_geometry is not None else 0,
|
||||
"surface_area_mm2": float(surface_geometry["area_mm2"]) if surface_geometry is not None else 0.0,
|
||||
"feature_results": [result.as_dict() for result in session.results.values()],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
|
||||
"runtime_diagnostics": [diagnostic.as_dict() for diagnostic in execution.diagnostics],
|
||||
"topology_records": [record.public_dict() for record in session.topology.records()],
|
||||
"topology_deltas": list(session.topology.topology_deltas()),
|
||||
"selector_resolution": session.selector_resolutions,
|
||||
}
|
||||
|
||||
|
||||
def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
|
||||
"""Rebuild CDSL through session-scoped atomic executors only."""
|
||||
execution = prepare_cdsl_execution(cdsl)
|
||||
if strict:
|
||||
error = _preflight_error(execution.analysis)
|
||||
if error is not None:
|
||||
raise error
|
||||
execution.execute_all(strict=strict)
|
||||
return finalize_cdsl_execution(execution, out_step)
|
||||
|
||||
@@ -43,6 +43,7 @@ from .topology import (
|
||||
SelectorResolution,
|
||||
TopologyDelta,
|
||||
TopologyDeltaRelation,
|
||||
TopologySectionRelation,
|
||||
TopologyLineage,
|
||||
TopologyRecord,
|
||||
TopologyRegistry,
|
||||
@@ -64,6 +65,7 @@ __all__ = [
|
||||
"ThreadSpec",
|
||||
"TopologyDelta",
|
||||
"TopologyDeltaRelation",
|
||||
"TopologySectionRelation",
|
||||
"TopologyLineage",
|
||||
"TopologyRecord",
|
||||
"TopologyRegistry",
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Verified FeatureScript query semantics available to the selector resolver.
|
||||
|
||||
This is intentionally a small, explicit allow-list. A numeric source version
|
||||
only identifies a FeatureScript release; it does not establish that this
|
||||
runtime has verified a query family's source semantics for that release.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectorQueryCapability:
|
||||
query_family: str
|
||||
featurescript_version: str
|
||||
standard_library: str
|
||||
standard_library_version: str
|
||||
source: str
|
||||
contract: str
|
||||
|
||||
|
||||
# These entries correspond to the direct builder contracts exercised by the
|
||||
# CADFS 1511 corpus. New versions and query families must be registered with
|
||||
# their source/API evidence before the runtime accepts them.
|
||||
_CAPABILITIES = {
|
||||
("CAP_FACE", "1511"): SelectorQueryCapability(
|
||||
"CAP_FACE",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history",
|
||||
"direct extrude cap operation role",
|
||||
),
|
||||
("CAP_EDGE", "1511"): SelectorQueryCapability(
|
||||
"CAP_EDGE",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history",
|
||||
"direct prism source edge to qualified start/end cap edge kernel lineage",
|
||||
),
|
||||
("OFFSET_FACE", "1511"): SelectorQueryCapability(
|
||||
"OFFSET_FACE",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history",
|
||||
"shell offset-face operation role with true dependency qualification",
|
||||
),
|
||||
# These entries authorize the deliberately narrow direct-prism lineage
|
||||
# path. The capability matrix keeps the broader query families explicitly
|
||||
# partial; all other generator and lifecycle combinations remain rejected.
|
||||
("SWEPT_FACE", "1511"): SelectorQueryCapability(
|
||||
"SWEPT_FACE",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history",
|
||||
"kernel-lineage resolver contract only",
|
||||
),
|
||||
("SWEPT_EDGE", "1511"): SelectorQueryCapability(
|
||||
"SWEPT_EDGE",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history",
|
||||
"kernel-lineage resolver contract only",
|
||||
),
|
||||
("SWEPT_BODY", "1511"): SelectorQueryCapability(
|
||||
"SWEPT_BODY",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history; one-item qUnion identity",
|
||||
"active direct-new-body member selected by its producing operation",
|
||||
),
|
||||
("INTERSECT", "1511"): SelectorQueryCapability(
|
||||
"INTERSECT",
|
||||
"1511",
|
||||
"onshape/std/geometry.fs",
|
||||
"1511.0",
|
||||
"CADFS FeatureScript 1511 exported query history; BRepAlgoAPI boolean Generated(face) and SectionEdges() exact handles",
|
||||
"two source-qualified boolean input faces to one final section edge kernel lineage",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def selector_query_capability(intent: dict[str, Any]) -> SelectorQueryCapability | None:
|
||||
"""Return an explicitly verified query capability for ``intent`` only."""
|
||||
source_query = intent.get("source_query")
|
||||
if not isinstance(source_query, dict):
|
||||
return None
|
||||
family = intent.get("query_family")
|
||||
version = source_query.get("featurescript_version")
|
||||
if not isinstance(family, str) or not isinstance(version, str):
|
||||
return None
|
||||
capability = _CAPABILITIES.get((family, version))
|
||||
if capability is None:
|
||||
return None
|
||||
if (
|
||||
source_query.get("standard_library") != capability.standard_library
|
||||
or source_query.get("standard_library_version") != capability.standard_library_version
|
||||
):
|
||||
return None
|
||||
return capability
|
||||
|
||||
|
||||
def known_selector_query_versions(query_family: str) -> tuple[str, ...]:
|
||||
"""Expose registered versions for deterministic unsupported diagnostics."""
|
||||
return tuple(sorted(version for family, version in _CAPABILITIES if family == query_family))
|
||||
|
||||
|
||||
def known_selector_query_standard_library_versions(query_family: str) -> tuple[tuple[str, str], ...]:
|
||||
"""Expose exact direct imports that back a registered query contract."""
|
||||
return tuple(sorted({
|
||||
(capability.standard_library, capability.standard_library_version)
|
||||
for (family, _version), capability in _CAPABILITIES.items()
|
||||
if family == query_family
|
||||
}))
|
||||
|
||||
|
||||
def is_direct_blind_extrude_cap_output_role(
|
||||
selector: dict[str, Any],
|
||||
producer: dict[str, Any] | None,
|
||||
sketches: dict[str, dict[str, Any]],
|
||||
) -> bool:
|
||||
"""Whether a selector names one cap from the direct prism contract.
|
||||
|
||||
This is the consumer-side mirror of the lowerer's CAP_FACE constructor.
|
||||
It deliberately describes a narrow builder contract, rather than treating
|
||||
an output-role string as general permission to select arbitrary topology.
|
||||
The runtime still has to prove the exact role in its active snapshot.
|
||||
"""
|
||||
if not isinstance(selector, dict) or not isinstance(producer, dict):
|
||||
return False
|
||||
intent = selector.get("selector_intent")
|
||||
if (
|
||||
selector.get("kind") != "face"
|
||||
or selector.get("output_role") not in {"extrude.start", "extrude.end"}
|
||||
or selector.get("source") != "runtime_snapshot"
|
||||
or selector.get("output_role_source") is not None
|
||||
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id"))
|
||||
or not isinstance(intent, dict)
|
||||
or intent.get("query_family") != "CAP_FACE"
|
||||
or intent.get("evidence") != "operation_role"
|
||||
or intent.get("output_role") != selector.get("output_role")
|
||||
):
|
||||
return False
|
||||
policy = intent.get("derivation_policy")
|
||||
if not isinstance(policy, dict) or policy.get("multiplicity") != "one" or set(policy.get("allowed") or ()) != {"boundary", "continuation"}:
|
||||
return False
|
||||
params = producer.get("params") or {}
|
||||
if (
|
||||
producer.get("atomic_id") != "extrude_add_blind"
|
||||
or params.get("result_mode") != "new_body"
|
||||
or (params.get("end_condition") or {}).get("type") != "blind"
|
||||
):
|
||||
return False
|
||||
if params.get("draft") is None:
|
||||
return True
|
||||
sketch = sketches.get(str(producer.get("sketch_id") or "")) or {}
|
||||
profile = sketch.get("profile") or {}
|
||||
if profile.get("type") == "circle":
|
||||
return True
|
||||
contours = profile.get("contours")
|
||||
return (
|
||||
profile.get("type") == "analytic_contours"
|
||||
and isinstance(contours, list)
|
||||
and len(contours) == 1
|
||||
and bool((contours[0] or {}).get("closed"))
|
||||
)
|
||||
@@ -16,6 +16,7 @@ from typing import Any
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
|
||||
|
||||
|
||||
_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$")
|
||||
@@ -45,6 +46,24 @@ def _contract_selectors(feature: dict[str, Any], contract: dict[str, Any]) -> li
|
||||
return [value for value in values if isinstance(value, dict)]
|
||||
|
||||
|
||||
def _up_to_surface_output_role_reference(feature: dict[str, Any], contract: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Return the one nested output-role slot that an extrusion may consume."""
|
||||
policy = (contract.get("nested_selector_policies") or {}).get("params.end_condition.reference")
|
||||
if (
|
||||
not isinstance(policy, dict)
|
||||
or policy.get("end_condition_type") != "up_to_surface"
|
||||
or policy.get("token_kind") != "face"
|
||||
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
|
||||
or policy.get("requires_immediate_owner") is not True
|
||||
):
|
||||
return None
|
||||
end_condition = (feature.get("params") or {}).get("end_condition") or {}
|
||||
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
|
||||
if end_condition.get("type") == "up_to_surface" and isinstance(reference, dict) and reference.get("output_role") is not None:
|
||||
return reference
|
||||
return None
|
||||
|
||||
|
||||
def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index: int) -> None:
|
||||
"""Enforce the provenance boundary before the runtime can bind a selector."""
|
||||
intent = selector.get("selector_intent")
|
||||
@@ -52,14 +71,15 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index:
|
||||
if selector.get("selector_intent_version") is not None:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} declares an intent version without selector_intent")
|
||||
return
|
||||
if selector.get("selector_intent_version") not in {None, "1.0"}:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent version")
|
||||
if not isinstance(intent, dict) or intent.get("version") != "1.0":
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an unsupported selector intent")
|
||||
legacy_version = selector.get("selector_intent_version")
|
||||
if legacy_version is not None and legacy_version != intent["version"]:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has conflicting selector intent versions")
|
||||
if intent.get("kind") not in {None, selector.get("kind")}:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} intent kind differs from selector kind")
|
||||
family = intent.get("query_family")
|
||||
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "OFFSET_FACE", "INTERSECT", "COPY"}
|
||||
derived = {"CAP_FACE", "CAP_EDGE", "SWEPT_FACE", "SWEPT_EDGE", "SWEPT_BODY", "OFFSET_FACE", "INTERSECT", "COPY"}
|
||||
if family in derived and not selector.get("owner_feature_id"):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} derived intent requires owner_feature_id")
|
||||
policy = intent.get("derivation_policy") or {}
|
||||
@@ -76,10 +96,90 @@ def _validate_selector_intent(selector: dict[str, Any], feature_id: str, index:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} owner match cannot use geometry fallback")
|
||||
source_query = intent.get("source_query") or {}
|
||||
version = source_query.get("featurescript_version") if isinstance(source_query, dict) else None
|
||||
if not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an unknown FeatureScript query version")
|
||||
# An absent source version is a valid preservation state. The resolver,
|
||||
# which owns execution eligibility, returns selector_query_version_unknown
|
||||
# instead of rewriting it as a fake numeric version during validation.
|
||||
if version is not None and (not isinstance(version, str) or not re.fullmatch(r"[0-9]+(?:\.[0-9]+)*", version)):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an invalid FeatureScript query version")
|
||||
if intent.get("output_role") is not None and intent.get("output_role") != selector.get("output_role"):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} intent output role differs from selector output role")
|
||||
source_entity = intent.get("source_entity")
|
||||
source_entities = intent.get("source_entities")
|
||||
if source_entity is not None and source_entities is not None:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} intent cannot mix one source entity with a source-vertex set")
|
||||
lineage_role = intent.get("lineage_role")
|
||||
if lineage_role is not None:
|
||||
if (
|
||||
family != "CAP_EDGE"
|
||||
or selector.get("kind") != "edge"
|
||||
or source_entity is None
|
||||
or lineage_role not in {"extrude.start", "extrude.end"}
|
||||
):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an invalid CAP_EDGE lineage role")
|
||||
if source_entities is not None:
|
||||
if family != "SWEPT_EDGE" or not isinstance(source_entities, list) or len(source_entities) < 2:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor requires SWEPT_EDGE and two source entities")
|
||||
pairs = []
|
||||
for source in source_entities:
|
||||
if not isinstance(source, dict):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is invalid")
|
||||
sketch_id, entity_id = source.get("sketch_id"), source.get("entity_id")
|
||||
if not isinstance(sketch_id, str) or not sketch_id or not isinstance(entity_id, str) or not entity_id:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor is incomplete")
|
||||
pairs.append((sketch_id, entity_id))
|
||||
if len(set(pairs)) != len(pairs):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} source-vertex anchor repeats an entity")
|
||||
intersection_sources = intent.get("intersection_sources")
|
||||
deferred_source_query = (
|
||||
policy.get("multiplicity") == "none"
|
||||
and intent.get("evidence") == "feature_script_query"
|
||||
)
|
||||
if family == "SWEPT_BODY" and not deferred_source_query:
|
||||
if (
|
||||
selector.get("kind") != "body"
|
||||
or selector.get("source") != "runtime_snapshot"
|
||||
or intent.get("evidence") != "active_body_member"
|
||||
or intent.get("body_member_contract") != "direct_new_body"
|
||||
or policy.get("allowed") != ["boundary"]
|
||||
or policy.get("multiplicity") != "one"
|
||||
or any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id", "output_role"))
|
||||
):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an invalid SWEPT_BODY member contract")
|
||||
if family == "INTERSECT":
|
||||
if deferred_source_query:
|
||||
# CADFS source preservation deliberately keeps unsupported outer
|
||||
# query semantics in the candidate. The resolver rejects this
|
||||
# non-executable state before any topology/geometry fallback;
|
||||
# validation must not erase the preceding executable checkpoint.
|
||||
if intersection_sources is not None:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} deferred INTERSECT cannot declare executable section sources")
|
||||
else:
|
||||
if (
|
||||
selector.get("kind") != "edge"
|
||||
or policy.get("allowed") != ["intersection"]
|
||||
or policy.get("multiplicity") != "one"
|
||||
or not isinstance(intersection_sources, list)
|
||||
or len(intersection_sources) != 2
|
||||
):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} has an invalid INTERSECT section contract")
|
||||
owners = set()
|
||||
for source in intersection_sources:
|
||||
if not isinstance(source, dict):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is invalid")
|
||||
source_family = source.get("query_family")
|
||||
source_owner = source.get("owner_feature_id")
|
||||
if source_family not in {"CAP_FACE", "SWEPT_FACE"} or not isinstance(source_owner, str) or not source_owner:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT source is incomplete")
|
||||
owners.add(source_owner)
|
||||
if source_family == "CAP_FACE":
|
||||
if source.get("output_role") not in {"extrude.start", "extrude.end"} or source.get("source_entity") is not None:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} CAP_FACE INTERSECT source is invalid")
|
||||
elif not isinstance(source.get("source_entity"), dict):
|
||||
raise ValueError(f"Feature {feature_id} selector {index} SWEPT_FACE INTERSECT source is invalid")
|
||||
if len(owners) != 2:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} INTERSECT sources must have distinct owners")
|
||||
elif intersection_sources is not None:
|
||||
raise ValueError(f"Feature {feature_id} selector {index} only INTERSECT may declare section sources")
|
||||
forbidden = {"runtime_id", "record_id", "topology_record_id", "task_id", "revision_id"}
|
||||
stack = [intent]
|
||||
while stack:
|
||||
@@ -166,6 +266,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
feature_ids: set[str] = set()
|
||||
preceding_features: dict[str, dict[str, Any]] = {}
|
||||
previous_feature_id: str | None = None
|
||||
deferred: list[str] = []
|
||||
unresolved: list[dict[str, Any]] = []
|
||||
contracts = _operation_contracts()
|
||||
@@ -185,21 +286,26 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
deferred.append(fid)
|
||||
contract = contracts.get(str(feature.get("atomic_id") or "")) or {}
|
||||
feature_selectors = _contract_selectors(feature, contract)
|
||||
output_role_selector_ids = {id(selector) for selector in feature_selectors if isinstance(selector, dict)}
|
||||
extent_selector = _up_to_surface_output_role_reference(feature, contract)
|
||||
output_role_selectors = [
|
||||
*feature_selectors,
|
||||
*([extent_selector] if extent_selector is not None else []),
|
||||
]
|
||||
output_role_selector_ids = {id(selector) for selector in output_role_selectors}
|
||||
selector_intent_ids = {
|
||||
id(selector.get("selector_intent"))
|
||||
for selector in feature_selectors
|
||||
for selector in output_role_selectors
|
||||
if isinstance(selector, dict) and isinstance(selector.get("selector_intent"), dict)
|
||||
}
|
||||
for index, selector in enumerate(feature_selectors):
|
||||
validated_selector_ids: set[int] = set()
|
||||
for index, selector in enumerate(output_role_selectors):
|
||||
_validate_selector_intent(selector, fid, index)
|
||||
validated_selector_ids.add(id(selector))
|
||||
owner = selector.get("owner_feature_id")
|
||||
binding_owner = selector.get("binding_feature_id")
|
||||
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} has a forward or missing owner_feature_id")
|
||||
if selector.get("output_role") is not None:
|
||||
if not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
|
||||
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
|
||||
if selector.get("kind") != "face" or not owner or owner not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} selector {index} output role requires a preceding face owner_feature_id")
|
||||
if selector.get("source") != "runtime_snapshot":
|
||||
@@ -207,6 +313,29 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
if any(selector.get(key) is not None for key in ("stable_id", "snapshot_id", "geometry", "binding_feature_id")):
|
||||
raise ValueError(f"Feature {fid} selector {index} output role cannot mix stable or geometry evidence")
|
||||
role_source = selector.get("output_role_source")
|
||||
selector_intent = selector.get("selector_intent")
|
||||
is_shell_cap_face_output_role = (
|
||||
feature.get("atomic_id") == "shell"
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
)
|
||||
if selector is extent_selector:
|
||||
if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches},
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} up_to_surface output role requires the immediately preceding direct new_body blind extrusion cap"
|
||||
)
|
||||
elif is_shell_cap_face_output_role:
|
||||
if owner != previous_feature_id or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, preceding_features.get(str(owner)), {str(sketch.get("id") or ""): sketch for sketch in sketches},
|
||||
):
|
||||
raise ValueError(
|
||||
f"Feature {fid} CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap"
|
||||
)
|
||||
elif not contract.get("selector_slot") or contract.get("selector_token_kind") != "face":
|
||||
raise ValueError(f"Feature {fid} selector {index} cannot consume a feature output role")
|
||||
if role_source is not None:
|
||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||
@@ -234,6 +363,18 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
elif selector.get("output_role_source") is not None:
|
||||
raise ValueError(f"Feature {fid} selector {index} output role source requires output_role")
|
||||
for selector in _mappings(feature):
|
||||
if (
|
||||
id(selector) not in validated_selector_ids
|
||||
and (selector.get("selector_intent") is not None or selector.get("selector_intent_version") is not None)
|
||||
):
|
||||
_validate_selector_intent(selector, fid, len(validated_selector_ids))
|
||||
validated_selector_ids.add(id(selector))
|
||||
owner = selector.get("owner_feature_id")
|
||||
binding_owner = selector.get("binding_feature_id")
|
||||
if owner is not None and owner not in feature_ids and binding_owner not in feature_ids:
|
||||
raise ValueError(
|
||||
f"Feature {fid} nested selector has a forward or missing owner_feature_id"
|
||||
)
|
||||
# ``output_role_source`` is provenance metadata nested inside a
|
||||
# feature selector, not a selector on its own.
|
||||
if (
|
||||
@@ -251,6 +392,10 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
target_feature_id = (feature.get("params") or {}).get("target_feature_id")
|
||||
if target_feature_id is not None and target_feature_id not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} shell target_feature_id requires a preceding body feature")
|
||||
if feature.get("atomic_id") in {"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"}:
|
||||
scope_feature_id = (feature.get("params") or {}).get("scope_feature_id")
|
||||
if scope_feature_id is not None and scope_feature_id not in feature_ids:
|
||||
raise ValueError(f"Feature {fid} hole scope_feature_id requires a preceding body feature")
|
||||
if feature.get("atomic_id") == "transform_bodies":
|
||||
params = feature.get("params") or {}
|
||||
references = params.get("pattern_instance_refs") or []
|
||||
@@ -354,6 +499,7 @@ def validate_semantic_cdsl(cdsl: dict[str, Any]) -> dict[str, Any]:
|
||||
unresolved.append({"feature_id": fid, "reasons": list(feature["unresolved"])})
|
||||
feature_ids.add(fid)
|
||||
preceding_features[fid] = feature
|
||||
previous_feature_id = fid
|
||||
|
||||
return {
|
||||
"schema_version": version,
|
||||
|
||||
@@ -23,6 +23,7 @@ from .topology import (
|
||||
TopologyDelta,
|
||||
TopologyRecord,
|
||||
TopologyRegistry,
|
||||
validate_selector_provenance_intent,
|
||||
)
|
||||
|
||||
|
||||
@@ -38,6 +39,7 @@ class GeometryAdapter(Protocol):
|
||||
def body_geometry(self, body: Any) -> dict[str, Any]: ...
|
||||
def surface_geometry(self, surface: Any) -> dict[str, Any]: ...
|
||||
def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
|
||||
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]: ...
|
||||
def face_with_holes(self, outer: Any, holes: list[Any]) -> Any: ...
|
||||
def loft(self, sketches: list[dict[str, Any]]) -> Any: ...
|
||||
def loft_with_topology_delta(self, sketches: list[dict[str, Any]]) -> tuple[Any, TopologyDelta | None]: ...
|
||||
@@ -115,6 +117,7 @@ class ExecutionSession:
|
||||
body_members: dict[str, Any] | None = None,
|
||||
topology_delta: TopologyDelta | None = None,
|
||||
topology_predecessors: list[TopologyRecord] | None = None,
|
||||
topology_anchors: list[TopologyRecord] | None = None,
|
||||
) -> None:
|
||||
# #7 multi-body:主体可能是 Compound(多个独立实体,例如两个不相交的
|
||||
# 拉伸)。body_id 现在反映真实实体结构而不是"最后一个特征的 id":
|
||||
@@ -123,12 +126,20 @@ class ExecutionSession:
|
||||
self.body = body
|
||||
self.body_id = f"body:{feature_id}"
|
||||
self.body_members = dict(body_members) if body_members is not None else {feature_id: body}
|
||||
# Source-profile anchors are transient construction facts, but unlike
|
||||
# generic role predecessors they must remain addressable by a later
|
||||
# selector intent. They never receive a body id, so active selector
|
||||
# scans cannot mistake them for current model topology.
|
||||
anchors = list(topology_anchors or ())
|
||||
for anchor in anchors:
|
||||
self.topology.register(anchor)
|
||||
predecessors = [*(topology_predecessors or ()), *anchors]
|
||||
solids = self.adapter.body_solids(body)
|
||||
if len(solids) <= 1:
|
||||
self.topology.replace_body_topology(
|
||||
feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id),
|
||||
topology_delta=topology_delta,
|
||||
additional_predecessors=topology_predecessors or (),
|
||||
additional_predecessors=predecessors,
|
||||
)
|
||||
else:
|
||||
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
|
||||
@@ -141,7 +152,7 @@ class ExecutionSession:
|
||||
]
|
||||
self.topology.replace_body_topologies(
|
||||
feature_id, members, active_body_id=self.body_id, topology_delta=topology_delta,
|
||||
additional_predecessors=topology_predecessors or (),
|
||||
additional_predecessors=predecessors,
|
||||
)
|
||||
self.topology.register(TopologyRecord(
|
||||
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
|
||||
@@ -150,6 +161,25 @@ class ExecutionSession:
|
||||
if replay_node is not None:
|
||||
self.replay_definitions[feature_id] = replay_node
|
||||
|
||||
def register_transient_prism_tool(
|
||||
self,
|
||||
feature_id: str,
|
||||
tool: Any,
|
||||
*,
|
||||
topology_delta: TopologyDelta,
|
||||
topology_anchors: list[TopologyRecord],
|
||||
) -> list[TopologyRecord]:
|
||||
"""Keep one direct-prism primary tool as boolean-input evidence only."""
|
||||
snapshot_id = f"transient:{feature_id}"
|
||||
records = self.adapter.topology_records(tool, feature_id, snapshot_id)
|
||||
return list(self.topology.register_transient_snapshot(
|
||||
feature_id,
|
||||
snapshot_id,
|
||||
records,
|
||||
topology_delta=topology_delta,
|
||||
anchors=topology_anchors,
|
||||
))
|
||||
|
||||
def register_surface(self, feature_id: str, surface: Any) -> str:
|
||||
# 曲面 feature 与实体 body 生命周期相互独立:不能调用 register_body,
|
||||
# 否则 surface 会覆盖 active solid 并改变最终 STEP 的实体结果。
|
||||
@@ -236,6 +266,17 @@ class ExecutionSession:
|
||||
|
||||
def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
|
||||
if selector.get("intersection_of") is not None:
|
||||
# The session computes vertex intersections directly from resolved
|
||||
# face components, so enforce the same outer provenance gate that
|
||||
# TopologyRegistry.resolve applies before any geometry operation.
|
||||
validation_error = validate_selector_provenance_intent(selector)
|
||||
if validation_error is not None:
|
||||
return self._record_selector_resolution(SelectorResolution(
|
||||
selector=selector,
|
||||
status="not_found",
|
||||
candidates=(),
|
||||
diagnostic=validation_error,
|
||||
))
|
||||
return self._record_selector_resolution(self._resolve_intersection_vertex(selector))
|
||||
owner = str(selector.get("owner_feature_id") or "")
|
||||
active_body_id = f"surface:{owner}" if owner in self.surface_members else self.body_id
|
||||
|
||||
@@ -17,12 +17,28 @@ _Ctx = dict[str, Any]
|
||||
_TOLERANCE_MM = 1e-5
|
||||
|
||||
|
||||
def _circle(center: list[float], radius_mm: float, construction: bool = False) -> _Ctx:
|
||||
return {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
|
||||
def _circle(
|
||||
center: list[float],
|
||||
radius_mm: float,
|
||||
construction: bool = False,
|
||||
source_entity_id: str | None = None,
|
||||
) -> _Ctx:
|
||||
output = {"type": "circle", "center": [float(center[0]), float(center[1])], "radius_mm": float(radius_mm), "construction": construction}
|
||||
if source_entity_id is not None:
|
||||
output["source_entity_id"] = source_entity_id
|
||||
return output
|
||||
|
||||
|
||||
def _line(start: list[float], end: list[float], construction: bool = False) -> _Ctx:
|
||||
return {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
|
||||
def _line(
|
||||
start: list[float],
|
||||
end: list[float],
|
||||
construction: bool = False,
|
||||
source_entity_id: str | None = None,
|
||||
) -> _Ctx:
|
||||
output = {"type": "line", "start": [float(start[0]), float(start[1])], "end": [float(end[0]), float(end[1])], "construction": construction}
|
||||
if source_entity_id is not None:
|
||||
output["source_entity_id"] = source_entity_id
|
||||
return output
|
||||
|
||||
|
||||
def _point(point: list[float]) -> list[float]:
|
||||
@@ -118,7 +134,8 @@ def _gen_circle(profile: _Ctx, _: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
cx, cy = float(center[0]), float(center[1])
|
||||
# 直接圆 profile 必须保留为一条完整的圆边。若拆成四条圆弧,后续按边
|
||||
# 选择的圆角/倒角会把同一拓扑圆误解为四个独立目标。
|
||||
return [_circle([cx, cy], radius)], []
|
||||
source_entity_id = profile.get("source_entity_id")
|
||||
return [_circle([cx, cy], radius, source_entity_id=source_entity_id if isinstance(source_entity_id, str) else None)], []
|
||||
|
||||
|
||||
def _gen_polygon(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
|
||||
@@ -215,7 +232,16 @@ def _circle_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
clockwise = bool(segment.get("clockwise", False))
|
||||
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
|
||||
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
|
||||
return [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
|
||||
edges = [_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise) for index in range(4)]
|
||||
source_entity_id = segment.get("source_entity_id")
|
||||
if isinstance(source_entity_id, str) and source_entity_id:
|
||||
# Region construction needs four arc segments, but this marker records
|
||||
# that all four came from exactly one logical source circle. It is not
|
||||
# an edge anchor: callers must rebuild one native circle wire and pass
|
||||
# final-face identity checks before using it for lineage.
|
||||
for edge in edges:
|
||||
edge["logical_circle_source_entity_id"] = source_entity_id
|
||||
return edges
|
||||
|
||||
|
||||
def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
@@ -242,14 +268,14 @@ def _ellipse_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
kind = segment.get("type")
|
||||
if kind == "line":
|
||||
return [_contour_line(segment["start"], segment["end"])]
|
||||
if kind == "arc":
|
||||
return [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
|
||||
if kind == "circle":
|
||||
return _circle_edges(segment)
|
||||
if kind == "ellipse":
|
||||
return _ellipse_edges(segment)
|
||||
if kind == "bspline":
|
||||
edges = [_contour_line(segment["start"], segment["end"])]
|
||||
elif kind == "arc":
|
||||
edges = [_contour_arc(segment["start"], segment["end"], segment["center"], segment.get("radius_mm"), segment.get("clockwise"))]
|
||||
elif kind == "circle":
|
||||
edges = _circle_edges(segment)
|
||||
elif kind == "ellipse":
|
||||
edges = _ellipse_edges(segment)
|
||||
elif kind == "bspline":
|
||||
points = segment.get("points") or []
|
||||
if len(points) < 2:
|
||||
raise ValueError("analytic_contours: bspline needs at least 2 interpolation points")
|
||||
@@ -304,8 +330,18 @@ def _segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
raise ValueError("analytic_contours: periodic bspline does not accept endpoint tangents")
|
||||
output["start_tangent_mm"] = _point(start_tangent)
|
||||
output["end_tangent_mm"] = _point(end_tangent)
|
||||
return [output]
|
||||
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
|
||||
edges = [output]
|
||||
else:
|
||||
raise ValueError(f"analytic_contours: unsupported segment type {kind!r}")
|
||||
source_entity_id = segment.get("source_entity_id")
|
||||
# Only a one-edge construction has the direct, one-to-one source identity
|
||||
# required by profile-to-prism lineage. Circles expanded to arcs carry a
|
||||
# separate logical-circle marker, not a source edge identity; the adapter
|
||||
# may use it only to reconstruct one native circle wire with exact final
|
||||
# membership proof. Other multi-edge approximations remain unanchored.
|
||||
if isinstance(source_entity_id, str) and len(edges) == 1:
|
||||
edges[0]["source_entity_id"] = source_entity_id
|
||||
return edges
|
||||
|
||||
|
||||
def _imprint_segment_edges(segment: _Ctx) -> list[_Ctx]:
|
||||
@@ -466,11 +502,18 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
raw_edges: list[_Ctx] = []
|
||||
for segment in contour.get("segments") or []:
|
||||
if segment.get("type") == "line":
|
||||
entities.append(_line(segment["start"], segment["end"]))
|
||||
entities.append(_line(
|
||||
segment["start"], segment["end"],
|
||||
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
|
||||
))
|
||||
elif segment.get("type") == "circle":
|
||||
if contour_open:
|
||||
raise ValueError(f"analytic_contours: open contour {index} cannot contain a full circle segment")
|
||||
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
|
||||
entities.append(_circle(
|
||||
segment.get("center") or [0.0, 0.0],
|
||||
float(segment.get("radius_mm") or 0.0),
|
||||
source_entity_id=segment.get("source_entity_id") if isinstance(segment.get("source_entity_id"), str) else None,
|
||||
))
|
||||
raw_edges.extend(_segment_edges(segment))
|
||||
if raw_edges:
|
||||
edges = _join(raw_edges, allow_open=contour_open)
|
||||
|
||||
+1391
-144
File diff suppressed because it is too large
Load Diff
@@ -395,6 +395,23 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertAlmostEqual(edge.tangent_at(0).Y, 1.0, places=6)
|
||||
self.assertAlmostEqual(edge.tangent_at(1).X, -1.0, places=6)
|
||||
|
||||
def test_bspline_interpolation_tangent_uses_the_runtime_parameter_domain(self) -> None:
|
||||
from cdsl_engine.build123d_adapter import interpolated_bspline_point_and_tangent
|
||||
|
||||
points = [(0.0, -23.11, 0.0), (0.0, -5.33, 0.0), (26.7, 9.39, 0.0)]
|
||||
parameters = [0.0, 0.43299474427180723, 1.0]
|
||||
point, tangent = interpolated_bspline_point_and_tangent(
|
||||
points,
|
||||
start_tangent=(0.0, 64.52, 0.0),
|
||||
end_tangent=(141.23, -13.99, 0.0),
|
||||
parameters=parameters,
|
||||
interpolation_index=1,
|
||||
)
|
||||
for actual, expected in zip(point, points[1]):
|
||||
self.assertAlmostEqual(actual, expected, places=12)
|
||||
self.assertAlmostEqual(tangent[0], 0.008342033594358346, places=10)
|
||||
self.assertAlmostEqual(tangent[1], 36.52292778091852, places=10)
|
||||
|
||||
def test_two_point_bspline_profile_requires_and_preserves_endpoint_tangents(self) -> None:
|
||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
||||
|
||||
@@ -930,51 +947,131 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
source = object()
|
||||
exact_result = object()
|
||||
geometrically_similar = object()
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:face", "face", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
|
||||
])
|
||||
source_anchor = TopologyRecord(
|
||||
"anchor:base:circle", "edge", "base", geometry={}, value=source,
|
||||
source_entity=("sketch_base", "circle"),
|
||||
)
|
||||
registry.register(source_anchor)
|
||||
registry.replace_body_topology("later", "body:later", [
|
||||
TopologyRecord("later:exact", "face", "later", "body:later", {"center_mm": [10, 0, 0]}, exact_result),
|
||||
TopologyRecord("later:similar", "face", "later", "body:later", {"center_mm": [0, 0, 0]}, geometrically_similar),
|
||||
], topology_delta=TopologyDelta("transform", (
|
||||
TopologyDeltaRelation("modified", "face", source, (exact_result,)),
|
||||
)))
|
||||
], topology_delta=TopologyDelta("extrude", (
|
||||
TopologyDeltaRelation(
|
||||
"generated", "edge", source, (exact_result,),
|
||||
source_kind="edge", result_kind="face", derivation="boundary",
|
||||
),
|
||||
)), additional_predecessors=[source_anchor])
|
||||
selector = {
|
||||
"kind": "face", "owner_feature_id": "base", "stable_id": "base:face",
|
||||
"kind": "face", "owner_feature_id": "base",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"geometry": {"center_mm": [0, 0, 0]},
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
|
||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
||||
"derivation_policy": {"allowed": ["continuation"], "multiplicity": "one"},
|
||||
"source_query": {
|
||||
"ast": {}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"source_entity": {"sketch_id": "sketch_base", "entity_id": "circle"},
|
||||
"derivation_policy": {"allowed": ["boundary"], "multiplicity": "one"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
}
|
||||
resolution = registry.resolve(selector, active_body_id="body:later")
|
||||
self.assertEqual(resolution.status, "resolved")
|
||||
self.assertEqual(resolution.record.record_id, "later:exact")
|
||||
self.assertEqual(registry.lineage()[0].derivation, "continuation")
|
||||
self.assertEqual(registry.lineage()[0].derivation, "boundary")
|
||||
|
||||
def test_complete_continuation_ignores_nonfinal_builder_handles(self) -> None:
|
||||
"""A boolean's intermediate Generated handles are not face fragments.
|
||||
|
||||
OCC can return both one final ``Modified(face)`` and Generated faces
|
||||
which are absent from the result snapshot for the same source face.
|
||||
The latter are useful diagnostics but cannot invalidate the exact
|
||||
one-to-one target continuation.
|
||||
"""
|
||||
registry = TopologyRegistry()
|
||||
source_edge = object()
|
||||
initial_face = object()
|
||||
final_face = object()
|
||||
intermediate_face = object()
|
||||
anchor = TopologyRecord(
|
||||
"anchor:base:edge", "edge", "base", geometry={}, value=source_edge,
|
||||
source_entity=("sketch_base", "edge"),
|
||||
)
|
||||
registry.register(anchor)
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:wall", "face", "base", "body:base", {}, initial_face),
|
||||
], topology_delta=TopologyDelta("extrude", (
|
||||
TopologyDeltaRelation(
|
||||
"generated", "edge", source_edge, (initial_face,),
|
||||
source_kind="edge", result_kind="face", derivation="boundary",
|
||||
),
|
||||
)), additional_predecessors=[anchor])
|
||||
registry.replace_body_topology("cut", "body:cut", [
|
||||
TopologyRecord("cut:wall", "face", "cut", "body:cut", {}, final_face),
|
||||
], topology_delta=TopologyDelta("subtract", (
|
||||
TopologyDeltaRelation("modified", "face", initial_face, (final_face,)),
|
||||
TopologyDeltaRelation(
|
||||
"generated", "face", initial_face, (intermediate_face,),
|
||||
derivation="boundary",
|
||||
),
|
||||
)))
|
||||
|
||||
resolution = registry.resolve({
|
||||
"kind": "face", "owner_feature_id": "base",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "face", "query_family": "SWEPT_FACE",
|
||||
"source_query": {
|
||||
"ast": {}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"source_entity": {"sketch_id": "sketch_base", "entity_id": "edge"},
|
||||
"derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
}, active_body_id="body:cut")
|
||||
|
||||
self.assertEqual(resolution.status, "resolved")
|
||||
self.assertEqual(resolution.record.record_id, "cut:wall")
|
||||
cut_relations = registry.topology_deltas()[-1]["relations"]
|
||||
self.assertTrue(any(item["coverage"] == "partial" for item in cut_relations))
|
||||
|
||||
def test_provenance_selector_rejects_non_unique_fragment(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
source = object()
|
||||
first = object()
|
||||
second = object()
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:edge", "edge", "base", "body:base", {"center_mm": [0, 0, 0]}, source),
|
||||
])
|
||||
source_anchor = TopologyRecord(
|
||||
"anchor:base:vertex", "vertex", "base", geometry={}, value=source,
|
||||
source_entities=(("sketch_base", "left"), ("sketch_base", "right")),
|
||||
)
|
||||
registry.register(source_anchor)
|
||||
registry.replace_body_topology("fillet", "body:fillet", [
|
||||
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {"center_mm": [0, 0, 0]}, first),
|
||||
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {"center_mm": [1, 0, 0]}, second),
|
||||
], topology_delta=TopologyDelta("fillet", (
|
||||
TopologyDeltaRelation("modified", "edge", source, (first, second)),
|
||||
)))
|
||||
TopologyDeltaRelation(
|
||||
"modified", "vertex", source, (first, second),
|
||||
source_kind="vertex", result_kind="edge", derivation="fragment",
|
||||
),
|
||||
)), additional_predecessors=[source_anchor])
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge",
|
||||
"kind": "edge", "owner_feature_id": "base",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
||||
"source_query": {
|
||||
"ast": {}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"source_entities": [
|
||||
{"sketch_id": "sketch_base", "entity_id": "left"},
|
||||
{"sketch_id": "sketch_base", "entity_id": "right"},
|
||||
],
|
||||
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "one"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
@@ -987,21 +1084,34 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
source = object()
|
||||
first = object()
|
||||
second = object()
|
||||
registry.replace_body_topology("base", "body:base", [
|
||||
TopologyRecord("base:edge", "edge", "base", "body:base", {}, source),
|
||||
])
|
||||
source_anchor = TopologyRecord(
|
||||
"anchor:base:vertex", "vertex", "base", geometry={}, value=source,
|
||||
source_entities=(("sketch_base", "left"), ("sketch_base", "right")),
|
||||
)
|
||||
registry.register(source_anchor)
|
||||
registry.replace_body_topology("fillet", "body:fillet", [
|
||||
TopologyRecord("fillet:first", "edge", "fillet", "body:fillet", {}, first),
|
||||
TopologyRecord("fillet:second", "edge", "fillet", "body:fillet", {}, second),
|
||||
], topology_delta=TopologyDelta("fillet", (
|
||||
TopologyDeltaRelation("modified", "edge", source, (first, second)),
|
||||
)))
|
||||
TopologyDeltaRelation(
|
||||
"modified", "vertex", source, (first, second),
|
||||
source_kind="vertex", result_kind="edge", derivation="fragment",
|
||||
),
|
||||
)), additional_predecessors=[source_anchor])
|
||||
resolution = registry.resolve({
|
||||
"kind": "edge", "owner_feature_id": "base", "stable_id": "base:edge",
|
||||
"kind": "edge", "owner_feature_id": "base",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
||||
"source_query": {"ast": {}, "featurescript_version": "1511"},
|
||||
"source_query": {
|
||||
"ast": {}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"source_entities": [
|
||||
{"sketch_id": "sketch_base", "entity_id": "left"},
|
||||
{"sketch_id": "sketch_base", "entity_id": "right"},
|
||||
],
|
||||
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"},
|
||||
"evidence": "kernel_history",
|
||||
},
|
||||
@@ -1010,6 +1120,75 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertIsNone(resolution.record)
|
||||
self.assertEqual([record.record_id for record in resolution.records], ["fillet:first", "fillet:second"])
|
||||
|
||||
def test_fillet_consumes_all_proven_fragment_records(self) -> None:
|
||||
"""A dress-up must consume every record allowed by all_fragments."""
|
||||
from build123d import Box
|
||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
||||
from cdsl_engine.runtime import ExecutionSession, FeaturePlanNode, execute_node
|
||||
|
||||
class RecordingAdapter(Build123dGeometryAdapter):
|
||||
def __init__(self) -> None:
|
||||
self.selected_edges: list[object] = []
|
||||
|
||||
def fillet_with_topology_delta(self, body, radius_mm, edges):
|
||||
self.selected_edges = list(edges)
|
||||
# Selection delivery is the behavior under test. Returning the
|
||||
# same valid B-rep keeps this focused on the executor contract.
|
||||
return body, None
|
||||
|
||||
adapter = RecordingAdapter()
|
||||
body = Box(10, 10, 10)
|
||||
first, second = body.edges()[:2]
|
||||
source = object()
|
||||
session = ExecutionSession(sketches={}, nodes={}, adapter=adapter)
|
||||
session.body = body
|
||||
session.body_id = "body:fragments"
|
||||
session.body_members = {"fragments": body}
|
||||
source_anchor = TopologyRecord(
|
||||
"anchor:source:vertex", "vertex", "source", geometry={}, value=source,
|
||||
source_entities=(("sketch_source", "left"), ("sketch_source", "right")),
|
||||
)
|
||||
session.topology.register(source_anchor)
|
||||
session.topology.replace_body_topology("fragments", "body:fragments", [
|
||||
TopologyRecord("fragments:first", "edge", "fragments", "body:fragments", value=first),
|
||||
TopologyRecord("fragments:second", "edge", "fragments", "body:fragments", value=second),
|
||||
], topology_delta=TopologyDelta("split", (
|
||||
TopologyDeltaRelation(
|
||||
"modified", "vertex", source, (first, second),
|
||||
source_kind="vertex", result_kind="edge", derivation="fragment",
|
||||
),
|
||||
)), additional_predecessors=[source_anchor])
|
||||
node = FeaturePlanNode(
|
||||
"fillet", "fillet", None, ("fragments",), {"radius_mm": 0.5}, ({
|
||||
"kind": "edge", "owner_feature_id": "source",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "edge", "query_family": "SWEPT_EDGE",
|
||||
"source_query": {
|
||||
"ast": {}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"source_entities": [
|
||||
{"sketch_id": "sketch_source", "entity_id": "left"},
|
||||
{"sketch_id": "sketch_source", "entity_id": "right"},
|
||||
],
|
||||
"derivation_policy": {"allowed": ["fragment"], "multiplicity": "all_fragments"},
|
||||
},
|
||||
},), None, "supported", {"id": "fillet"},
|
||||
)
|
||||
|
||||
result = execute_node(node, session)
|
||||
|
||||
self.assertEqual(result.status, "executed")
|
||||
self.assertEqual(len(adapter.selected_edges), 2)
|
||||
self.assertTrue(adapter.selected_edges[0].is_same(first))
|
||||
self.assertTrue(adapter.selected_edges[1].is_same(second))
|
||||
self.assertEqual(
|
||||
[record["record_id"] for record in session.selector_resolutions[-1]["records"]],
|
||||
["fragments:first", "fragments:second"],
|
||||
)
|
||||
|
||||
def test_boolean_section_edges_are_recorded_as_intersection_lineage(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
section_edge = object()
|
||||
@@ -1022,6 +1201,138 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
self.assertEqual(lineage["result_record_ids"], ["boolean:section"])
|
||||
self.assertTrue(delta["relations"][0]["section_edge"])
|
||||
|
||||
def test_boolean_builder_qualifies_section_edges_with_exact_input_faces(self) -> None:
|
||||
from build123d import Location, Solid
|
||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
||||
|
||||
adapter = Build123dGeometryAdapter()
|
||||
target = Solid.make_box(10, 10, 10)
|
||||
tool = Solid.make_box(10, 10, 10).moved(Location((5, 5, 5)))
|
||||
result, delta = adapter.cut_with_topology_delta(target, tool)
|
||||
|
||||
self.assertTrue(result.is_valid)
|
||||
self.assertIsNotNone(delta)
|
||||
self.assertTrue(delta.section_relations)
|
||||
final_edges = [edge.wrapped for edge in result.edges()]
|
||||
target_faces = [face.wrapped for face in target.faces()]
|
||||
tool_faces = [face.wrapped for face in tool.faces()]
|
||||
for relation in delta.section_relations:
|
||||
self.assertTrue(any(relation.result_value.IsSame(edge) for edge in final_edges))
|
||||
self.assertTrue(any(relation.source_values[0].IsSame(face) for face in target_faces))
|
||||
self.assertTrue(any(relation.source_values[1].IsSame(face) for face in tool_faces))
|
||||
# Qualified results must not also be exposed through the unqualified
|
||||
# SectionEdges-only diagnostic channel.
|
||||
self.assertFalse(delta.section_values)
|
||||
|
||||
def test_boolean_section_selector_executes_through_source_qualified_lineage(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
def source_rectangle(points: list[list[float]]) -> list[dict]:
|
||||
return [
|
||||
{"type": "line", "start": start, "end": end, "source_entity_id": f"E{index}"}
|
||||
for index, (start, end) in enumerate(zip(points, [*points[1:], points[0]]))
|
||||
]
|
||||
|
||||
workplane = _workplane()
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "section-selector",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [
|
||||
{
|
||||
"id": "left_sketch", "source_sketch_id": "L", "workplane": workplane,
|
||||
"profile": {"type": "analytic_contours", "contours": [{
|
||||
"role": "outer", "closed": True,
|
||||
"segments": source_rectangle([[0, 0], [10, 0], [10, 10], [0, 10]]),
|
||||
}]},
|
||||
},
|
||||
{
|
||||
"id": "right_sketch", "source_sketch_id": "R", "workplane": workplane,
|
||||
"profile": {"type": "analytic_contours", "contours": [{
|
||||
"role": "outer", "closed": True,
|
||||
"segments": source_rectangle([[5, -5], [15, -5], [15, 5], [5, 5]]),
|
||||
}]},
|
||||
},
|
||||
]},
|
||||
"features": [
|
||||
{
|
||||
"id": "left", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "left_sketch",
|
||||
"params": {"distance_mm": 4, "result_mode": "new_body"},
|
||||
},
|
||||
{
|
||||
"id": "right", "atomic_id": "extrude_add_blind", "depends_on": ["left"], "sketch_id": "right_sketch",
|
||||
"params": {"distance_mm": 4, "result_mode": "new_body"},
|
||||
},
|
||||
{
|
||||
"id": "boolean", "atomic_id": "boolean_bodies", "depends_on": ["left", "right"],
|
||||
"params": {
|
||||
"operation": "subtract", "target_feature_ids": ["left"],
|
||||
"tool_feature_ids": ["right"], "keep_tools": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "fillet", "atomic_id": "fillet", "depends_on": ["boolean"], "params": {"radius_mm": 0.1},
|
||||
"selectors": [{
|
||||
"kind": "edge", "owner_feature_id": "boolean", "source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "edge", "query_family": "INTERSECT",
|
||||
"source_query": {
|
||||
"ast": {"call": "makeQuery"}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"derivation_policy": {"allowed": ["intersection"], "multiplicity": "one"},
|
||||
"evidence": "kernel_history",
|
||||
"intersection_sources": [
|
||||
{
|
||||
"query_family": "SWEPT_FACE", "owner_feature_id": "left",
|
||||
"source_entity": {"sketch_id": "L", "entity_id": "E0"},
|
||||
},
|
||||
{
|
||||
"query_family": "SWEPT_FACE", "owner_feature_id": "right",
|
||||
"source_entity": {"sketch_id": "R", "entity_id": "E3"},
|
||||
},
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
],
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = rebuild_cdsl(cdsl, Path(directory) / "section-selector.step")
|
||||
|
||||
self.assertEqual(result["feature_results"][-1]["feature_id"], "fillet")
|
||||
section_relations = [
|
||||
relation for delta in result["topology_deltas"] if delta["feature_id"] == "boolean"
|
||||
for relation in delta["relations"] if relation.get("source_qualified")
|
||||
]
|
||||
self.assertTrue(section_relations)
|
||||
|
||||
# A primary REMOVE owns no active tool member. It can still retain the
|
||||
# same source-qualified section evidence only through its transient
|
||||
# direct-prism tool snapshot.
|
||||
primary = deepcopy(cdsl)
|
||||
primary["part_id"] = "primary-section-selector"
|
||||
primary["features"][1].update({
|
||||
"id": "cut", "atomic_id": "extrude_cut_blind", "depends_on": ["left"],
|
||||
"params": {"distance_mm": 4},
|
||||
})
|
||||
primary["features"] = [primary["features"][0], primary["features"][1], primary["features"][3]]
|
||||
primary["features"][2]["depends_on"] = ["cut"]
|
||||
selector = primary["features"][2]["selectors"][0]
|
||||
selector["owner_feature_id"] = "cut"
|
||||
selector["selector_intent"]["intersection_sources"][1]["owner_feature_id"] = "cut"
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
primary_result = rebuild_cdsl(primary, Path(directory) / "primary-section-selector.step")
|
||||
|
||||
self.assertEqual(primary_result["feature_results"][-1]["feature_id"], "fillet")
|
||||
primary_delta = next(
|
||||
delta for delta in primary_result["topology_deltas"]
|
||||
if delta["feature_id"] == "cut" and delta["operation"] == "subtract"
|
||||
)
|
||||
self.assertEqual(set(primary_delta["input_snapshot_ids"]), {"body:left", "transient:cut"})
|
||||
self.assertTrue(any(relation.get("source_qualified") for relation in primary_delta["relations"]))
|
||||
self.assertTrue(any(record.get("transient") for record in primary_result["topology_records"]))
|
||||
|
||||
def test_shell_offset_role_source_selects_one_exact_builder_relation(self) -> None:
|
||||
registry = TopologyRegistry()
|
||||
extrude_start = object()
|
||||
@@ -1177,6 +1488,124 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
with self.assertRaisesRegex(ValueError, "requires a preceding body feature"):
|
||||
validate_semantic_cdsl(invalid_shell_target)
|
||||
|
||||
def test_up_to_surface_consumes_only_an_immediate_direct_cap_output_role(self) -> None:
|
||||
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
||||
from cdsl_engine.semantic_validation import validate_semantic_cdsl
|
||||
|
||||
cap_reference = {
|
||||
"kind": "face", "owner_feature_id": "base_add", "output_role": "extrude.end",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "face", "query_family": "CAP_FACE",
|
||||
"source_query": {
|
||||
"ast": {"call": "makeQuery"}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"},
|
||||
"evidence": "operation_role", "output_role": "extrude.end",
|
||||
},
|
||||
}
|
||||
cdsl = self._base_block()
|
||||
cdsl["features"][0]["execution_status"] = "supported"
|
||||
cdsl["features"][0]["params"].update({
|
||||
"result_mode": "new_body", "end_condition": {"type": "blind", "solidworks_code": 0},
|
||||
})
|
||||
cdsl["geometry"]["sketches"].append({
|
||||
"id": "cut", "workplane": _workplane(),
|
||||
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
|
||||
})
|
||||
cdsl["features"].append({
|
||||
"id": "cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
|
||||
"params": {
|
||||
"distance_mm": 1,
|
||||
"end_condition": {"type": "up_to_surface", "solidworks_code": 2, "reference": cap_reference},
|
||||
},
|
||||
"sketch_id": "cut", "execution_status": "supported",
|
||||
})
|
||||
|
||||
self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"])
|
||||
self.assertTrue(analyze_cdsl(cdsl).feature_results[-1].executable)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "up-to-surface-cap-role.step")
|
||||
resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "cut")
|
||||
self.assertEqual(resolution["status"], "resolved")
|
||||
self.assertEqual(resolution["resolution_mode"], "operation_role")
|
||||
self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"])
|
||||
|
||||
non_new_body = deepcopy(cdsl)
|
||||
non_new_body["features"][0]["params"]["result_mode"] = "fuse"
|
||||
with self.assertRaisesRegex(ValueError, "direct new_body blind extrusion cap"):
|
||||
validate_semantic_cdsl(non_new_body)
|
||||
self.assertIn(
|
||||
"unsupported_extent_output_role_selector",
|
||||
[blocker.code for blocker in analyze_cdsl(non_new_body).feature_results[-1].blockers],
|
||||
)
|
||||
|
||||
non_immediate = deepcopy(cdsl)
|
||||
non_immediate["features"].insert(1, {
|
||||
"id": "gap", "atomic_id": "reference_plane", "depends_on": ["base_add"],
|
||||
"params": {"plane": _workplane()}, "execution_status": "supported",
|
||||
})
|
||||
non_immediate["features"][-1]["depends_on"] = ["gap"]
|
||||
with self.assertRaisesRegex(ValueError, "immediately preceding direct new_body blind extrusion cap"):
|
||||
validate_semantic_cdsl(non_immediate)
|
||||
self.assertIn(
|
||||
"unsupported_extent_output_role_selector",
|
||||
[blocker.code for blocker in analyze_cdsl(non_immediate).feature_results[-1].blockers],
|
||||
)
|
||||
|
||||
def test_shell_consumes_only_an_immediate_direct_cap_output_role(self) -> None:
|
||||
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
||||
from cdsl_engine.semantic_validation import validate_semantic_cdsl
|
||||
|
||||
cap_selector = {
|
||||
"kind": "face", "owner_feature_id": "base_add", "output_role": "extrude.end",
|
||||
"source": "runtime_snapshot", "confidence": 1.0,
|
||||
"selector_intent": {
|
||||
"version": "1.0", "kind": "face", "query_family": "CAP_FACE",
|
||||
"source_query": {
|
||||
"ast": {"call": "makeQuery"}, "featurescript_version": "1511",
|
||||
"standard_library": "onshape/std/geometry.fs",
|
||||
"standard_library_version": "1511.0",
|
||||
},
|
||||
"derivation_policy": {"allowed": ["boundary", "continuation"], "multiplicity": "one"},
|
||||
"evidence": "operation_role", "output_role": "extrude.end",
|
||||
},
|
||||
}
|
||||
cdsl = self._base_block()
|
||||
cdsl["features"][0]["execution_status"] = "supported"
|
||||
cdsl["features"][0]["params"].update({
|
||||
"result_mode": "new_body", "end_condition": {"type": "blind", "solidworks_code": 0},
|
||||
})
|
||||
cdsl["features"].append({
|
||||
"id": "shell", "atomic_id": "shell", "depends_on": ["base_add"],
|
||||
"params": {"thickness_mm": 1, "inward": True}, "selectors": [cap_selector],
|
||||
"execution_status": "supported",
|
||||
})
|
||||
|
||||
self.assertTrue(validate_semantic_cdsl(cdsl)["future_rebuild_ready"])
|
||||
self.assertTrue(analyze_cdsl(cdsl).feature_results[-1].executable)
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
rebuilt = rebuild_cdsl(cdsl, Path(directory) / "cap-role-shell.step")
|
||||
resolution = next(item for item in rebuilt["selector_resolution"] if item["feature_id"] == "shell")
|
||||
self.assertEqual(resolution["status"], "resolved")
|
||||
self.assertEqual(resolution["resolution_mode"], "operation_role")
|
||||
self.assertEqual(resolution["selected"]["output_roles"], ["extrude.end"])
|
||||
|
||||
non_immediate = deepcopy(cdsl)
|
||||
non_immediate["features"].insert(1, {
|
||||
"id": "gap", "atomic_id": "reference_plane", "depends_on": ["base_add"],
|
||||
"params": {"plane": _workplane()}, "execution_status": "supported",
|
||||
})
|
||||
non_immediate["features"][-1]["depends_on"] = ["gap"]
|
||||
with self.assertRaisesRegex(ValueError, "CAP_FACE output role requires the immediately preceding direct new_body blind extrusion cap"):
|
||||
validate_semantic_cdsl(non_immediate)
|
||||
self.assertIn(
|
||||
"unsupported_cap_face_output_role_selector",
|
||||
[blocker.code for blocker in analyze_cdsl(non_immediate).feature_results[-1].blockers],
|
||||
)
|
||||
|
||||
def test_transformed_owner_selector_executes_a_downstream_fillet(self) -> None:
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
@@ -2303,6 +2732,40 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
for item in downstream["selector_resolution"]
|
||||
))
|
||||
|
||||
def test_sweep_add_builds_a_solid_from_two_point_bspline_with_endpoint_tangents(self) -> None:
|
||||
from cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
|
||||
|
||||
profile_plane = {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 1, 0]}
|
||||
cdsl = {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "two-point-sweep",
|
||||
"meta": {"unit": "mm"},
|
||||
"geometry": {"sketches": [{
|
||||
"id": "profile", "workplane": profile_plane,
|
||||
"profile": {"type": "circle", "radius_mm": 2},
|
||||
}]},
|
||||
"features": [{
|
||||
"id": "sweep", "atomic_id": "sweep_add", "depends_on": [], "sketch_id": "profile",
|
||||
"params": {"path": {
|
||||
"workplane": _workplane(),
|
||||
"segment": {
|
||||
"type": "bspline", "start": [0, 0], "end": [6, 10],
|
||||
"points": [[0, 0], [6, 10]], "parameters": [0, 1],
|
||||
"start_tangent": [0, 10], "end_tangent": [10, 0],
|
||||
},
|
||||
}},
|
||||
}],
|
||||
}
|
||||
self.assertTrue(analyze_cdsl(cdsl).runtime_eligible)
|
||||
missing_tangent = deepcopy(cdsl)
|
||||
missing_tangent["features"][0]["params"]["path"]["segment"].pop("end_tangent")
|
||||
blocker = analyze_cdsl(missing_tangent).feature_results[0].blockers[0]
|
||||
self.assertEqual(blocker.code, "invalid_sweep_path")
|
||||
self.assertEqual(blocker.message, "A two-point B-spline sweep path requires both endpoint tangents")
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
result = rebuild_cdsl(cdsl, Path(directory) / "two-point-sweep.step")
|
||||
self.assertEqual(result["solid_count"], 1)
|
||||
self.assertGreater(result["volume_mm3"], 0)
|
||||
|
||||
def test_sweep_history_falls_back_for_hollow_profiles(self) -> None:
|
||||
from build123d import Edge, Face, Plane, Vector, Wire
|
||||
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
|
||||
@@ -2572,6 +3035,71 @@ class EngineRuntimeFoundationTests(unittest.TestCase):
|
||||
result = rebuild_cdsl(base, Path(directory) / "local-hole.step")
|
||||
self.assertAlmostEqual(result["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
|
||||
|
||||
def test_scoped_holes_preserve_the_original_body_member(self) -> None:
|
||||
from cdsl_engine.runtime import prepare_cdsl_execution
|
||||
|
||||
cdsl = self._base_block()
|
||||
host_frame = {
|
||||
"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0],
|
||||
"y_dir": [0, 1, 0], "normal": [0, 0, 1],
|
||||
}
|
||||
for feature_id, position in (("first_hole", [-2, 0, 0]), ("second_hole", [2, 0, 0])):
|
||||
cdsl["features"].append({
|
||||
"id": feature_id, "atomic_id": "hole_wizard",
|
||||
"depends_on": ["base_add" if feature_id == "first_hole" else "first_hole"],
|
||||
"params": {
|
||||
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 5,
|
||||
"end_condition": {"type": "blind", "solidworks_code": 0},
|
||||
"positions": [{"mm": position}], "host_face": {"frame": host_frame},
|
||||
"scope_feature_id": "base_add",
|
||||
},
|
||||
})
|
||||
execution = prepare_cdsl_execution(cdsl)
|
||||
self.assertTrue(all(result.executable for result in execution.analysis.feature_results))
|
||||
execution.execute_all()
|
||||
self.assertEqual(set(execution.session.body_members), {"base_add"})
|
||||
self.assertLess(float(execution.session.body.volume), 1000.0)
|
||||
|
||||
def test_unscoped_hole_keeps_the_legacy_feature_owned_member(self) -> None:
|
||||
from cdsl_engine.runtime import prepare_cdsl_execution
|
||||
|
||||
cdsl = self._base_block()
|
||||
cdsl["features"].append({
|
||||
"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
|
||||
"params": {
|
||||
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 5,
|
||||
"end_condition": {"type": "blind", "solidworks_code": 0},
|
||||
"positions": [{"mm": [0, 0, 0]}],
|
||||
"host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}},
|
||||
},
|
||||
})
|
||||
execution = prepare_cdsl_execution(cdsl)
|
||||
self.assertTrue(all(result.executable for result in execution.analysis.feature_results))
|
||||
execution.execute_all()
|
||||
self.assertEqual(set(execution.session.body_members), {"hole"})
|
||||
|
||||
def test_scoped_hole_requires_a_sole_live_member_before_execution(self) -> None:
|
||||
from cdsl_engine.runtime import analyze_cdsl
|
||||
|
||||
cdsl = _two_body_boolean_cdsl(
|
||||
"union", _rectangle([-5, -5], [5, 5]), _rectangle([20, -5], [30, 5]),
|
||||
)
|
||||
cdsl["features"] = cdsl["features"][:2]
|
||||
cdsl["features"].append({
|
||||
"id": "scoped_hole", "atomic_id": "hole_wizard", "depends_on": ["left_body", "right_body"],
|
||||
"params": {
|
||||
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 2,
|
||||
"end_condition": {"type": "blind", "solidworks_code": 0},
|
||||
"positions": [{"mm": [0, 0, 0]}],
|
||||
"host_face": {"frame": {"origin_mm": [0, 0, 4], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}},
|
||||
"scope_feature_id": "left_body",
|
||||
},
|
||||
})
|
||||
analysis = analyze_cdsl(cdsl)
|
||||
result = analysis.feature_results[-1]
|
||||
self.assertFalse(result.executable)
|
||||
self.assertIn("hole_scope_body_ambiguous", [blocker.code for blocker in result.blockers])
|
||||
|
||||
def test_counterbore_and_countersink_holes_execute_with_explicit_host_frames(self) -> None:
|
||||
"""Keep both legacy hole contracts covered by an actual kernel rebuild."""
|
||||
from cdsl_engine.runtime import rebuild_cdsl
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -133,6 +133,21 @@ def test_author_operation_context_exposes_server_injected_selector_contract():
|
||||
assert context["authoring_sketch_template"] is None
|
||||
|
||||
|
||||
def test_authoring_requirements_context_does_not_leak_open_analysis_fields():
|
||||
context = WorkflowCoordinator._authoring_requirements_context({
|
||||
"explicit_requirements": ["make a plate"],
|
||||
"assumptions": ["use millimetres"],
|
||||
"manual_targets": ["inspect the result"],
|
||||
"acceptance_targets": [{"kind": "plate", "expected": {"host_face": "max_z"}}],
|
||||
})
|
||||
|
||||
assert context == {
|
||||
"explicit_requirements": ["make a plate"],
|
||||
"assumptions": ["use millimetres"],
|
||||
"manual_targets": ["inspect the result"],
|
||||
}
|
||||
|
||||
|
||||
def test_schema_repair_hint_contains_the_exact_authoring_sketch_form():
|
||||
assert "diameter_mm" in WorkflowCoordinator._repair_hint(
|
||||
"AUTHOR_SCHEMA_INVALID", "bodies.0.features.0.sketch.profile",
|
||||
|
||||
Reference in New Issue
Block a user