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

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