Compare commits

6 Commits

Author SHA1 Message Date
likang 334eac5259 Merge pull request 'cdsl 的第一步修改,创建meta 的概述性数据,intent 模块对特整层进行描述' (#24) from ganjihong into main
Reviewed-on: #24
2026-09-16 10:31:53 +08:00
ganjihong 09a25cdc6b cdsl 2026-09-16 10:22:36 +08:00
likang 48cec45fee Merge pull request 'feat(cadfs): expand selector provenance coverage' (#23) from lk_dev into main
Reviewed-on: #23
2026-09-14 11:20:10 +08:00
likang de21ee8a5b feat(cadfs): expand selector provenance coverage 2026-09-14 11:11:12 +08:00
likang c6d3bfddec Merge pull request 'feat(selector): 增加离线候选遍历与严格回放验证 Demo' (#22) from lk_dev into main
Reviewed-on: #22
2026-09-10 15:15:23 +08:00
likang 994d06aaea feat(selector): 增加离线候选遍历与严格回放验证 Demo
- 新增 selector_candidate_demo,移除 provenance intent 后枚举候选 selector
- 对候选分支执行有界重建与严格 STEP 比较
- 仅在候选遍历完整且唯一 strict 通过时生成 selector 映射记录
- 增加 selector 候选搜索、预算限制和记录生成的测试
- 保持生产 selector resolver 不受 Demo 逻辑影响
- 更新 CADFS 能力台账,记录 IMPRINT 派生 profile 的 lineage selector 缺口
2026-09-10 15:12:57 +08:00
58 changed files with 29314 additions and 1247 deletions
+1
View File
@@ -32,6 +32,7 @@ build/
*.tsbuildinfo
# Runtime data and generated local artifacts
output/
backend/data/
backend/live-evals/
cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md
+193 -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,130 @@ 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.
## Semantic Annotation (meta and intent)
Every document carries semantic annotations for downstream training. They are
purely descriptive: the compiler carries them through verbatim, geometry never
depends on them, and they are never acceptance targets.
Document level — include `meta` with at least one of:
```json
{
"meta": {
"description": "R8 rounded square bushing, 100×100×12, central Ø45 bore",
"function": "Spacer sleeve over a Ø45 shaft; rounded corners for handling"
}
}
```
- `description`: one sentence naming the part with its key specifications
(≤60 characters). `function`: what the part does and where it fits
(≤200 characters). Narratives are written in the request language; keys and
controlled labels are English `snake_case`.
Feature level — attach `intent` to every feature:
```json
{
"intent": {
"label": "shaft_passage",
"summary": "Ø45 central through bore, concentric with the outer contour",
"why": "The fitting face of the sleeve; diameter follows the mating shaft",
"provenance": "authored"
}
}
```
- `label`: one controlled vocabulary term in `snake_case` — for example
`housing_blank`, `shaft_passage`, `fastener_hole`, `bolt_circle`,
`tap_hole`, `counterbore_seat`, `countersink_seat`, `locating_pin_hole`,
`bearing_seat`, `press_fit_boss`, `mounting_boss`, `mounting_foot`,
`lifting_eye`, `slot_adjustment`, `coolant_channel`, `lubrication_gallery`,
`vent_hole`, `drain_port`, `fluid_inlet`, `process_corner_relief`,
`weld_prep`, `machining_setup_tab`, `inspection_access`,
`stress_relief_fillet`, `stiffening_rib`, `weight_relief`,
`mass_saving_pocket`, `load_path_flange`, `wall_thickness_transition`,
`gear_teeth`, `rack_teeth`, `thread_drive`, `cam_track`, `bend_wing`,
`cosmetic_surface`, `datum_plane_feature`, `datum_axis_feature`. The list
is open: an accurate new `snake_case` term is valid, but prefer vocabulary.
- `summary` (required, ≤80 characters): what it is plus the key parameters.
Use parameterized wording (`M8`, `Ø75`, `R8`), never a restatement of the
request prose.
- `why` (optional, ≤400 characters): the functional reason this feature exists.
- `provenance`: always `"authored"` when the model writes it.
- Do not invent fields inside `intent`, and never treat a mismatch between an
annotation and geometry as acceptable — the label must match the feature
actually constructed.
## Sketches And Coordinates
@@ -95,16 +215,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 +231,62 @@ 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.
6. Every feature carries an `intent` with a truthful `label`, a parameterized
`summary` of at most 80 characters, and `provenance: "authored"`; the
document carries `meta` with at least one of `description`/`function`.
If any invariant is false, revise the construction before emitting the single
complete `cad.author.v1` document.
@@ -106,10 +106,17 @@ class AuthoringCompiler:
sketch_id = f"sketch_{source_positions[feature.name]:03d}"
sketches.append({"id": sketch_id, **self._runtime_sketch(feature.sketch.model_dump(mode="json"))})
output["sketch_id"] = sketch_id
if feature.intent is not None:
# Semantic annotation only: carried through for training data,
# never consumed by runtime geometry or validation semantics.
output["intent"] = feature.intent.model_dump(mode="json", exclude_none=True)
features.append(output)
document_meta: dict[str, Any] = {"unit": "mm"}
if doc.meta is not None:
document_meta.update(doc.meta.model_dump(mode="json", exclude_none=True))
runtime = {
"schema": "cad.runtime.v1", "schema_version": "1.0.0", "kind": "part",
"part_id": "compiled", "meta": {"unit": "mm"},
"part_id": "compiled", "meta": document_meta,
"bodies": [
{"id": body_ids[body.name], "name": body.name}
for body in doc.bodies
@@ -108,6 +108,33 @@ class SelectorIntent(AuthorModel):
return value
class FeatureIntent(AuthorModel):
"""Feature-level semantic annotation for training data.
Mirrors ``$defs/featureIntent`` in ``cdsl_schema.json``: purely
descriptive, never read by the compiler for geometry decisions.
"""
label: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_]{2,63}$")
summary: str = Field(min_length=1, max_length=80)
why: str | None = Field(default=None, min_length=1, max_length=400)
ties_to_requirement: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,80}$")
provenance: Literal["authored", "annotated", "imported"]
class DocumentMeta(AuthorModel):
"""Document-level semantic annotation (part description and function)."""
description: str | None = Field(default=None, min_length=1, max_length=60)
function: str | None = Field(default=None, min_length=1, max_length=200)
@model_validator(mode="after")
def require_at_least_one(self) -> "DocumentMeta":
if self.description is None and self.function is None:
raise ValueError("meta requires at least one of description or function")
return self
class AuthorFeature(AuthorModel):
name: str = Field(pattern=_NAME)
operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$")
@@ -121,6 +148,10 @@ class AuthorFeature(AuthorModel):
default=None,
description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.",
)
intent: FeatureIntent | None = Field(
default=None,
description="Optional semantic annotation for training; never consumed by geometry.",
)
class AuthorBody(AuthorModel):
@@ -133,6 +164,10 @@ class AuthoringDocument(AuthorModel):
units: str = Field(default="mm", pattern=r"^mm$")
coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$")
assumptions: list[str] = Field(default_factory=list, max_length=64)
meta: DocumentMeta | None = Field(
default=None,
description="Optional document-level semantic annotation (description/function).",
)
bodies: list[AuthorBody] = Field(min_length=1, max_length=32)
acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128)
+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,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+486 -27
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"],
@@ -25,6 +24,18 @@
"required": ["schema", "geometry", "features"],
"additionalProperties": false,
"$defs": {
"featureIntent": {
"type": "object",
"properties": {
"label": {"type": "string", "pattern": "^[a-z][a-z0-9_]{2,63}$"},
"summary": {"type": "string", "minLength": 1, "maxLength": 80},
"why": {"type": "string", "minLength": 1, "maxLength": 400},
"ties_to_requirement": {"type": "string", "pattern": "^[A-Za-z0-9_.:-]{1,80}$"},
"provenance": {"enum": ["authored", "annotated", "imported"]}
},
"required": ["summary", "provenance"],
"additionalProperties": false
},
"number": {"type": "number"},
"positive": {"type": "number", "exclusiveMinimum": 0},
"positiveInteger": {"type": "integer", "minimum": 1},
@@ -96,12 +107,23 @@
"properties": {
"type": {"type": "string", "minLength": 1},
"solidworks_code": {"type": "integer"},
"reference": {"$ref": "#/$defs/selectorRef"},
"reference": {"oneOf": [{"$ref": "#/$defs/selectorRef"}, {"$ref": "#/$defs/sourceVertexDatumRef"}]},
"offset_mm": {"type": "number", "minimum": 0}
},
"required": ["type", "solidworks_code"],
"additionalProperties": false
},
"sourceVertexDatumRef": {
"type": "object",
"properties": {
"kind": {"const": "source_vertex"},
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"point_mm": {"$ref": "#/$defs/point3"}
},
"required": ["kind", "source_sketch_id", "source_entity_id", "point_mm"],
"additionalProperties": false
},
"selectorOrUnresolved": {
"oneOf": [
{"$ref": "#/$defs/selectorRef"},
@@ -141,6 +163,14 @@
"maxItems": 16,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
},
"initial_output_roles": {"type": "boolean"},
"cap_output_profile_sources": {
"type": "array",
"minItems": 2,
"maxItems": 2,
"uniqueItems": true,
"items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
}
},
"required": ["profile_sketch_ids"],
@@ -161,20 +191,92 @@
"additionalProperties": false
},
"sweepPath": {
"type": "object",
"properties": {
"workplane": {"$ref": "#/$defs/workplane"},
"segment": {"$ref": "#/$defs/analyticSegment"}
},
"required": ["workplane", "segment"],
"additionalProperties": false
"oneOf": [
{
"type": "object",
"properties": {
"workplane": {"$ref": "#/$defs/workplane"},
"segment": {"$ref": "#/$defs/analyticSegment"},
"segments": {
"type": "array",
"minItems": 2,
"items": {"$ref": "#/$defs/analyticSegment"}
}
},
"required": ["workplane"],
"oneOf": [
{"required": ["segment"], "not": {"required": ["segments"]}},
{"required": ["segments"], "not": {"required": ["segment"]}}
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"segments": {
"type": "array",
"minItems": 2,
"items": {"$ref": "#/$defs/spatialSweepSegment"}
}
},
"required": ["segments"],
"additionalProperties": false
}
]
},
"sweepParams": {
"type": "object",
"properties": {
"path": {"$ref": "#/$defs/sweepPath"},
"is_frenet": {"type": "boolean"},
"result_mode": {"enum": ["fuse", "new_body"]}
"result_mode": {"enum": ["fuse", "new_body"]},
"initial_output_roles": {"type": "boolean"},
"cap_output_contract": {
"type": "object",
"properties": {
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"profile_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_reversed": {"type": "boolean"}
},
"required": ["profile_source", "profile_entity", "path_source", "path_entity", "path_reversed"],
"additionalProperties": false
},
"swept_face_contract": {
"type": "object",
"properties": {
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"profile_entities": {
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 160},
"minItems": 1,
"uniqueItems": true
},
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_reversed": {"type": "boolean"}
},
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
"additionalProperties": false
},
"swept_edge_contract": {
"type": "object",
"properties": {
"profile_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"profile_entities": {
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 160},
"minItems": 2,
"uniqueItems": true
},
"path_source": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"path_entity": {"type": "string", "minLength": 1, "maxLength": 160},
"path_reversed": {"type": "boolean"}
},
"required": ["profile_source", "profile_entities", "path_source", "path_entity", "path_reversed"],
"additionalProperties": false
}
},
"required": ["path"],
"additionalProperties": false
@@ -406,14 +508,17 @@
"operation": {"enum": ["union", "subtract", "intersect"]},
"target_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"target_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"target_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"tool_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"tool_pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"keep_tools": {"type": "boolean"}
"tool_transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"keep_tools": {"type": "boolean"},
"targetless_body_set": {"type": "boolean"}
},
"required": ["operation"],
"allOf": [
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}]},
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}]}
{"anyOf": [{"required": ["target_feature_ids"]}, {"required": ["target_pattern_instance_refs"]}, {"required": ["target_transform_copy_refs"]}]},
{"anyOf": [{"required": ["tool_feature_ids"]}, {"required": ["tool_pattern_instance_refs"]}, {"required": ["tool_transform_copy_refs"]}]}
],
"additionalProperties": false
},
@@ -454,12 +559,22 @@
"required": ["transform_feature_id", "source_feature_id"],
"additionalProperties": false
},
"transformSourceMemberAlias": {
"type": "object",
"properties": {
"source_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"active_member_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}
},
"required": ["source_feature_id", "active_member_feature_id"],
"additionalProperties": false
},
"transformBodiesParams": {
"type": "object",
"properties": {
"source_feature_ids": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"}},
"pattern_instance_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/patternInstanceBodyRef"}},
"transform_copy_refs": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformCopyBodyRef"}},
"source_member_aliases": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"$ref": "#/$defs/transformSourceMemberAlias"}},
"transform": {"$ref": "#/$defs/bodyTransform"},
"make_copy": {"type": "boolean"}
},
@@ -534,6 +649,22 @@
"required": ["axis"],
"additionalProperties": false
},
"referencePointParams": {
"type": "object",
"properties": {"point_mm": {"$ref": "#/$defs/point3"}},
"required": ["point_mm"],
"additionalProperties": false
},
"assignVariableParams": {
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 256},
"value": {"type": "number"},
"value_kind": {"enum": ["any", "length"]}
},
"required": ["name", "value", "value_kind"],
"additionalProperties": false
},
"holeWizardParams": {
"type": "object",
"properties": {
@@ -543,6 +674,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 +702,148 @@
"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
},
"selectorIntentQueryExprNode": {
"oneOf": [
{
"type": "object",
"properties": {
"node": {"const": "set"},
"operator": {"enum": ["union", "intersection", "subtraction"]},
"operands": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "operator", "operands"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "filter"},
"filter": {"enum": ["adjacent", "owner_body", "body_type", "construction"]},
"input": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "filter", "input", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "topology_query"},
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"topology_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "owner", "topology_type", "entity_type", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "created_by"},
"owner": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "owner", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "source_entity"},
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"entity_type": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"entity": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "sketch", "entity_type", "entity", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "sketch_region"},
"sketch": {"$ref": "#/$defs/selectorIntentQueryExprNode"},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "sketch", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "opaque_call"},
"name": {"type": "string", "minLength": 1},
"arguments": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "name", "arguments"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "list"},
"items": {"type": "array", "items": {"$ref": "#/$defs/selectorIntentQueryExprNode"}}
},
"required": ["node", "items"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "map"},
"entries": {
"type": "array",
"items": {
"type": "object",
"properties": {
"key": {"type": "string"},
"value": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
},
"required": ["key", "value"],
"additionalProperties": false
}
}
},
"required": ["node", "entries"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"node": {"const": "literal"},
"value": {}
},
"required": ["node", "value"],
"additionalProperties": false
}
]
},
"selectorIntentQueryExpr": {
"type": "object",
"properties": {
"version": {"const": "1.0"},
"root": {"$ref": "#/$defs/selectorIntentQueryExprNode"}
},
"required": ["version", "root"],
"additionalProperties": false
},
"selectorIntent": {
@@ -580,8 +851,9 @@
"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"},
"query_expr": {"$ref": "#/$defs/selectorIntentQueryExpr"},
"source_entity": {
"type": "object",
"properties": {
@@ -591,7 +863,119 @@
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"source_entities": {
"type": "array",
"minItems": 1,
"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
}
},
"blend_sources": {
"type": "object",
"properties": {
"edge": {
"type": "object",
"properties": {
"query_family": {"const": "CAP_EDGE"},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
},
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
"additionalProperties": false
},
"face": {
"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
}
},
"required": ["edge", "face"],
"additionalProperties": false
},
"blend_face_source": {
"type": "object",
"properties": {
"query_family": {"const": "CAP_EDGE"},
"owner_feature_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity": {
"type": "object",
"properties": {
"sketch_id": {"type": "string", "minLength": 1},
"entity_id": {"type": "string", "minLength": 1}
},
"required": ["sketch_id", "entity_id"],
"additionalProperties": false
},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]}
},
"required": ["query_family", "owner_feature_id", "source_entity", "lineage_role"],
"additionalProperties": false
},
"output_role": {"$ref": "#/$defs/featureOutputRole"},
"consumer_contract": {"enum": ["direct_prism_cap_face_workplane", "primary_add_shell_union_continuation", "primary_add_up_to_surface_union_continuation", "primary_add_dressup_union_continuation", "symmetric_direct_prism_two_sided_up_to_surface_cap_pair", "symmetric_direct_prism_shell_swept_face_up_to_surface_pair", "immediate_retained_source_prism_swept_face_up_to_surface", "shell_retained_direct_prism_cap_offset_face_profile", "direct_prism_shell_offset_edge_tdd", "direct_prism_shell_offset_edge_vertex"]},
"lineage_role": {"enum": ["extrude.start", "extrude.end"]},
"body_member_contract": {"enum": ["direct_new_body"]},
"query_set_contract": {"enum": ["proven_operand_union", "proven_operand_intersection", "proven_operand_subtraction"]},
"owner_body_contract": {"enum": ["exact_input_owner"]},
"copy_contract": {"enum": ["primary_cut_cap_edge", "primary_cut_cap_face_workplane", "primary_cut_swept_face_workplane"]},
"set_kind": {"enum": ["face", "edge"]},
"body_scope": {"enum": ["active_member"]},
"empty_policy": {"enum": ["reject"]},
"multiple_policy": {"enum": ["all", "one"]},
"derivation_policy": {
"type": "object",
"properties": {
@@ -601,7 +985,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"],
@@ -621,6 +1005,8 @@
"snapshot_id": {"type": "string", "minLength": 1},
"binding_feature_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{1,80}$"},
"match_mode": {"enum": ["unique", "all"]},
"query_input": {"$ref": "#/$defs/selectorRef"},
"query_operands": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"matched_selectors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/selectorRef"}},
"intersection_of": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/selectorRef"}},
"selector_intent_version": {"const": "1.0"},
@@ -628,7 +1014,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 +1035,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": [
@@ -661,6 +1048,33 @@
],
"additionalProperties": false
},
"spatialSweepSegment": {
"type": "object",
"properties": {
"type": {"enum": ["line", "arc", "bspline"]},
"start_mm": {"$ref": "#/$defs/point3"},
"end_mm": {"$ref": "#/$defs/point3"},
"center_mm": {"$ref": "#/$defs/point3"},
"normal": {"$ref": "#/$defs/point3"},
"radius_mm": {"$ref": "#/$defs/positive"},
"points_mm": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/point3"}},
"parameters": {"type": "array", "minItems": 2, "items": {"type": "number"}},
"periodic": {"type": "boolean"},
"clockwise": {"type": "boolean"},
"start_tangent_mm": {"$ref": "#/$defs/point3"},
"end_tangent_mm": {"$ref": "#/$defs/point3"},
"source_sketch_id": {"type": "string", "minLength": 1, "maxLength": 160},
"source_entity_id": {"type": "string", "minLength": 1, "maxLength": 160}
},
"required": ["type", "source_sketch_id", "source_entity_id"],
"allOf": [
{"if": {"properties": {"type": {"const": "line"}}}, "then": {"required": ["start_mm", "end_mm"]}},
{"if": {"properties": {"type": {"const": "arc"}}}, "then": {"required": ["start_mm", "end_mm", "center_mm", "normal", "radius_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}}}, "then": {"required": ["points_mm"]}},
{"if": {"properties": {"type": {"const": "bspline"}, "points_mm": {"maxItems": 2}}}, "then": {"required": ["start_tangent_mm", "end_tangent_mm", "parameters"], "properties": {"periodic": {"const": false}}}}
],
"additionalProperties": false
},
"constructionBsplineSegment": {
"type": "object",
"properties": {
@@ -684,7 +1098,8 @@
"type": "object",
"properties": {
"role": {"enum": ["outer", "inner", "open", "unknown"]},
"closed": {"type": "boolean"},
"closed": {"type": "boolean"},
"surface_wire": {"type": "boolean"},
"segments": {"type": "array", "items": {"$ref": "#/$defs/analyticSegment"}}
},
"required": ["role", "closed", "segments"],
@@ -713,10 +1128,24 @@
"type": "object",
"properties": {
"anchor_entity_id": {"type": "string", "minLength": 1, "maxLength": 160},
"external_anchor_id": {"type": "string", "minLength": 1, "maxLength": 160},
"side": {"enum": [-1, 1]},
"intersection_index": {"type": "integer", "minimum": 0}
},
"required": ["anchor_entity_id", "side"],
"required": ["side"],
"oneOf": [
{"required": ["anchor_entity_id"]},
{"required": ["external_anchor_id"]}
],
"additionalProperties": false
},
"imprintExternalAnchor": {
"type": "object",
"properties": {
"id": {"type": "string", "minLength": 1, "maxLength": 160},
"selector": {"$ref": "#/$defs/selectorRef"}
},
"required": ["id", "selector"],
"additionalProperties": false
},
"imprintSelection": {
@@ -733,13 +1162,37 @@
"type": "object",
"properties": {
"type": {"const": "planar_imprint"},
"source_entities": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}}
"source_entities": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSourceEntity"}},
"selections": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintSelection"}},
"external_anchors": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/imprintExternalAnchor"}}
},
"required": ["type", "source_entities", "selections"],
"allOf": [
{
"if": {"not": {"required": ["external_anchors"]}},
"then": {"properties": {"source_entities": {"minItems": 2}}}
}
],
"additionalProperties": false
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "sweep_add", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "hole_wizard"]},
"multiSourceRegionsProfile": {
"type": "object",
"properties": {
"type": {"const": "multi_source_regions"},
"source_sketch_ids": {"type": "array", "minItems": 2, "items": {"type": "string", "minLength": 1, "maxLength": 160}, "uniqueItems": true},
"profiles": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/directProfile"}}
},
"required": ["type", "source_sketch_ids", "profiles"],
"additionalProperties": false
},
"directProfile": {
"oneOf": [
{"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"}
]
},
"feature_atomic_ids": {"enum": ["extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_cut_through", "extrude_from_face", "extrude_surface", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut", "revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "box_add", "cylinder_add", "thread_add", "thread_cut", "bend_add", "gear_add", "rack_add", "fillet", "chamfer", "shell", "boolean_bodies", "transform_bodies", "delete_bodies", "pattern_linear", "pattern_mirror", "pattern_circular", "reference_plane", "reference_axis", "reference_point", "assign_variable", "hole_wizard"]},
"feature": {
"type": "object",
"properties": {
@@ -751,7 +1204,8 @@
"params": {"type": "object"},
"execution_status": {"enum": ["supported", "deferred"]},
"selectors": {"type": "array", "items": {"$ref": "#/$defs/selectorRef"}},
"unresolved": {"type": "array", "items": {"type": "string", "minLength": 1}}
"unresolved": {"type": "array", "items": {"type": "string", "minLength": 1}},
"intent": {"$ref": "#/$defs/featureIntent"}
},
"required": ["id", "atomic_id", "depends_on", "params"],
"additionalProperties": false,
@@ -765,7 +1219,9 @@
{"if": {"properties": {"atomic_id": {"const": "extrude_from_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/extrudeFromFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_add_with_cap_face"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftCapFaceParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "loft_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/loftParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "sweep_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/sweepParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_add"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_cut"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "revolve_surface"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/revolveSurfaceParams"}}}},
@@ -792,21 +1248,24 @@
{"if": {"properties": {"atomic_id": {"const": "pattern_circular"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/circularPatternParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_plane"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePlaneParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_axis"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referenceAxisParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "reference_point"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/referencePointParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "assign_variable"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/assignVariableParams"}}}},
{"if": {"properties": {"atomic_id": {"const": "hole_wizard"}}}, "then": {"properties": {"params": {"$ref": "#/$defs/holeWizardParams"}}}}
]
},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "planar_imprint"]},
"profile_type": {"enum": ["circle", "polygon", "analytic_contours", "multi_source_regions", "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/multiSourceRegionsProfile"},
{"$ref": "#/$defs/planarImprintProfile"}
]
},
"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
}
@@ -24,9 +24,11 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
params = node.params
target_ids = _member_sources(
node, session, "target_feature_ids", pattern_instance_parameter="target_pattern_instance_refs",
allow_transform_copies=True, transform_copy_parameter="target_transform_copy_refs",
)
tool_ids = _member_sources(
node, session, "tool_feature_ids", pattern_instance_parameter="tool_pattern_instance_refs",
allow_transform_copies=True, transform_copy_parameter="tool_transform_copy_refs",
)
targets = {feature_id: session.body_members[feature_id] for feature_id in target_ids}
tools = {feature_id: session.body_members[feature_id] for feature_id in tool_ids}
@@ -49,6 +51,12 @@ def _execute_boolean_bodies(node: FeaturePlanNode, session: "ExecutionSession")
}
members[node.feature_id] = result
if bool(params.get("keep_tools")):
# In a targetless FeatureScript body-set operation every selected
# member is semantically a tool. Lowering chooses the first source
# member only to satisfy CDSL's binary executor shape, so retain that
# left operand too when the explicit targetless contract requests it.
if bool(params.get("targetless_body_set")):
members.update(targets)
members.update(tools)
session.register_body(
node.feature_id, _combine_members(session, members), body_members=members, topology_delta=topology_delta,
+409 -52
View File
@@ -6,13 +6,14 @@ used by exactly one family lives in that family's module instead.
from __future__ import annotations
from dataclasses import replace
import math
from typing import TYPE_CHECKING, Any, Callable
from ..extents import _extent_vectors_from_normal, _normal_from_sketch
from ..runtime_base import ExtentVector, FeatureExecutionError
from ..specs import AxisSpec, HoleSpec, PlaneSpec, Vector3, pattern_instance_member_id, transform_copy_member_id, vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyRecord
from ..topology import FeaturePlanNode, FeatureResult, RuntimeDiagnostic, SelectorResolution, TopologyDelta, TopologyDeltaRelation, TopologyRecord
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@@ -57,20 +58,76 @@ def _validate_revolve_axis_in_sketch_plane(axis: AxisSpec, sketch: dict[str, Any
)
def _cut_explicit_body_members(session: "ExecutionSession", tool: Any) -> dict[str, Any]:
def _cut_explicit_body_members(
session: "ExecutionSession", tool: Any,
) -> tuple[dict[str, Any], tuple[TopologyDelta, ...]]:
"""Apply a cut to each independently owned body without erasing ownership.
A CADFS NEW body stays independently addressable even when a later REMOVE
feature affects several active bodies. Cutting the aggregate first loses
that identity, so this path uses the equivalent per-member set difference
and drops only members that the tool removes completely.
and drops only members that the tool removes completely. Each non-empty
member result retains its own OCC builder history; callers may compose
those disjoint exact relations into one operation-wide delta.
"""
members: dict[str, Any] = {}
deltas: list[TopologyDelta] = []
for feature_id, body in session.body_members.items():
result = session.adapter.cut(body, tool)
if abs(float(result.volume)) > 1e-12:
result, delta = session.adapter.cut_with_topology_delta(body, tool)
if delta is not None:
deltas.append(delta)
if result is not None and abs(float(result.volume)) > 1e-12:
members[feature_id] = result
return members
return members, tuple(deltas)
def _compose_member_cut_deltas(deltas: tuple[TopologyDelta, ...]) -> TopologyDelta | None:
"""Combine exact independent member-cut histories without inventing links."""
if not deltas or any(delta.history_status != "proven" for delta in deltas):
return None
return TopologyDelta(
operation="subtract",
relations=tuple(relation for delta in deltas for relation in delta.relations),
section_values=tuple(value for delta in deltas for value in delta.section_values),
section_relations=tuple(relation for delta in deltas for relation in delta.section_relations),
blend_relations=tuple(relation for delta in deltas for relation in delta.blend_relations),
history_reason="per_member_exact_cut_history",
)
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 retain its direct-prism input snapshot whenever the
tool, profile anchors, and prism builder history are singular and exact.
The target may contain several independent members: that changes the cut
result cardinality but not the identity of the one transient tool input.
This gate does not make any multi-member selector executable; a later
resolver must still prove its complete operation-wide relation and active
member. 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.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(
@@ -78,8 +135,11 @@ def _extruded_tool(
faces: list[Any],
profile_normal: Vector3,
session: "ExecutionSession",
*,
record_multiface_prism_history: bool = False,
use_operation_wide_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,20 +147,59 @@ def _extruded_tool(
taper_deg = float(draft["angle_deg"])
if not bool(draft["pull_direction"]):
taper_deg = -taper_deg
topology_delta: TopologyDelta | None = None
if (
use_operation_wide_prism_history
and draft is None
and len(faces) > 1
and len(extents) == 1
and extents[0].trim_to is None
):
composed = session.adapter.extrude_faces_with_composed_topology_delta(faces, extents[0].vector)
if composed is not None:
return composed
topology_deltas: list[tuple[int, TopologyDelta]] = []
solids: list[Any] = []
exact_two_sided_prism = (
node.atomic_id in {"extrude_add_two_sided", "extrude_cut_two_sided"}
and draft is None
and len(faces) == 1
and len(extents) == 2
and all(extent.trim_to is None for extent in extents)
)
for face in faces:
for extent in extents:
for extent_index, extent in enumerate(extents):
if draft is not None:
if len(faces) == 1 and len(extents) == 1:
solid, topology_delta = session.adapter.extrude_taper_with_topology_delta(
face, extent.vector, taper_deg,
)
if topology_delta is not None:
topology_deltas.append((extent_index, 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:
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
elif extent.trim_to is None and (len(extents) == 1 or exact_two_sided_prism) 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.
try:
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
except ValueError:
# Keep the established executable profile result when a
# selected IMPRINT region is valid as a face but cannot be
# a standalone valid prism. Its later fuse may still be
# valid. There is no complete builder witness in this
# case, so the entire multi-region topology delta is
# withheld below instead of mixing proven and guessed
# source anchors.
solid = session.adapter.extrude(face, extent.vector)
else:
topology_deltas.append((extent_index, topology_delta))
solids.append(solid)
elif extent.trim_to is None:
solids.append(session.adapter.extrude(face, extent.vector))
@@ -111,7 +210,24 @@ 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
relations: list[TopologyDeltaRelation] = []
for extent_index, topology_delta in topology_deltas:
for relation in topology_delta.relations:
# Both prism builders start on the source plane. Its two source
# caps are internal to the fused two-sided result. The reverse
# extent's far ``LastShape`` is the FeatureScript start cap; map
# that exact builder handle before the registry checks final
# membership. No source-plane or geometry-derived relation is
# promoted to a CAP role.
if exact_two_sided_prism and extent_index == 1 and relation.output_role == "extrude.end":
relation = replace(relation, output_role="extrude.start")
relations.append(relation)
return tool, TopologyDelta(
operation="extrude",
relations=tuple(relations),
)
def _apply_primary_tool(
@@ -121,29 +237,110 @@ 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")
members = _cut_explicit_body_members(session, tool)
members, member_cut_deltas = _cut_explicit_body_members(session, 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:
member_id = next(iter(session.body_members))
body = members[member_id]
cut_delta = member_cut_deltas[0] if len(member_cut_deltas) == 1 else None
# 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
members = {member_id: body}
else:
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:
# A transient tool is optional evidence. Keep the exact
# per-member target history when its tool snapshot cannot
# be registered completely.
topology_predecessors = None
body = session.adapter.combine(None, next(iter(members.values())))
for member in list(members.values())[1:]:
body = session.adapter.combine(body, member)
topology_delta = _compose_member_cut_deltas(member_cut_deltas)
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}
else:
body = session.adapter.fuse(session.body, tool)
# An ADD can replace both the prior active solid and its direct prism
# tool. Retain a selector relation only when both snapshots are
# singular and OCC supplies the exact union history. The direct-prism
# snapshot remains transient: its source anchors cannot be selected
# until the union proves a complete successor in the active result.
can_trace_add = (
session.body is not None
and topology_delta is not None
and topology_delta.operation == "extrude"
and topology_delta.history_status == "proven"
and bool(topology_delta.relations)
and bool(topology_anchors)
and len(session.adapter.body_solids(session.body)) == 1
and len(session.adapter.body_solids(tool)) == 1
)
if can_trace_add:
fused, fuse_delta = session.adapter.fuse_with_topology_delta(session.body, tool)
if fuse_delta is not None:
topology_predecessors = session.register_transient_prism_tool(
node.feature_id,
tool,
topology_delta=topology_delta,
topology_anchors=list(topology_anchors or ()),
)
body = fused
topology_delta = fuse_delta
topology_anchors = None
else:
body = fused
topology_delta = None
topology_anchors = None
else:
body = session.adapter.fuse(session.body, tool)
members = {node.feature_id: body}
# A fuse rebuilds subshape identity. Builder evidence belongs only to
# an unchanged standalone/new-body prism snapshot.
if session.body is not None:
topology_delta = 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 +354,11 @@ 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)
support_face = session.sketch_attachment_faces.get(str(node.sketch_id))
external_anchor_edges = session.sketch_imprint_external_edges.get(str(node.sketch_id))
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(
selected_sketch, support_face=support_face, external_anchor_edges=external_anchor_edges,
)
if not faces:
raise ValueError("sketch does not create a closed profile region")
if node.atomic_id == "extrude_add_blind_with_hole":
@@ -169,6 +370,17 @@ 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
)
planar_imprint_profile = profile.get("type") == "planar_imprint"
# 3. 按特征类型生成子实体:
if node.atomic_id.startswith("extrude_"):
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
@@ -177,7 +389,35 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
tool, topology_delta = _extruded_tool(
node, faces, _normal_from_sketch(selected_sketch), session,
# A multi-region prism gets per-region builder history only when
# every profile region has at least one exact source boundary.
# This covers direct circles and the adapter's bounded IMPRINT
# splitter path, without turning arbitrary multi-face profiles
# into a different construction algorithm.
record_multiface_prism_history=(direct_all_circle_profile or planar_imprint_profile)
and len(source_anchor_specs) >= len(faces),
use_operation_wide_prism_history=planar_imprint_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)
@@ -193,12 +433,47 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
if bool(node.params.get("reverse")):
angle = -angle
tool = None
for solid in (session.adapter.revolve(face, angle, axis) for face in faces):
tool = session.adapter.fuse(tool, solid)
can_record_revolve_history = (
node.atomic_id == "revolve_add"
and node.params.get("result_mode") == "new_body"
and len(faces) == 1
)
for face in faces:
if can_record_revolve_history:
try:
solid, topology_delta = session.adapter.revolve_with_topology_delta(face, angle, axis)
except ValueError:
# Retain the established executable revolve when OCC
# cannot expose a complete builder-history witness.
solid = session.adapter.revolve(face, angle, axis)
topology_delta = None
else:
solid = session.adapter.revolve(face, angle, axis)
tool = solid if tool is None else session.adapter.fuse(tool, solid)
if tool is None:
raise ValueError("revolve produced no solid")
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,
))
return _apply_primary_tool(
node, session, tool, cutting="cut" in node.atomic_id, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
@@ -248,10 +523,14 @@ def _pattern_instance_sources(
return resolved
def _transform_copy_sources(node: FeaturePlanNode, session: "ExecutionSession") -> list[str]:
def _transform_copy_sources(
node: FeaturePlanNode,
session: "ExecutionSession",
parameter: str = "transform_copy_refs",
) -> list[str]:
"""Resolve source-qualified outputs of preceding multi-body COPY transforms."""
resolved: list[str] = []
for reference in node.params.get("transform_copy_refs") or ():
for reference in node.params.get(parameter) or ():
if not isinstance(reference, dict):
raise ValueError("transform COPY reference must be an object")
transform_id = str(reference.get("transform_feature_id") or "")
@@ -285,12 +564,13 @@ def _member_sources(
*,
pattern_instance_parameter: str | None = None,
allow_transform_copies: bool = False,
transform_copy_parameter: str = "transform_copy_refs",
) -> list[str]:
source_ids = [str(value) for value in node.params.get(parameter) or []]
if pattern_instance_parameter is not None:
source_ids.extend(_pattern_instance_sources(node, session, pattern_instance_parameter))
if allow_transform_copies:
source_ids.extend(_transform_copy_sources(node, session))
source_ids.extend(_transform_copy_sources(node, session, transform_copy_parameter))
if not source_ids:
raise ValueError(f"{node.atomic_id} requires explicit {parameter}")
missing = [feature_id for feature_id in source_ids if feature_id not in session.body_members]
@@ -304,37 +584,119 @@ def _sweep_path(node: FeaturePlanNode, session: "ExecutionSession") -> Any:
path = node.params.get("path") or {}
if not isinstance(path, dict):
raise ValueError("sweep path must be an object")
plane = PlaneSpec.from_mapping(path.get("workplane") or {})
spatial = path.get("workplane") is None
plane = None if spatial else PlaneSpec.from_mapping(path.get("workplane") or {})
segment = path.get("segment") or {}
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2 or any(not isinstance(point, list) or len(point) != 2 for point in local_points):
raise ValueError("sweep path requires two-dimensional points")
segments = path.get("segments")
def point(value: list[float]) -> Vector3:
def local_point(value: Any) -> Vector3:
if plane is None:
raise ValueError("planar sweep path requires a workplane")
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path requires two-dimensional points")
return vector_add(
plane.origin_mm,
vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1]))),
)
def tangent(value: Any) -> Vector3 | None:
if value is None:
return None
def local_vector(value: Any) -> Vector3:
if plane is None:
raise ValueError("planar sweep path requires a workplane")
if not isinstance(value, list) or len(value) != 2:
raise ValueError("sweep path tangent must contain two coordinates")
return vector_add(vector_scale(plane.x_dir, float(value[0])), vector_scale(plane.y_dir, float(value[1])))
if segments is not None:
if segment:
raise ValueError("sweep path cannot mix segment and segments")
if not isinstance(segments, list) or len(segments) < 2:
raise ValueError("sweep segmented path requires at least two segments")
materialized: list[dict[str, Any]] = []
for index, source in enumerate(segments):
if not isinstance(source, dict):
raise ValueError("sweep path segment must be an object")
kind = str(source.get("type") or "")
if kind not in {"line", "arc", "bspline"}:
raise ValueError(f"unsupported sweep path segment {kind!r}")
target: dict[str, Any] = {"type": kind}
if spatial:
if kind in {"line", "arc"}:
target["start_mm"] = source.get("start_mm")
target["end_mm"] = source.get("end_mm")
if kind == "arc":
target["center_mm"] = source.get("center_mm")
target["normal"] = source.get("normal")
target["radius_mm"] = source.get("radius_mm")
target["clockwise"] = bool(source.get("clockwise", False))
if kind == "bspline":
target["points_mm"] = source.get("points_mm")
if source.get("start_tangent_mm") is not None:
target["start_tangent_mm"] = source.get("start_tangent_mm")
if source.get("end_tangent_mm") is not None:
target["end_tangent_mm"] = source.get("end_tangent_mm")
if source.get("parameters") is not None:
target["parameters"] = [float(value) for value in source.get("parameters") or []]
if source.get("periodic") is not None:
target["periodic"] = bool(source.get("periodic"))
materialized.append(target)
continue
if kind in {"line", "arc"}:
target["start_mm"] = local_point(source.get("start"))
target["end_mm"] = local_point(source.get("end"))
if kind == "arc":
target["center_mm"] = local_point(source.get("center"))
target["normal"] = plane.normal
target["clockwise"] = bool(source.get("clockwise", False))
if kind == "bspline":
points = source.get("points")
if not isinstance(points, list) or len(points) < 2:
raise ValueError("sweep B-spline path requires at least two interpolation points")
target["points_mm"] = [local_point(value) for value in points]
if source.get("start_tangent") is not None:
target["start_tangent_mm"] = local_vector(source.get("start_tangent"))
if source.get("end_tangent") is not None:
target["end_tangent_mm"] = local_vector(source.get("end_tangent"))
if source.get("parameters") is not None:
target["parameters"] = [float(value) for value in source.get("parameters") or []]
if source.get("periodic") is not None:
target["periodic"] = bool(source.get("periodic"))
materialized.append(target)
return session.adapter.sweep_path_segments(materialized)
if spatial:
raise ValueError("spatial sweep path requires captured segments")
if not isinstance(segment, dict):
raise ValueError("sweep path segment must be an object")
kind = str(segment.get("type") or "")
if kind == "line":
local_points = [segment.get("start"), segment.get("end")]
elif kind == "circle":
return session.adapter.sweep_circle_path(
local_point(segment.get("center")),
plane.x_dir,
plane.normal,
radius_mm=float(segment["radius_mm"]),
)
elif kind == "arc":
return session.adapter.sweep_arc_path(
local_point(segment.get("start")),
local_point(segment.get("end")),
local_point(segment.get("center")),
plane.normal,
radius_mm=float(segment["radius_mm"]),
clockwise=bool(segment["clockwise"]),
)
elif kind == "bspline":
local_points = segment.get("points") or []
else:
raise ValueError(f"unsupported sweep path segment {kind!r}")
if len(local_points) < 2:
raise ValueError("sweep path requires two-dimensional points")
return session.adapter.sweep_path(
[point(value) for value in local_points],
start_tangent=tangent(segment.get("start_tangent")),
end_tangent=tangent(segment.get("end_tangent")),
[local_point(value) for value in local_points],
start_tangent=local_vector(segment["start_tangent"]) if segment.get("start_tangent") is not None else None,
end_tangent=local_vector(segment["end_tangent"]) if segment.get("end_tangent") is not None else None,
parameters=[float(value) for value in segment.get("parameters") or []] or None,
)
@@ -410,15 +772,10 @@ def _direct_output_role_records(
return result
def _host_plane(resolution: SelectorResolution) -> PlaneSpec:
def _host_plane(resolution: SelectorResolution, adapter: Any) -> PlaneSpec:
if resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "host face was not resolved")
geometry = resolution.record.geometry
return PlaneSpec.from_mapping({
"origin_mm": geometry["center_mm"],
"x_dir": [1, 0, 0] if abs(float(geometry["normal"][0])) < 0.9 else [0, 1, 0],
"normal": geometry["normal"],
})
return adapter.planar_face_workplane(resolution.record.value)
def _hole_starts(
@@ -6,6 +6,7 @@ that later features resolve through owner-qualified selectors.
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
@@ -61,3 +62,28 @@ def _reference_axis_executor(node: FeaturePlanNode, session: "ExecutionSession",
# 7. 注册为拓扑上下文,并返回结果对象(携带该轴)。
session.topology.register_context(node.feature_id, axis)
return session.result(node, context=axis)
@atomic_executor("reference_point")
def _reference_point_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
"""Execute an explicit source-datum point without changing model topology."""
del sketch
point = node.params.get("point_mm")
if (
not isinstance(point, list)
or len(point) != 3
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in point)
):
raise ValueError("reference point requires one finite three-dimensional point")
return session.result(node, include_body=False)
@atomic_executor("assign_variable")
def _assign_variable_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
"""Replay a source variable declaration after lowering has consumed it."""
del sketch
name = node.params.get("name")
value = node.params.get("value")
if not isinstance(name, str) or not name or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
raise ValueError("assign_variable requires one finite named value")
return session.result(node, include_body=False)
@@ -17,6 +17,60 @@ if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
def _single_member_dressup_members(
node: FeaturePlanNode,
session: "ExecutionSession",
body: Any,
selected_edges: list[Any],
) -> dict[str, Any] | None:
"""Preserve unchanged body members after one exact-member dress-up.
The adapter only reports Compound history when every selected edge maps to
one source solid. Keep the same proof at the body-graph layer: all other
members must appear unchanged in the result and exactly one result solid
must remain for the changed member. Otherwise aggregate replay remains
valid, but no member-lifecycle transfer is asserted.
"""
if not selected_edges or len(session.body_members) < 2:
return None
source_members: dict[str, Any] = {}
for member_id, member in session.body_members.items():
solids = session.adapter.body_solids(member)
if len(solids) != 1:
return None
source_members[member_id] = solids[0]
selected_members = {
member_id
for edge in selected_edges
for member_id, member in source_members.items()
if any(edge.is_same(candidate) for candidate in member.edges())
}
if len(selected_members) != 1:
return None
changed_member_id = next(iter(selected_members))
result_solids = session.adapter.body_solids(body)
unchanged: dict[str, Any] = {}
matched_result_indexes: set[int] = set()
for member_id, member in source_members.items():
if member_id == changed_member_id:
continue
matches = [
index for index, result in enumerate(result_solids)
if session.topology._same_topology_value(member, result)
]
if len(matches) != 1 or matches[0] in matched_result_indexes:
return None
matched_result_indexes.add(matches[0])
unchanged[member_id] = result_solids[matches[0]]
changed = [
result for index, result in enumerate(result_solids)
if index not in matched_result_indexes
]
if len(changed) != 1:
return None
return {**unchanged, node.feature_id: changed[0]}
def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
# 圆角特征(fillet)执行入口:对选中边按半径做圆角,平滑尖角与棱边。
@@ -28,11 +82,15 @@ def _execute_fillet(node: FeaturePlanNode, session: "ExecutionSession") -> Featu
if radius <= 0:
raise ValueError("fillet radius_mm must be > 0")
# 3. 解析目标边(支持 tangent_propagation 相切传播),并执行圆角。
edges = _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation")))
body, topology_delta = session.adapter.fillet_with_topology_delta(
session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
session.body, radius, edges,
)
# 4. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
body_members=_single_member_dressup_members(node, session, body, edges),
)
return session.result(node)
@@ -86,7 +144,10 @@ def _execute_chamfer(node: FeaturePlanNode, session: "ExecutionSession") -> Feat
detail={"distance_mm": distance, "surface_count": len(session.surface_members)},
))
# 5. 登记新主体并返回结果。
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
body_members=_single_member_dressup_members(node, session, body, edges),
)
return session.result(node, diagnostics=diagnostics)
+39 -4
View File
@@ -31,6 +31,17 @@ 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")
scoped_body = None
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")
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)
if not scoped_solids:
raise ValueError("hole scope body has no active solid")
# 2. 确定宿主面 host_face
host_selector = node.params.get("host_face")
if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
@@ -45,8 +56,15 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
selector = next((item for item in selectors if item.get("kind") == "face"), None)
if selector is None:
raise ValueError("hole requires host_face selector or frame")
host = _host_plane(session.resolve(selector))
positions_are_local = False
host = _host_plane(session.resolve(selector), session.adapter)
intent = selector.get("selector_intent") if isinstance(selector, dict) else None
# Existing selector-hosted holes store world positions. Only the new
# runtime-attached COPY(CAP_FACE) sketch preserves local coordinates
# until its exact host relation has materialized.
positions_are_local = (
isinstance(intent, dict)
and intent.get("copy_contract") == "primary_cut_cap_face_workplane"
)
# 3. 解析孔规格 HoleSpec(直径、深度、类型等,wizard 模式提供额外默认值)。
spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
# 4. A host-face normal is an outward B-rep orientation, so its inverse
@@ -61,7 +79,7 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
spec,
_hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
inward,
session.adapter.body_span(session.body, inward) + 2.0,
session.adapter.body_span(scoped_body or session.body, inward) + 2.0,
)
# 6. 从主体上减去工具实体,登记新主体并返回结果。
# thread 是装饰螺纹(无螺距、不进实体几何,SolidWorks/STEP 的螺纹孔
@@ -74,5 +92,22 @@ 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)
if scope_feature_id is None:
result_body = session.adapter.cut(session.body, tool)
members = None
else:
# CADFS ``scope`` identifies the target body member. Never apply the
# cutter to the aggregate merely because unrelated live members share
# the exported part; unchanged members remain exact body-graph nodes.
result_body = session.adapter.cut(scoped_body, tool)
members = {**session.body_members, scope_feature_id: result_body}
body = None
for member in members.values():
body = session.adapter.combine(body, member)
if body is None:
raise ValueError("hole scope cut produced no active body")
result_body = body
session.register_body(
node.feature_id, result_body, replay_node=node, body_members=members,
)
return session.result(node, diagnostics=diagnostics)
@@ -5,8 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Any
from ..registry import atomic_executor
from ..topology import FeaturePlanNode, FeatureResult
from .common import _sweep_path
from ..topology import FeaturePlanNode, FeatureResult, TopologyRecord
from .common import _apply_primary_tool, _sweep_path
if TYPE_CHECKING: # pragma: no cover - import for type checkers only
from ..session import ExecutionSession
@@ -66,13 +66,18 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
profile = sketch or session.sketches.get(str(node.sketch_id))
if profile is None:
raise ValueError("sweep has no resolved profile sketch")
faces = session.adapter.faces_for_sketch(profile)
faces, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(profile)
if len(faces) != 1:
raise ValueError("sweep requires exactly one closed profile region")
solid, topology_delta = session.adapter.sweep_with_topology_delta(
faces[0], _sweep_path(node, session),
is_frenet=bool(node.params.get("is_frenet", False)),
)
if node.atomic_id == "sweep_cut":
# The PipeShell is a transient cutting tool. Its own builder history
# cannot become selector provenance after the BRepAlgoAPI_Cut; only
# the cut's exact target-side delta may survive registration.
return _apply_primary_tool(node, session, solid, cutting=True)
is_new_body = node.params.get("result_mode") == "new_body"
body = session.adapter.combine(session.body, solid) if is_new_body else session.adapter.fuse(session.body, solid)
# A union rebuilds topology, so the pipe-shell builder cannot prove the
@@ -80,10 +85,37 @@ def _execute_sweep_add(node: FeaturePlanNode, session: "ExecutionSession", sketc
# subshape identity and may expose evidence for the new member.
if session.body is not None and not is_new_body:
topology_delta = None
session.register_body(node.feature_id, body, replay_node=node, topology_delta=topology_delta)
topology_anchors: list[TopologyRecord] = []
if topology_delta is not None:
for index, spec in enumerate(source_anchor_specs):
kind = spec.get("kind")
if kind not in {"edge", "vertex"} or spec.get("value") is None:
continue
source_entity = spec.get("source_entity")
source_entities = tuple(spec.get("source_entities") or ())
if not isinstance(source_entity, tuple) 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=spec["value"],
source_entity=source_entity if isinstance(source_entity, tuple) else None,
source_entities=source_entities,
))
session.register_body(
node.feature_id, body, replay_node=node, topology_delta=topology_delta,
topology_anchors=topology_anchors,
)
return session.result(node)
@atomic_executor("sweep_add")
def _sweep_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
@atomic_executor("sweep_cut")
def _sweep_cut_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
return _execute_sweep_add(node, session, sketch)
@@ -73,3 +73,21 @@ def _execute_extrude_surface(node: FeaturePlanNode, session: "ExecutionSession")
def _extrude_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_extrude_surface(node, session)
def _execute_loft_surface(node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult:
profile_ids = node.params.get("profile_sketch_ids") or []
profiles: list[dict[str, Any]] = []
for sketch_id in profile_ids:
profile = session.sketches.get(str(sketch_id))
if profile is None:
raise ValueError(f"surface loft profile sketch {sketch_id!r} is not resolved")
profiles.append(profile)
surface_id = session.register_surface(node.feature_id, session.adapter.loft_surface(profiles))
return session.result(node, include_body=False, surface_id=surface_id)
@atomic_executor("loft_surface")
def _loft_surface_executor(node: FeaturePlanNode, session: "ExecutionSession", sketch: dict[str, Any] | None) -> FeatureResult:
del sketch
return _execute_loft_surface(node, session)
+34 -3
View File
@@ -8,6 +8,7 @@ selector resolution, never on executors.
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
from .runtime_base import ExtentVector, FeatureExecutionError
@@ -44,12 +45,29 @@ def _targeted_extent_vector(
end_condition: dict[str, Any] | None = None,
offset_mm: float | None = None,
) -> ExtentVector:
if session.body is None:
reference = _extent_reference(node, end_condition) if condition != "through_next" else None
source_vertex_point: Vector3 | None = None
if condition == "up_to_vertex" and isinstance(reference, dict) and reference.get("kind") == "source_vertex":
raw_point = reference.get("point_mm")
if not (
isinstance(raw_point, list)
and len(raw_point) == 3
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in raw_point)
):
raise FeatureExecutionError(
"invalid_source_vertex_extent",
"The source-vertex extent datum must contain one finite 3D point",
extent=condition,
)
source_vertex_point = (float(raw_point[0]), float(raw_point[1]), float(raw_point[2]))
elif session.body is None:
raise FeatureExecutionError("missing_extent_body", "Selector-dependent extent requires an existing body", extent=condition)
if condition == "through_next":
target = session.body
elif source_vertex_point is not None:
target = None
else:
reference = _extent_reference(node, end_condition)
assert isinstance(reference, dict)
resolution = session.resolve(reference)
if resolution.status != "resolved" or resolution.record is None:
raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "extent target was not resolved")
@@ -62,7 +80,7 @@ def _targeted_extent_vector(
)
target = resolution.record.value
if condition == "up_to_vertex":
target_point = session.adapter.vertex_coordinates(target)
target_point = source_vertex_point or session.adapter.vertex_coordinates(target)
projections = [
vector_dot(vector_subtract(target_point, point), direction)
for face in faces
@@ -89,6 +107,19 @@ def _targeted_extent_vector(
distance = session.adapter.uniform_intersection_distance(target, faces, direction)
except ValueError as error:
message = str(error)
if condition == "up_to_surface" and message == "extent target is not reached by every profile ray":
# Do not reinterpret a partially hit finite face: that path
# has explicit trimmed-solid semantics below. Only a wholly
# unreachable planar face may terminate on its supporting
# plane, and the adapter proves one positive, uniform
# profile-to-plane distance before returning it.
if not session.adapter.target_has_forward_intersection(target, faces, direction):
try:
distance = session.adapter.uniform_planar_supporting_surface_distance(target, faces, direction)
except ValueError:
pass
else:
return ExtentVector(vector_scale(direction, distance))
code = "non_uniform_extent_target" if "non-uniform" in message else "extent_target_not_reached"
if condition in {"up_to_surface", "through_next"}:
# #5 高级终止条件:profile 与目标面非均匀相交(部分采样点未
@@ -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
@@ -409,6 +409,28 @@ def _rotated_node(node: FeaturePlanNode, instance_id: str, axis: AxisSpec, angle
for key in ("x_dir", "y_dir", "normal"):
if path_plane.get(key):
path_plane[key] = list(_rotated_vector(tuple(float(v) for v in path_plane[key]), axis, angle_rad))
elif isinstance(path, dict) and isinstance(path.get("segments"), list):
# A source-only spatial sweep path has no common workplane. Its
# captured points and directions are absolute, so replayed circular
# pattern instances must rotate each geometric field independently.
for segment in path["segments"]:
if not isinstance(segment, dict):
continue
for key in ("start_mm", "end_mm", "center_mm"):
value = segment.get(key)
if isinstance(value, list) and len(value) == 3:
segment[key] = _rotated_point(value, axis, angle_rad)
points = segment.get("points_mm")
if isinstance(points, list):
segment["points_mm"] = [
_rotated_point(point, axis, angle_rad)
for point in points
if isinstance(point, list) and len(point) == 3
]
for key in ("normal", "start_tangent_mm", "end_tangent_mm"):
value = segment.get(key)
if isinstance(value, list) and len(value) == 3:
segment[key] = list(_rotated_vector(tuple(float(v) for v in value), axis, angle_rad))
host = params.get("host_face")
host_frame = host.get("frame") if isinstance(host, dict) else None
positions_are_local = isinstance(host_frame, dict) and all(
+15 -7
View File
@@ -4,17 +4,19 @@
"cdsl_json_schema_file": "cdsl_schema.json",
"maintenance_rule": "The CDSL-only runtime contract is limited to direct generic profiles and runtime.py EXECUTORS. Legacy macro profiles are importer compatibility syntax and must be lowered by cdsl_importer.legacy_profile_adapter before generic runtime validation.",
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "planar_imprint"],
"runtime_supported_profiles": ["circle", "polygon", "analytic_contours", "multi_source_regions", "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_or_primary_add_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}},
"extrude_surface": {"atomic_id":"extrude_surface","contract_version":"1.1","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}},
"loft_add": {"atomic_id":"loft_add","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":16,"uniqueItems":true}},"required":["profile_sketch_ids"],"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":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"loft_surface": {"atomic_id":"loft_surface","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":2,"maxItems":2,"uniqueItems":true}},"required":["profile_sketch_ids"],"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":["loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["surface_shell"],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"loft_add_with_cap_face": {"atomic_id":"loft_add_with_cap_face","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"profile_sketch_ids":{"type":"array","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"},"minItems":1,"maxItems":1,"uniqueItems":true}},"required":["profile_sketch_ids"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":1,"snapshot_bound":true},"semantic_preflight":["loft_cap_face","loft_profiles_exist","loft_profiles_closed","loft_profiles_single_region"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"sweep_add": {"atomic_id":"sweep_add","contract_version":"3.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"},"result_mode":{"enum":["fuse","new_body"]}},"required":["path"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"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}},
"sweep_cut": {"atomic_id":"sweep_cut","contract_version":"1.0","fragment_shape":{"sketch":"required","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"path":{"type":"object"},"is_frenet":{"type":"boolean"}},"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":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","open_path"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
"extrude_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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","requires_immediate_owner":true},"params.reverse_end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"symmetric_direct_prism_two_sided_up_to_surface_cap_pair","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_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_or_primary_add_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}},
@@ -33,6 +35,8 @@
"thread_cut": {"atomic_id":"thread_cut","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"major_diameter_mm":{"type":"number","exclusiveMinimum":0},"minor_diameter_mm":{"type":"number","exclusiveMinimum":0},"pitch_mm":{"type":"number","exclusiveMinimum":0},"length_mm":{"type":"number","exclusiveMinimum":0},"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},"angle_deg":{"type":"number","exclusiveMinimum":0,"exclusiveMaximum":180},"lefthand":{"type":"boolean"},"crest_radius_mm":{"type":"number","minimum":0},"root_radius_mm":{"type":"number","minimum":0}},"required":["major_diameter_mm","minor_diameter_mm","pitch_mm","length_mm","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":["requires_active_solid"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"reference_plane": {"atomic_id":"reference_plane","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"plane":{"type":"object","properties":{"origin_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"x_dir":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},"normal":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["origin_mm","x_dir","normal"],"additionalProperties":false}},"required":["plane"],"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","reference_plane_nonzero_normal"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"reference_axis": {"atomic_id":"reference_axis","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"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}},"required":["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":["requires_active_solid","reference_axis_nonzero_direction"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"reference_point": {"atomic_id":"reference_point","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"point_mm":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3}},"required":["point_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":["reference_point_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"assign_variable": {"atomic_id":"assign_variable","contract_version":"1.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"name":{"type":"string","minLength":1,"maxLength":256},"value":{"type":"number"},"value_kind":{"enum":["any","length"]}},"required":["name","value","value_kind"],"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":["assign_variable_finite"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"hole_wizard": {
"runtime_capability": {"body_mutating": true, "requires_active_body": true, "replayable": true, "requires_selector": false, "open_profile_ok": false},
"atomic_id": "hole_wizard",
@@ -67,11 +71,11 @@
"fillet": {"atomic_id":"fillet","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"radius_mm":{"type":"number","exclusiveMinimum":0},"tangent_propagation":{"type":"boolean"}},"required":["radius_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"chamfer": {"atomic_id":"chamfer","contract_version":"3.0","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"distance_mm":{"type":"number","exclusiveMinimum":0},"distance_2_mm":{"type":"number","exclusiveMinimum":0},"angle_rad":{"type":"number","exclusiveMinimum":0,"maximum":3.141592653589793},"tangent_propagation":{"type":"boolean"}},"required":["distance_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"edge","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"none"},"semantic_preflight":["selected_edges_exist"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"shell": {"atomic_id":"shell","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"required"},"author_params_schema":{"type":"object","properties":{"thickness_mm":{"type":"number","exclusiveMinimum":0},"inward":{"type":"boolean"},"target_feature_id":{"type":"string","pattern":"^[A-Za-z0-9_-]{1,80}$"}},"required":["thickness_mm"],"additionalProperties":false},"selector_policy":{"slot":"feature.selectors","token_kind":"face","min_items":1,"max_items":64,"snapshot_bound":true},"server_injected_paths":["feature.selectors"],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_id","token_kind":"body","min_items":0,"max_items":1,"snapshot_bound":true},"semantic_preflight":["requires_active_solid","selected_faces_exist","shell_target_body_exists"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":true,"open_profile_ok":false}},
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.1","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"boolean_bodies": {"atomic_id":"boolean_bodies","contract_version":"3.2","fragment_shape":{"sketch":"forbidden","params":"required_object","selector_tokens":"forbidden"},"author_params_schema":{"type":"object","properties":{"operation":{"enum":["union","subtract","intersect"]},"target_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"target_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_feature_ids":{"type":"array","items":{"type":"string","pattern":"^[a-z][a-z0-9_:-]{0,95}$"},"minItems":1,"maxItems":16,"uniqueItems":true},"tool_pattern_instance_refs":{"type":"array","items":{"$ref":"#/$defs/patternInstanceBodyRef"},"minItems":1,"maxItems":16,"uniqueItems":true},"keep_tools":{"type":"boolean"},"targetless_body_set":{"type":"boolean"}},"required":["operation"],"additionalProperties":false},"selector_policy":{"slot":null,"token_kind":null,"min_items":0,"max_items":0,"snapshot_bound":false},"server_injected_paths":[],"reference_policy":{"mode":"snapshot_bound","slot":"params.target_feature_ids","token_kind":"feature","min_items":0,"max_items":16,"snapshot_bound":true},"semantic_preflight":["source_bodies_exist"],"candidate_verifiers":[],"runtime_capability":{"body_mutating":false,"requires_active_body":false,"replayable":false,"requires_selector":false,"open_profile_ok":false}},
"transform_bodies": {
"runtime_capability": {"body_mutating": false, "requires_active_body": false, "replayable": false, "requires_selector": false, "open_profile_ok": false},
"atomic_id": "transform_bodies",
"contract_version": "3.3",
"contract_version": "3.4",
"fragment_shape": {"sketch": "forbidden", "params": "required_object", "selector_tokens": "forbidden"},
"author_params_schema": {
"type": "object",
@@ -79,6 +83,7 @@
"source_feature_ids": {"type": "array", "items": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
"pattern_instance_refs": {"type": "array", "items": {"type": "object", "properties": {"pattern_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "instance_index": {"type": "integer", "minimum": 1}}, "required": ["pattern_feature_id", "source_feature_id", "instance_index"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
"transform_copy_refs": {"type": "array", "items": {"type": "object", "properties": {"transform_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["transform_feature_id", "source_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 64, "uniqueItems": true},
"source_member_aliases": {"type": "array", "items": {"type": "object", "properties": {"source_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}, "active_member_feature_id": {"type": "string", "pattern": "^[a-z][a-z0-9_:-]{0,95}$"}}, "required": ["source_feature_id", "active_member_feature_id"], "additionalProperties": false}, "minItems": 1, "maxItems": 16, "uniqueItems": true},
"transform": {
"type": "object",
"properties": {
@@ -129,6 +134,9 @@
"analytic_contours": {
"summary": "Closed executable line, arc, circle, ellipse and interpolation B-spline contours. An ellipse retains its local center, radii and major-axis direction. Executable B-splines preserve ordered interpolation points; periodic contours repeat their first point only to state closure and are interpolated from unique points. A non-periodic two-point B-spline is permitted only with explicit, strictly increasing parameters and both endpoint tangents, giving the OCP interpolation contract sufficient curvature information; a two-point B-spline without those fields is invalid rather than a line fallback. B-splines may explicitly use chord or centripetal parameterization; CADFS closed skFitSpline uses centripetal. Imported construction B-splines remain non-executable audit geometry."
},
"multi_source_regions": {
"summary": "A direct qUnion of two or more closed qSketchRegion sources on exactly one shared workplane. Every child profile is solved as an independent region set before union, so cross-source nested contours are never reclassified as holes. This is profile geometry only and carries no single-source topology anchor."
},
"planar_imprint": {
"summary": "An exact planar arrangement derived from one sketch's original analytic entities. Each selected region retains an IMPRINT source edge, its face side, and optional INTERSECT vertex order and fragment side. The adapter splits a bounded support face with OCC and rejects non-unique or unbounded selections; it never samples curves into a polygon or substitutes an unrelated sketch contour."
}
+2 -2
View File
@@ -29,10 +29,10 @@ class AtomicExecutor(Protocol):
#: at import time instead of surfacing as an unknown-atomic blocker later.
ALL_ATOMIC_IDS = frozenset({
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_surface",
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "sweep_add",
"extrude_cut_through", "extrude_from_face", "loft_add", "loft_add_with_cap_face", "loft_surface", "sweep_add", "sweep_cut",
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
"reference_plane", "reference_axis",
"reference_plane", "reference_axis", "reference_point", "assign_variable",
"hole_wizard", "fillet", "chamfer", "shell", "pattern_linear", "pattern_mirror",
"pattern_circular", "boolean_bodies", "transform_bodies", "delete_bodies",
"thread_add", "thread_cut",
+103 -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,83 @@ 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:
if node.sketch_id is not None:
self.session.resolve_sketch_attachment(str(node.sketch_id), feature_id=node.feature_id)
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 +193,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 +231,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,8 @@ from .topology import (
SelectorResolution,
TopologyDelta,
TopologyDeltaRelation,
TopologyBlendRelation,
TopologySectionRelation,
TopologyLineage,
TopologyRecord,
TopologyRegistry,
@@ -64,6 +66,8 @@ __all__ = [
"ThreadSpec",
"TopologyDelta",
"TopologyDeltaRelation",
"TopologyBlendRelation",
"TopologySectionRelation",
"TopologyLineage",
"TopologyRecord",
"TopologyRegistry",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
{
"_meta": {
"title": "CDSL 语义词表几何签名(初稿 v0.1)",
"protocol": "cad.cdsl.llm.v1",
"date": "2026-09-14",
"role": "三层关联机制的字典层:label ↔ 几何期望的机器可读绑定。签名是期望(expectation),不是模板(template)。",
"checking_semantics": "签名检查只产生警告与训练数据分级,永不阻塞建模与重建(与 AGENTS.md 一致:不把语义分歧当程序错误)。",
"input_dependency": "metric_expectations 依赖重建管线输出的逐特征指标(derived_metrics per_feature delta)。缺失 delta 时,退化检查为:metric_expectations 中 *_delta 键跳过,其余照查。",
"inheritance": "label 可带 parent,检查时先应用父签名再应用自身(并集)。composite=true 表示该语义典型地由特征组合实现(如打孔+阵列),检查时沿 depends_on 链聚合 delta。",
"out_of_vocabulary": "词表外 label 无签名,程序侧零校验(开放词表的设计使然);训练侧应引导收敛到词表内。",
"known_limitation": "纯用途差异在几何上不可分:coolant_channel 与 lubrication_gallery、cosmetic_surface 与 stress_relief_fillet 的几何签名几乎相同,其区分依赖 intent.why 与上下文,不依赖本签名。",
"cross_field_reference": "param_expectations 的值可以引用另一参数路径(如 {\">\": \"diameter_mm\"}),匹配器解析为跨字段比较。",
"signature_version": "0.1",
"vocabulary_count": 37
},
"signatures": {
"bolt_circle": {
"parent": "fastener_hole",
"composite": true,
"allowed_atomics": ["pattern_circular", "pattern_linear", "hole_wizard", "hole_blind"],
"param_expectations": {
"pattern_count": {">=": 3},
"positions": {">=": 3}
},
"metric_expectations": {
"hole_count_delta": {">=": 3},
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"relations": ["instances_same_diameter", "instances_on_common_circle"],
"notes_zh": "一组等径紧固孔沿公共圆周分布。典型实现:hole_wizard(1孔)+pattern_circular 阵列,或一次多 positions。检查时沿 depends_on 聚合打孔与阵列两特征的 delta。"
},
"fastener_hole": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
"param_expectations": {
"end_condition.type": {"in": ["through_all", "through_all_both", "blind"]}
},
"metric_expectations": {
"hole_count_delta": {">=": 1},
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "紧固件过孔(光孔)。词表父节点:tap_hole / bolt_circle / 沉头类均为其子型或伴生型。"
},
"tap_hole": {
"parent": "fastener_hole",
"allowed_atomics": ["hole_wizard", "thread_cut"],
"param_expectations": {
"thread": {"required_if_atomic": "hole_wizard"}
},
"metric_expectations": {
"hole_count_delta": {">=": 1},
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true,
"creates_thread_helix": false
},
"notes_zh": "螺纹孔。注意:hole_wizard 的 thread 装饰当前被引擎降级为光孔(thread_decoration_ignored 诊断),creates_thread_helix 恒为 false,签名如实反映现状;thread_cut 才产生真实螺旋几何。"
},
"counterbore_seat": {
"parent": "fastener_hole",
"allowed_atomics": ["hole_counterbore", "hole_wizard"],
"param_expectations": {
"counterbore_diameter_mm": {">": "diameter_mm"},
"counterbore_depth_mm": {">": 0}
},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true,
"creates_planar_shoulder": true
},
"notes_zh": "沉头柱坑:主孔 + 同轴大直径浅坑,坑底形成环形平面肩。"
},
"countersink_seat": {
"parent": "fastener_hole",
"allowed_atomics": ["hole_countersink", "hole_wizard"],
"param_expectations": {
"countersink_diameter_mm": {">": "diameter_mm"},
"countersink_angle_rad": {">": 0, "<": 3.141592653589793}
},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cone_face": true
},
"notes_zh": "沉头锥坑:主孔 + 锥形扩口,锥面是与柱面区分的强拓扑指纹。"
},
"locating_pin_hole": {
"allowed_atomics": ["hole_wizard", "hole_blind"],
"param_expectations": {},
"metric_expectations": {
"hole_count_delta": {">=": 1},
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "定位销孔。配合公差与双孔位置度是设计要点,但公差不在 CDSL 当前表达能力内,机器只能查到 存在性/数量;配合语义写 why。"
},
"bearing_seat": {
"allowed_atomics": ["extrude_add_blind", "cylinder_add", "revolve_add", "hole_wizard"],
"param_expectations": {},
"metric_expectations": {},
"topology_expectations": {
"creates_cylindrical_fit_surface": true
},
"notes_zh": "轴承位(轴颈或座孔)。功能型语义,几何签名天然宽:内核是圆柱配合面(外圆或内孔),方向(增/减材)与安装形式有关。尺寸配合关系写 why。"
},
"shaft_passage": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
"param_expectations": {
"end_condition.type": {"in": ["through_all", "through_all_both"]}
},
"metric_expectations": {
"hole_count_delta": {">=": 1},
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"relations": ["axis_aligned_with_body_center_or_axis"],
"notes_zh": "过轴通孔。贯穿是强约束(签名可查);同心是典型但非必然(签名标注为关系提示)。"
},
"press_fit_boss": {
"allowed_atomics": ["extrude_add_blind", "cylinder_add"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_outer_cylindrical_face": true
},
"notes_zh": "压配合凸台:增材圆柱特征。过盈量等配合信息写在 why,几何只可查 增材+圆柱面。"
},
"alignment_datum": {
"allowed_atomics": ["reference_plane", "reference_axis", "extrude_add_blind", "hole_blind"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">=": 0}
},
"topology_expectations": {},
"notes_zh": "对中/对位基准结构。可以是参考几何(零材料变化)也可以是小凸台/销孔;签名宽,依赖 why。"
},
"mounting_boss": {
"allowed_atomics": ["extrude_add_blind", "cylinder_add"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_outer_cylindrical_face": true
},
"notes_zh": "安装凸台:增材圆柱特征,常带后续紧固孔(组合语义)。"
},
"mounting_foot": {
"allowed_atomics": ["extrude_add_blind"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_planar_faces": true
},
"notes_zh": "安装底脚:增材板状特征,形成安装平面。"
},
"lifting_eye": {
"allowed_atomics": ["extrude_add_blind", "revolve_add", "sweep_add"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "吊环/吊耳:增材结构带贯穿吊孔(吊索穿过)。孔的存在是较强指纹。"
},
"slot_adjustment": {
"allowed_atomics": ["extrude_cut_blind", "extrude_cut_through", "hole_wizard", "hole_blind"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_planar_walls": true
},
"notes_zh": "调整长孔/滑槽:允许位置调节的细长切口。典型实现:长圆 polygon 切除,或两孔+直切。"
},
"coolant_channel": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "sweep_add"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "冷却通道。与 lubrication_gallery 几何签名几乎相同(能力边界案例),区分靠 why 与介质上下文。"
},
"lubrication_gallery": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "sweep_add"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "润滑油路。同上,几何上与冷却通道不可分。"
},
"vent_hole": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
"param_expectations": {},
"metric_expectations": {
"hole_count_delta": {">=": 1},
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "排气孔:小直径贯穿孔,通常贯穿(呼吸/排气的功能要求)。"
},
"drain_port": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "排液口。位置在最低点是设计要点但不在几何签名能力内,写 why。"
},
"fluid_inlet": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through", "extrude_add_blind"],
"param_expectations": {},
"metric_expectations": {},
"topology_expectations": {},
"notes_zh": "进液口。可能是孔(减材)也可能是接管凸台+孔(增减组合),签名宽。"
},
"process_corner_relief": {
"allowed_atomics": ["extrude_cut_blind", "extrude_cut_through", "chamfer", "fillet"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {},
"notes_zh": "工艺让位(避让刀具/相邻件干涉)。材料切除量小是典型特征。"
},
"weld_prep": {
"allowed_atomics": ["chamfer"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_cone_face": true
},
"notes_zh": "焊接坡口:倒角原子直接映射(坡口即倒角),锥面是强指纹。"
},
"machining_setup_tab": {
"allowed_atomics": ["extrude_add_blind"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {},
"notes_zh": "装夹工艺台:临时增材,后续工序去除。生命周期语义写在 why(几何本身与普通小凸台不可分)。"
},
"inspection_access": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_through"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_inner_cylindrical_face": true
},
"notes_zh": "测量/探针可达孔。"
},
"stress_relief_fillet": {
"allowed_atomics": ["fillet"],
"param_expectations": {
"radius_mm": {">": 0}
},
"metric_expectations": {
"volume_delta": {"<": 0},
"face_count_delta": {">": 0}
},
"topology_expectations": {
"creates_torus_face": true
},
"notes_zh": "应力缓解圆角。torus 面(圆角面)+面数增加是修饰操作生效的强指纹;与 cosmetic_surface 的区别是用途而非几何。"
},
"stiffening_rib": {
"allowed_atomics": ["extrude_add_blind"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_planar_faces": true
},
"notes_zh": "加强筋:增材薄壁特征。薄(相对壁厚)是定义的一部分但属跨字段相对约束,初稿不做机器检查。"
},
"weight_relief": {
"allowed_atomics": ["hole_wizard", "hole_blind", "extrude_cut_blind", "extrude_cut_through"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {},
"notes_zh": "减重(孔/槽)。通常多孔或多腔(volume delta 显著为负)。"
},
"mass_saving_pocket": {
"allowed_atomics": ["extrude_cut_blind"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_planar_faces": true
},
"notes_zh": "减重腔:盲切腔体,不留穿。与 weight_relief 的区别是形式(腔 vs 孔阵)。"
},
"load_path_flange": {
"allowed_atomics": ["extrude_add_blind", "revolve_add"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {},
"notes_zh": "承载法兰/接盘。功能型语义,签名宽。"
},
"wall_thickness_transition": {
"allowed_atomics": ["shell", "extrude_add_blind"],
"param_expectations": {},
"metric_expectations": {},
"topology_expectations": {},
"notes_zh": "壁厚过渡。实现多样(抽壳/变截面拉伸),签名宽。"
},
"gear_teeth": {
"allowed_atomics": ["gear_add"],
"param_expectations": {
"module_mm": {">": 0},
"teeth_count": {">=": 8}
},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_involute_or_bspline_faces": true
},
"notes_zh": "轮齿:gear_add 原子专属。渐开线/自由曲面齿面是强指纹(亦可用于检测 gear_add 是否真的生成几何)。"
},
"rack_teeth": {
"allowed_atomics": ["rack_add"],
"param_expectations": {
"module_mm": {">": 0}
},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_involute_or_bspline_faces": true
},
"notes_zh": "齿条齿:rack_add 原子专属。"
},
"thread_drive": {
"allowed_atomics": ["thread_add"],
"param_expectations": {
"pitch_mm": {">": 0}
},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_thread_helix": true
},
"notes_zh": "传动螺纹(外螺纹实体段):thread_add 专属,螺旋面是强指纹。"
},
"cam_track": {
"allowed_atomics": ["sweep_add", "extrude_cut_through", "extrude_cut_blind"],
"param_expectations": {},
"metric_expectations": {},
"topology_expectations": {
"creates_bspline_curve_geometry": true
},
"notes_zh": "凸轮轨道:曲线扫掠或曲线槽,B 样条路径/曲线是其指纹。"
},
"bend_wing": {
"allowed_atomics": ["bend_add"],
"param_expectations": {
"chain": {">=": 1}
},
"metric_expectations": {
"volume_delta": {">": 0}
},
"topology_expectations": {
"creates_cylindrical_bend_face": true
},
"notes_zh": "折弯翼:bend_add 原子专属,折弯内/外圆柱面是指纹。"
},
"cosmetic_surface": {
"allowed_atomics": ["fillet", "chamfer"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"<": 0}
},
"topology_expectations": {
"creates_torus_face": true
},
"notes_zh": "外观修饰(倒圆/倒角)。与 stress_relief_fillet 几何签名几乎相同(能力边界案例),区别写 why。"
},
"datum_plane_feature": {
"allowed_atomics": ["reference_plane"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"==": 0},
"face_count_delta": {"==": 0}
},
"topology_expectations": {},
"notes_zh": "设计基准面:零材料变化 + 零拓扑变化 + 特定原子,是全部签名中最确定的一条(强指纹)。"
},
"datum_axis_feature": {
"allowed_atomics": ["reference_axis"],
"param_expectations": {},
"metric_expectations": {
"volume_delta": {"==": 0},
"face_count_delta": {"==": 0}
},
"topology_expectations": {},
"notes_zh": "设计基准轴:同上,强指纹。"
}
}
}
+150 -3
View File
@@ -14,6 +14,7 @@ from typing import Any, Protocol
from .build123d_adapter import Build123dGeometryAdapter
from .runtime_base import FeatureExecutionError
from .sketch_solver import resolve_profile
from .specs import AxisSpec, BendSpec, GearSpec, HoleSpec, PlaneSpec, RackSpec, ThreadSpec, Vector3
from .topology import (
FeaturePlanNode,
@@ -23,6 +24,7 @@ from .topology import (
TopologyDelta,
TopologyRecord,
TopologyRegistry,
validate_selector_provenance_intent,
)
@@ -37,17 +39,25 @@ class GeometryAdapter(Protocol):
def body_solids(self, body: Any) -> list[Any]: ...
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(self, sketch: dict[str, Any], *, support_face: Any | None = None,
external_anchor_edges: dict[str, Any] | None = None) -> list[Any]: ...
def faces_for_sketch_with_source_anchors(self, sketch: dict[str, Any], *, support_face: Any | None = None,
external_anchor_edges: dict[str, Any] | None = None) -> 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]: ...
def loft_surface(self, sketches: list[dict[str, Any]]) -> Any: ...
def loft_with_cap_face(self, cap_face: Any, sketches: list[dict[str, Any]]) -> Any: ...
def sweep(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> Any: ...
def sweep_with_topology_delta(self, section: Any, spine: Any, *, inner_wires: list[Any] | None = None, make_solid: bool = True, is_frenet: bool = False, transition: Any = None) -> tuple[Any, TopologyDelta | None]: ...
def sweep_path(self, points: list[Vector3], *, start_tangent: Vector3 | None = None, end_tangent: Vector3 | None = None, parameters: list[float] | None = None) -> Any: ...
def sweep_arc_path(self, start: Vector3, end: Vector3, center: Vector3, normal: Vector3, *, radius_mm: float, clockwise: bool) -> Any: ...
def sweep_circle_path(self, center: Vector3, x_dir: Vector3, normal: Vector3, *, radius_mm: float) -> Any: ...
def face_normal(self, face: Any) -> Vector3: ...
def planar_face_workplane(self, face: Any) -> PlaneSpec: ...
def extrude(self, face: Any, direction: Vector3) -> Any: ...
def extrude_with_topology_delta(self, face: Any, direction: Vector3) -> tuple[Any, TopologyDelta]: ...
def extrude_faces_with_composed_topology_delta(self, faces: list[Any], direction: Vector3) -> tuple[Any, TopologyDelta] | None: ...
def extrude_taper_with_topology_delta(self, face: Any, direction: Vector3, taper_deg: float) -> tuple[Any, TopologyDelta | None]: ...
def extrude_taper(self, face: Any, direction: Vector3, taper_deg: float) -> Any: ...
def extrude_trimmed(self, face: Any, target: Any, direction: Vector3) -> Any: ...
@@ -55,6 +65,7 @@ class GeometryAdapter(Protocol):
def extrude_surface(self, wires: list[Any], direction: Vector3) -> Any: ...
def combine_surfaces(self, *surfaces: Any) -> Any: ...
def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def revolve_with_topology_delta(self, face: Any, angle_deg: float, axis: AxisSpec) -> tuple[Any, TopologyDelta]: ...
def revolve_surface(self, wire: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
def intersect(self, left: Any, right: Any) -> Any: ...
def intersect_with_topology_delta(self, left: Any, right: Any) -> tuple[Any, TopologyDelta | None]: ...
@@ -80,6 +91,8 @@ class GeometryAdapter(Protocol):
def profile_touches_target(self, target: Any, faces: list[Any]) -> bool: ...
def next_body_face_after(self, body: Any, faces: list[Any], direction: Vector3, *, excluded_face: Any) -> Any: ...
def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def target_has_forward_intersection(self, target: Any, faces: list[Any], direction: Vector3) -> bool: ...
def uniform_planar_supporting_surface_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
def fillet_with_topology_delta(self, body: Any, radius_mm: float, edges: list[Any]) -> tuple[Any, TopologyDelta | None]: ...
def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
@@ -102,10 +115,85 @@ class ExecutionSession:
results: dict[str, FeatureResult] = field(default_factory=dict)
replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
body_members: dict[str, Any] = field(default_factory=dict)
body_member_snapshot_ids: dict[str, str] = field(default_factory=dict)
surface_members: dict[str, Any] = field(default_factory=dict)
sketch_attachment_faces: dict[str, Any] = field(default_factory=dict)
sketch_imprint_external_edges: dict[str, dict[str, Any]] = field(default_factory=dict)
selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
active_feature_id: str = ""
def resolve_sketch_attachment(self, sketch_id: str, *, feature_id: str | None = None) -> None:
"""Resolve one runtime-attached sketch immediately before consumption."""
sketch = self.sketches.get(sketch_id)
if sketch is None or not isinstance(sketch.get("attachment"), dict):
return
previous_feature_id = self.active_feature_id
if feature_id is not None:
self.active_feature_id = feature_id
try:
resolution = self.resolve(sketch["attachment"])
if resolution.status != "resolved" or resolution.record is None or resolution.record.kind != "face":
detail = resolution.diagnostic.message if resolution.diagnostic else "attached face was not resolved"
raise FeatureExecutionError("sketch_attachment_unresolved", detail)
try:
plane = self.adapter.planar_face_workplane(resolution.record.value)
except ValueError as error:
raise FeatureExecutionError("sketch_attachment_nonplanar", str(error)) from error
external_edges: dict[str, Any] = {}
profile = sketch.get("profile") or {}
for entry in profile.get("external_anchors") or ():
anchor_id = entry.get("id") if isinstance(entry, dict) else None
selector = entry.get("selector") if isinstance(entry, dict) else None
if not isinstance(anchor_id, str) or not anchor_id or not isinstance(selector, dict):
raise FeatureExecutionError("imprint_external_anchor_invalid", "external anchor contract is invalid")
edge_resolution = self.resolve(selector)
if (
edge_resolution.status != "resolved"
or edge_resolution.record is None
or edge_resolution.record.kind != "edge"
):
detail = edge_resolution.diagnostic.message if edge_resolution.diagnostic else "external anchor edge was not resolved"
raise FeatureExecutionError("imprint_external_anchor_unresolved", detail)
if not any(edge.wrapped.IsSame(edge_resolution.record.value.wrapped) for edge in resolution.record.value.edges()):
raise FeatureExecutionError(
"imprint_external_anchor_not_support_boundary",
"external anchor edge is not an exact boundary of the attached support face",
)
external_edges[anchor_id] = edge_resolution.record.value
materialized = deepcopy(sketch)
materialized["workplane"] = plane.as_dict()
# IMPRINT face-side disambiguation is expressed in the source
# sketch's oriented plane. A native attached face may have the
# opposite normal, which reverses the physical side of the same
# local curve after its coordinates are mapped to that face.
# This is a session-local frame conversion, not selector or
# geometry fallback data.
source_profile = materialized.get("profile") or {}
if source_profile.get("type") == "planar_imprint":
try:
source_plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
except ValueError as error:
raise FeatureExecutionError("sketch_attachment_frame_invalid", str(error)) from error
orientation = sum(
source_plane.normal[index] * plane.normal[index]
for index in range(3)
)
if orientation < 0.0:
for selection in source_profile.get("selections") or ():
if isinstance(selection, dict) and selection.get("face_side") in {-1, 1}:
selection["face_side"] = -selection["face_side"]
# Preflight intentionally leaves attached sketches local. Resolve now
# so every world-space contour is rebuilt from the proven face frame.
materialized = resolve_profile(materialized)
self.sketches[sketch_id] = materialized
# Keep exact supports only in the live session. They are never CDSL
# geometry, serialized record IDs, or selector fallback evidence.
self.sketch_attachment_faces[sketch_id] = resolution.record.value
if external_edges:
self.sketch_imprint_external_edges[sketch_id] = external_edges
finally:
self.active_feature_id = previous_feature_id
def register_body(
self,
feature_id: str,
@@ -115,20 +203,47 @@ 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"
# 每个独立 Solid 一个 body:{feature}:{index},供 selector 精确匹配目标
# 实体;单体保持 body:{feature}(与历史行为完全一致)。
previous_members = dict(self.body_members)
previous_snapshot_ids = dict(self.body_member_snapshot_ids)
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)
member_snapshot_ids: dict[str, str] = {}
for member_key, member in self.body_members.items():
matches = [
index for index, solid in enumerate(solids)
if TopologyRegistry._same_topology_value(member, solid)
]
if len(matches) == 1:
member_snapshot_ids[member_key] = (
self.body_id if len(solids) == 1 else f"{self.body_id}:{matches[0]}"
)
member_preservations = [
(previous_snapshot_ids[key], member_snapshot_ids[key])
for key, member in previous_members.items()
if key in previous_snapshot_ids and key in member_snapshot_ids
and TopologyRegistry._same_topology_value(member, self.body_members[key])
]
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, member_preservations=member_preservations,
)
else:
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
@@ -141,7 +256,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, member_preservations=member_preservations,
)
self.topology.register(TopologyRecord(
record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
@@ -149,6 +264,26 @@ class ExecutionSession:
))
if replay_node is not None:
self.replay_definitions[feature_id] = replay_node
self.body_member_snapshot_ids = member_snapshot_ids
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
@@ -168,6 +303,7 @@ class ExecutionSession:
self.body = None
self.body_id = None
self.body_members = {}
self.body_member_snapshot_ids = {}
def _record_selector_resolution(self, resolution: SelectorResolution) -> SelectorResolution:
evidence = resolution.as_dict()
@@ -236,6 +372,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
+199 -23
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]:
@@ -453,6 +489,49 @@ def _contains(point: tuple[float, float], loop: list[tuple[float, float]]) -> bo
return inside
def _point_on_loop_boundary(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
"""Return whether a sampled contour point lies on one sampled boundary.
Region nesting is topology, not a ray-casting tie-break. A contour that
shares a vertex or an edge with another contour cannot be a hole of it.
The ordinary parity test intentionally leaves that boundary case
unspecified, so keep it out of containment classification explicitly.
"""
if len(loop) < 2:
return False
px, py = point
previous = loop[-1]
for current in loop:
dx, dy = current[0] - previous[0], current[1] - previous[1]
length_squared = dx * dx + dy * dy
if length_squared <= _TOLERANCE_MM * _TOLERANCE_MM:
if math.dist(point, previous) <= _TOLERANCE_MM:
return True
else:
projection = ((px - previous[0]) * dx + (py - previous[1]) * dy) / length_squared
if -_TOLERANCE_MM <= projection <= 1.0 + _TOLERANCE_MM:
nearest = (previous[0] + projection * dx, previous[1] + projection * dy)
if math.dist(point, nearest) <= _TOLERANCE_MM:
return True
previous = current
return False
def _strictly_contains_loop(outer: list[tuple[float, float]], inner: list[tuple[float, float]]) -> bool:
"""Require every sampled inner boundary point to be strictly inside outer.
This conservative predicate rejects touching and intersecting contours
rather than manufacturing an invalid face with a self-identical or shared
hole. Curved contours are already sampled by ``_sample_loop`` before
this stage, so the decision uses the same region representation as the
existing parity classifier.
"""
return bool(inner) and all(
not _point_on_loop_boundary(point, outer) and _contains(point, outer)
for point in inner
)
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
loops: list[_Ctx] = []
entities: list[_Ctx] = []
@@ -466,13 +545,30 @@ 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:
# A pure surface extrusion consumes its source wire directly.
# Do not invent the closing edge used by the solid-cut open
# profile contract: it would turn one requested ruled face into
# a different shell. This marker is emitted only by the CADFS
# ToolBodyType.SURFACE lowering contract.
if bool(contour.get("surface_wire")):
if not contour_open or contour.get("role") != "open":
raise ValueError(f"analytic_contours: surface wire {index} must be an open contour")
meta.setdefault("_surface_wires", []).append(_join(raw_edges, allow_open=True))
continue
edges = _join(raw_edges, allow_open=contour_open)
contour_opened = False
if contour_open:
@@ -494,11 +590,17 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
if not loops:
return entities, []
for loop in loops:
loop["role"] = "inner" if sum(_contains(loop["points"][0], other["points"]) for other in loops if other is not loop) % 2 else "outer"
loop["role"] = "inner" if sum(
_strictly_contains_loop(other["points"], loop["points"])
for other in loops if other is not loop
) % 2 else "outer"
outers = [loop for loop in loops if loop["role"] == "outer"]
regions = [{"outer": outer["edges"], "holes": [], "open": bool(outer.get("open"))} for outer in outers]
for inner in (loop for loop in loops if loop["role"] == "inner"):
containing = [outer for outer in outers if _contains(inner["points"][0], outer["points"])]
containing = [
outer for outer in outers
if _strictly_contains_loop(outer["points"], inner["points"])
]
if not containing:
raise ValueError("analytic_contours: inner contour has no containing outer contour")
if any(outer.get("open") for outer in containing):
@@ -509,6 +611,58 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
return entities, []
def _multi_source_child_as_analytic(profile: _Ctx) -> _Ctx:
"""Use the established region classifier once per source profile."""
kind = profile.get("type")
if kind == "analytic_contours":
return deepcopy(profile)
if kind == "circle":
return {
"type": "analytic_contours",
"contours": [{"role": "outer", "closed": True, "segments": [{
"type": "circle", "center": deepcopy(profile.get("center") or [0.0, 0.0]),
"radius_mm": profile.get("radius_mm"),
}]}],
}
if kind == "polygon":
vertices = profile.get("vertices") or []
if len(vertices) < 3:
raise ValueError("multi_source_regions: polygon needs at least three vertices")
return {
"type": "analytic_contours",
"contours": [{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": deepcopy(vertices[index]), "end": deepcopy(vertices[(index + 1) % len(vertices)])}
for index in range(len(vertices))
]}],
}
raise ValueError(f"multi_source_regions: unsupported child profile type {kind!r}")
def _gen_multi_source_regions(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Resolve each qSketchRegion source independently before taking its union.
Flattening all contours would reclassify a nested region from a separate
sketch as a hole. FeatureScript qUnion selects each source region, so its
operands must retain independent outer/hole classification here.
"""
sources, profiles = profile.get("source_sketch_ids") or [], profile.get("profiles") or []
if len(sources) < 2 or len(sources) != len(profiles) or len(set(sources)) != len(sources):
raise ValueError("multi_source_regions: source sketch ids must be unique and match profiles")
entities: list[_Ctx] = []
regions: list[_Ctx] = []
for child in profiles:
if not isinstance(child, dict):
raise ValueError("multi_source_regions: child profile must be an object")
child_meta: _Ctx = {"_regions": [], "_has_open_contour": False, "_surface_wires": []}
child_entities, _ = _gen_analytic_contours(_multi_source_child_as_analytic(child), child_meta)
if child_meta["_has_open_contour"] or child_meta["_surface_wires"] or not child_meta["_regions"]:
raise ValueError("multi_source_regions: every child must resolve to closed regions")
entities.extend(child_entities)
regions.extend(child_meta["_regions"])
meta["_regions"] = regions
return entities, []
def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Prepare source curves for an exact OCC planar-arrangement split.
@@ -528,8 +682,23 @@ def _gen_planar_imprint(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ct
if not edges:
raise ValueError(f"planar_imprint: source entity {source_id!r} has no curve")
source_entities.append({"id": source_id, "edges": edges})
if len(source_entities) < 2:
raise ValueError("planar_imprint: at least two source entities are required")
external_anchors = profile.get("external_anchors") or []
external_ids = {
str(entry.get("id") or "")
for entry in external_anchors
if isinstance(entry, dict)
}
single_external_fragment = (
len(source_entities) == 1
and bool(external_ids)
and all(
isinstance(selection, dict)
and str((selection.get("fragment") or {}).get("external_anchor_id") or "") in external_ids
for selection in profile.get("selections") or ()
)
)
if len(source_entities) < 2 and not single_external_fragment:
raise ValueError("planar_imprint: at least two source entities or one external anchor are required")
meta["_imprint_entities"] = source_entities
meta["_imprint_selections"] = deepcopy(profile.get("selections") or [])
return [], []
@@ -539,6 +708,7 @@ CORE_SHAPE_GENERATORS: dict[str, Any] = {
"circle": _gen_circle,
"polygon": _gen_polygon,
"analytic_contours": _gen_analytic_contours,
"multi_source_regions": _gen_multi_source_regions,
"planar_imprint": _gen_planar_imprint,
}
SHAPE_GENERATORS = CORE_SHAPE_GENERATORS
@@ -546,6 +716,7 @@ SHAPE_CAPABILITIES: dict[str, _Ctx] = {
"circle": {"detectable": True, "arity": "circle", "description": "single circular contour"},
"polygon": {"detectable": True, "arity": "polygon", "description": "closed straight-edge contour"},
"analytic_contours": {"detectable": True, "arity": "analytic", "description": "closed line, arc, circle, ellipse and B-spline contours"},
"multi_source_regions": {"detectable": True, "arity": "multi_source", "description": "independent direct sketch regions on one shared frame"},
}
@@ -566,7 +737,7 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
raise ValueError(f"sketch {sketch.get('id')}: unsupported profile type {profile.get('type')!r}")
meta: _Ctx = {
"id": sketch.get("id"), "_entities": sketch.get("entities"), "_regions": [], "_has_open_contour": False,
"_imprint_entities": [], "_imprint_selections": [],
"_imprint_entities": [], "_imprint_selections": [], "_surface_wires": [],
}
entities, contour = generator(profile, meta)
output = deepcopy(sketch)
@@ -584,6 +755,11 @@ def resolve_profile(sketch: _Ctx) -> _Ctx:
]
if meta["_has_open_contour"]:
output["_open_contour"] = True
if meta["_surface_wires"]:
output["surface_wires_mm"] = [
_transform_contours(wire, workplane) if workplane else wire
for wire in meta["_surface_wires"]
]
if meta["_imprint_entities"]:
output["imprint_entities_mm"] = [
{
File diff suppressed because it is too large Load Diff
+43 -3
View File
@@ -13,6 +13,11 @@ from __future__ import annotations
import math
from typing import Any
from OCP.BRep import BRep_Tool
from OCP.TopAbs import TopAbs_EDGE, TopAbs_VERTEX
from OCP.TopExp import TopExp_Explorer
from OCP.TopoDS import TopoDS
from .specs import canonical_plane_signature
from .topology import TopologyRecord
@@ -48,6 +53,40 @@ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyR
edges = list(body.edges())
vertices = list(body.vertices())
# build123d may wrap a vertex extracted from a solid in a distinct OCC
# handle. Builder FirstShape/LastShape vertex history is only comparable
# with the final result's native explorer handles, so retain those exact
# handles for selector records. Faces and edges keep their established
# build123d export path.
result_vertices: list[Any] = []
explorer = TopExp_Explorer(body.wrapped, TopAbs_VERTEX)
while explorer.More():
candidate = explorer.Current()
if not any(candidate.IsSame(existing) for existing in result_vertices):
result_vertices.append(candidate)
explorer.Next()
result_edges: list[Any] = []
explorer = TopExp_Explorer(body.wrapped, TopAbs_EDGE)
while explorer.More():
candidate = explorer.Current()
if not any(candidate.IsSame(existing) for existing in result_edges):
result_edges.append(candidate)
explorer.Next()
def exact_incident_edge_count(vertex: Any) -> int:
count = 0
for edge in result_edges:
edge_vertices: list[Any] = []
edge_explorer = TopExp_Explorer(edge, TopAbs_VERTEX)
while edge_explorer.More():
candidate = edge_explorer.Current()
if not any(candidate.IsSame(existing) for existing in edge_vertices):
edge_vertices.append(candidate)
edge_explorer.Next()
if any(vertex.IsSame(candidate) for candidate in edge_vertices):
count += 1
return count
def index_for(shape: Any, candidates: list[Any]) -> int | None:
"""Map a subshape returned by a face/edge back to body topology."""
# 用 is_same 把面/边的子形状映射回主体拓扑列表的下标。
@@ -302,10 +341,11 @@ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyR
geometry=geometry,
))
# 5. 导出顶点记录:含坐标与关联边数。
for index, vertex in enumerate(vertices):
point = [vertex.X, vertex.Y, vertex.Z]
for index, vertex in enumerate(result_vertices):
point_value = BRep_Tool.Pnt_s(TopoDS.Vertex_s(vertex))
point = [point_value.X(), point_value.Y(), point_value.Z()]
records.append(TopologyRecord(
record_id=f"{body_id}:vertex:{index}", kind="vertex", feature_id=feature_id, body_id=body_id, value=vertex,
geometry={"center_mm": point, "incident_edge_count": len(vertex_edges[index])},
geometry={"center_mm": point, "incident_edge_count": exact_incident_edge_count(vertex)},
))
return records
@@ -57,7 +57,7 @@ from cdsl_engine.runtime import rebuild_cdsl # noqa: E402
try:
from build123d import Face, Plane, Vector # noqa: F401
from build123d import Face, Plane, Solid, Vector # noqa: F401
_HAS_BUILD123D = True
except ImportError:
_HAS_BUILD123D = False
@@ -137,6 +137,39 @@ class ExtentTrimContractTests(unittest.TestCase):
self.assertAlmostEqual(trimmed.volume, 500.0, places=5)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_planar_supporting_surface_accepts_only_a_complete_forward_profile(self) -> None:
"""A wholly unreachable planar face may supply its support plane.
The finite target is deliberately outside the profile's x/y range,
so no ray intersects its trim. The underlying z=5 plane still gives
one exact +z termination distance. This is distinct from the
partial-hit trim contract below.
"""
profile = Face.make_rect(2, 2, Plane(origin=(8, 0, 0)))
target = Face.make_rect(2, 2, Plane(origin=(0, 0, 5)))
self.assertTrue(all(
Build123dGeometryAdapter._forward_intersection_distance(target, point, Vector(0, 0, 1)) is None
for point in Build123dGeometryAdapter.profile_sample_points(profile)
))
self.assertAlmostEqual(
Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(target, [profile], (0, 0, 1)),
5.0,
places=6,
)
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_planar_supporting_surface_rejects_parallel_and_non_planar_targets(self) -> None:
profile = Face.make_rect(2, 2)
parallel = Face.make_rect(2, 2, Plane(origin=(0, 0, 5), z_dir=(1, 0, 0)))
cylindrical = next(face for face in Solid.make_cylinder(2, 4).faces() if face.geom_type != Face.make_rect(1, 1).geom_type)
with self.assertRaisesRegex(ValueError, "parallel"):
Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(parallel, [profile], (0, 0, 1))
with self.assertRaisesRegex(ValueError, "planar"):
Build123dGeometryAdapter.uniform_planar_supporting_surface_distance(cylindrical, [profile], (0, 0, 1))
@unittest.skipUnless(_HAS_BUILD123D, "build123d is not available")
def test_up_to_surface_hanging_profile_is_trimmed_not_rejected(self) -> None:
"""集成裁剪契约:profile 悬空超出目标面时不再拒绝,悬空部分被切掉。
@@ -205,6 +205,15 @@ class MultiBodyContractTests(unittest.TestCase):
if r["kind"] == "face" and r.get("body_id", "").startswith("body:cut_1:")
}
self.assertEqual(member_ids, {"body:cut_1:0", "body:cut_1:1"})
cut_delta = next(item for item in rebuilt["topology_deltas"] if item["feature_id"] == "cut_1")
self.assertEqual(cut_delta["operation"], "subtract")
self.assertEqual(cut_delta["history_status"], "proven")
self.assertEqual(cut_delta["history_reason"], "per_member_exact_cut_history")
self.assertEqual(
{record_id.split(":face:", 1)[0] for relation in cut_delta["relations"]
for record_id in relation["source_record_ids"] if ":face:" in record_id},
{"body:add_2:0", "body:add_2:1"},
)
def test_face_selector_resolves_on_multi_body_via_prefix_matching(self) -> None:
"""多体 + selector resolve 契约:face selector 经前缀匹配命中正确实体。
File diff suppressed because it is too large Load Diff
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",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,299 @@
# FeatureScript Query Capability Matrix
This matrix is the versioned allow-list consumed by the CDSL selector resolver.
It records what the current runtime may execute, not what FeatureScript text it
can parse. A missing entry is intentionally rejected with
`selector_query_unsupported`; a missing source version is rejected with
`selector_query_version_unknown`. Every registered row currently requires the
direct source import `onshape/std/geometry.fs@1511.0` in addition to its
FeatureScript language version. The lowerer preserves every direct
`onshape/std/*` import as `{path, version}` metadata, but an unregistered
library revision is not treated as compatible merely because its path matches.
The planned expansion of query algebra, topology lineage and body lifecycle is
defined in [CADFS_FULL_CAPABILITY_TARGET.md](CADFS_FULL_CAPABILITY_TARGET.md),
under “Selector 精度与覆盖目标(2026-09-10 起)”. A row is admitted only after
its explicit source/CDSL contract, exact kernel evidence and consumer semantics
are tested; source STEP comparison remains separately reported and does not
promote a restricted row into general family support.
`SWEPT_EDGE@1511` direct-prism dress-up continuation update (2026-09-12): a
direct source vertex may retain its existing `boundary + continuation` policy
past the producer's feature position only when the topology registry proves a
complete, one-to-one, active lineage. `00094474` F3 is strict after an
intervening datum feature; `00027017` F3 is rejected as non-unique after a
body mutation and retains its F2 checkpoint. `00370320` F4 proves a direct
`extrude boundary -> subtract continuation` and is RP passing. This changes
neither the direct profile, version, body-lifecycle nor no-fallback
restrictions in the rows below. Source anchors are additionally scoped to the
selector's explicit producer owner: a later operation that materializes the
same source sketch entity or endpoint pair cannot become a second anchor.
`00407468` F3 verifies four `qUnion(SWEPT_EDGE)` operands from F1 through F2;
F1--F3 replay and RP comparison pass in
`output/swept-edge-producer-anchor-00407468-20260912` (strict area/volume
precision checks do not pass). The rule does not admit generic repeated-source,
boolean, IMPRINT, COPY, pattern, or N:M lineage forms.
`CAP_FACE@1511` symmetric direct-prism shell update (2026-09-12): an immediate
shell may remove either or both physical caps of an independent, undrafted,
`new_body` symmetric blind prism whose profile is exactly one original circle.
Each CAP OSD must name that sole circle source edge. The two `MakePrism`
builders share the source plane, so the executor maps only their far exact
handles to `extrude.end` and `extrude.start`, checks final-snapshot `IsSame`,
and never exposes the source-plane seam as a cap. `00019252` removes both
caps and RP-passes; `00000316` resolves both caps and executes its shell before
an unrelated `OFFSET_EDGE` query stops later replay. This bridge is limited to
the immediate shell consumer: multi-edge/hole/IMPRINT/split profiles, draft,
ADD/CUT, non-blind extents, continuation, other consumers/versions, and all
geometry, stable-ID, current-body, or source-STEP fallback remain deferred.
`CAP_FACE@1511` initial direct-sweep shell update (2026-09-12): an immediate
shell may consume one `sweep.start` or `sweep.end` only where the producer is
an independent `new_body` direct PipeShell sweep and its CDSL contract retains
one profile source edge plus one source path edge. The CAP OSD must name that
exact profile edge and the matching path `start`/`end` vertex; after any
lowering path reversal, the source endpoint is explicitly mapped to the
physical PipeShell output role before the resolver requires its unique
complete active builder relation. `00330012` F3 resolves `sweep.end` and
rebuilds. `00658358` F4 keeps its F3 checkpoint because its CAP source pair
does not satisfy this contract. No geometry, stable-ID, current-body or source
STEP fallback is introduced; segmented/spatial/hollow/fused/additive sweeps,
copy/pattern successors, later lifecycle, non-shell consumers and generic
sweep CAP queries remain deferred.
`CAP_EDGE@1511` initial direct-sweep dress-up update (2026-09-12): an
immediate `fillet`/`chamfer` may consume one cap edge from an independent
`new_body` direct PipeShell sweep only when its profile is one retained direct
circle edge and its CAP OSD contains exactly that edge plus the corresponding
source path endpoint. PipeShell has no per-profile-edge `FirstShape`/`LastShape`
overload, so the adapter records the relation only when the builder-proven cap
face has exactly one final-snapshot boundary edge (`IsSame` verified). The
runtime additionally receives an exact source-edge anchor and resolves only
the complete/proven role-qualified boundary relation. A `00330012` CAP_EDGE
source variant resolves by `kernel_lineage` and rebuilds; a contradictory
endpoint variant keeps its F2 checkpoint. Multi-edge/inner-wire/split profile,
segmented/spatial/hollow/fused/additive sweep, continuation, copy/pattern,
later lifecycle, other consumers and generic CAP_EDGE remain deferred. No
geometry, stable-ID, current-body or source STEP fallback is allowed.
`CAP_EDGE@1511` primary ADD union-continuation update (2026-09-13): an
immediate `fillet`/`chamfer` may follow a direct, undrafted blind
`extrude_add_blind` only when its default ADD/fuse has exactly one old active
solid, one direct-prism tool solid, complete source anchors, and exact
`BRepAlgoAPI_Fuse` history. The tool is registered only as a transient prism
snapshot; its cap edge becomes selectable only through one complete/proven,
one-to-one `extrude -> union` continuation into the active final member.
`00953397` F7 and `00957101` F4 both resolve by `kernel_lineage` and are RP
approximate. `00406939` has a multi-solid ADD tool and remains a deferred
FeatureScript query. Split/merge, partial/missing/fuzzy union history,
IMPRINT/partial profile, non-CAP_EDGE family, later lifecycle, COPY/pattern,
and all geometry, stable-ID, current-body, or source-STEP fallbacks remain
outside this bridge.
`CAP_FACE@1511` primary ADD shell union-continuation update (2026-09-13): an
immediate shell may consume one cap role from a direct, undrafted blind
`extrude_add_blind` only when its default ADD/fuse has one old active solid,
one direct-prism tool solid, and exact `BRepAlgoAPI_Fuse` history. The tool is
transient; the resolver must establish a complete/proven one-to-one
`extrude -> union` continuation into the active final member before handing
the face to shell. In a 27-history fresh replay scan, seven histories had that
exact relation; `00074047` F4 and `00350698` F7 resolve it by `operation_role`,
then their native shell operation independently fails. `00293014` remains
`selector_output_role_ambiguous`; `00590599` multi-solid ADD remains
`selector_query_unsupported`. This does not authorize extent, dress-up,
multiple removals, split/merge, multi-solid/IMPRINT/partial profile,
missing/partial/fuzzy union history, later lifecycle, COPY/pattern, or any
geometry, stable-ID, current-body, or source-STEP fallback.
`CAP_FACE@1511` primary ADD up-to-surface union-continuation update (2026-09-13):
a one-sided immediate `up_to_surface` extent may consume one primary-ADD cap only
under the separately tagged `primary_add_up_to_surface_union_continuation`
contract. The producer is an undrafted blind `extrude_add_blind` defaulting to
ADD/fuse; its transient direct-prism cap becomes selectable only after one old
active solid plus one tool solid yield exact complete/proven one-to-one
`extrude -> union` history into the active final member. `00007973` F5 -> F7
resolves by `operation_role` and freshly rebuilds. `00638700` F3 -> F5 records
an ambiguous union successor and is rejected as `selector_output_role_ambiguous`.
Two-sided extents, non-immediate/later consumers, split/merge/multi-solid,
draft, incomplete/fuzzy history and all geometry, stable-ID, current-body, or
source-STEP fallback remain outside this bridge; it does not complete general
CAP_FACE or `up_to_surface` coverage.
`CAP_FACE@1511` symmetric two-sided up-to-surface pair update (2026-09-13):
an independent direct `new_body` `extrude_add_two_sided` may consume the two
opposite far caps of its immediately preceding symmetric direct prism only as
one `symmetric_direct_prism_two_sided_up_to_surface_cap_pair`. Both producer
ends must be blind and undrafted; each source CAP query must be a singleton
direct `qUnion(makeQuery(... CAP_FACE ...))` whose OSD names the complete,
unchanged original profile from one source sketch. The pair must have the same
owner and exactly `{extrude.start, extrude.end}`; neither side is legal alone.
The executor's two `MakePrism.LastShape` handles are already final-snapshot
roles, and runtime resolves each through complete/proven `boundary` evidence.
Fresh `00215642` F1 -> F3 resolves both roles and rebuilds its executable
prefix. `00935255` F7 names two different owners and remains deferred. CUT,
ADD/fuse, mixed/non-blind ends, draft, partial/IMPRINT/split profile selection,
same-role or different-owner pairs, non-immediate/later lifecycle, other
versions, and every geometry/stable-ID/current-body/source-STEP fallback remain
rejected; this does not complete generic two-sided `up_to_surface` or
`CAP_FACE` coverage.
`SWEPT_FACE@1511` initial direct-sweep dress-up update (2026-09-12): an
immediate `fillet`/`chamfer` may consume one side face from the same independent
`new_body` direct PipeShell sweep only when its OSD contains exactly one edge
from a complete direct analytic closed profile and one direct source path edge.
The adapter records only `Generated(profile_edge)` face outputs that remain in
the final solid by `IsSame`; the runtime resolves from the exact profile-edge
anchor, while the path edge remains disambiguation evidence. `00330012` circle
and four-edge source-form variants resolve by `kernel_lineage` and rebuild. A
mismatched source pair is kept as a deferred query without geometry or stable-ID
fallback; `00954785` confirms that a multi-segment qUnion path cannot inherit a
representative source edge. Inner/split, segmented/spatial/hollow/fused/additive, continuation,
copy/pattern/later lifecycle, shell/extent/sketch-host consumers and generic
sweep side-face semantics remain deferred.
`CAP_FACE@1511` direct-prism workplane update (2026-09-14): a following
`newSketch` may attach to one physical cap of the immediately preceding,
independent, undrafted, blind `extrude_add_blind` `new_body` prism. Lowering
keeps the CAP output-role selector and a `direct_prism_cap_face_workplane`
consumer contract; runtime resolves the live native face before rebuilding the
sketch coordinates. Semantic validation rejects a delayed or multiple consumer.
`00126630` F1 -> F2 supplies real lowering evidence, while an equivalent
direct consumer resolves by `operation_role`. The separately constrained
attached CAP-edge IMPRINT tuple below is its only profile-region consumer. This
admits neither other CAP-edge profile regions, ADD/fuse, CUT, symmetric
or drafted/non-blind producers, later lifecycle, COPY/pattern/boolean
successors, nor static frame, stable-ID, geometry, current-body or STEP
fallback.
Attached planar-IMPRINT external-boundary foundation (2026-09-14): the CDSL
profile contract can name a session-resolved edge as an external fragment
anchor only when a proven runtime face attachment supplies the splitter support.
Runtime requires that the selector resolve to one edge which is `IsSame` to a
native boundary of that support face. It is generally only a CDSL/runtime
foundation; the one source-qualified CAP-edge form below is the sole current
exception.
Attached direct-prism CAP-edge IMPRINT profile tuple (2026-09-14): FeatureScript
1511 may extrude one selected local circle from a preceding attached
sketch only when its sole `INTERSECT(VERTEX)` witness pairs that curve with one
direct `CAP_EDGE` from the exact same immediate independent blind `new_body`
prism and exact CAP-face attachment role. The lowerer emits one
`planar_imprint.external_anchors` selector, not boundary geometry; semantic
validation requires its runtime face attachment, and runtime proves both the
CAP edge's kernel lineage and native `IsSame` support-boundary membership before
the splitter consumes it. When native attachment orientation reverses the
source sketch normal, the session converts IMPRINT `face_side` only in its
live materialized frame. `00126630` F1 -> F3 lowers completely and its F1/F3
prefix rebuilds (`165528 mm3`), resolving the CAP face by `operation_role` and
CAP edge by `kernel_lineage`; its later F4 CAP-edge dress-up remains deferred.
The owner-mutation rejection returns F3 to
`extrude_profile_topology:cap_edge`. This is one real-history source tuple,
not general CAP-edge profiles: multiple anchors/fragments/local curves,
non-analytic or periodic curves, other attachment types, CUT/ADD, delayed or
continued producers, copies/patterns/booleans, and all unproven region
cardinality remain deferred.
| Query family | FeatureScript version | Source evidence | CDSL/runtime contract | Verified boundary |
| --- | --- | --- | --- | --- |
| `qUnion` / `qIntersection` / `qSubtraction` / `QUERY_SET` | `1511` | CADFS exported set-query syntax with exact recursive `query_expr@1.0` nodes; `qIntersection`/`qSubtraction` ordering semantics additionally verified against the local Onshape 1511 standard-library mirror | A `proven_operand_*` parent may be consumed by `fillet` or `chamfer` when every recursive branch ends in a direct, kernel-proven `makeQuery` leaf of one face/edge kind. Each parent declares `active_member` scope, reject-empty/all-multiple policy, and ordered child selectors; nested set nodes retain their own source expression and contract | This remains a set compositor, not a leaf-query allow-list entry. Every branch must have the identical FeatureScript/library tuple, exact matching AST node, runtime-snapshot evidence, an existing executable provenance contract, and complete active results. The resolver recursively evaluates children, preserves operand order, deduplicates exact record IDs for union, and applies exact-record intersection/subtraction. A `QUERY_SET` in the declared `feature.selectors` slot of `fillet`/`chamfer` may pass that slot only to recursive `query_operands`, so an already-contractual immediate direct-prism/planar-IMPRINT `CAP_FACE` output-role leaf receives the normal owner, lifecycle and cardinality checks; `00965724` F2's `{extrude.start, extrude.end}` union converts completely and rebuilds. Output roles in metadata or non-`query_operands` structures, output-role leaves without that existing direct contract, mixed kind/version, geometry/stable-ID/binding evidence, filters, `qAdjacent`/owner/body queries, and shell/extent/hole/transform/reference consumers remain rejected, as do empty or partial/inactive branches. `00000715` F2 and `00354246` F2 remain valid evidence; a nested-union variant of `00000715` now also rebuilds successfully. This does not constitute general query-family completion. |
| `CAP_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct prism `BRepPrimAPI_MakePrism` cap handles, direct `BRepOffsetAPI_ThruSections.FirstShape/LastShape` face handles for the loft subset | Direct `extrude.start` / `extrude.end` builder output role; an immediate `up_to_surface` reference or shell face-removal selector may consume the one-sided prism role. An immediate shell may additionally consume either exact far cap of a direct one-circle symmetric blind prism, or `loft.start` / `loft.end` from the initial direct-loft subset. | One-sided prism consumers must immediately follow a direct `extrude_add_blind` `new_body` blind producer. The symmetric shell-only subset requires FeatureScript 1511, one original circle source edge, independent undrafted `extrude_add_two_sided`, both blind ends, exact far-cap final `IsSame` roles and no continuation; `00019252` RP-passes after removing both caps and `00000316` resolves both caps before a later unrelated selector stops. The loft subset is FeatureScript `1511` only: exactly two direct closed IMPRINT sheet profiles, default/`NEW` operation, no prior solid, wire profiles, connections, matching or endpoint derivative options; the CAP OSD must name exactly one of those profile sources. `isStart` is not used to choose the role: source-profile position maps to `loft.start`/`loft.end`, then the resolver requires its unique complete active builder relation. `00023963`, `00051031`, and `00059941` resolve the shell role and execute; their final RP comparisons are rejected. `00157619` and `00139197` resolve before native OCC thick-solid failure. `00925274` F3 resolves prism `extrude.start` for `up_to_surface`; `00212904` F2 and `00789939` F2 resolve immediate prism shell removals through `extrude.start` and `extrude.end`. Multi-edge/hole/IMPRINT/split symmetric profiles, draft, ADD/CUT, non-blind ends, later mutations, other loft versions or forms, non-immediate consumers, and generic CAP queries remain unsupported. No stable-ID, binding-ID, geometry, current-body, or source-STEP fallback is used. |
| `CAP_FACE` primary ADD dress-up | `1511` | `00051494` F3 -> F4 and `00660816` F3 -> F4 direct exported histories; transient direct-prism cap plus exact `BRepAlgoAPI_Fuse` relation | A default ADD/fuse `extrude_add_blind` CAP role may drive its immediately following `fillet` or `chamfer` through `primary_add_dressup_union_continuation` | The producer must be undrafted blind ADD/fuse with one old solid and one transient direct-prism tool. Runtime resolves only a complete/proven, one-to-one `extrude -> union` active-member successor, then expands the resolved physical face to actual body-boundary edges while excluding periodic seams. `00051494` resolves by `operation_role` and rebuilds; `00660816` has a one-to-many successor and remains `selector_output_role_ambiguous`. Shell/extent use separate contracts. Multi-cap sets, split/merge/multi-solid/IMPRINT/partial profiles, draft, non-immediate/later lifecycle, other consumers, stable-ID/binding-ID/geometry/current-body/source-STEP and transient-tool fallback remain rejected. This does not complete CAP_FACE, ADD, dress-up or S1 lifecycle coverage. |
| `CAP_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` exact cap-edge handles, exact single-solid `BRepAlgoAPI_Fuse` history for the primary-ADD subset, plus the one-edge direct-PipeShell cap-face boundary witness | A direct fillet/chamfer consumer of either (a) an independent, undrafted, `new_body` blind prism: one exact direct source-profile edge -> one role-qualified `extrude.start` or `extrude.end` cap edge, followed only by complete/proven one-to-one continuations to the active body; (b) a default primary ADD/fuse of one old active solid plus one direct blind-prism tool solid, through one exact `extrude -> union` continuation; or (c) the immediately preceding independent `new_body` direct PipeShell sweep with one direct circle profile edge and one direct path edge -> one role-qualified `sweep.start` or `sweep.end` cap edge | Prism selectors must name one retained direct source entity and explicit CAP side. The adapter accepts a prism cap handle only after final-snapshot `IsSame`; resolver follows its source anchor and role through `boundary`, and accepts a continuation only when every relation is complete/proven and operation cardinality remains one-to-one. The primary-ADD tool is transient and never directly selectable: lowering permits it only for FeatureScript 1511 `CAP_EDGE`, immediate fillet/chamfer, direct undrafted blind `extrude_add_blind`, default ADD/fuse, singular old/tool solids and exact union history. `00953397` F7 and `00957101` F4 resolve `extrude -> union` and are RP approximate; `00406939` multi-solid ADD remains deferred. The sweep subset has no PipeShell per-edge history overload: it requires exact profile/path-endpoint OSD, a singleton profile/cap boundary edge, immediate fillet/chamfer consumer, and only the initial `boundary` relation. `00330012` CAP_EDGE source variant resolves by `kernel_lineage` and rebuilds; contradictory endpoint source is rejected with an F2 checkpoint. A direct all-circle annulus with one contained circle is included for the prism form when both source wires retain exact final-face membership; outer/inner roles remain distinct. A direct analytic region with a solver-split circular hole is included only when all four arcs carry the same explicit logical-circle source marker and that source maps uniquely to one unsplit profile circle; the adapter reconstructs one native wire before the same final-face check. Corpus evidence: `00021014` F2 resolves nine non-hole start/end selectors and is strict/RP passing; hole matrix `00479470`, `00501170`, `00526649` is RP passing and `00621329` is strict/RP passing. `00694309` F4 resolves F1's start CAP edge through F3's proven primary-cut continuation and is strict/RP passing. `00566233` resolves both F2 selectors before an OCC chamfer feasibility failure, while `00614954`/`00678961` resolve F2 before later unsupported queries. `00735367` resolves F2 from its line-outer/circular-hole region and executes F3 before an unrelated F4 unsupported query. Incomplete/mixed logical-circle markers do not create a circular-hole anchor; partial/branched continuation, trimmed/split profiles, multi-region-with-hole profiles, draft, multiple/two-sided extents, multi-solid/IMPRINT primary ADD, missing/partial/fuzzy union history, later lifecycle, and unproven boolean/SPLIT/COPY/pattern continuations are rejected. The sweep form additionally rejects multi-edge/inner-wire/split profile, segmented/spatial/hollow/fused/additive path, continuation, COPY/pattern/later lifecycle and non-dress-up consumers. This is not general CAP_EDGE replay. |
| `CAP_EDGE` | `2491` | CADFS `00049094` / `00404726` exported `onshape/std/geometry.fs@2491.0` symmetric-extrude histories; two exact `BRepPrimAPI_MakePrism.LastShape(source_edge)` far-cap handles, each checked against the final fused result | An immediate fillet/chamfer may consume an independent, undrafted `new_body` `extrude_add_two_sided` whose primary and reverse ends are both blind and whose lowered profile exactly equals the complete original source profile | The executor records one prism delta per direction. The primary far `LastShape` is `extrude.end`; the reverse far `LastShape` is explicitly remapped to `extrude.start`. Source-plane `FirstShape` results are internal fusion seams and cannot be selected. Each role must retain complete/proven final-snapshot `IsSame` evidence; lowerer emits `boundary` only, with no continuation or fallback evidence. `00049094` F2 resolves four CAP edges as one `QUERY_SET` and the full history rebuilds, but comparison is rejected. `00404726` remains `selector_query_unsupported` at F2 because its selected multi-region/IMPRINT profile is not this complete direct profile. CUT, draft, non-blind/mixed extents, other versions, partial/IMPRINT/split/multi-region profiles, fused/boolean/COPY/pattern/later lifecycle and all geometry/stable-ID/current-body/source-STEP fallback remain rejected. This is not general 2491 CAP_EDGE replay. |
| `CAP_VERTEX` | `1511` | CADFS FeatureScript 1511 direct `makeQuery(..., CAP_VERTEX, VERTEX, {disambiguationData: [OSD([edge, edge])], isStart})`; `BRepPrimAPI_MakePrism.FirstShape(source_vertex)` / `LastShape(source_vertex)` exact cap-vertex handles | A one-sided `up_to_vertex` extent may consume an independent `new_body` undrafted blind prism cap vertex. The OSD must name exactly two distinct retained direct profile edges from one source sketch with exactly one shared original endpoint. Between producer and consumer, only reference planes or independent undrafted `new_body` blind prisms are permitted. | The resolver uses the grouped transient source-vertex anchor and only the role-qualified `generated vertex -> vertex`, `boundary`, complete/proven final-snapshot relation. A separate body may intervene only when the session maps the declared semantic member to exactly one old and one new solid snapshot, and every face/edge/vertex has exactly one reciprocal `IsSame` counterpart; the registry emits `body_member_preserve` only then. Aggregate `body` records are excluded because they are snapshot metadata rather than a member subshape. Vertex records are exported from a final B-rep explorer because build123d's repeated `body.vertices()` wrappers do not retain `IsSame` identity with prism history handles. `00330726` F1 -> F3 -> F5 resolves by `extrude` plus `body_member_preserve` and executes; its fresh full pipeline remains `converted_partial` / `rebuilt_rejected` due to later F7/F8/F9/F10 gaps and a 3 vs 4 solid mismatch. Other versions, changed/replaced/deleted or incomplete/non-unique members, draft, cut/fuse, split/IMPRINT/multi-profile geometry, non-shared or repeated OSD edges, two-sided extent, inactive/non-unique records, backward/non-uniform target projection, and every geometry/stable-ID/current-body/source-STEP fallback remain rejected. Thus this is a narrow runtime contract, not CAP_VERTEX family completion. |
| direct source `sQuery(..., VERTEX, ...)` extent datum | `1511` | A current source audit found 16 `UP_TO_VERTEX` histories: 15 direct source datum targets (`00383982`, `00444951`, `00503730`, `00510558`, `00753006`, `00894150`, `00975649`) plus one each `CAP_VERTEX` and `INTERSECT(VERTEX)` form; each direct datum has an explicit source workplane and named entity endpoint | A one-vertex direct source query may terminate a one-sided or either side of a two-sided `up_to_vertex` extrusion as `{kind: source_vertex, source_sketch_id, source_entity_id, point_mm}` | This is not a topology selector. Schema and semantic validation require one known source sketch, source entity and finite 3D point; selector binding skips it and runtime checks one positive uniform projection across the profile without resolving a topology record. `00444951`'s three extents convert, rebuild and strict/RP-pass in fresh `/private/tmp/cadfs-source-vertex-extent-20260914-r2`. The seven-sample matrix at `/private/tmp/cadfs-source-vertex-extent-matrix-20260914` executes every datum feature in `00510558`, `00753006`, `00894150` and `00975649`, while `00503730` preserves its F5 prefix; their complete comparisons remain rejected or partial for independent downstream modelling gaps. `00383982` stops before its datum feature at a separate unbounded IMPRINT profile failure. `qAdjacent`, set/derived/runtime vertex queries, CAP/COPY/pattern/IMPRINT/INTERSECT vertices, non-1511 source, invalid/non-finite datum and geometry/stable-ID/current-body/source-STEP fallback are rejected. This does not complete `CAP_VERTEX`, `INTERSECT(VERTEX)`, generic vertex selection or general `up_to_vertex`. |
| `BLEND_EDGE` | `1511` | CADFS 1511 direct query history; fillet/chamfer `Generated(source_edge)` patch, `Modified(source_face)` target and final `IsSame` shared-boundary witness | One immediate native fillet/chamfer consumer of a direct independent blind `new_body` prism: exactly one direct `CAP_EDGE` plus either a complete direct `CAP_FACE`, or the same-anchor direct `SWEPT_FACE`, in `blendedFrom`, with an identical single face query in `blendedInto`, resolves to one exact active final patch-boundary edge | `blend_sources` retains the pair independently of outer selector fields. The resolver requires one source-profile edge anchor, its role-qualified cap edge, and either its direct cap face role or its exact `Generated(source_edge -> face)` swept-face relation, followed by one complete/proven `exact_blend_boundary` relation. `00414347` F3 is RP passing but not strict; `00690433` F3 executes but comparison is rejected; `00596552` F5 and `00456146` F4 now lower their same-anchor SWEPT_FACE leaves under this contract, but fresh replays stop respectively at the pre-existing native chamfer feasibility failure and another selector relation ambiguity, so they are contract/lowering evidence only. `00801833` F3 executes before its F4 checkpoint; `00614954` F3 rejects its cap-role mismatch. `MERGE`, `SPLIT`, `COPY`, `BLEND_FACE`, differing source anchors, multi-member sets, non-immediate lifecycle, ambiguous/incomplete/inactive history, angle/two-offset dress-up, other producer/version, and geometry/stable-ID/current-body/source-STEP fallbacks remain rejected. This is not general BLEND_EDGE replay. |
| `BLEND_FACE` | `1511` | CADFS direct dress-up sketch workplanes; final-snapshot native fillet/chamfer `Generated(input_edge)` patch handles | A runtime-attached `newSketch` can host on one immediate native fillet/chamfer patch when one OSD source edge is also its sole role-qualified direct-prism `CAP_EDGE`; producer is an undrafted blind `extrude_add_blind` `new_body` direct profile prism | `blend_face_source` stores no static frame, geometry, stable ID, or record ID. Semantic validation repeats the producer/dress-up lifecycle gate; resolver proves source anchor -> cap edge -> one complete/proven generated active patch, then uses that actual planar B-rep face for the workplane. `00313870` F2 -> F3/F4 and `00436592` F3 -> F4 lower as attached sketches. Fresh replays preserve checkpoints: `00313870` stops at a pre-existing F2 chamfer feasibility failure, while `00436592` executes F3 then stops at later F6 unsupported query; neither is strict/RP evidence. Multi-edge OSD, two-sided/IMPRINT/split/drafted/non-direct profiles, non-immediate/nested dress-up, MERGE/SPLIT/COPY/pattern/boolean successors, non-planar/ambiguous/partial/inactive patches, other versions, and geometry/stable-ID/current-body/source-STEP fallbacks remain rejected. This is not general BLEND_FACE or generic sketch attachment coverage. |
| `COPY` | `1511` | CADFS `00252195` direct `COPY(CAP_EDGE)` plus `00573124`/`00951631` immediate `COPY(CAP_FACE)` workplane histories; exact direct-prism cap and primary-cut `BRepAlgoAPI_Cut` history | An immediate fillet/chamfer may consume one CAP edge, or one following sketch profile/hole location may attach to same-owner CAP face, from an undrafted default blind `extrude_cut_blind` | The edge form requires `primary_cut_cap_edge` and one direct source edge. The face-host form requires `primary_cut_cap_face_workplane`: semantic preflight cross-checks its complete unchanged OSD set against the producer's actual direct profile source sketch/entities, then runtime requires one role-qualified cap -> transient-face boundary and one same-owner complete/proven `subtract` continuation to one active planar face. Only then does runtime derive a native-UV/support-plane workplane and re-resolve local sketch coordinates. Both return `copy_lineage`; transient tools are never selectable. `00252195` resolves then hits native chamfer failure; `00573124`/`00951631` rebuild but fresh strict/RP comparison rejects both, while non-immediate `00252794` F8 remains deferred. COPY `SWEPT_FACE|BODY`, patterns/transforms, draft/two-sided/non-direct or non-immediate lifecycle, opposite-direction attached holes, multiple/nonplanar/partial/split/merge successors, other versions and every geometry/stable/snapshot/current-body/source-STEP fallback remain rejected. This is not general COPY replay. |
| `SWEPT_BODY` | `1511` | CADFS FeatureScript 1511 exported singleton `qUnion([makeQuery(..., SWEPT_BODY, EntityType.BODY)])` history | An immediate one-sided `up_to_body` extent may reference exactly one active body record produced by a preceding independent `new_body` blind prism | The source body must be the immediately preceding `extrude_add_blind`, with `result_mode: new_body`, blind undrafted direct-prism semantics, and an active producer body record. Lowering emits only `active_body_member` evidence with `body_member_contract: direct_new_body`; resolver proves the active record and producer identity, then returns `body_member`. `00694309` F3 resolves this reference; its F4 strict/RP pass is separately established by the bounded `CAP_EDGE` continuation contract, not by widening this body-member contract. Later successors, `ADD`, cut/revolve/sweep, `COPY`, boolean/pattern/delete, multiple active bodies, multiple query items, other versions, and all stable-ID, geometry, binding-ID, aggregate/current-body fallbacks are rejected. |
| multi-source transform `COPY(BODY)` | `1540` evidence | A direct `COPY(instanceName=1)` chain from one direct source of a preceding `makeCopy:true` transform with at least two selected body sources | Explicit `booleanBodies` target/tool selection may consume the source-qualified copy through `{transform_feature_id, source_feature_id}` | The owner must be preceding, be `transform_bodies`, preserve at least two direct `source_feature_ids`, and retain the selected source's independently active COPY member. Schema, semantic validation, capability preflight and executor reject an owner/source mismatch, single-source COPY, a producer aggregate, inactive member, targetless COPY boolean and any geometry/stable-ID/current-body fallback. The corpus has no native COPY-to-boolean history: a source-only contract variant extends real 1540 `00699847` F3's two-source COPY with F5 `booleanBodies`; all five features lower and execute, but it is not strict/RP evidence and does not mark generic COPY lifecycle complete. |
| single-source chained transform `COPY(BODY)` | `1511` | CADFS `00184423` and `00322866` direct `COPY(instanceName=1)` histories retain an original `SWEPT_BODY` owner while each transform consumes the preceding explicit member | A `make_copy:true` `transform_bodies` feature may retain `{source_feature_id, active_member_feature_id}` provenance metadata when exactly one direct runtime source is the immediately preceding active COPY member | Lowering derives the semantic source only from the recursive `derivedFrom` chain and emits one alias only when it differs from the selected `source_feature_ids` member. Schema/semantic validation require one single-source `make_copy` transform, one preceding semantic source, and an alias member exactly equal to the direct selected source; aliases never participate in runtime body selection. `00184423` F3--F5 and `00322866` rebuild in fresh RP pipeline as `rebuilt_strict` and `rebuilt_approximate`. Multi-source/aggregate COPY, patterns, boolean/delete/non-copy successors, missing or mismatched members, arbitrary transforms, selector topology propagation, and all geometry/stable-ID/current-body/source-STEP fallback remain rejected. This is body-lifecycle provenance only, not general COPY or transform coverage. |
| `qOwnerBody` / `OWNER_BODY` | `1511` | FeatureScript 1511 query AST plus a synthetic `00694309` history variant that replaces its equivalent direct `SWEPT_BODY` reference with `qOwnerBody(makeQuery(..., SWEPT_FACE, FACE, ...))` | A one-sided `up_to_body` extent may project one direct, kernel-proven `SWEPT_FACE` or direct builder-proven `CAP_FACE` input to its exact active body record | The parent must preserve `filter: owner_body` with one typed nested selector, identical FeatureScript/library tuple, `exact_input_owner`, `active_member`, reject-empty and one-result policy. Runtime first resolves the child with its existing provenance contract, requires exactly one result whose `body_id` equals the active aggregate ID, then returns the unique non-transient body record with that same ID. It does not infer ownership from the current aggregate, feature creator, geometry, stable/snapshot/binding ID, or partial/multi-record lineage. Multi-solid member IDs, nested/query-set inputs, later producer lifecycle, non-direct topology producers and generic `qOwnerBody` consumers remain rejected. The corpus currently has no native `qOwnerBody` call, so this is a synthetic contract boundary, not query-family completion. |
| `OFFSET_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; a complete direct-prism profile OSD set may also name the shell's retained cap | (a) `shell.offset_face` builder output role plus one TDD source cap, or (b) an immediate `extrude_from_face` profile from the one retained cap of a direct-prism shell | (a) requires an immediate direct shell owner and one explicit `extrude.start` or `extrude.end` true dependency. (b) is a separate `shell_retained_direct_prism_cap_offset_face_profile` contract: source and library must be 1511; the immediate shell depends only on an undrafted direct `new_body` blind prism, removes exactly one of its CAP roles, and the OFFSET query has exactly one OSD whose source-edge set equals the complete unchanged direct profile. The lowerer names the opposite, retained CAP role as `output_role_source`; semantic validation and preflight repeat the immediate-owner, single-removal, direct-producer, complete-set and blind additive-new-body checks. Runtime selects only the unique active `shell.offset_face` carrying the exact complete/proven kernel relation from that retained role, never a wall or closing descendant. `00719927` F1--F4 executes and F3 resolves this way in `output/offset-face-retained-cap-20260913`, but its final strict/RP comparison is rejected, so it is execution evidence only. Partial/multi-source OSD, multiple removals, the removed cap, draft/cut/fused/revolve/sweep/COPY/pattern producers, non-immediate shell/lifecycle, non-1511 source and geometry/stable-ID/current-body/source-STEP fallback remain rejected. |
| `OFFSET_EDGE` | `1511` | CADFS 1511 shell history in either (a) singleton OSD plus singleton TDD direct-prism `CAP_EDGE`, or (b) one OSD containing two distinct direct profile edges with one shared original endpoint; exact `MakePrism` and final-snapshot shell history | An immediate fillet/chamfer may consume (a) one retained one-sided direct-prism cap edge through `direct_prism_shell_offset_edge_tdd`, or (b) one direct-prism source vertex's swept edge through `direct_prism_shell_offset_edge_vertex` | TDD requires matching outer/nested source edge and routes only through its requested `extrude.start|end` cap boundary; vertex requires exactly two distinct unchanged source edges, one grouped source-vertex anchor and no cap role. Both require an independent undrafted one-sided blind `new_body` producer, one immediate inward shell depending only on it, a single CAP-face removal, and only complete/proven one-to-one lineage into the active member. `00650671` F3 executes through the retained-cap contract; `00768679` F8 rebuilds but comparison rejects volume/area. `00059593` resolves all four OSD-only vertex leaves and rebuilds in `output/offset-edge-vertex-matrix-20260913`, but comparison is rejected, so it is execution evidence only. A set mixing forms (`00791920`) is rejected as a whole: TDD leaves remain explicit while OSD leaves stay deferred. `TDD(SWEPT_EDGE)` is separate and remains deferred: the three native co-occurrences (`00239888`, `00882527`, `00902029`) respectively involve an IMPRINT/pre-shell-filleted prism, BLEND, and revolve/BLEND, with no valid direct-prism immediate-shell continuation. Semantic validation and capability preflight recursively apply these lifecycle predicates to every `QUERY_SET` leaf, so a parent cannot bypass the producer/shell gate. Neither contract treats generated `shell.wall`, cap/face boundary traversal, stable ID, geometry, current body or source STEP as equivalent evidence. Two-sided, draft/ADD/CUT, partial/IMPRINT/split profile, multiple removal faces, non-immediate/later mutation, COPY/pattern/transform, non-unique component/vertex, other versions and all fallback remain rejected. This does not complete general OFFSET_EDGE or inner shell-wall semantics. |
| `SWEPT_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(edge)` relation plus final-snapshot `BRepAlgoAPI_Cut.Modified/Preserved` target history, and direct-PipeShell `Generated(profile_edge)` final-face outputs | An immediate fillet/chamfer consumer or shell removal, a one-sided `up_to_surface` extent, or the separately paired shell successor two-sided extent may use a retained direct-prism source-profile edge -> generated side face, with only complete/proven continuations to one active face. Separately, an immediate fillet/chamfer may use an independent `new_body` direct PipeShell sweep with one selected edge from a complete direct analytic closed profile and one direct path edge -> initial generated side face | The prism contract requires an independent, undrafted, `new_body` blind source and one exact direct wire/face construction anchor. The one-sided extent variant carries no output role, stable ID, binding ID, or geometry hint. `immediate_retained_source_prism_swept_face_up_to_surface` separately admits one immediate 1511 `new_body` blind prism made from an IMPRINT-selected profile only when exactly one OSD edge is unchanged and singleton-identical in both selected and original source profiles; it permits only that next one-sided extent and `boundary`, never continuation. Semantic validation and capability preflight repeat the producer/source-import/edge witness, and runtime requires exact generated edge-to-final-face lineage. `00408613` F1 -> F3 resolves `F0/E0.bottom` by `kernel_lineage`; its later F4 CAP_EDGE failure retains the F3 checkpoint. Tampering the anchor to the excluded `E1` is rejected by both gates. The paired `symmetric_direct_prism_shell_swept_face_up_to_surface_pair` requires exactly two distinct original non-construction line anchors from one prism, immediately followed by a shell depending only on that prism; a target side selected for shell removal is rejected so a `shell.closing_descendant` cannot stand in for `shell.offset_face`. Runtime separately proves each `edge -> prism wall -> shell.offset_face` chain. `00180262` F1 -> F2 -> F4 resolves both targets and executes, but its RP comparison is rejected. For a single-solid primary cut, target-side builder history is registered independently of whether its tool has direct-prism history; transient tool topology remains restricted to the separate source-qualified `INTERSECT` contract. The sweep subset requires an exact two-item profile/path-edge OSD, a complete unique source-edge list for the one profile contour, and a source path query with exactly one `sQuery`; every `Generated(profile_edge)` face is final-snapshot `IsSame` verified, runtime anchors only on that profile edge, and source-pair failure stays a deferred query without geometry/stable-ID fallback. `00330012` circle and four-edge source-form variants resolve and rebuild; real `00954785` multi-segment path remains deferred. Resolver traverses only the requested result topology kind and rejects partial, split, merge or inactive branches. `00925274` resolves F1 E0 through F3, F6, F9 and F12 target continuations and is strict/RP passing. Direct all-circle construction includes one annular hole; independently constructed direct circle regions retain one delta per source face only when every generated result remains in the final snapshot. IMPRINT/split source profiles other than the singleton exact-edge extent tuple, draft, fused source results, unproven/split continuations, revolve, inner profile sweep, multi-segment/spatial/hollow/fused/additive sweep, loft, copy and pattern are rejected. The sweep form also excludes continuation, later lifecycle and shell/extent/sketch-host consumers except the stated pair. This is not family completion. |
| `SWEPT_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(vertex)`, direct full-revolve `BRepPrimAPI_MakeRevol.Generated(vertex)`, and direct-PipeShell `Generated(profile_vertex)` relations, all verified by final-snapshot membership | Either (a) the existing independent, undrafted, `new_body` blind-prism source vertex -> generated vertical edge contract, (b) an independent `FULL` `revolve_add` `new_body` from the original non-IMPRINT source profile and same-sketch line axis -> one active swept circular edge, or (c) an immediate fillet/chamfer on an independent `new_body` direct PipeShell sweep: two adjacent source profile edges -> one source vertex -> one initial swept edge | Every form requires exact direct source-edge anchors with one shared endpoint, complete active final-membership relations, and no geometry/stable-ID/current-body fallback. The prism form retains its analytic-hole boundary; the revolve form registers only exact `Generated(vertex)` edge handles and permits initial `boundary` plus only complete/proven one-to-one `continuation`. The sweep form requires a complete unique direct analytic contour, an exact three-item OSD of two adjacent profile edges plus one single `sQuery` direct path edge, and only initial boundary consumption; its adapter records only final-solid `Generated(vertex)` edges. `00330012` four-edge/one-line source-form resolves and rebuilds; a non-adjacent pair is deferred. A fresh all-corpus scan found 12 native sweep SWEPT_EDGE histories but none meets this strict producer/path/consumer contract, so it is not real-corpus coverage. `00025622` F2 and `00048326` F3 rebuild strict through the full-revolve path; `00407186` F2 resolves initially, while its later F3 continuation is correctly rejected as non-unique. IMPRINT/materialized 1511 revolve profiles, split/merge/deletion, partial/surface/additive/fused revolve, axis/profile from different sources, draft/multiple/two-sided prism, boolean/SPLIT successors, sweep profiles with circle/inner/split forms, segmented/spatial/hollow/fused/additive paths, sweep continuation, loft, copy and pattern are rejected. Existing prism evidence remains `00000715` F3, `00007264` F2 (comparison-rejected), `00039669` F2 (strict/RP), and `00151159` F2 (RP). This is not family completion. |
| `SWEPT_EDGE` | `2491` | CADFS `00404735` F1/F3 exported `onshape/std/geometry.fs@2491.0` history; `BRepPrimAPI_MakeRevol.Generated(vertex)` verified against the final solid snapshot | A full, independent `revolve_add` `new_body` with a direct profile or an explicitly verified complete unchanged materialization, and a direct in-sketch axis: a uniquely identified non-construction source vertex, expressed by its complete incident source-edge set -> one active swept circular edge | The lowerer requires `FULL`, exact source/library tuple, the profile contract above, a producer still present in the history, at least two distinct direct source edges with exactly one shared endpoint, and no geometry/stable-ID/current-body fallback. The adapter registers only `Generated(vertex)` edge handles which pass final-result membership; resolver permits the initial `boundary` and subsequently only complete/proven one-to-one `continuation` relations. In `00404735`, F3 (`E0`/`E5`) lowers with `kernel_history` evidence and rebuilds; F2 remains deferred because its OSD pair does not identify one direct shared endpoint. Partial/surface/additive/fused revolve, changed/incomplete/split materialization, axis/profile from different sources, axis endpoints, single/non-unique source, unsupported library revisions, split/merge/deletion, and all generic revolve/SWEPT_EDGE forms remain rejected. This is a single 2491 producer contract, not general revolve or family completion. |
| `INTERSECT` | `1511` | CADFS FeatureScript 1511 exported query history; OCC boolean `SectionEdges()` plus exact `Generated(face)` handles from both inputs | One `INTERSECT EDGE` of exactly two source-qualified direct-prism `CAP_FACE`/`SWEPT_FACE` inputs from either one explicit `boolean_bodies` target/tool pair, or one immediate primary `extrude_cut_blind` target plus its transient direct-prism tool snapshot | The adapter creates a relation only when the same section edge is returned by `Generated(face)` for exactly one face in each boolean input and `IsSame` binds all three handles to the input/final snapshots. CAP input recovery reads the unique complete/proven builder-role fact from its producer delta, never a cached output role on a later continuation; `00789417` therefore resolves F12's F10-snapshot section edge, while F11's missing active edge continuation correctly rejects its later consumer as `selector_body_member_inactive`. A primary cut records its tool only as transient historical topology: exactly one active target solid/member, one tool solid, complete direct-prism anchors/history and one immediate consumer are required; the tool itself cannot be selected. Resolver requires one complete relation, the `intersection`/`one` policy and final active member. Unqualified `SectionEdges()`, duplicate output edges, incomplete source snapshots, keep-tools, multiple target/tool members, copied/transformed/patterned/TDD/IMPRINT inputs, non-direct prisms, primary query `disambiguationData` whose FeatureScript semantics are unverified, and unknown versions remain rejected. This is a contract test boundary; `00020311`/`00029250` retain their F3 checkpoints because their `OD(0/1)` selectors are deferred. Real-corpus RP evidence is not yet sufficient to mark general `INTERSECT` replay complete. |
## Source Sketch Path Queries (Not Runtime Selector Capabilities)
This section is intentionally outside the resolver allow-list above. The query
does not select rebuilt body topology: it selects source sketch reference-wire
geometry while lowering a `sweep` path. It therefore creates no
`selector_intent`, kernel-lineage relation, or stable-ID fallback permission.
| Source query / consumer | FeatureScript version | Source evidence | Lowering and runtime boundary | Evidence |
| --- | --- | --- | --- | --- |
| `qUnion([qBodyType(qCreatedBy(sketch, EDGE), WIRE)])` with optional exact `qConstructionFilter(..., NO)` as `sweep.path` | `1511` + direct `onshape/std/geometry.fs@1511.0` | Local Onshape standard-library mirror `query.fs`: `BodyType.WIRE` describes sketch curves (lines or curves), `qBodyType` retains entities owned by that body type, and `qConstructionFilter(..., NO)` retains only non-construction entities. `qCreatedBy(..., EDGE)` excludes source sketch points. The repository does not vendor the exact 1511 standard-library snapshot, so this is source-semantics evidence rather than a claim that all revisions are compatible. | A singleton non-construction `line` / `bspline` retains the existing contract. One direct source sketch may provide a multi-curve result, or an outer union may contain two or more exact versioned source-wire operands. All resulting `line` / `arc` / non-periodic `bspline` curves must form one source-ordered, connected, non-branching open wire with no repeated source entity; two-point B-splines require both endpoint derivatives. For cross-sketch operands, each curve is captured through its explicit source workplane to a global self-contained spatial `path.segments` variant, with no common-workplane projection, datum/result/final-STEP/current-body lookup, selector intent, kernel lineage, CAP output-role, or stable-ID fallback. A `NO` filter removes construction curves; without it any construction curve rejects the contract; `skPoint` is not an `EDGE`. Closed/disconnected/branching/repeated/degenerate paths, circles/ellipses/unknown curves, non-exact wrappers/filters, other versions, loft guides, surface/profile consumers, and runtime-body uses remain deferred. | The fresh seven-sample RP matrix `output/qbodytype-direct-sketch-wire-matrix-20260910-v1` rebuilds all singleton direct-path cases: `00191739` is strict, `00726304` is RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` are comparison-rejected, and two-point B-spline `00896761` is `comparison_timeout` at 60 seconds. `00885126` F2 lowers `E0 -> E2.filletArc -> E1`, executes one solid sweep, and preserves its prefix STEP in `/private/tmp/cadfs-segmented-wire-00885126-20260910`; `00786708` executes F2/F5/F7 then is `rebuilt_rejected`. Cross-sketch `00610979` F4 captures filtered F1/F3 source wires as four global segments and rebuilds one solid with no runtime diagnostic in `output/cadfs-spatial-wire-00610979-20260910`; complete-history comparison is rejected because later CAP_FACE-dependent F5/F9 remain deferred and source has four solids. These are execution classifications, not similarity claims. |
| `qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` as `loft` `ToolBodyType.SURFACE` wire profiles | `1511` + direct `onshape/std/geometry.fs@1511.0` | Same source-only query semantics; `00174697` supplies two independent circular source wires on parallel explicit planes. | Exactly two distinct source sketches, each with one direct closed non-construction wire (one circle or a connected non-branching line/arc/B-spline cycle), may lower to `loft_surface`. The adapter uses `BRepOffsetAPI_ThruSections(False, False)` and registers an independent shell; it never fuses it into the active solid or grants topology/provenance selector continuation. Spine, guides, connections, matching, endpoint derivatives, sheet profiles, non-`NEW` operation, mixed/derived/runtime wires, construction ambiguity, open/disconnected/branched/inner wires and other library versions are deferred with `loft_surface_wire_profiles`. | `00174697` lowers and freshly rebuilds to one shell (`surface_count: 1`, `solid_count: 0`). This is one direct surface-loft form, not general `qBodyType`, construction filter, loft, or surface-selector coverage. |
| `qBodyType(qCreatedBy(sourceSketch, EDGE), WIRE)` as a `LINE_ANGLE` datum axis | `1511` + direct `onshape/std/geometry.fs@1511.0` | Same source query semantics, with `cplane.fs::lineAnglePlane` consuming the selected source axis rather than runtime model topology | The exact query result must contain one `line` from one source sketch. The line's explicit source workplane supplies the global axis; construction is valid for datum geometry. The bridge rejects multi-line, arc/B-spline, filters/composition, derived topology, unknown versions, and runtime body selection. It emits a CDSL frame, not a selector intent or lineage claim. | `00506444` F6 selects one construction line from each of F4/F5, lowers one reference plane, and allows F8 to execute in a fresh rebuild. `00474220` F4 selects an arc wire and remains a deterministic `line-angle reference selection is unsupported` defer. This is one real 1511 positive form, so it does not complete `qBodyType`, construction filtering, or general datum-axis coverage. |
| Direct-prism `CAP_VERTEX` as a `THREE_POINT` / `PLANE_POINT` datum point | `1511` + direct `onshape/std/geometry.fs@1511.0` | A direct `makeQuery(..., CAP_VERTEX, VERTEX, {disambiguationData: [OSD([edge, edge])], isStart})` names one source-defined physical point, rather than a live selector result | The owner must be an undrafted independent `new_body` blind prism whose lowered profile exactly equals its original direct source profile. OSD must contain exactly two distinct non-construction profile edges from that source sketch with one shared endpoint. The lowerer maps this endpoint into the selected prism cap frame; it allows datum, independent undrafted `new_body` blind prisms, and one exact producer `CAP_FACE` single-cap shell after the producer because CAP_VERTEX remains a producer-history datum, never a shell-result vertex. It emits an explicit cPlane frame, no selector intent or topology lookup. The common analytic-contour region solver also requires a hole loop to lie strictly inside its outer loop at every sampled boundary point; shared/touching loops remain independent regions instead of creating an invalid self-hole. Hole/boolean/other dress-up/transform/COPY/pattern/delete mutations, draft/ADD/CUT/two-sided, IMPRINT/split/multi-profile, ambiguous/repeated endpoint source, other versions, geometry/current-body/stable-ID and STEP fallback are rejected. | `00243142` and `00245768` F2/F4 complete replay in fresh `output/cplane-cap-vertex-datum-20260912-r2`; both final comparisons are rejected (`00245768` also has a solid-count mismatch). `00212904` F2 direct CAP shell followed by F3 THREE_POINT also lowers; both requested and removed cap positions remain producer-history datum points. `00053942` F4 follows a hole and is rejected as `CAP_VERTEX datum source is unsupported`. This is a source-datum bridge only, not CAP_VERTEX selector, generic cPlane, or query-family completion. |
| `PLANE_POINT` with direct-prism `CAP_FACE` and direct source vertex | `1511` | The FeatureScript query AST retains `FACE` and `VERTEX` kinds independently of whether either query uses `qCreatedBy`; a direct blind-prism CAP frame supplies the physical face frame | The cPlane must have exactly two entities: one face and one vertex. The lowerer takes the face frame and locates the direct source vertex in its explicit source sketch frame; it does not reinterpret either query as a runtime topology selector. Multiple faces, multiple vertices, untyped/extra inputs, and unresolved source frames defer. | `00228556` F3 uses F1 `CAP_FACE` plus F2 `sQuery(..., VERTEX, ...)`; F1/F3/F6 execute in `output/cplane-plane-point-cap-face-20260912`, with RP pass and strict volume/area precision diagnostics. No runtime body, stable-ID, geometric-nearness, or STEP fallback is used. General topology faces/vertices, query composition and generic PLANE_POINT semantics remain deferred. |
| Direct-prism `SWEPT_EDGE` as a `LINE_ANGLE` datum axis | `1511` + direct `onshape/std/geometry.fs@1511.0` | A direct source-profile vertex, identified by exactly two distinct original profile edges with one shared endpoint, plus the one-sided prism span | The producer must be the immediately preceding undrafted `new_body` blind `extrude_add_blind`; its lowered profile must exactly equal the complete original source profile. The datum axis is calculated from the source vertex copied to the prism start cap and the explicit start-to-end span. It is not a runtime selector, topology lookup, stable-ID, geometry, active-body, or STEP fallback. Derived/combined edge queries, non-immediate producers, draft, ADD/CUT/two-sided/IMPRINT or changed profiles, ambiguous/repeated/non-shared OSD edges, other versions, and a derived face/curve as the second LINE_ANGLE reference remain deferred. | `00040198` F2 (single axis) and `00722278` F2 (axis plus Front datum) lower to executable reference planes and complete fresh rebuilds. `00644299` F2 deliberately remains deferred because its second reference is a derived `SWEPT_FACE`; this narrow datum bridge does not implement general derived cPlane semantics. |
## Deferred source queries
### Remaining Deferred Tuples
The following source families are registered capability work items, not runtime
allow-list entries. They intentionally remain `feature_script_query` with a
non-executable `multiplicity: "none"` policy until their source contract,
complete final-snapshot builder history, consumer semantics, and multi-corpus
evidence are all present.
| Query family | Observed corpus | Required contract before enabling | Current status |
| --- | --- | --- | --- |
| `BLEND_EDGE` remaining tuples | 382 queries in 183 CADFS histories, including `00407186` F5 and `00614954` F3 | Explicit N:M source-set, producer/lifecycle and final-snapshot incidence contracts for `SWEPT_*`, `MERGE`, `SPLIT`, `COPY`, `BLEND_FACE`, non-immediate consumers and dress-up variants | The direct-prism CAP_EDGE/CAP_FACE tuple is registered above. All other tuples remain non-executable `feature_script_query` selectors; no geometry, stable-ID, current-body or source-STEP fallback is authorized. |
| `MERGE(FACE)` sketch host | 294 direct outer sketch workplanes, including 261 FeatureScript 1511 hosts; 90 have two SWEPT_FACE inputs and 77 have one explicit OSD edge per input | A source-to-body-member merge operation model, complete/proven operation-wide N:M final-face relations, explicit active-member scope and an attached-sketch consumer contract | No MERGE form is executable. An outer MERGE must not inherit a nested CAP_FACE or SWEPT_FACE static frame: `00013930` F7, `00020631` F4 and `00036155` F5 now stop at a named `sketch_deferred` diagnostic while preserving preceding executable features. `NewBodyOperationType.ADD` correctly takes CDSL's fuse path, but its `*.boolean.opBoolean` owner token, aggregate, or generic union history cannot by itself authorize a particular N:M source-face successor. Geometry, face order, stable ID, aggregate/current body and source STEP are forbidden fallbacks. |
| `MID_CAP_EDGE` | 49 queries in 23 CADFS histories: 1511 loft/sweep positions 0--6 and 1549 loft positions 0--1, including `00330207`, `00309311`, `00315819`, and `00703441` | Exact source/library semantics for `capPos`, an operation-wide source-section/edge relation, and a final-snapshot edge handle for every selected section; the consumer must then prove cardinality and active body membership | OCP `BRepOffsetAPI_ThruSections` 7.9.3.1 exposes only no-argument `FirstShape`/`LastShape`; for the default smooth loft used by every observed MID history, `Generated(source_edge)` returns a side face and source/intermediate section edges are absent from the final snapshot. `ruled:true` preserves section edges, but no observed MID history requests that distinct loft semantic. Therefore an index, face-boundary traversal, geometric coincidence, stable ID, current body, or source STEP cannot stand in for `capPos`; all MID queries remain non-executable `feature_script_query` selectors. |
Every CADFS topology selector now retains a `selector_intent`, including
selectors for query families and source versions not present in this matrix.
Such selectors use `evidence: "feature_script_query"` and a non-executable
`multiplicity: "none"` policy. Their geometry and legacy stable ID remain
diagnostic context only: `TopologyRegistry.resolve` rejects them before either
can bind. Direct datum planes are the sole exception; they use
`query_family: "GEOMETRIC"` with `evidence: "explicit_datum"` and retain the
explicit-frame resolution path.
`COPY(FACE)` does not inherit a nested `CAP_FACE` workplane frame. A separate
`primary_cut_cap_face_workplane` contract admits only a 1511 immediate default
primary `extrude_cut_blind`, same-owner direct `COPY(CAP_FACE)`, complete unchanged
source-profile OSD, and one following sketch consumer. The runtime proves one
transient direct-prism cap role and its same-owner complete/proven `subtract`
continuation to one active planar face, then derives the workplane from that exact
face's native UV orientation and support plane before resolving the local profile
or hole locations. `00573124` F3--F5 and `00951631` F3--F5 rebuild by
`copy_lineage`; both fresh strict/RP comparisons are rejected, so this is execution
evidence only. `00252794` F8 remains deferred because F5/F6/F7 mutate the body
after F3. Source frames, stable/snapshot IDs, geometry, current body, source STEP,
nonplanar/ambiguous/partial successors, `COPY(SWEPT_FACE|BODY)`, patterns,
transforms, draft/two-sided/non-direct tools, opposite-direction attached holes,
other versions and non-immediate lifecycle are not fallbacks or covered forms.
`COPY(SWEPT_FACE)` has a separate `primary_cut_swept_face_workplane` form. It
requires 1511, one same-owner immediate default undrafted blind primary cut, and
one direct original source-profile edge. The runtime follows only the exact
source-edge anchor to one complete/proven transient prism side face, then one
same-owner complete/proven subtract continuation to a single active planar face;
the native face supplies the workplane. `00321940` F3--F5 rebuilds with
`copy_lineage`, but fresh strict/RP comparison is rejected; `00171671` lowers its F4 attachment before an unrelated F5
selector remains deferred. `00326645` is a real rejection because its side face
has no unique subtract continuation. Two-sided/IMPRINT/partial/non-direct or
multi-edge profiles, non-immediate, nonplanar, split/merge/inactive successors,
body/pattern/transform COPY, other versions, static source frames, stable IDs,
geometry, current body, and source STEP remain outside this contract. A fresh
9,347-history lowering pass materializes 12 such attachments; that count is
contract-shape coverage only, not runtime or comparison acceptance.
This closes the former legacy path where `1793` loft `SWEPT_EDGE` selectors in
`00005267` F4 could bind from endpoint bounding boxes despite no registered
FeatureScript query contract. The current result is a `selector_query_unsupported`
failure with the executable F3 checkpoint exported. Equivalent bounded
rejections now cover unproven CAP/SWEPT/OFFSET/COPY/INTERSECT fallbacks; they do
not reduce the matrix's direct-prism coverage or claim general query support.
The repository does not currently vendor the FeatureScript standard-library
sources for other versions or `makeQuery` encodings. They are not inferred
from a `15xx` prefix or a single corpus sample.
+156
View File
@@ -0,0 +1,156 @@
# CADFS → CDSL Engine 周报
**周期:2026-09-07 2026-09-11**
**数据集:** `data/cadfs-sample/CADFS_test`,共 9,347 个样本
**最新全量工件:** `cadfs_to_cdsl/output`,报告生成于 `2026-09-11 18:28:01`
**历史工件:** `cadfs_to_cdsl/output-history/*`
**能力依据:** `cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md``CADFS_FULL_CAPABILITY_TARGET.md`
## 1. 本周结论
1. **CADFS → CDSL 的转换覆盖继续提升。** 最新全量结果中,`converted_complete` 为 6,626 个(70.89%),`converted_partial` 为 2,325 个(24.87%);无可执行特征的样本降至 366 个(3.92%),解析/降级失败 30 个(0.32%)。
2. **转换完整不等于可执行,也不等于几何相似。** 最新结果有 8,126 个 STEP 工件,但其中包含失败样本保留的可执行前缀;真正完成一次 rebuild 的样本为 4,597 个,其中 RP 接受 1,781 个、strict 接受 744 个。
3. **最新快照的 RP/strict 接受数低于 2026-09-09 快照。** 这与本周 selector provenance、single-session replay 和 fallback 收紧同步发生;`selector_query_unsupported` 成为主要 rebuild 失败层。该变化不能只按百分比判定为几何能力退化,应结合失败 feature、前缀 STEP 和诊断逐层分析。
4. **本周 engine 的主要增量集中在 provenance、拓扑历史和有界 source contract。** 已落地的多数是窄范围、可证明的 contract;没有把单个样本的通过结果升级为通用 CADFS feature 完成。
## 2. 当前 CADFS → CDSL 数据转换情况
### 2.1 最新全量结果
| 层级 | 状态 | 数量 | 占 9,347 样本 | 说明 |
| --- | --- | ---: | ---: | --- |
| 转换 | `converted_complete` | 6,626 | 70.89% | FeatureScript history 全部有 CDSL 表达,仍需单独看 runtime/comparison |
| 转换 | `converted_partial` | 2,325 | 24.87% | 保留可转换前缀,后续 feature 有 deferred/unsupported 等诊断 |
| 转换 | `deferred_no_executable_feature` | 366 | 3.92% | 没有可执行 feature checkpoint |
| 转换 | `parse_failed` | 30 | 0.32% | parser/lowering 层失败 |
| rebuild/比较 | `rebuilt_strict` | 744 | 7.96% | CDSL、STEP rebuild 和 strict comparison 均通过 |
| rebuild/比较 | `rebuilt_approximate` | 1,037 | 11.09% | RP 工程相似通过,strict 未通过 |
| rebuild/比较 | `rebuilt_rejected` | 2,778 | 29.72% | 有 rebuild/comparison,但不满足 RP 验收 |
| 执行 | `rebuild_failed` | 3,932 | 42.07% | runtime/selector/OCC 在完整或增量 replay 中失败 |
| 执行 | `runtime_ineligible` | 408 | 4.37% | CDSL 已产生,但能力预检拒绝完整 runtime execution |
| 基础设施 | `comparison_timeout` | 38 | 0.41% | 比较 worker 超过 60 秒 |
| 基础设施 | `rebuild_timeout` | 14 | 0.15% | rebuild 超时 |
接受口径:RP 接受 = `rebuilt_strict + rebuilt_approximate` = **1,78119.05%**strict 接受 = **7447.96%**。当前 4,559 个样本有 comparison report,其中 strict 占 16.32%RP 占 39.07%;这个分母与全量样本不同,不能混用。
### 2.2 CDSL、STEP、比较工件覆盖
| 工件 | 数量 | 占全量 | 位置/说明 |
| --- | ---: | ---: | --- |
| candidate CDSL | 8,951 | 95.76% | `output/samples/<sample_id>/candidate.cdsl.json` |
| bound CDSL | 8,485 | 90.78% | 已完成 selector/body binding 的候选 |
| STEP | 8,126 | 86.94% | 包含完整 rebuild 和失败时保留的最佳 executable prefix |
| comparison | 4,559 | 48.77% | `comparison.json``comparison_summary.csv` |
| GLB | 0(全量目录扫描) | 0% | 最新全量 `output` 未生成 GLB;专项回归工件中的 GLB 不计入全量统计 |
所有样本的本地 annotation、FeatureScript、image、STEP、STL modality 均存在;JSONL 对齐 fallback 为 0。每个失败样本仍按当前流程保留 `diagnostics.json``history.json``rebuild.json` 以及可用的 `rebuild.step` 前缀。
### 2.3 历史快照对比
以下数据按各快照的 `full_run_report.md` 汇总。旧快照的 manifest 状态命名曾使用 `rebuilt`/`rebuilt_approximate` 等不同字段,因此不把旧 manifest 与最新 manifest 直接拼接;比较时以各自报告的 conversion、RP、strict 和工件计数为准。
| 报告时间(快照目录) | complete | partial | deferred | RP accepted | strict accepted | STEP | candidate | bound | comparison |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 2026-09-02 (`20260907-185128`) | 2,427 | 4,315 | 2,602 | 1,15912.40% | 5786.18% | 1,812 | 8,108 | 1,773 | 1,719 |
| 2026-09-07 (`20260907-235209`) | 2,829 | 4,283 | 2,235 | 1,35814.53% | 6547.00% | 5,554 | 7,112 | 5,554 | 5,250 |
| 2026-09-08 (`20260908-215959`) | 5,367 | 3,266 | 693 | 2,03121.73% | 9059.68% | 6,134 | 8,633 | 6,134 | 6,090 |
| 2026-09-09 (`20260909-191531`) | 6,232 | 2,710 | 396 | 2,25324.10% | 98910.58% | 8,176 | 8,942 | 8,364 | 6,007 |
| 2026-09-11(最新 `output` | **6,626** | **2,325** | **366** | **1,78119.05%** | **7447.96%** | **8,126** | **8,951** | **8,485** | **4,559** |
从 2026-09-02 到最新快照,完整转换增加 4,199 个(+44.92 个百分点),deferred 减少 2,236 个(-23.92 个百分点),candidate/bound CDSL 覆盖明显扩大。最新快照相较 2026-09-09 的 strict/RP 和 comparison 数下降,应归因到当前代码的更严格 provenance/selector gate、runtime 分类和本次全量执行边界,不能仅凭接受率得出“本周 engine 几何能力整体下降”的结论。
与 2026-09-09 快照逐项比较:`converted_complete` **+394**、`converted_partial` **-385**、deferred **-30**、candidate CDSL **+9**、bound CDSL **+121**STEP **-50**、comparison **-1,448**、RP accepted **-472**、strict accepted **-245**。后四项反映本次全量执行的 replay/比较覆盖和 provenance gate 变化,需与保留的 prefix STEP、diagnostics 和 comparison 工件一起解读。
### 2.4 FeatureScript 操作覆盖与主要缺口
最新 source history 中的操作出现次数如下,说明当前回归集已覆盖较宽的 operation 分布:
| 操作 | 次数 | 操作 | 次数 |
| --- | ---: | --- | ---: |
| `newSketch` | 20,857 | `extrude` | 18,168 |
| `fillet` | 5,210 | `revolve` | 2,087 |
| `chamfer` | 1,818 | `cPlane` | 1,620 |
| `hole` | 1,203 | `shell` | 729 |
| `mirror` | 483 | `transform` | 468 |
| `sweep` | 378 | `loft` | 377 |
| `circularPattern` | 252 | `booleanBodies` | 228 |
| `deleteBodies` | 142 | | |
按受影响样本统计的主要 capability gaps(一个样本可能同时命中多个 gap)为:
| Gap | 受影响样本 | Gap | 受影响样本 |
| --- | ---: | --- | ---: |
| `fillet` | 928 | `extrude` | 888 |
| `chamfer` | 264 | `revolve` | 201 |
| `shell_face_selector` | 162 | `mirror` | 139 |
| `extrude_profile_topology:cap_face` | 112 | `extrude_profile_topology:swept_face` | 94 |
| `cPlane` | 92 | `extrude_profile_topology:cap_edge` | 88 |
| `sweep_path` | 71 | `hole_location_vertex` | 70 |
| `loft` | 65 | `circularPattern` | 50 |
| `hole_scope_body_source` | 44 | `shell` | 42 |
| `sweep` | 40 | `booleanBodies` | 36 |
当前共记录 5,095 条 diagnostic record,分布为 `feature_deferred` 3,449、`unsupported_engine_capability` 1,004、`sketch_deferred` 612、`parse_or_lowering_failed` 30;这些是诊断条数,不是互斥样本数。主要 runtime 失败包括:`selector_query_unsupported`F2 904、F4 593、F3 380、F6 278、F5 204)、OCC union invalid shape157)、`BRep_API: command not done`151)和 planar IMPRINT region 无界(116)。
## 3. 本周 engine 新增和完善的能力
### 3.1 Selector source preservation 与 query 语义
| 能力 | 本周新增/完善 | 已验证边界与证据 | 仍未覆盖 |
| --- | --- | --- | --- |
| 版本化 query AST | `selector_intent` 同时保留带行号的原始 `source_query.ast``query_expr@1.0`;递归保存 `qUnion``qIntersection``qSubtraction``qAdjacent``qOwnerBody``qBodyType``qConstructionFilter``qCreatedBy` 以及未知调用,未知语义保留为 `opaque_call`。schema/semantic validation 校验 expression 与 intent version 一致。 | parser/lowering/selector/runtime 回归已覆盖;未知 query 不再被改写成近似 selector。 | 这是 source-preservation foundation,不等于这些 query family 已可 runtime 执行;通用 filter、created/modified/generated/deleted 关系仍 deferred。 |
| Provenance query set | `fillet/chamfer` 增加窄 `QUERY_SET` contractdirect `qUnion``qIntersection``qSubtraction` 通过 exact active record ID 做 union/intersection/subtraction,递归 set 在 9 月 11 日补齐;空集、partial、inactive、mixed kind/version、geometry/stable-ID/output-role 混用均拒绝。 | `backend.tests.test_selector_provenance_contract` 35 passed`cadfs_to_cdsl.tests.test_lowering` 117 passed`backend.tests.test_engine_runtime_foundation` 140 passed、1 skipped。`00000715` 的变体证明 intersection 空集诊断和 subtraction left-only 语义。 | 全量 corpus 未发现 native `qIntersection`/`qSubtraction` call;仍仅限 FeatureScript 1511、direct provenance child、fillet/chamfer,不能称通用 query-set 能力。shell 对 nested/intersection/subtraction 仍拒绝。 |
| `qOwnerBody` 精确 owner bridge | 为 `UP_TO_BODY` 增加递归 `query_input``owner_body_contract: exact_input_owner`;先解析唯一 proven topology child,再以完全相同 body ID 投影到唯一 active body record。 | 合成 `00694309` 变体完成 rebuilt38 项 selector provenance 测试及 lowering 联跑 156 passed。 | 全量 corpus 暂无 native `qOwnerBody`;不支持多 member、multi-solid、later successor、其它 consumer 或 current aggregate/geometry fallback。 |
| fallback 与 replay 收紧 | `selector_intent.version` 成为唯一 canonical versionproduction `rebuild_candidate` 改为 single-session incremental replay;普通 context selector 只在 active context 中存在唯一同 kind record 时回退,COPY/instance selector 不回退。 | 失败统一保留 selector diagnostic 和 prefix checkpoint`selector_query_unsupported``selector_kernel_history_missing``selector_body_member_inactive` 分层可归因。 | 仍缺通用 CAP/SWEPT/OFFSET/COPY/INTERSECT lineage,不能以 stable ID、最终 STEP 或 current body 补齐 source 语义。 |
### 3.2 OCC topology history、output role 与 body provenance
| 能力 | 本周新增/完善 | 已验证边界与证据 | 仍未覆盖 |
| --- | --- | --- | --- |
| 多 builder final-history bridge | planar IMPRINT 多区域 blind prism 先分别保留 `BRepPrimAPI_MakePrism` history,再用 `BRepAlgoAPI_Fuse(SetToFillHistory)` 映射到 final snapshot;只登记 `IsSame` 可证明的 source→final relationdeleted/missing branch 保留 `partial/unknown`。 | `00354246` 的 7 个 `SWEPT_FACE` 由 native lineage 解析后在 OCC fillet feasibility 处失败;`00403485` 的 deleted side branch 正确报告 `selector_kernel_history_missing` 并保留 F1 STEP。最终聚合回归为 293 passed、1 skipped。 | 仅限 planar IMPRINT、至少两个 bounded region、无 draft、单向 blind;一般 fuse N:M、draft/trim/multi-body、boolean/COPY/pattern 后继仍未完成。 |
| output-role / all-fragments | direct prism/sweep/loft/shell 的 start/end cap、side、swept edge、offset/closing/wall 等 role 继续以 operation history 保存;`all_fragments` 要求每个 source fragment 都有 complete/proven final relation,不再返回部分集合冒充成功。 | 既有 direct CAP_FACE/CAP_EDGE/SWEPT_FACE/SWEPT_EDGE、shell、loft 回归均按 final snapshot 和 active member 绑定。 | 一般 CAP/SWEPT/OFFSET/MID_CAP、split/merge、复杂 dress-up 后继仍无完整 N:M resolver。 |
| body member / COPY provenance | boolean target/tool 现在接受 `{pattern_feature_id, source_feature_id, instance_index}`direct surviving `new_body` 的 mirror/circular/transform COPY、single-body successor、fused sole-body circular COPY 和 multi-source transform copy 均增加显式 member/provenance 校验。 | `00000385` F6、`00293508` UNION 及 body graph/runtime 回归证明 target/tool 不再回退到 pattern aggregate;专项 GLB 由同一 rebuild STEP 生成,仅作预览。 | 第二个 `new_body`、aggregate/linear/nested pattern、excluded instance、multi-body boolean/delete/copy 的完整生命周期仍未完成;不能宣称完整 `booleanBodies`/`circularPattern`/`mirror`。 |
### 3.3 CADFS feature/source contract 扩展
| 能力 | 本周新增/完善 | 已验证边界与证据 | 仍未覆盖 |
| --- | --- | --- | --- |
| source-only segmented sweep path | 同一 source sketch 的 ordered line/arc/non-periodic B-spline 多段路径,及跨 source sketch 的 global 3D spatial path;要求 explicit source identity、connected/open/non-branching、无重复/退化段,runtime 用 `BRepBuilderAPI_MakeWire` + pipe-shell。circular pattern 会同步旋转 spatial points/normal/tangent。 | `00376556` 为 executable approximate`00786708` 的 filtered line/arc/line path 可执行但完整 history rejected`00610979` 跨 sketch F4 prefix 可执行;`00034285` 的空消息 `AssertionError` 被稳定翻译为 `OCC sweep operation raised while building the native sweep`。 | 仍是 source-only lowering,不是 runtime `qBodyType` selectorclosed/branched wire、guide/surface/profile consumer、sweep output lineage、一般 multi-sketch path 未完成。 |
| planar IMPRINT dispatch | 修正多 face `IMPRINT FACE` lowering 的 dispatch,避免把合法多区域 profile 错降成普通 analytic contour;保留 source entities、face side 和 fragment intent。 | `00354246` F1 现在保持 typed `planar_imprint`F2 的 selector request 不再丢失,失败停在可归因 selector/OCC 层并保留 F1 STEP。 | 不增加 IMPRINT/SWEPT/INTERSECT family 的通用 success coverageunbounded region、different sketch、trim/copy/pattern successor 仍拒绝。 |
| direct prism topology consumers | 扩展 direct `CAP_FACE` immediate `UP_TO_SURFACE``SWEPT_FACE` continuation、`SWEPT_BODY` immediate `UP_TO_BODY``CAP_EDGE` fillet/chamfer、CAP_FACE shell removal,以及 direct hole profile 的 `SWEPT_EDGE`/`CAP_EDGE` exact source anchors。 | `00835610` drafted CAP_FACE pipeline RP 通过;`00694309` body target contract 有完整 history`00039669``00151159` 等 direct prism hole lineage matrix 有 strict/RP 证据;未证明的 selector 继续拒绝。 | 复杂 profile、draft 内环、fused/multi-region、boolean/COPY/pattern 后继和通用 CAP/SWEPT selector 仍不在 allow-list。 |
| reference/semantic details | 完善 `LINE_ANGLE` direct `skCircle` axis 与 source gate、`cPlane` `oppositeDirection` signed offset、direct sketch-vertex hole location/sole scoped body、shell `OFFSET_FACE`/`parts` source-qualified contract 和 `INTERSECT EDGE` source-qualified section relation/transient tool history。 | 相关原子、selector/runtime suite 和受控 core/shard 回归已记录;`INTERSECT` 未证明的 SectionEdges 不会直接升级为 lineage。 | 任意曲线/面/connector axis、multi-body hole host、generic OFFSET、复杂 shell、primary/copy/pattern intersection 仍未完成。 |
### 3.4 诊断和离线工具完善
- native sweep 的空 `AssertionError` 现在转成稳定、可归因的 OCC 诊断,不以 geometry fallback 伪造实体。
- single-session selector replay 和 prefix retention 使失败点、最后可执行 STEP、`history.json`/`rebuild.json`/`diagnostics.json` 保持一致;当前全量报告中的 F2/F4/F3 selector failures 可直接定位到 feature。
- engine 执行边界继续收敛到显式 schema/capability contract、按 family 的 executor/runtime 路径和 topology evidence export;这使 selector、body lifecycle 和 OCC failure 可以在同一条 replay history 上归因,而不是依赖旧的平行 runtime 路径。
- 新增 `selector_candidate_demo.py` 的 geometry probe、strict replay、bounded branch search 和 query-group recovery。它只在隔离的 copied CDSL 上实验,并要求唯一 strict branch 后再 fresh replay**不改变 production resolver、RP 阈值或 capability matrix,因此不计为 engine selector 能力完成。**
## 4. 本周能力边界与下周重点
### 已能对外说明的结果
- 转换器可以为大多数样本生成 candidate/bound CDSL,并在失败时保留 prefix STEP;完整转换率已达到 70.89%。
- selector source AST、版本和 body/provenance policy 不再被静默抹平;一部分 direct prism、IMPRINT、sweep、shell、boolean/pattern/transform 场景能够以 exact kernel history 执行。
- RP 与 strict comparison 已独立报告;source STEP 精度、拓扑不一致、比较超时和 OCC/runtime failure 不再混为 converter 失败。
### 不能宣称已经完成的范围
- 不能把 `fillet``extrude``chamfer``revolve``shell``mirror``sweep``booleanBodies``circularPattern` 的单个窄 contract 说成整个 feature family 完成。
- 不能把 2026-09-10 的 query-group strict winner、专项 GLB 或单个 RP/strict 样本当作通用 selector mapping。
- 当前最大未覆盖面仍是 selector query unsupported、复杂 profile/拓扑后继、body lifecycle、OCC invalid shape/unbounded IMPRINT 以及 comparison timeout;这些都需要继续保持有界拒绝和可执行前缀。
### 建议下周优先级
1. 先处理全量 runtime 中占比最高的 `selector_query_unsupported`,按 query family、producer history 和 consumer 分层推进,不用 geometry/current-body fallback 换取通过。
2. 扩展 planar IMPRINT、CAP/SWEPT/OFFSET 和 boolean/pattern 的多实体 relation component,至少补齐多个真实 source/lifecycle 的 RP 回归后再升级 capability matrix。
3. 针对 OCC union invalid shape、`BRep_API: command not done`、unbounded IMPRINT 和 comparison timeout 分别建立 kernel/input/infrastructure 证据,继续保留每个失败样本的 STEP/diagnostic/compare 工件。
## 5. 证据入口
- 最新汇总:`cadfs_to_cdsl/output/full_run_report.md``summary.json``manifest.jsonl``capability_gaps.json``comparison_summary.csv`
- 最新样本工件:`cadfs_to_cdsl/output/samples/<sample_id>/`
- 历史汇总:`cadfs_to_cdsl/output-history/<snapshot>/full_run_report.md`
- 能力进度台账(本地,不提交):`cadfs_to_cdsl/ENGINE_CAPABILITY_GAPS_PROGRESS.local.md`
- 全量能力目标:`cadfs_to_cdsl/CADFS_FULL_CAPABILITY_TARGET.md`
@@ -0,0 +1,50 @@
# CADFS → CDSL 周报(2026-09-11
本周完成 9,347 个 CADFS 样本处理,CDSL 完整转换 6,626 个(70.89%),RP/strict 通过 1,781/744 个,失败样本均保留 STEP 前缀。相比 9 月 7 日首次全量归档,候选 CDSL、可执行 STEP 与 RP/strict 接受数均提升;本周新增/完善集中在 selector 血缘、拓扑历史、body/COPY 与受限特征 contract,复杂 selector、多 body 生命周期仍待补齐。
完善绘图引擎,CADFS 样本处理。
## 转换指标
| 指标 | 本次 | 首次归档(2026-09-07 | 变化 |
|---|---:|---:|---:|
| 生成候选 CDSL | 8,951 | 7,112 | +1,839 |
| 完整转换 CDSL | 6,62670.89% | 2,82930.27% | +3,797+40.62 pp |
| 生成可执行 STEP | 8,126 | 5,554 | +2,572 |
| RP 通过 | 1,78119.05% | 1,35814.53% | +423+4.52 pp |
| 严格通过 | 7447.96% | 6547.00% | +90+0.96 pp |
注:几何比对数下降受本次 replay/比较覆盖影响;RP 和 strict 接受数相较首次归档均提升。
## 重要改动与新增
| 类别 | 能力/指标 | 首次(2026-09-07 | 本次 | 变化 | 结论 |
|---|---|---|---|---|---|
| Selector 源语义 | `selector_intent` / `query_expr@1.0` | 不保留递归 query AST | 保留 set、filter、未知调用 | 新增 contract | 不再将未知 selector 静默近似化 |
| 集合/owner 查询 | `QUERY_SET``qOwnerBody` | 无对应 bridge | exact active record-ID 集合、`UP_TO_BODY` owner bridge | 新增 | 仅 FS1511 direct provenance`qOwnerBody` 仅合成回归 |
| Replay 与回退 | 单次 session、context fallback、前缀保留 | selector 回放/归因较弱 | incremental replayCOPY/instance 不回退 | 收紧 | 失败层与最后 STEP checkpoint 可追溯 |
| 直接 prism 血缘 | CAP/SWEPT/EDGE/BODY consumer | 基础输出 role | extent、fillet/chamfer、shell、hole 等 direct consumer | 扩展 | 仅 direct-prism allow-list,不覆盖复杂后继 |
| IMPRINT 拓扑历史 | 多 face/multi-region | 缺 final relation | 多区域 prism + fuse final-history bridge | 新增/修复 | deleted/missing fragment 保持拒绝 |
| Sweep 路径 | 单一受限路径 | 覆盖有限 | 同/跨草图 line、arc、B-spline spatial wire | 扩展 | 仅 source-only、open/non-branching path |
| Shell contract | face/offset/body source | 主路径有限 | SWEPT_FACE、OFFSET_FACE、parts、方向语义 | 扩展 | 复杂/多 body shell 未完成 |
| Body/COPY/Boolean | aggregate/current body 风险 | 来源校验不足 | pattern instance、sole-body copy、boolean target/tool provenance | 收紧 | 多 body/嵌套 pattern 生命周期未完成 |
| Datum/Hole/Section | 细分 source contract 缺失 | 覆盖有限 | `LINE_ANGLE``cPlane`、vertex-hole、`INTERSECT EDGE` | 扩展 | 仅 direct source/sole scoped-body |
| 运行时诊断 | OCC/selector 失败归因 | 信息不稳定 | sweep 异常规范化、strict replay 与 prefix 工件 | 完善 | 离线 query 搜索仅作诊断,不计生产能力 |
| 测试/能力台账 | 回归边界分散 | 证据不完整 | selector/runtime/lowering 与真实 shard 回归补齐 | 完善 | 仍按各能力的受限边界验收 |
### 主要缺口收敛(同名 capability gap 直接比较)
| 能力缺口 | 首次 | 本次 | 变化 |
|---|---:|---:|---:|
| `extrude` | 3,600 | 888 | -2,712 |
| `fillet` | 1,904 | 928 | -976 |
| `shell` | 696 | 42 | -654 |
| `revolve` | 662 | 201 | -461 |
| `extrude_profile_topology:intersect` | 383 | 34 | -349 |
| `chamfer` | 588 | 264 | -324 |
| `sweep` | 326 | 40 | -286 |
| `transform` | 252 | 13 | -239 |
| `booleanBodies` | 187 | 36 | -151 |
| `mirror` | 241 | 139 | -102 |
| `cPlane` | 168 | 92 | -76 |
注:gap 为受影响样本数,同一样本可命中多个 gap;只比较两次快照中同名键。`hole``shell_face_selector``sweep_path` 等已拆分为新子项,不能直接与首次单项相减。
+47 -7
View File
@@ -6,6 +6,27 @@ from .featurescript_lexer import Token, lex
from .ir import Call, FeatureIR, ModelIR, SketchIR
_IMPORT_RE = re.compile(r"\bimport\s*\(\s*(?P<arguments>.*?)\s*\)\s*;", re.DOTALL)
_IMPORT_PATH_RE = re.compile(r"\bpath\s*:\s*['\"]([^'\"]+)['\"]")
_IMPORT_VERSION_RE = re.compile(r"\bversion\s*:\s*['\"]([^'\"]+)['\"]")
def _standard_library_imports(source: str) -> list[dict[str, str]]:
"""Preserve each directly imported Onshape standard-library revision."""
imports: list[dict[str, str]] = []
for match in _IMPORT_RE.finditer(source):
arguments = match.group("arguments")
path = _IMPORT_PATH_RE.search(arguments)
if path is None or "onshape/std/" not in path.group(1):
continue
item = {"path": path.group(1)}
version = _IMPORT_VERSION_RE.search(arguments)
if version is not None:
item["version"] = version.group(1)
imports.append(item)
return imports
def _string(value: Any) -> Any:
if isinstance(value, str) and len(value) >= 2 and value[0] in {'"', "'"} and value[-1] == value[0]:
try:
@@ -28,6 +49,12 @@ class Parser:
def primary(self) -> Any:
token = self.pop()
if token.value in {"+", "-"}:
# FeatureScript permits a signed parenthesized scalar such as
# ``-(138.6) / 2 * mm``. Keep it in the existing arithmetic AST
# so every downstream constant/units validator sees the same
# expression shape as a binary subtraction.
return Call("__binary__", [0.0, token.value, self.primary()], token.line)
if token.value == "(":
value = self.expression(); self.accept(")"); return value
if token.kind == "string": return _string(token.value)
@@ -130,18 +157,32 @@ def _arg_map(call: Call) -> dict[str, Any]:
return next((arg for arg in reversed(call.args) if isinstance(arg, dict)), {})
def _feature_id(call: Call) -> str | None:
"""Return the declared ID for one direct FeatureScript feature call.
FeatureScript operations share the ``operation(context, id + "F...",
definition)`` shape. Retaining this generic boundary makes an unknown
source operation visible to the capability registry and lowering instead
of silently omitting it because its name is absent from a parser list.
"""
if len(call.args) < 2 or call.args[0] != "context":
return None
feature_id = symbolic_string(call.args[1])
return feature_id if feature_id.startswith("F") else None
def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
parser = Parser(source); calls = parser.statements()
version = re.search(r"\bFeatureScript\s+(\d+(?:\.\d+)*)\s*;", source)
standard_library = re.search(
r"\bimport\s*\(\s*path\s*:\s*[\"']([^\"']*onshape/std/[^\"']*)[\"']",
source,
)
standard_library_imports = _standard_library_imports(source)
primary_standard_library = standard_library_imports[0] if standard_library_imports else {}
model = ModelIR(
sample_id,
raw_source=source,
featurescript_version=version.group(1) if version else None,
standard_library=standard_library.group(1) if standard_library else None,
standard_library=primary_standard_library.get("path"),
standard_library_version=primary_standard_library.get("version"),
standard_library_imports=standard_library_imports,
)
for call in calls:
if call.name == "newSketch":
@@ -154,8 +195,7 @@ def parse_featurescript(source: str, sample_id: str = "unknown") -> ModelIR:
if model.sketches:
args = _arg_map(call); eid = str(call.args[1]) if len(call.args) > 1 else f"E{len(model.sketches[-1].entities)}"
model.sketches[-1].entities.append(FeatureIR(eid, call.name, args, line_start=call.line, raw_source=call.name))
elif call.name in {"extrude", "revolve", "fillet", "chamfer", "hole", "linearPattern", "mirror", "cPlane", "referenceAxis", "shell", "loft", "sweep", "circularPattern", "booleanBodies", "deleteBodies", "transform", "draft", "thicken", "split", "moveFace", "replaceFace", "deleteFace", "derive"}:
fid = symbolic_string(call.args[1]) if len(call.args) > 1 else f"feature_{len(model.features)}"
elif (fid := _feature_id(call)) is not None:
feature_ir = FeatureIR(fid, call.name, _arg_map(call), line_start=call.line, raw_source=call.name)
model.features.append(feature_ir); model.steps.append(feature_ir)
return model
+2
View File
@@ -41,3 +41,5 @@ class ModelIR:
# selector can be audited against the FeatureScript API contract it used.
featurescript_version: str | None = None
standard_library: str | None = None
standard_library_version: str | None = None
standard_library_imports: list[dict[str, str]] = field(default_factory=list)
+6692 -246
View File
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
"""Source-derived FeatureScript operation coverage inventory.
The registry records what the current source corpus actually contains. It is
deliberately independent from conversion diagnostics: an old output directory
cannot make an operation appear supported, and a planned operation cannot be
reported as observed merely because its name is listed in the roadmap.
"""
from __future__ import annotations
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any, Iterable
from .dataset import Sample
from .featurescript_parser import parse_featurescript
REGISTRY_SCHEMA = "cadfs_to_cdsl.operation_registry.v1"
# These are roadmap work items, not parser aliases or support declarations.
P3_OPERATION_WORK_ITEMS = (
"draft",
"thicken",
"split",
"moveFace",
"deleteFace",
"replaceFace",
"transform",
"derive",
"import",
"bend_add",
)
def build_operation_registry(samples: Iterable[Sample]) -> dict[str, Any]:
"""Parse every available FeatureScript source and inventory feature calls.
Parse failures and missing FeatureScript modalities are recorded separately
instead of being treated as a zero-count result. Sample examples remain
bounded only for report size; the count is computed from every feature.
"""
counts: Counter[str] = Counter()
sample_ids: dict[str, set[str]] = defaultdict(set)
parse_failures: list[dict[str, str]] = []
source_sample_count = 0
for sample in samples:
source_path = sample.files.get("featurescript")
if not source_path:
continue
source_sample_count += 1
try:
model = parse_featurescript(Path(source_path).read_text(encoding="utf-8"), sample.sample_id)
except Exception as error:
parse_failures.append({"sample_id": sample.sample_id, "error": f"{type(error).__name__}: {error}"})
continue
for feature in model.features:
counts[feature.operation] += 1
sample_ids[feature.operation].add(sample.sample_id)
operations: dict[str, dict[str, Any]] = {}
for operation in sorted(counts):
operations[operation] = {
"state": "observed",
"feature_count": counts[operation],
"sample_count": len(sample_ids[operation]),
"sample_ids": sorted(sample_ids[operation]),
}
for operation in P3_OPERATION_WORK_ITEMS:
if operation in operations:
operations[operation]["roadmap_work_item"] = True
else:
operations[operation] = {
"state": "not_observed_in_current_source",
"feature_count": 0,
"sample_count": 0,
"sample_ids": [],
"roadmap_work_item": True,
}
return {
"schema": REGISTRY_SCHEMA,
"source_sample_count": source_sample_count,
"parse_failure_count": len(parse_failures),
"parse_failures": parse_failures,
"operations": operations,
}
+120 -18
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import hashlib, multiprocessing, random
from copy import deepcopy
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Any
@@ -8,6 +9,7 @@ from .compare import compare_steps
from .dataset import Sample, scan_dataset
from .featurescript_parser import parse_featurescript
from .lowering import lower_model
from .operation_registry import build_operation_registry
from .rebuild import rebuild_candidate
from .reports import generate_reports, read_json, write_json, write_manifest
@@ -22,9 +24,34 @@ def _fingerprint(sample: Sample) -> str:
def _sample_dir(output: Path, sample_id: str) -> Path: return output / "samples" / sample_id
def _semantic_valid_prefix(
cdsl: dict[str, Any],
validate_semantic_cdsl: Any,
) -> tuple[dict[str, Any], dict[str, Any]] | None:
"""Return the longest contiguous semantically valid feature prefix.
This is an artifact-recovery boundary, not permission to execute an
invalid candidate. The selected prefix is independently validated and
saved under an explicit prefix filename; the complete rejected CDSL stays
diagnostic-only.
"""
features = cdsl.get("features") or []
for count in range(len(features) - 1, 0, -1):
prefix = deepcopy(cdsl)
prefix["features"] = prefix["features"][:count]
try:
semantic = validate_semantic_cdsl(prefix)
except Exception:
continue
return prefix, semantic
return None
def scan(input_root: Path, output: Path) -> list[dict[str, Any]]:
records = [sample.as_dict() for sample in scan_dataset(input_root)]
samples = scan_dataset(input_root)
records = [sample.as_dict() for sample in samples]
write_json(output / "dataset_index.json", {"schema": "cadfs_to_cdsl.dataset_index.v1", "input": str(input_root), "sample_count": len(records), "records": records})
write_json(output / "operation_registry.json", build_operation_registry(samples))
initial = [{"sample_id": item["sample_id"], "status": "scanned", "diagnostics": item["diagnostics"]} for item in records]
write_manifest(output / "manifest.jsonl", initial)
return records
@@ -56,26 +83,91 @@ def convert_one(sample: Sample, output: Path, *, force: bool = False) -> dict[st
if cached.get("input_fingerprint") == fingerprint and cached.get("conversion_status"):
return cached
if force:
for name in ("candidate.cdsl.json", "bound.cdsl.json", "rebuild.step", "rebuild.json", "rebuild.worker.json", "comparison.json", "comparison.worker.json"):
for name in (
"candidate.cdsl.json", "candidate.invalid.cdsl.json", "candidate.prefix.cdsl.json",
"bound.cdsl.json", "prefix.bound.cdsl.json", "rebuild.step", "prefix.rebuild.step",
"rebuild.json", "prefix.rebuild.json", "rebuild.worker.json", "comparison.json",
"comparison.worker.json",
):
(directory / name).unlink(missing_ok=True)
diagnostics = list(sample.diagnostics)
try:
feature_path = Path(sample.files["featurescript"])
model = parse_featurescript(feature_path.read_text(encoding="utf-8"), sample.sample_id)
provenance = {"source_featurescript": str(feature_path), **{f"source_{key}_sha256": value for key, value in sample.hashes.items()}, "jsonl": sample.metadata}
result = lower_model(model, provenance); diagnostics.extend(result.diagnostics)
write_json(directory / "history.json", result.history); write_json(directory / "diagnostics.json", diagnostics)
if result.cdsl is not None:
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
semantic = validate_semantic_cdsl(result.cdsl); write_json(directory / "candidate.cdsl.json", result.cdsl)
else: semantic = None
status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": result.status, "conversion_status": result.status, "input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics)}
except Exception as exc:
missing = isinstance(exc, FileNotFoundError)
diagnostics.append({"code": "source_missing" if missing else "parse_or_lowering_failed", "message": str(exc), "type": type(exc).__name__})
diagnostics.append({"code": "source_missing" if missing else "parse_failed", "message": str(exc), "type": type(exc).__name__})
write_json(directory / "diagnostics.json", diagnostics)
final_status = "source_missing" if missing else "parse_failed"
status = {"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id, "status": final_status, "conversion_status": final_status, "input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics)}
write_json(status_path, status); return status
provenance = {
"source_featurescript": str(feature_path),
**{f"source_{key}_sha256": value for key, value in sample.hashes.items()},
"jsonl": sample.metadata,
}
try:
result = lower_model(model, provenance)
except Exception as exc:
diagnostics.append({"code": "lowering_failed", "message": str(exc), "type": type(exc).__name__})
write_json(directory / "diagnostics.json", diagnostics)
status = {
"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id,
"status": "lowering_failed", "conversion_status": "lowering_failed",
"input_fingerprint": fingerprint, "diagnostic_count": len(diagnostics),
}
write_json(status_path, status); return status
diagnostics.extend(result.diagnostics)
write_json(directory / "history.json", result.history)
if result.cdsl is None:
write_json(directory / "diagnostics.json", diagnostics)
status = {
"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id,
"status": result.status, "conversion_status": result.status,
"input_fingerprint": fingerprint, "semantic_validation": None, "diagnostic_count": len(diagnostics),
}
write_json(status_path, status); return status
try:
from engine.cdsl_engine.semantic_validation import validate_semantic_cdsl
semantic = validate_semantic_cdsl(result.cdsl)
except Exception as exc:
diagnostics.append({"code": "semantic_validation_failed", "message": str(exc), "type": type(exc).__name__})
write_json(directory / "candidate.invalid.cdsl.json", result.cdsl)
prefix_result = _semantic_valid_prefix(result.cdsl, validate_semantic_cdsl)
prefix_status: dict[str, Any] | None = None
if prefix_result is not None:
prefix, prefix_semantic = prefix_result
prefix_features = prefix.get("features") or []
write_json(directory / "candidate.prefix.cdsl.json", prefix)
prefix_status = {
"feature_count": len(prefix_features),
"last_feature_id": prefix_features[-1].get("id"),
"semantic_validation": prefix_semantic,
}
diagnostics.append({
"code": "semantic_valid_prefix_preserved",
"feature_count": len(prefix_features),
"last_feature_id": prefix_features[-1].get("id"),
"message": "longest contiguous semantic-valid CDSL prefix was preserved separately",
})
write_json(directory / "diagnostics.json", diagnostics)
status = {
"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id,
"status": "semantic_validation_failed", "conversion_status": "semantic_validation_failed",
"input_fingerprint": fingerprint,
"semantic_validation": {"valid": False, "error": {"type": type(exc).__name__, "message": str(exc)}},
"diagnostic_count": len(diagnostics),
}
if prefix_status is not None:
status["semantic_valid_prefix"] = prefix_status
write_json(status_path, status); return status
write_json(directory / "candidate.cdsl.json", result.cdsl)
write_json(directory / "diagnostics.json", diagnostics)
status = {
"schema": "cadfs_to_cdsl.status.v1", "sample_id": sample.sample_id,
"status": result.status, "conversion_status": result.status,
"input_fingerprint": fingerprint, "semantic_validation": semantic, "diagnostic_count": len(diagnostics),
}
write_json(status_path, status); return status
@@ -108,12 +200,19 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
# must be rebuilt instead of being hidden behind the conversion label.
# ``rebuild_candidate`` keeps the two outcomes distinct by reporting a
# runtime-ineligible candidate when no executable body can be produced.
if not (directory / "candidate.cdsl.json").exists(): return status
rebuild_path = directory / "rebuild.json"; step_path = directory / "rebuild.step"
if not force and rebuild_path.exists() and status.get("rebuild_status"):
if status.get("rebuild_status") != "rebuilt" or step_path.exists(): return status
candidate_path = directory / "candidate.cdsl.json"
is_prefix = False
if not candidate_path.exists():
candidate_path = directory / "candidate.prefix.cdsl.json"
is_prefix = candidate_path.exists()
if not candidate_path.exists(): return status
rebuild_path = directory / ("prefix.rebuild.json" if is_prefix else "rebuild.json")
step_path = directory / ("prefix.rebuild.step" if is_prefix else "rebuild.step")
status_key = "prefix_rebuild_status" if is_prefix else "rebuild_status"
if not force and rebuild_path.exists() and status.get(status_key):
if status.get(status_key) != "rebuilt" or step_path.exists(): return status
worker_result = directory / "rebuild.worker.json"
outcome = _isolated(_rebuild_worker, (str(directory / "candidate.cdsl.json"), str(step_path), str(worker_result)), worker_result, timeout_seconds)
outcome = _isolated(_rebuild_worker, (str(candidate_path), str(step_path), str(worker_result)), worker_result, timeout_seconds)
if outcome == "completed":
result = read_json(worker_result); worker_result.unlink(missing_ok=True)
bound_cdsl = result.pop("bound_cdsl", None)
@@ -123,14 +222,17 @@ def rebuild_one(sample: Sample, output: Path, *, force: bool = False, timeout_se
if bound_cdsl is None and prefix_bound_cdsl is not None:
bound_cdsl = prefix_bound_cdsl
prefix["bound_feature_count"] = prefix.get("feature_count")
if bound_cdsl is not None: write_json(directory / "bound.cdsl.json", bound_cdsl)
if bound_cdsl is not None:
write_json(directory / ("prefix.bound.cdsl.json" if is_prefix else "bound.cdsl.json"), bound_cdsl)
else:
step_path.unlink(missing_ok=True)
error_type = "TimeoutError" if outcome == "timeout" else "WorkerProcessError"
message = f"rebuild exceeded {timeout_seconds:g} seconds" if outcome == "timeout" else "rebuild worker exited without a result"
result = {"status": "rebuild_timeout" if outcome == "timeout" else "rebuild_failed", "error": {"type": error_type, "message": message}}
write_json(rebuild_path, result)
status["rebuild_status"] = result["status"]; status["status"] = result["status"]
status[status_key] = result["status"]
if not is_prefix:
status["status"] = result["status"]
write_json(status_path, status); return status
+99 -2
View File
@@ -54,17 +54,109 @@ def query_ast(value: Any) -> dict[str, Any] | list[Any] | str | float | bool | N
return str(value)
_SET_COMBINATORS = {
"qUnion": "union",
"qIntersection": "intersection",
"qSubtraction": "subtraction",
}
_FILTERS = {
"qAdjacent": "adjacent",
"qOwnerBody": "owner_body",
"qBodyType": "body_type",
"qConstructionFilter": "construction",
}
def _query_expression_node(value: Any) -> dict[str, Any]:
"""Return a typed, lossless-enough representation of a source query.
``source_query.ast`` remains the source-level record, including parser line
numbers. This second representation makes set boundaries and filters
explicit so a later resolver can interpret them without re-parsing
FeatureScript text. Unknown calls stay opaque instead of being flattened
into a nearby supported topology query.
"""
if isinstance(value, Call):
name = value.name
args = value.args
if name in _SET_COMBINATORS:
operands = args[0] if len(args) == 1 and isinstance(args[0], list) else args
return {
"node": "set",
"operator": _SET_COMBINATORS[name],
"operands": [_query_expression_node(item) for item in operands],
}
if name in _FILTERS:
return {
"node": "filter",
"filter": _FILTERS[name],
"input": _query_expression_node(args[0]) if args else {"node": "literal", "value": None},
"arguments": [_query_expression_node(item) for item in args[1:]],
}
if name == "makeQuery":
return {
"node": "topology_query",
"owner": _query_expression_node(args[0]) if len(args) > 0 else {"node": "literal", "value": None},
"topology_type": _query_expression_node(args[1]) if len(args) > 1 else {"node": "literal", "value": None},
"entity_type": _query_expression_node(args[2]) if len(args) > 2 else {"node": "literal", "value": None},
"arguments": [_query_expression_node(item) for item in args[3:]],
}
if name == "qCreatedBy":
return {
"node": "created_by",
"owner": _query_expression_node(args[0]) if args else {"node": "literal", "value": None},
"arguments": [_query_expression_node(item) for item in args[1:]],
}
if name in {"sQuery", "sketchEntityQuery"}:
return {
"node": "source_entity",
"sketch": _query_expression_node(args[0]) if len(args) > 0 else {"node": "literal", "value": None},
"entity_type": _query_expression_node(args[1]) if len(args) > 1 else {"node": "literal", "value": None},
"entity": _query_expression_node(args[2]) if len(args) > 2 else {"node": "literal", "value": None},
"arguments": [_query_expression_node(item) for item in args[3:]],
}
if name == "qSketchRegion":
return {
"node": "sketch_region",
"sketch": _query_expression_node(args[0]) if args else {"node": "literal", "value": None},
"arguments": [_query_expression_node(item) for item in args[1:]],
}
return {
"node": "opaque_call",
"name": name,
"arguments": [_query_expression_node(item) for item in args],
}
if isinstance(value, list):
return {"node": "list", "items": [_query_expression_node(item) for item in value]}
if isinstance(value, dict):
return {
"node": "map",
"entries": [
{"key": str(key), "value": _query_expression_node(item)}
for key, item in value.items()
],
}
if value is None or isinstance(value, (str, float, bool, int)):
return {"node": "literal", "value": value}
return {"node": "literal", "value": str(value)}
def query_expr(value: Any) -> dict[str, Any]:
"""Produce the versioned CDSL query-expression contract for one query."""
return {"version": "1.0", "root": _query_expression_node(value)}
def parse_query(value: Any) -> QueryInfo:
info = QueryInfo(ast=query_ast(value))
for call in walk_calls(value):
info.calls.append(call.name)
if call.name in {"qUnion", "qIntersection", "qSubtraction", "qAdjacent"}:
if call.name in {"qUnion", "qIntersection", "qSubtraction"}:
info.query_combinators.append(call.name)
if call.name in {"qBodyType", "qOwnerBody"}:
info.body_scope.append(call.name)
if call.name in {"TDD", "trueDependencyDisambiguation"}:
info.disambiguation.append(call.name)
if call.name in {"qBodyType", "qOwnerBody", "qAdjacent"}:
if call.name in {"qBodyType", "qOwnerBody", "qAdjacent", "qConstructionFilter"}:
info.filters.append(call.name)
if call.name in {"makeQuery", "qCreatedBy"} and call.args:
owner = symbolic_string(call.args[0])
@@ -76,6 +168,11 @@ def parse_query(value: Any) -> QueryInfo:
definition = next((arg for arg in call.args if isinstance(arg, dict)), {})
if isinstance(definition.get("isStart"), str): info.is_start = definition["isStart"].lower() == "true"
elif "isStart" in definition: info.is_start = bool(definition["isStart"])
elif call.name == "qCreatedBy" and len(call.args) > 1 and info.topology_type is None:
# qCreatedBy is itself a typed FeatureScript query. Keep its
# requested kind so consumers can distinguish a datum plane
# from a point without inspecting feature IDs or geometry.
info.kind = str(call.args[1]).lower()
if call.name in {"sQuery", "sketchEntityQuery"} and len(call.args) >= 3:
sketch = symbolic_string(call.args[0]); info.source_sketch = sketch.split(".", 1)[0]
if info.topology_type is None: info.kind = str(call.args[1]).lower()
+92 -38
View File
@@ -6,36 +6,38 @@ import re
from typing import Any
def _last_executable_prefix(
cdsl: dict[str, Any],
failed_feature_id: str | None,
output: Path,
) -> dict[str, Any] | None:
"""Bind and export the longest verified prefix before a failed feature."""
from .selector_binding import bind_candidate_selectors
from engine.cdsl_engine.runtime import rebuild_cdsl
def _last_executable_prefix(error: Exception, output: Path) -> dict[str, Any] | None:
"""Export the checkpoint already produced by the failed live replay."""
execution = getattr(error, "incremental_execution", None)
bound = getattr(error, "bound_cdsl", None)
if execution is None or not isinstance(bound, dict):
return None
session = execution.session
if not session.results or (session.body is None and not session.surface_members):
return None
from engine.cdsl_engine.runtime import finalize_cdsl_execution
features = list(cdsl.get("features") or [])
failed_index = next(
(index for index, feature in enumerate(features) if feature.get("id") == failed_feature_id),
len(features),
try:
result = finalize_cdsl_execution(execution, output)
except Exception:
return None
last_feature_id = next(reversed(session.results))
features = list(bound.get("features") or [])
feature_count = next(
(index + 1 for index, feature in enumerate(features) if feature.get("id") == last_feature_id),
0,
)
for feature_count in range(failed_index, 0, -1):
prefix = deepcopy(cdsl)
prefix["features"] = features[:feature_count]
try:
bound_prefix, _binding = bind_candidate_selectors(prefix)
result = rebuild_cdsl(bound_prefix, output, strict=True)
except Exception:
continue
return {
"failed_feature_id": failed_feature_id,
"feature_count": feature_count,
"last_feature_id": str(features[feature_count - 1].get("id") or ""),
"bound_cdsl": bound_prefix,
"result": result,
}
return None
if feature_count == 0:
return None
prefix = deepcopy(bound)
prefix["features"] = features[:feature_count]
return {
"failed_feature_id": getattr(error, "failed_feature_id", None) or _failed_feature_id(error),
"feature_count": feature_count,
"last_feature_id": last_feature_id,
"bound_cdsl": prefix,
"result": result,
}
def _failed_feature_id(error: Exception) -> str | None:
@@ -47,23 +49,75 @@ def _failed_feature_id(error: Exception) -> str | None:
return match.group(1) if match else None
def _last_preflight_executable_prefix(
cdsl: dict[str, Any], analysis: Any, output: Path,
) -> dict[str, Any] | None:
"""Persist the contiguous executable prefix rejected by later preflight."""
feature_results = list(getattr(analysis, "feature_results", ()) or ())
first_blocked = next(
(index for index, result in enumerate(feature_results) if not result.executable),
None,
)
if first_blocked in (None, 0):
return None
features = list(cdsl.get("features") or [])
if first_blocked > len(features):
return None
prefix = deepcopy(cdsl)
prefix["features"] = features[:first_blocked]
try:
prefix_result = rebuild_candidate(prefix, output)
except Exception:
return None
if prefix_result.get("status") != "rebuilt":
nested_prefix = prefix_result.get("last_executable_prefix")
return nested_prefix if isinstance(nested_prefix, dict) else None
result = prefix_result.get("result")
if not isinstance(result, dict):
return None
return {
"failed_feature_id": feature_results[first_blocked].feature_id,
"feature_count": first_blocked,
"last_feature_id": features[first_blocked - 1].get("id"),
"bound_cdsl": prefix_result.get("bound_cdsl", prefix),
"result": result,
}
def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
from engine.cdsl_engine.runtime import analyze_cdsl, rebuild_cdsl
from .selector_binding import bind_candidate_selectors
from engine.cdsl_engine.runtime import analyze_cdsl, finalize_cdsl_execution
from .selector_binding import bind_and_execute_candidate_selectors
analysis = analyze_cdsl(cdsl)
analysis_dict = analysis.as_dict() if hasattr(analysis, "as_dict") else {"runtime_eligible": analysis.runtime_eligible}
if not analysis.runtime_eligible:
return {"status": "runtime_ineligible", "analysis": analysis_dict}
bound: dict[str, Any] | None = None
result = {"status": "runtime_ineligible", "analysis": analysis_dict}
prefix = _last_preflight_executable_prefix(cdsl, analysis, output)
if prefix is not None:
result["last_executable_prefix"] = prefix
return result
try:
bound, binding = bind_candidate_selectors(cdsl)
result = rebuild_cdsl(bound, output, strict=True)
return {"status": "rebuilt", "analysis": analysis_dict, "selector_binding": binding, "bound_cdsl": bound, "result": result}
replay = bind_and_execute_candidate_selectors(cdsl)
result = finalize_cdsl_execution(replay.execution, output)
return {
"status": "rebuilt",
"analysis": analysis_dict,
"selector_binding": replay.evidence,
"bound_cdsl": replay.bound_cdsl,
"result": result,
}
except Exception as exc:
detail = {"type": type(exc).__name__, "message": str(exc)}
if hasattr(exc, "selector_resolutions"): detail["selector_resolutions"] = exc.selector_resolutions
prefix = _last_executable_prefix(cdsl, _failed_feature_id(exc), output)
if hasattr(exc, "selector_resolutions"):
detail["selector_resolutions"] = exc.selector_resolutions
prefix = _last_executable_prefix(exc, output)
result = {"status": "rebuild_failed", "analysis": analysis_dict, "error": detail}
if bound is not None: result["bound_cdsl"] = bound
bound = getattr(exc, "bound_cdsl", None)
if isinstance(bound, dict):
result["bound_cdsl"] = bound
binding = getattr(exc, "selector_binding", None)
if isinstance(binding, list):
result["selector_binding"] = binding
if prefix is not None: result["last_executable_prefix"] = prefix
return result
+11 -6
View File
@@ -190,10 +190,14 @@ def generate_markdown_report(
key=lambda item: (-item[1], item[0]),
)
unsupported_ops = {
"shell", "sweep", "draft", "thicken", "split", "booleanBodies", "circularPattern",
"moveFace", "replaceFace", "deleteFace", "import", "derive",
}
operation_registry = _read_optional_json(output / "operation_registry.json") or {}
registry_operations = operation_registry.get("operations") or {}
planned_unobserved_operations = sorted(
name for name, value in registry_operations.items()
if isinstance(value, dict)
and value.get("roadmap_work_item") is True
and value.get("state") == "not_observed_in_current_source"
)
exact_mappings = {
"extrude": "extrude_add_blind / extrude_add_two_sided / extrude_cut_blind",
"loft": "loft_add (simple closed sketch profiles only)",
@@ -312,9 +316,9 @@ def generate_markdown_report(
"",
*_table(["FeatureScript operation", "CDSL atomic policy"], sorted(exact_mappings.items())),
"",
"Known unsupported FeatureScript operations recorded as capability gaps:",
"Roadmap operation work items absent from the scanned FeatureScript source:",
"",
", ".join(sorted(unsupported_ops)),
", ".join(planned_unobserved_operations) if planned_unobserved_operations else "- None recorded; inspect operation_registry.json for observed work items.",
"",
"Engine executor atomic IDs:",
"",
@@ -345,6 +349,7 @@ def generate_markdown_report(
f"- Manifest: `{output / 'manifest.jsonl'}`",
f"- Summary JSON: `{output / 'summary.json'}`",
f"- Capability gaps JSON: `{output / 'capability_gaps.json'}`",
f"- Source operation registry: `{output / 'operation_registry.json'}`",
f"- Unsupported capabilities Markdown: `{output / 'unsupported_capabilities.md'}`",
f"- Comparison CSV: `{output / 'comparison_summary.csv'}`",
"",
+233 -140
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import math
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
import tempfile
from typing import Any
@@ -95,6 +96,14 @@ def _binding_targets(selector: dict[str, Any]):
yield selector
def _runtime_selector(placeholder: dict[str, Any]) -> dict[str, Any]:
"""Convert a legacy prefix placeholder to the runtime selector contract."""
selector = dict(placeholder)
selector.setdefault("source", "runtime_snapshot")
selector.setdefault("confidence", 1.0)
return selector
def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
bound = []
for record in records:
@@ -109,160 +118,244 @@ def _bound_selector(placeholder: dict[str, Any], records: list[dict[str, Any]])
return bound[0], bound
def _selector_roots(feature: dict[str, Any]) -> list[dict[str, Any]]:
roots = list(feature.get("selectors") or [])
for name in ("end_condition", "reverse_end_condition"):
condition = (feature.get("params") or {}).get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
# A source-sketch vertex is an immutable extent datum, not a topology
# selector. It has no runtime record to bind; the extent executor
# consumes its validated point directly.
if isinstance(reference, dict) and reference.get("kind") != "source_vertex":
roots.append(reference)
return roots
def _selector_key(selector: dict[str, Any]) -> tuple[Any, ...]:
stable_id = selector.get("stable_id")
if stable_id is not None:
return ("stable_id", str(stable_id))
source = selector.get("output_role_source")
return (
"output_role",
selector.get("owner_feature_id"),
selector.get("kind"),
selector.get("output_role"),
source.get("owner_feature_id") if isinstance(source, dict) else None,
source.get("output_role") if isinstance(source, dict) else None,
)
def _bind_feature_selectors(
feature: dict[str, Any],
*,
registry: Any,
body_id_for_feature: dict[str, str | None],
) -> list[dict[str, Any]]:
"""Bind one feature immediately before it runs in the shared session."""
targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)]
resolved: list[dict[str, Any]] = []
for placeholder in targets:
binding_feature_id = placeholder.get("binding_feature_id")
if binding_feature_id is None:
active_body_id = body_id_for_feature.get("__current__")
else:
if not isinstance(binding_feature_id, str) or binding_feature_id not in body_id_for_feature:
raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
active_body_id = body_id_for_feature[binding_feature_id]
intent = placeholder.get("selector_intent")
runtime_selector = _runtime_selector(placeholder)
resolution = registry.resolve(runtime_selector, active_body_id=active_body_id)
# Legacy owner-qualified, geometry-free context selectors predate the
# runtime's canonical evidence fields. Their compatibility contract
# permits a unique active context object, but never relaxes a
# provenance, output-role, or instance-locked selector.
if (
resolution.status != "resolved"
and not isinstance(intent, dict)
and not runtime_selector.get("owner_match_required")
and not runtime_selector.get("output_role")
):
fallback = dict(runtime_selector)
fallback.pop("owner_feature_id", None)
resolution = registry.resolve(fallback, active_body_id=active_body_id)
if resolution.status != "resolved":
code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}"
raise ValueError(f"{feature['id']}: {code} during incremental replay")
selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ()))
if not selected:
raise ValueError(f"{feature['id']}: selector_not_found during incremental replay")
public_records = [record.public_dict() for record in selected]
# Operation-role and provenance selectors stay declarative in bound
# CDSL. A runtime record ID is execution evidence, never their durable
# semantic replacement.
if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"):
resolved.extend(public_records)
continue
selector, bound_selectors = _bound_selector(placeholder, public_records)
placeholder.clear()
placeholder.update(selector)
resolved.extend(bound_selectors)
selectors = feature.get("selectors") or []
feature["selectors"] = list({_selector_key(selector): selector for selector in selectors}.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0]
return resolved
@dataclass
class IncrementalBindingReplay:
"""Bound candidate plus the one session that produced its evidence."""
bound_cdsl: dict[str, Any]
evidence: list[dict[str, Any]]
execution: Any
def bind_and_execute_candidate_selectors(cdsl: dict[str, Any]) -> IncrementalBindingReplay:
"""Bind and execute a CADFS candidate in one ordered kernel replay.
A feature is bound only against topology facts registered by earlier
features in this session. Historical ``binding_feature_id`` values select
a retained snapshot ID, so the binder never needs to re-run a prefix or
recreate an OCC body. Resolver failures retain the live session for the
caller to export its last executable checkpoint.
"""
from engine.cdsl_engine.runtime import prepare_cdsl_execution
bound = deepcopy(cdsl)
execution = prepare_cdsl_execution(bound)
if not execution.analysis.runtime_eligible:
first = next((result for result in execution.analysis.feature_results if not result.executable), None)
code = first.blockers[0].code if first and first.blockers else "runtime_ineligible"
raise ValueError(code)
bound_features = {str(feature.get("id") or ""): feature for feature in bound.get("features") or []}
body_id_for_feature: dict[str, str | None] = {"__current__": None}
evidence: list[dict[str, Any]] = []
for index, node in enumerate(execution.analysis.plan):
feature = node.source_feature
try:
resolved = _bind_feature_selectors(
feature,
registry=execution.session.topology,
body_id_for_feature=body_id_for_feature,
)
public_feature = bound_features[node.feature_id]
public_feature["selectors"] = deepcopy(feature.get("selectors") or [])
public_feature["params"] = deepcopy(feature.get("params") or {})
evidence.append({
"feature_id": node.feature_id,
"prefix_feature_count": index,
"selectors": public_feature["selectors"],
"resolved": resolved,
})
execution.execute_next(strict=True)
body_id_for_feature[node.feature_id] = execution.session.body_id
body_id_for_feature["__current__"] = execution.session.body_id
except Exception as error:
setattr(error, "bound_cdsl", bound)
setattr(error, "selector_binding", evidence)
setattr(error, "incremental_execution", execution)
setattr(error, "failed_feature_id", node.feature_id)
raise
return IncrementalBindingReplay(bound, evidence, execution)
def bind_candidate_selectors(cdsl: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Rebuild every selector-bearing prefix and bind against its active body."""
"""Legacy diagnostic binder for incomplete or externally supplied CDSL.
The production CADFS rebuild path uses ``bind_and_execute_candidate_selectors``.
This compatibility entry point deliberately keeps prefix replay explicit
for callers that need to inspect a partial document before it is runtime
eligible. It rehydrates exact exported topology facts and calls the same
resolver; provenance selectors never become geometry guesses here.
"""
from engine.cdsl_engine.runtime import rebuild_cdsl
bound = deepcopy(cdsl); evidence = []
from engine.cdsl_engine.topology import TopologyRegistry
bound = deepcopy(cdsl)
evidence: list[dict[str, Any]] = []
with tempfile.TemporaryDirectory(prefix="cadfs-bind-") as temporary:
for index, feature in enumerate(bound.get("features") or []):
roots = list(feature.get("selectors") or [])
for name in ("end_condition", "reverse_end_condition"):
condition = (feature.get("params") or {}).get(name)
reference = condition.get("reference") if isinstance(condition, dict) else None
if isinstance(reference, dict): roots.append(reference)
targets = [target for root in roots for target in _binding_targets(root)]
if not targets: continue
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None]] = {}
targets = [target for root in _selector_roots(feature) for target in _binding_targets(root)]
if not targets:
continue
prefix_cache: dict[int, tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]] = {}
def prefix_records(binding_feature_id: str | None, owner_feature_id: str | None) -> list[dict[str, Any]]:
def prefix_snapshot(binding_feature_id: str | None) -> tuple[list[dict[str, Any]], str | None, list[dict[str, Any]]]:
prefix_count = index
if binding_feature_id is not None:
binding_index = next((item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id), None)
if binding_index is None: raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
binding_index = next(
(item_index for item_index, item in enumerate(bound["features"][:index]) if item["id"] == binding_feature_id),
None,
)
if binding_index is None:
raise ValueError(f"{feature['id']}: selector binding feature is missing or forward")
prefix_count = binding_index + 1
if prefix_count not in prefix_cache:
prefix = deepcopy(bound); prefix["features"] = bound["features"][:prefix_count]
if not prefix["features"]: raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(prefix, Path(temporary) / f"prefix-{index}-{prefix_count}.step", strict=True)
body_id = next((item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")), None)
prefix_cache[prefix_count] = (list(report.get("topology_records") or []), body_id)
all_records, body_id = prefix_cache[prefix_count]
return [
item for item in all_records
# Reference planes and axes are session context, not body
# topology. They must remain available while binding a mirror
# or extent selector against a body-bearing prefix.
if item.get("kind") in {"plane", "axis"}
or not body_id
or item.get("body_id") == body_id
or str(item.get("body_id") or "").startswith(f"{body_id}:")
]
resolved = []
for placeholder in targets:
records = prefix_records(placeholder.get("binding_feature_id"), placeholder.get("owner_feature_id"))
output_role = str(placeholder.get("output_role") or "").strip()
intent = placeholder.get("selector_intent")
if (
isinstance(intent, dict)
and intent.get("query_family") != "GEOMETRIC"
and not output_role
):
# Prefix rebuilding is retained as a diagnostic adapter.
# It must not turn an unproven FeatureScript provenance
# query into a geometry-scored stable selector.
raise ValueError(
f"{feature['id']}: selector_kernel_history_missing after prefix rebuild"
prefix = deepcopy(bound)
prefix["features"] = bound["features"][:prefix_count]
if not prefix["features"]:
raise ValueError(f"{feature['id']}: selector has no executable prefix")
report = rebuild_cdsl(
prefix,
Path(temporary) / f"prefix-{index}-{prefix_count}.step",
strict=True,
)
if output_role:
# Builder output roles are not geometry placeholders. They
# remain in the bound CDSL so runtime can resolve the
# current active B-rep face through exact kernel history.
# Replacing one with stable_id/geometry would mix evidence
# and make a stale snapshot appear durable.
owner = placeholder.get("owner_feature_id")
role_source = placeholder.get("output_role_source")
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
candidates = [
record for record in records
if record.get("kind") == placeholder.get("kind")
and owner in (record.get("owner_feature_ids") or [record.get("feature_id")])
and output_role in (record.get("output_roles") or [])
and (
role_source is None
or any(
item.get("output_role") == output_role
and item.get("owner_feature_id") == source_owner
and item.get("source_output_role") == source_role
for item in record.get("output_role_sources") or []
)
)
]
if len(candidates) != 1:
status = "not_found" if not candidates else "ambiguous"
raise ValueError(
f"{feature['id']}: selector_output_role_{status} after prefix rebuild"
)
resolved.append(candidates[0])
continue
geometry = placeholder.get("geometry") or {}
candidates = _circle_records(geometry, records) if geometry.get("source_circle_radius_mm") else []
if not candidates:
same_kind = [record for record in records if record.get("kind") == placeholder.get("kind")]
owner = placeholder.get("owner_feature_id")
owner_matches = [record for record in same_kind if owner in (record.get("owner_feature_ids") or [record.get("feature_id")])]
# Pattern instance provenance is stronger than an ordinary
# feature owner: an unresolved instance cannot fall back to
# an aggregate face with matching geometry. Ordinary source
# selectors retain their established geometry-binding path
# across topology-changing dress-up operations.
pool = owner_matches if placeholder.get("owner_match_required") else (owner_matches or same_kind)
if not geometry:
# Context selectors (notably a generated mirror plane)
# may have no geometric snapshot. Their owner-qualified
# singleton identity is sufficient and must not be scored
# as a zero-information geometric match.
if len(pool) != 1:
status = "not_found" if not pool else "ambiguous"
raise ValueError(f"{feature['id']}: selector_{status} after prefix rebuild")
candidates = [pool[0]]
else:
scored = [(score, record) for record in pool if (score := _score(geometry, record.get("geometry") or {})) is not None and score >= 0.8]
# A source owner is preferred for ordinary geometry
# selectors, but can be retained by an unrelated exact
# continuation after a dress-up. If none of that
# owner's active records satisfies the full geometry
# signature, bind against the current active body and
# still require a unique threshold-qualified match.
# Instance-qualified selectors never take this path.
if not scored and not placeholder.get("owner_match_required"):
scored = [
(score, record)
for record in same_kind
if (score := _score(geometry, record.get("geometry") or {})) is not None
and score >= 0.8
]
scored.sort(key=lambda value: (-value[0], str(value[1].get("record_id"))))
if scored:
if placeholder.get("match_mode") != "all" and len(scored) > 1 and abs(scored[0][0] - scored[1][0]) <= 1e-9:
raise ValueError(f"{feature['id']}: selector_ambiguous after prefix rebuild")
candidates = [item[1] for item in scored] if placeholder.get("match_mode") == "all" else [scored[0][1]]
if not candidates: raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
selector, bound_selectors = _bound_selector(placeholder, candidates)
placeholder.clear(); placeholder.update(selector)
resolved.extend(bound_selectors)
feature_selectors = feature.get("selectors") or []
def selector_key(selector: dict[str, Any]) -> tuple[Any, ...]:
stable_id = selector.get("stable_id")
if stable_id is not None:
return ("stable_id", str(stable_id))
source = selector.get("output_role_source")
return (
"output_role",
selector.get("owner_feature_id"),
selector.get("kind"),
selector.get("output_role"),
source.get("owner_feature_id") if isinstance(source, dict) else None,
source.get("output_role") if isinstance(source, dict) else None,
)
body_id = next(
(item.get("body_id") for item in reversed(report.get("feature_results") or []) if item.get("body_id")),
None,
)
prefix_cache[prefix_count] = (
list(report.get("topology_records") or []),
body_id,
list(report.get("topology_deltas") or []),
)
return prefix_cache[prefix_count]
unique = {selector_key(selector): selector for selector in feature_selectors}
feature["selectors"] = list(unique.values())
resolved: list[dict[str, Any]] = []
for placeholder in targets:
records, body_id, topology_deltas = prefix_snapshot(placeholder.get("binding_feature_id"))
registry = TopologyRegistry.from_public_snapshot(records, topology_deltas)
intent = placeholder.get("selector_intent")
runtime_selector = _runtime_selector(placeholder)
resolution = registry.resolve(runtime_selector, active_body_id=body_id)
if (
resolution.status != "resolved"
and not isinstance(intent, dict)
and not runtime_selector.get("owner_match_required")
and not runtime_selector.get("output_role")
):
fallback = dict(runtime_selector)
fallback.pop("owner_feature_id", None)
resolution = registry.resolve(fallback, active_body_id=body_id)
if resolution.status != "resolved":
code = resolution.diagnostic.code if resolution.diagnostic is not None else f"selector_{resolution.status}"
raise ValueError(f"{feature['id']}: {code} after prefix rebuild")
selected = list(resolution.records or ((resolution.record,) if resolution.record is not None else ()))
if not selected:
raise ValueError(f"{feature['id']}: selector_not_found after prefix rebuild")
public_records = [record.public_dict() for record in selected]
if (isinstance(intent, dict) and intent.get("query_family") != "GEOMETRIC") or placeholder.get("output_role"):
resolved.extend(public_records)
continue
selector, bound_selectors = _bound_selector(placeholder, public_records)
placeholder.clear()
placeholder.update(selector)
resolved.extend(bound_selectors)
feature["selectors"] = list({_selector_key(selector): selector for selector in feature.get("selectors") or []}.values())
if feature.get("atomic_id") == "pattern_mirror":
planes = [selector for selector in feature["selectors"] if selector.get("kind") == "plane"]
if len(planes) != 1:
raise ValueError(f"{feature['id']}: mirror plane binding is not unique")
feature.setdefault("params", {})["mirror_plane"] = planes[0]
evidence.append({"feature_id": feature["id"], "prefix_feature_count": index, "selectors": feature["selectors"], "resolved": resolved})
evidence.append({
"feature_id": feature["id"],
"prefix_feature_count": index,
"selectors": feature["selectors"],
"resolved": resolved,
})
return bound, evidence
File diff suppressed because it is too large Load Diff
+281 -67
View File
@@ -10,7 +10,7 @@ from cadfs_to_cdsl.rebuild import rebuild_candidate
class IntegrationTests(unittest.TestCase):
def test_known_rp_roundtrip_00000173(self):
def test_known_rp_source_preserves_prefix_when_selector_query_is_deferred(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00000173.txt"; gold = root / "step_abc/0000/00000173.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
@@ -18,11 +18,9 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
result = rebuild_candidate(cdsl, rebuilt)
self.assertEqual(result["status"], "rebuilt")
comparison = compare_steps(gold, rebuilt)
self.assertFalse(comparison["strict"]["passed"])
self.assertTrue(comparison["rp"]["passed"])
self.assertEqual(comparison["decision"], "approximate_pass")
self.assertEqual(result["status"], "rebuild_failed")
self.assertEqual(result["error"]["message"], "f_F2: selector_query_unsupported during incremental replay")
self.assertEqual(result["last_executable_prefix"]["last_feature_id"], "f_F1")
def test_standard_tapped_through_counterbore_00002243(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -43,9 +41,31 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
self.assertEqual(rebuild_candidate(result.cdsl, rebuilt)["status"], "rebuilt")
comparison = compare_steps(root / "step_abc/0000/00002243.step", rebuilt)
self.assertTrue(comparison["strict"]["passed"])
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3")
def test_direct_sketch_vertex_holes_preserve_scoped_body_00406667(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0040/00406667.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00406667"), {}).cdsl
holes = [item for item in candidate["features"] if item["atomic_id"] == "hole_wizard"]
self.assertEqual([item["id"] for item in holes], ["f_F3", "f_F5"])
self.assertEqual([item["params"]["scope_feature_id"] for item in holes], ["f_F1", "f_F1"])
self.assertEqual([item["params"]["positions"] for item in holes], [
[{"mm": [0.0, 0.0, 0.0]}], [{"mm": [0.0, 26.69, 0.0]}],
])
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(
[item["feature_id"] for item in outcome["result"]["feature_results"]],
["f_F1", "f_F3", "f_F5"],
)
def test_rectilinear_fillet_arcs_00129362_preserve_recovered_sketch_geometry(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -75,9 +95,15 @@ class IntegrationTests(unittest.TestCase):
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F3")
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(fillet["selectors"][0]["kind"], "face")
self.assertNotIn("geometry", fillet["selectors"][0])
self.assertEqual(fillet["selectors"][0]["selector_intent"]["query_family"], "SWEPT_FACE")
self.assertEqual(
fillet["selectors"][0]["geometry"],
{"axis_origin_mm": [0.0, 0.0, 0.0], "axis_direction": [0.0, -1.0, 0.0], "radius_mm": 45.66},
fillet["selectors"][0]["selector_intent"]["source_entity"],
{"sketch_id": "F0", "entity_id": "E0"},
)
self.assertEqual(
fillet["selectors"][0]["selector_intent"]["derivation_policy"],
{"allowed": ["boundary"], "multiplicity": "one"},
)
chamfer = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
@@ -98,21 +124,198 @@ class IntegrationTests(unittest.TestCase):
[4.48, 2.36],
)
self.assertNotIn("result_mode", next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"])
self.assertEqual(
next(item for item in result.cdsl["features"] if item["id"] == "f_F7")["params"]["result_mode"],
"new_body",
)
self.assertEqual(next(item for item in result.cdsl["features"] if item["id"] == "f_F9")["params"]["result_mode"], "new_body")
from build123d import import_step
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
runtime_box = outcome["result"]["bbox_mm"]
imported_box = import_step(str(rebuilt)).bounding_box()
actual = [imported_box.min.X, imported_box.min.Y, imported_box.min.Z, imported_box.max.X, imported_box.max.Y, imported_box.max.Z]
expected = runtime_box["min"] + runtime_box["max"]
for value, target in zip(actual, expected): self.assertAlmostEqual(value, target, places=5)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F5: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F4")
def test_inward_shells_00789939_lower_and_rebuild(self):
def test_swept_face_extent_continues_through_primary_cuts_00925274(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0092/00925274.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}).cdsl
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
result = outcome["result"]
self.assertEqual(
[item["feature_id"] for item in result["feature_results"]],
["f_F1", "f_F3", "f_F4", "f_F6", "f_F7", "f_F9", "f_F10", "f_F12", "f_F13", "f_F15", "f_F17", "f_F18_plane", "f_F18"],
)
f9_resolution = next(
item for item in result["selector_resolution"] if item["feature_id"] == "f_F9"
)
self.assertEqual(f9_resolution["status"], "resolved")
self.assertEqual(f9_resolution["resolution_mode"], "kernel_lineage")
self.assertEqual(f9_resolution["evidence"]["result_records"], ["body:f_F6:face:0"])
self.assertEqual(
[item["feature_id"] for item in f9_resolution["evidence"]["relations"]],
["f_F1", "f_F3", "f_F6"],
)
def test_direct_hole_prism_swept_edge_resolves_00007264(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00007264.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00007264"), {})
fillet = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertEqual(len(fillet["selectors"]), 1)
query_set = fillet["selectors"][0]
self.assertEqual(query_set["selector_intent"]["query_family"], "QUERY_SET")
self.assertEqual(query_set["selector_intent"]["query_set_contract"], "proven_operand_union")
self.assertTrue(all(
operand["selector_intent"]["query_family"] == "SWEPT_EDGE"
for operand in query_set["query_operands"]
))
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(result.cdsl, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
binding = next(item for item in outcome["selector_binding"] if item["feature_id"] == "f_F2")
self.assertEqual(len(binding["resolved"]), 4)
resolution = next(item for item in outcome["result"]["selector_resolution"] if item["feature_id"] == "f_F2")
self.assertEqual(resolution["resolution_mode"], "query_set_union")
vertical_edges = [
relation
for delta in outcome["result"]["topology_deltas"]
if not any(str(snapshot_id).startswith("transient:") for snapshot_id in delta["output_snapshot_ids"])
for relation in delta["relations"]
if relation["source_kind"] == "vertex"
and relation["result_kind"] == "edge"
and relation["source_record_ids"]
]
self.assertEqual(
len({(tuple(item["source_record_ids"]), tuple(item["result_record_ids"])) for item in vertical_edges}),
4,
)
self.assertTrue(all(
relation["coverage"] == "complete" and relation["lineage_status"] == "proven"
for relation in vertical_edges
))
def test_annular_swept_face_selector_00974931(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0097/00974931.txt"
gold = root / "step_abc/0097/00974931.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00974931"), {}).cdsl
fillet = next(item for item in candidate["features"] if item["id"] == "f_F2")
selector = fillet["selectors"][0]
self.assertEqual(fillet["atomic_id"], "fillet")
self.assertNotIn("geometry", selector)
self.assertNotIn("stable_id", selector)
self.assertEqual(selector["selector_intent"]["query_family"], "SWEPT_FACE")
self.assertEqual(
selector["selector_intent"]["source_entity"],
{"sketch_id": "F0", "entity_id": "E0"},
)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertEqual(outcome["status"], "rebuild_failed")
f2_resolution = next(
item for item in outcome["last_executable_prefix"]["result"]["selector_resolution"]
if item["feature_id"] == "f_F2"
)
self.assertEqual(f2_resolution["status"], "resolved")
self.assertEqual(f2_resolution["resolution_mode"], "kernel_lineage")
self.assertEqual(f2_resolution["evidence"]["semantic_anchor"], {
"type": "source_entity", "sketch_id": "F0", "entity_id": "E0",
})
self.assertEqual(
[item["feature_id"] for item in outcome["last_executable_prefix"]["result"]["feature_results"]],
["f_F1", "f_F2"],
)
self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay")
def test_direct_prism_cap_edge_lineage_00021014(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0002/00021014.txt"
gold = root / "step_abc/0002/00021014.step"
if not feature.exists() or not gold.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00021014"), {}).cdsl
fillet = next(item for item in candidate["features"] if item["id"] == "f_F2")
cap_edges = [
selector for selector in fillet["selectors"]
if (selector.get("selector_intent") or {}).get("query_family") == "CAP_EDGE"
]
self.assertEqual(len(cap_edges), 9)
self.assertTrue(all("stable_id" not in selector and "geometry" not in selector for selector in cap_edges))
self.assertEqual(
{selector["selector_intent"]["lineage_role"] for selector in cap_edges},
{"extrude.start", "extrude.end"},
)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
comparison = compare_steps(gold, rebuilt)
cap_resolutions = [
item for item in outcome["result"]["selector_resolution"]
if item.get("feature_id") == "f_F2"
and item.get("evidence", {}).get("semantic_anchor", {}).get("type") == "source_entity"
]
self.assertEqual(len(cap_resolutions), 10)
direct_caps = [
item for item in cap_resolutions
if item["evidence"]["relations"][0].get("output_role") in {"extrude.start", "extrude.end"}
]
self.assertEqual(len(direct_caps), 9)
self.assertTrue(all(item["resolution_mode"] == "kernel_lineage" for item in direct_caps))
self.assertTrue(comparison["strict"]["passed"])
def test_direct_mixed_hole_cap_edge_lineage_00735367(self):
"""A solver-split circular hole restores one proven source cap edge."""
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0073/00735367.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00735367"), {}).cdsl
fillet = next(item for item in candidate["features"] if item["id"] == "f_F2")
selector = fillet["selectors"][0]
self.assertEqual(selector["selector_intent"]["query_family"], "CAP_EDGE")
self.assertEqual(selector["selector_intent"]["source_entity"], {"sketch_id": "F0", "entity_id": "E1"})
self.assertEqual(selector["selector_intent"]["lineage_role"], "extrude.end")
self.assertNotIn("geometry", selector)
self.assertNotIn("stable_id", selector)
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(candidate, rebuilt)
self.assertTrue(rebuilt.exists())
self.assertEqual(outcome["status"], "runtime_ineligible")
prefix = outcome["last_executable_prefix"]["result"]
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F3")
self.assertEqual([item["feature_id"] for item in prefix["feature_results"]], ["f_F1", "f_F2", "f_F3"])
resolution = next(item for item in prefix["selector_resolution"] if item["feature_id"] == "f_F2")
self.assertEqual(resolution["status"], "resolved")
self.assertEqual(resolution["resolution_mode"], "kernel_lineage")
self.assertEqual(resolution["evidence"]["semantic_anchor"], {
"type": "source_entity", "sketch_id": "F0", "entity_id": "E1",
})
self.assertEqual(resolution["evidence"]["relations"][0]["output_role"], "extrude.end")
self.assertTrue(resolution["evidence"]["relations"][0]["source_record_ids"])
def test_inward_shells_00789939_preserve_prefix_when_face_query_is_deferred(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00789939.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -128,8 +331,10 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = Path(tmp) / "rebuild.step"
outcome = rebuild_candidate(result.cdsl, rebuilt)
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 2)
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["message"], "f_F6: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F5")
self.assertEqual(outcome["last_executable_prefix"]["result"]["solid_count"], 2)
def test_outward_cap_shells_lower_and_rebuild_prefixes(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -155,7 +360,10 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(prefix, Path(tmp) / "outward-shell.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(outcome["result"]["solid_count"], 1)
self.assertEqual(
[item["feature_id"] for item in outcome["result"]["feature_results"]],
["f_F1", "f_F2"],
)
def test_sweep_00542223_preserves_its_open_bspline_path(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -164,7 +372,7 @@ class IntegrationTests(unittest.TestCase):
result = lower_model(parse_featurescript(feature.read_text(), "00542223"), {})
sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
segment = sweep["params"]["path"]["segment"]
self.assertEqual(result.status, "converted_complete")
self.assertEqual(result.status, "converted_partial")
self.assertEqual(sweep["atomic_id"], "sweep_add")
self.assertEqual(sweep["sketch_id"], "sketch_F4")
self.assertEqual(segment["points"], [[-40.0, -50.0], [-20.0, -14.96], [0.0, 5.0]])
@@ -172,6 +380,44 @@ class IntegrationTests(unittest.TestCase):
self.assertEqual(segment["end_tangent"], [52.88, 49.52])
self.assertNotIn("F2", [item.get("feature_id") for item in result.diagnostics])
self.assertNotIn("F5", [item.get("feature_id") for item in result.diagnostics])
self.assertIn("F7", [item.get("feature_id") for item in result.diagnostics])
def test_direct_sketch_wire_qbodytype_path_lowers_and_rebuilds_00896761(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0089/00896761.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00896761"), {}).cdsl
sweep = next(item for item in candidate["features"] if item["id"] == "f_F2")
segment = sweep["params"]["path"]["segment"]
self.assertEqual(sweep["atomic_id"], "sweep_add")
self.assertEqual(segment["type"], "bspline")
self.assertEqual(segment["source_entity_id"], "E2")
self.assertEqual(segment["points"], [[0.0, 0.0], [87.21, 282.41]])
self.assertEqual(segment["start_tangent"], [0.0, 936.12])
self.assertEqual(segment["end_tangent"], [168.9, 48.72])
from engine.cdsl_engine.runtime import analyze_cdsl
self.assertTrue(analyze_cdsl(candidate).runtime_eligible)
with tempfile.TemporaryDirectory() as tmp:
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual([item["feature_id"] for item in outcome["result"]["feature_results"]], ["f_F2"])
def test_multi_entity_sketch_wire_qbodytype_path_lowers_as_a_source_ordered_wire_00786708(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0078/00786708.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
result = lower_model(parse_featurescript(feature.read_text(), "00786708"), {})
self.assertEqual(result.status, "converted_complete")
first_sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F2")
self.assertEqual(
[(item["source_entity_id"], item["type"]) for item in first_sweep["params"]["path"]["segments"]],
[("E1", "arc"), ("E0", "line"), ("E2.MirrorCS", "arc")],
)
later_sweep = next(item for item in result.cdsl["features"] if item["id"] == "f_F5")
self.assertEqual(later_sweep["params"]["path"]["segment"]["source_entity_id"], "E5")
def test_circular_pattern_00542223_preserves_all_sweep_arms(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
@@ -186,14 +432,13 @@ class IntegrationTests(unittest.TestCase):
with tempfile.TemporaryDirectory() as tmp:
rebuilt = rebuild_cdsl(candidate, Path(tmp) / "pattern.step")
self.assertEqual(rebuilt["solid_count"], 1)
bbox = rebuilt["bbox_mm"]
self.assertAlmostEqual(bbox["min"][0], -52.14101625137758)
self.assertAlmostEqual(bbox["min"][1], -57.500000100000065)
self.assertAlmostEqual(bbox["max"][0], 52.14101625137762)
self.assertAlmostEqual(bbox["max"][1], 37.5000001000001)
self.assertEqual(rebuilt["solid_count"], 2)
self.assertEqual(
[item["feature_id"] for item in rebuilt["feature_results"]],
["f_F1", "f_F3", "f_F5", "f_F6"],
)
def test_fused_body_circular_copy_faces_bind_and_shell_00542223(self):
def test_deferred_pattern_shell_keeps_later_executable_feature_00542223(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0054/00542223.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
@@ -203,52 +448,21 @@ class IntegrationTests(unittest.TestCase):
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
self.assertEqual(outcome["status"], "rebuilt")
result = outcome["result"]
self.assertIn("f_F7", [item["feature_id"] for item in result["feature_results"]])
shell_selectors = [
item for item in result["selector_resolution"]
if item["feature_id"] == "f_F7"
]
self.assertEqual(
{item["selector"]["owner_feature_id"] for item in shell_selectors},
{"f_F5", "f_F6.c1.f_F5", "f_F6.c2.f_F5", "f_F1"},
[item["feature_id"] for item in outcome["result"]["feature_results"]],
["f_F1", "f_F3", "f_F5", "f_F6", "f_F10"],
)
self.assertTrue(all(item["status"] == "resolved" for item in shell_selectors))
def test_face_chamfer_ignores_periodic_seams_00111611(self):
def test_face_chamfer_source_query_is_not_geometry_bound_00111611(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0011/00111611.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
from copy import deepcopy
from engine.cdsl_engine.capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract
from engine.cdsl_engine.runtime import EXECUTORS, ExecutionSession, _execute_node, _selector_edges
from engine.cdsl_engine.sketch_solver import CORE_SHAPE_GENERATORS, resolve_required_sketches
from cadfs_to_cdsl.selector_binding import bind_candidate_selectors
candidate = lower_model(parse_featurescript(feature.read_text(), "00111611"), {}).cdsl
bound, _ = bind_candidate_selectors(candidate)
resolved = resolve_required_sketches(
deepcopy(bound), sketch_ids_required_by_contract(bound), errors={},
)
analysis = CapabilityAnalyzer(
atomic_ids=EXECUTORS, profile_types=CORE_SHAPE_GENERATORS,
).analyze(resolved)
session = ExecutionSession(
sketches={str(item["id"]): item for item in resolved["geometry"]["sketches"]},
nodes={node.feature_id: node for node in analysis.plan},
)
for node in analysis.plan:
if node.feature_id == "f_F5":
edges = _selector_edges(node, session, tangent_propagation=True)
break
_execute_node(node, session)
else:
self.fail("F5 chamfer was not planned")
self.assertEqual(len(edges), 8)
self.assertTrue(all(str(edge.geom_type).endswith("CIRCLE") for edge in edges))
with self.assertRaisesRegex(ValueError, "f_F5: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(candidate)
def test_circular_remove_pattern_replays_cut_sources_00159804(self):
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
File diff suppressed because it is too large Load Diff
+57 -2
View File
@@ -4,8 +4,8 @@ import unittest
from cadfs_to_cdsl.featurescript_lexer import lex
from cadfs_to_cdsl.featurescript_parser import parse_featurescript
from cadfs_to_cdsl.ir import Call
from cadfs_to_cdsl.lowering import _queries, _source_refs
from cadfs_to_cdsl.query_parser import parse_query
from cadfs_to_cdsl.lowering import _number, _queries, _source_refs
from cadfs_to_cdsl.query_parser import parse_query, query_expr
from cadfs_to_cdsl.units import length_mm
@@ -28,6 +28,14 @@ TRANSFORM_SOURCE = SOURCE.replace(
''',
)
UNKNOWN_FEATURE_SOURCE = SOURCE.replace(
'\n});\n',
'''
assignVariable(context, id + "F2", {"name" : "diameter", "value" : 10 * mm});
});
''',
)
class ParserTests(unittest.TestCase):
def test_lexer_ignores_comments_and_preserves_lines(self):
@@ -47,6 +55,10 @@ class ParserTests(unittest.TestCase):
self.assertEqual(model.features[-1].operation, "transform")
self.assertEqual(model.features[-1].params["transformType"], "TransformType.TRANSLATION_3D")
def test_unknown_direct_context_feature_is_preserved_for_capability_diagnostics(self):
model = parse_featurescript(UNKNOWN_FEATURE_SOURCE, "unknown-feature")
self.assertEqual([(feature.feature_id, feature.operation) for feature in model.features], [("F1", "extrude"), ("F2", "assignVariable")])
def test_query_parser(self):
query = Call("makeQuery", [Call("__binary__", ["id", "+", "F1.opExtrude"]), "CAP_EDGE", "EDGE", {"isStart": False, "x": Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"])}])
value = parse_query(query)
@@ -55,13 +67,56 @@ class ParserTests(unittest.TestCase):
self.assertEqual(value.ast["call"], "makeQuery")
self.assertEqual(value.ast["args"][0]["call"], "__binary__")
def test_signed_parenthesized_scalar_preserves_unit_expression_semantics(self):
model = parse_featurescript(SOURCE.replace(
'"depth":120 * mm', '"depth":-(10 + 2) / 2 * mm', 1,
), "signed-scalar")
depth = model.features[-1].params["depth"]
self.assertEqual((depth.name, depth.args[1]), ("__binary__", "*"))
self.assertEqual(_number(depth), -6.0)
def test_query_expression_preserves_nested_set_and_filter_boundaries(self):
source = Call("qUnion", [[
Call("qConstructionFilter", [
Call("qBodyType", [
Call("qCreatedBy", [Call("__binary__", ["id", "+", "F1"]), "EDGE"]),
"BodyType.WIRE",
]),
"ConstructionObject.NO",
]),
Call("qSubtraction", [
Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E0"]),
Call("sQuery", [Call("__binary__", ["id", "+", "F0.wireOp"]), "EDGE", "E1"]),
]),
]])
expression = query_expr(source)
self.assertEqual(expression["version"], "1.0")
self.assertEqual(expression["root"]["node"], "set")
self.assertEqual(expression["root"]["operator"], "union")
filtered, subtraction = expression["root"]["operands"]
self.assertEqual((filtered["node"], filtered["filter"]), ("filter", "construction"))
self.assertEqual((filtered["input"]["node"], filtered["input"]["filter"]), ("filter", "body_type"))
self.assertEqual((subtraction["node"], subtraction["operator"]), ("set", "subtraction"))
parsed = parse_query(source)
self.assertEqual(parsed.query_combinators, ["qUnion", "qSubtraction"])
self.assertEqual(
parsed.filters,
["qConstructionFilter", "qBodyType"],
)
def test_source_version_and_standard_library_are_retained(self):
source = '''FeatureScript 1511;
import(path : "onshape/std/geometry.fs", version : "1511.0");
import(version : "1511.0", path : "onshape/std/common.fs");
export const f = defineFeature(function(context, id, definition) {});'''
model = parse_featurescript(source, "versioned")
self.assertEqual(model.featurescript_version, "1511")
self.assertEqual(model.standard_library, "onshape/std/geometry.fs")
self.assertEqual(model.standard_library_version, "1511.0")
self.assertEqual(model.standard_library_imports, [
{"path": "onshape/std/geometry.fs", "version": "1511.0"},
{"path": "onshape/std/common.fs", "version": "1511.0"},
])
def test_block_scoped_query_aliases_do_not_use_the_last_assignment(self):
source = r'''
+34 -1
View File
@@ -3,7 +3,9 @@ from __future__ import annotations
import json, tempfile, unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from cadfs_to_cdsl.reports import write_json
from cadfs_to_cdsl.dataset import Sample
from cadfs_to_cdsl.operation_registry import build_operation_registry
from cadfs_to_cdsl.reports import generate_markdown_report, write_json
class ReportTests(unittest.TestCase):
@@ -17,5 +19,36 @@ class ReportTests(unittest.TestCase):
self.assertIn(payload["index"], range(64))
self.assertEqual(list(path.parent.glob(".summary.json.*.tmp")), [])
def test_operation_registry_distinguishes_absent_roadmap_work_items(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = root / "00000001.txt"
source.write_text(
'FeatureScript 1511; export const f = defineFeature(function(context, id, definition) {'
' extrude(context, id + "F1", {"depth": 1 * mm}); });',
encoding="utf-8",
)
registry = build_operation_registry([Sample("00000001", files={"featurescript": str(source)})])
self.assertEqual(registry["schema"], "cadfs_to_cdsl.operation_registry.v1")
self.assertEqual(registry["operations"]["extrude"]["feature_count"], 1)
self.assertEqual(registry["operations"]["transform"]["state"], "not_observed_in_current_source")
self.assertEqual(registry["operations"]["thicken"]["feature_count"], 0)
def test_markdown_report_uses_source_registry_instead_of_static_operation_claims(self):
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
write_json(output / "operation_registry.json", {
"operations": {
"transform": {"roadmap_work_item": True, "state": "observed"},
"thicken": {"roadmap_work_item": True, "state": "not_observed_in_current_source"},
},
})
report = generate_markdown_report(output, [])
text = report.read_text(encoding="utf-8")
self.assertIn("thicken", text)
self.assertNotIn("draft, thicken, split", text)
if __name__ == "__main__": unittest.main()
+39 -29
View File
@@ -138,27 +138,45 @@ class SelectorBindingTests(unittest.TestCase):
self.assertEqual(len(binding["resolved"]), 1)
self.assertEqual(binding["resolved"][0]["output_roles"], ["extrude.end"])
def test_shell_offset_face_role_binds_only_its_true_dependency_source(self) -> None:
def test_rebuild_candidate_uses_one_incremental_replay(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0001/00016195.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00016195"), {})
# The production path must bind and export from one live session. If
# it called the old compatibility binder, this patched entry point
# would fail while rebuilding selector-bearing prefixes.
with tempfile.TemporaryDirectory() as directory:
with patch(
"engine.cdsl_engine.runtime.rebuild_cdsl",
side_effect=AssertionError("production rebuild invoked legacy prefix replay"),
):
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00016195.step")
self.assertEqual(outcome["status"], "rebuilt")
self.assertEqual(
[item["feature_id"] for item in outcome["result"]["feature_results"]],
[item["id"] for item in candidate.cdsl["features"]],
)
self.assertTrue(outcome["selector_binding"])
def test_shell_offset_face_preserves_true_dependency_but_rejects_unproven_cap_selector(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0010/00107631.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00107631"), {})
self.assertEqual(candidate.status, "converted_complete")
# F2 retains F1's end-cap provenance, but F3 also selects the distinct
# start-cap plane. Its direct owner has no geometry-qualified active
# candidate, so binding must consider the unique active F2 face without
# weakening the exact qualified OFFSET_FACE role.
# F2 consumes its immediate direct CAP_FACE output role. F3 retains
# its exact OFFSET_FACE true-dependency intent, but its separate
# start-cap removal selector is deferred and must not rebind
# geometrically.
prefix = deepcopy(candidate.cdsl)
prefix["features"] = prefix["features"][:3]
bound, evidence = bind_candidate_selectors(prefix)
first, offset = bound["features"][-1]["selectors"]
self.assertEqual(first["owner_feature_id"], "f_F2")
self.assertEqual(first["stable_id"], "body:f_F2:face:1")
self.assertEqual(first["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(first["source"], "runtime_snapshot")
self.assertEqual(first["confidence"], 1.0)
first, offset = prefix["features"][-1]["selectors"]
self.assertEqual(first["selector_intent"]["query_family"], "CAP_FACE")
self.assertEqual(first["selector_intent"]["derivation_policy"]["multiplicity"], "none")
self.assertEqual({key: offset[key] for key in (
"kind", "owner_feature_id", "output_role", "output_role_source", "source", "confidence",
)}, {
@@ -168,33 +186,25 @@ class SelectorBindingTests(unittest.TestCase):
})
self.assertEqual(offset["selector_intent"]["query_family"], "OFFSET_FACE")
self.assertEqual(offset["selector_intent"]["disambiguation"]["type"], "true_dependency")
binding = next(item for item in evidence if item["feature_id"] == "f_F3")
self.assertEqual(binding["resolved"][0]["snapshot_id"], "body:f_F2:face:1")
self.assertEqual(binding["resolved"][1]["record_id"], "body:f_F2:face:5")
with self.assertRaisesRegex(ValueError, "f_F3: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(prefix)
# Binding must not hide the OCC feasibility boundary. The requested
# second shell currently produces an invalid shape, so preserve the
# F2 prefix instead of changing thickness or removal faces.
with tempfile.TemporaryDirectory() as directory:
outcome = rebuild_candidate(candidate.cdsl, Path(directory) / "00107631.step")
self.assertEqual(outcome["status"], "rebuild_failed")
self.assertEqual(outcome["error"]["type"], "RuntimeExecutionError")
self.assertEqual(outcome["error"]["message"], "OCC shell operation produced an invalid shape")
self.assertEqual(outcome["error"]["message"], "f_F3: selector_query_unsupported during incremental replay")
self.assertEqual(outcome["last_executable_prefix"]["failed_feature_id"], "f_F3")
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F2")
def test_intersection_vertex_binds_a_pattern_copy_with_exact_instance_owner_evidence(self) -> None:
def test_intersection_vertex_preserves_outer_copy_provenance_without_geometry_binding(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0042/00423838.txt"
if not feature.exists(): self.skipTest("CADFS sample is not installed")
candidate = lower_model(parse_featurescript(feature.read_text(), "00423838"), {})
reference = next(item for item in candidate.cdsl["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertTrue(all(item["owner_match_required"] for item in reference["intersection_of"][:2]))
bound, evidence = bind_candidate_selectors(candidate.cdsl)
reference = next(item for item in bound["features"] if item["id"] == "f_F7")["params"]["end_condition"]["reference"]
self.assertEqual(reference["selector_intent"]["query_family"], "INTERSECT")
copy_components = reference["intersection_of"][:2]
self.assertTrue(all(item["owner_feature_id"] == "f_F4.c4.f_F1" for item in copy_components))
self.assertTrue(all(item.get("snapshot_id") for item in copy_components))
binding = next(item for item in evidence if item["feature_id"] == "f_F7")
self.assertGreaterEqual(len(binding["resolved"]), 2)
self.assertEqual([item["selector_intent"]["query_family"] for item in copy_components], ["COPY", "COPY"])
with self.assertRaisesRegex(ValueError, "f_F7: selector_query_unsupported after prefix rebuild"):
bind_candidate_selectors(candidate.cdsl)
@@ -0,0 +1,184 @@
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from cadfs_to_cdsl.dataset import Sample
from cadfs_to_cdsl.reports import read_json
from cadfs_to_cdsl.selector_candidate_demo import (
_query_candidate_groups,
_search_candidate_records,
_strip_selector_search_tokens,
run_geometry_probe,
strip_provenance_intents,
)
def _contains_selector_intent(value: object) -> bool:
if isinstance(value, dict):
return "selector_intent" in value or any(_contains_selector_intent(child) for child in value.values())
if isinstance(value, list):
return any(_contains_selector_intent(child) for child in value)
return False
class SelectorCandidateDemoTests(unittest.TestCase):
@staticmethod
def _query_group_candidate(query_family: str, source_entity: str | None = None, source_entities: list[str] | None = None) -> dict:
intent = {
"version": "1.0",
"kind": "edge" if query_family == "SWEPT_EDGE" else "face",
"query_family": query_family,
"source_query": {"ast": {"call": "makeQuery", "args": ["owner", query_family]}},
"derivation_policy": {"allowed": ["boundary"], "multiplicity": "all_fragments" if query_family == "SWEPT_FACE" else "one"},
}
if source_entity is not None:
intent["source_entity"] = {"sketch_id": "S", "entity_id": source_entity}
if source_entities is not None:
intent["source_entities"] = [{"sketch_id": "S", "entity_id": entity_id} for entity_id in source_entities]
return {"kind": intent["kind"], "owner_feature_id": "f_extrude", "selector_intent": intent}
def _query_group_provenance(self, selector: dict) -> dict:
return {
"geometry": {
"sketches": [{
"id": "sketch_S", "source_sketch_id": "S",
"workplane": {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]},
"profile": {"type": "planar_imprint", "source_entities": [
{"id": "E1", "curve": {"type": "line", "start": [0, 0], "end": [3, 0]}},
{"id": "E2", "curve": {"type": "line", "start": [3, 0], "end": [3, 2]}},
]},
}],
},
"features": [
{"id": "f_extrude", "atomic_id": "extrude_add_blind", "sketch_id": "sketch_S", "params": {"distance_mm": 5}},
{"id": "f_consumer", "selectors": [selector]},
],
}
def test_search_candidates_excludes_records_below_normal_resolution_threshold(self) -> None:
resolution = type("Resolution", (), {
"candidates": (
{"record_id": "low", "score": 0.5},
{"record_id": "high-b", "score": 1.0},
{"record_id": "high-a", "score": 1.0},
{"record_id": "zero", "score": 0.0},
),
})()
candidates, count = _search_candidate_records(resolution, maximum=8)
self.assertEqual(count, 2)
self.assertEqual([item["record_id"] for item in candidates], ["high-a", "high-b"])
def test_strip_provenance_intents_removes_nested_selector_metadata(self) -> None:
value = {
"selector_intent": {"version": "1.0"},
"output_role": "extrude.end",
"output_role_source": {"owner_feature_id": "f0", "output_role": "extrude.end"},
"params": {"reference": {"selector_intent_version": "1.0"}},
}
self.assertEqual(strip_provenance_intents(value), 2)
self.assertEqual(value, {"params": {"reference": {}}})
def test_query_group_keeps_all_ranked_swept_face_fragments_together(self) -> None:
selector = self._query_group_candidate("SWEPT_FACE", source_entity="E1")
provenance = self._query_group_provenance(selector)
resolution = type("Resolution", (), {"candidates": (
{"record_id": "fragment-a", "score": 1.0, "geometry": {"surface_type": "plane", "plane_normal": [0, -1, 0], "plane_offset_mm": 0}},
{"record_id": "fragment-b", "score": 1.0, "geometry": {"surface_type": "plane", "plane_normal": [0, 1, 0], "plane_offset_mm": 0}},
{"record_id": "wrong-wall", "score": 1.0, "geometry": {"surface_type": "plane", "plane_normal": [1, 0, 0], "plane_offset_mm": 3}},
)})()
descriptor, groups, count = _query_candidate_groups(
provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8,
)
self.assertEqual(count, 1)
self.assertEqual(descriptor["ranking_status"], "source_geometry_ranked")
self.assertEqual(descriptor["multiplicity"], "all_fragments")
self.assertEqual([record["record_id"] for record in groups[0]["records"]], ["fragment-a", "fragment-b"])
self.assertEqual(groups[0]["ranking"]["method"], "all_fragments_source_geometry")
def test_query_group_ranks_the_source_pair_vertical_edge(self) -> None:
selector = self._query_group_candidate("SWEPT_EDGE", source_entities=["E1", "E2"])
provenance = self._query_group_provenance(selector)
resolution = type("Resolution", (), {"candidates": (
{"record_id": "source-vertex", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [3, 0, 0], "end_mm": [3, 0, 5]}},
{"record_id": "other-vertex", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [0, 0, 0], "end_mm": [0, 0, 5]}},
)})()
descriptor, groups, count = _query_candidate_groups(
provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8,
)
self.assertEqual(count, 1)
self.assertEqual(descriptor["query_family"], "SWEPT_EDGE")
self.assertEqual(groups[0]["records"][0]["record_id"], "source-vertex")
self.assertEqual(groups[0]["ranking"]["method"], "source_pair_extrusion_vertex")
def test_query_group_ranks_the_line_arc_intersection_edge(self) -> None:
selector = self._query_group_candidate("SWEPT_EDGE", source_entities=["E1", "E2"])
provenance = self._query_group_provenance(selector)
provenance["geometry"]["sketches"][0]["profile"]["source_entities"][0]["curve"] = {
"type": "arc", "start": [-1, 0], "end": [1, 0], "center": [0, 0], "radius_mm": 1, "clockwise": True,
}
provenance["geometry"]["sketches"][0]["profile"]["source_entities"][1]["curve"] = {
"type": "line", "start": [0, -2], "end": [0, 2],
}
resolution = type("Resolution", (), {"candidates": (
{"record_id": "arc-line-intersection", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [0, 1, 0], "end_mm": [0, 1, 5]}},
{"record_id": "unrelated-vertical", "score": 1.0, "geometry": {"curve_type": "line", "start_mm": [0, -1, 0], "end_mm": [0, -1, 5]}},
)})()
_descriptor, groups, count = _query_candidate_groups(
provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8,
)
self.assertEqual(count, 1)
self.assertEqual(groups[0]["records"][0]["record_id"], "arc-line-intersection")
def test_query_group_ignores_a_candidate_missing_cylinder_axis_evidence(self) -> None:
selector = self._query_group_candidate("SWEPT_FACE", source_entity="E1")
provenance = self._query_group_provenance(selector)
provenance["geometry"]["sketches"][0]["profile"]["source_entities"][0]["curve"] = {
"type": "arc", "start": [-1, 0], "end": [1, 0], "center": [0, 0], "radius_mm": 1, "clockwise": True,
}
resolution = type("Resolution", (), {"candidates": (
{"record_id": "missing-axis", "score": 1.0, "geometry": {"surface_type": "cylinder", "radius_mm": 1}},
)})()
descriptor, groups, count = _query_candidate_groups(
provenance, "f_consumer:selectors[0]", selector, resolution, maximum=8,
)
self.assertEqual(count, 1)
self.assertEqual(descriptor["ranking_status"], "source_geometry_unavailable")
self.assertEqual(groups[0]["records"][0]["record_id"], "missing-axis")
def test_experiment_search_tokens_are_not_persisted(self) -> None:
value = {"selectors": [{"_selector_search_token": "f:selectors[0]"}]}
self.assertEqual(_strip_selector_search_tokens(value), 1)
self.assertEqual(value, {"selectors": [{}]})
def test_geometry_probe_keeps_heuristic_success_separate_from_provenance(self) -> None:
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
feature = root / "featurescript_rp/0000/00002243.txt"
step = root / "step_abc/0000/00002243.step"
if not feature.exists() or not step.exists():
self.skipTest("CADFS sample is not installed")
sample = Sample("00002243", {"featurescript": str(feature), "step": str(step)})
with tempfile.TemporaryDirectory() as temporary:
output = Path(temporary)
report = run_geometry_probe(sample, output)
record = read_json(output / "samples" / sample.sample_id / "selector-record.json")
compiled = read_json(output / "samples" / sample.sample_id / "selector_record.compiled.cdsl.json")
self.assertEqual(report["provenance"]["rebuild_status"], "rebuild_failed")
self.assertEqual(report["geometry_probe"]["rebuild_status"], "rebuilt")
self.assertEqual(report["geometry_probe"]["comparison"], {
"decision": "strict_pass",
"strict_passed": True,
"rp_passed": True,
})
self.assertEqual(report["strategy"]["classification"], "heuristic_only")
self.assertEqual(report["selector_record"]["status"], "strict_replayed")
self.assertEqual(report["selector_record"]["record_count"], 2)
self.assertEqual(record["verification"], {
"geometry_probe_strict": True,
"compiled_cdsl_strict": True,
"compiled_cdsl": report["selector_record"]["compiled_cdsl"],
})
self.assertEqual([entry["mapping"] for entry in record["records"]], ["one_to_one", "one_to_one"])
self.assertTrue(all(entry["source_selector"]["selector_intent"]["query_family"] == "CAP_EDGE" for entry in record["records"]))
self.assertFalse(_contains_selector_intent(compiled))
@@ -0,0 +1,73 @@
# CADFS 四次全量输出对比
## 统计口径
- 四次运行均使用同一批 9,347 个 CADFS 样本;日期使用各 `full_run_report.md` 的实际生成时间。
- `生成可执行 STEP``rebuild.step` 工件存在计数。它与最终 runtime 状态为 `rebuilt` 不同:后者失败时,系统仍可能保留最后可执行的 STEP 前缀。
- 能力缺口为受影响样本数,同一样本可能命中多个能力项,因此不能横向相加为失败样本总数。
- executor 原子操作新增,表示引擎可调度该操作;不代表所有 FeatureScript 参数、selector、Body 生命周期和拓扑来源语义都已完整支持。
## 输出成功数量
| 指标 | 2026-09-02 | 2026-09-07 | 2026-09-08 | 2026-09-09 | 首次到最新变化 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 生成候选 CDSL | 8,108 (86.74%) | 7,112 (76.09%) | 8,633 (92.36%) | 8,942 (95.67%) | +834 (+8.93pp) |
| 绑定 CDSL | 1,773 (18.97%) | 5,554 (59.42%) | 6,134 (65.62%) | 8,364 (89.48%) | +6,591 (+70.51pp) |
| 生成可执行 STEP | 1,812 (19.39%) | 5,554 (59.42%) | 6,134 (65.62%) | 8,176 (87.47%) | +6,364 (+68.08pp) |
| 最终 runtime 为 `rebuilt` | 1,811 (19.38%) | 5,554 (59.42%) | 6,134 (65.62%) | 6,055 (64.78%) | +4,244 (+45.40pp) |
| 完成几何比较 | 1,719 (18.39%) | 5,250 (56.17%) | 6,090 (65.16%) | 6,007 (64.27%) | +4,288 (+45.88pp) |
| RP 通过 | 1,159 (12.40%) | 1,358 (14.53%) | 2,031 (21.73%) | 2,253 (24.10%) | +1,094 (+11.70pp) |
| 严格通过 | 578 (6.18%) | 654 (7.00%) | 905 (9.68%) | 989 (10.58%) | +411 (+4.40pp) |
结论:候选 CDSL 在第二次运行短暂下降后恢复至 95.67%。可执行 STEP 从 1,812 增至 8,176,是最明显的产出改善。9 月 9 日的最终 `rebuilt` 数比上次少 79,但可执行 STEP 多 2,042,表明工件存在数与终态重建成功数在该轮明显分离,不能将两者混为同一成功指标。几何比较数较 9 月 8 日少 83,RP 和严格通过数则继续增长。
## 新增执行能力
| 运行阶段 | 新增 executor 原子操作 | 能力结论 |
| --- | --- | --- |
| 2026-09-02 | 基线:盲拉伸/双侧拉伸/切除、圆角、倒角、孔、镜像/线性阵列、参考轴/面、回转等 | 基础建模链路已可执行,但 `loft``sweep``shell` 等仍在已知不支持清单中。 |
| 2026-09-07 | `box_add``cylinder_add``extrude_cut_through``extrude_cut_two_sided``loft_add``pattern_circular``thread_add``thread_cut` | `loft` 不再列入已知不支持 FeatureScript 操作;圆周阵列、螺纹和更多拉伸方式进入执行器。 |
| 2026-09-08 | `bend_add``boolean_bodies``extrude_add_blind_with_hole``extrude_surface``loft_add_with_cap_face``revolve_surface``shell``sweep_add` | 增加多 Body 布尔、壳、扫掠、曲面回转和带孔拉伸执行路径;但 `shell``sweep` 仍有大量 source-lowering/selector 缺口。 |
| 2026-09-09 | `delete_bodies``extrude_from_face``gear_add``rack_add``transform_bodies` | Body 删除、从面拉伸、齿轮/齿条和 Body 变换进入执行器;对应的 query、copy、pattern 等变体仍需按 contract 和拓扑来源继续覆盖。 |
## 已优化能力
下表使用每次 `summary.json` 的能力缺口计数。`减少` 表示该能力在运行中被记录为缺口的样本减少,不表示该特征已经完全支持。
| 能力缺口 | 09-02 | 09-07 | 09-08 | 09-09 | 首次到最新变化 | 结论 |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| `extrude` | 3,861 | 3,600 | 1,197 | 899 | -2,962 | 最大覆盖改善;仍需处理 profile/extent/selector 变体。 |
| `fillet` | 1,914 | 1,904 | 1,417 | 1,084 | -830 | 明显下降,但仍是最新运行的最大单项缺口。 |
| `shell` | 696 | 696 | 57 | 43 | -653 | 执行器加入壳操作后主缺口大幅下降;面选择器问题仍存在。 |
| `revolve` | 741 | 662 | 344 | 200 | -541 | 回转主路径覆盖持续改善。 |
| `extrude_profile_topology:intersect` | 382 | 383 | 362 | 33 | -349 | 末轮拓扑交集选择器覆盖有显著改善。 |
| `chamfer` | 589 | 588 | 381 | 265 | -324 | 降幅明确,运行时构造失败仍是主要剩余问题。 |
| `hole` | 623 | 582 | 386 | 369 | -254 | 孔特征覆盖提升,但仍影响 369 个样本。 |
| `loft` | 308 | 107 | 68 | 66 | -242 | `loft_add` 后快速下降,cap-face 等拓扑变体仍未完成。 |
| `sweep` | 326 | 326 | 133 | 122 | -204 | `sweep_add` 后主缺口下降,path 选择器仍是限制。 |
| `circularPattern` | 228 | 139 | 74 | 51 | -177 | 圆周阵列持续改善。 |
| `booleanBodies` | 187 | 187 | 92 | 35 | -152 | 多 Body 布尔执行路径加入后下降;target 选择仍有独立缺口。 |
| `mirror` | 262 | 241 | 158 | 140 | -122 | 镜像覆盖逐步提升。 |
| `cPlane` | 147 | 168 | 84 | 69 | -78 | 基准面覆盖提升,但不同行/点/曲线来源仍需处理。 |
| `extrude_profile_topology:cap_face` | 200 | 201 | 183 | 112 | -88 | cap-face 来源仍是拉伸后续特征的重要阻塞点。 |
| `extrude_profile_topology:cap_edge` | 133 | 134 | 91 | 88 | -45 | cap-edge 改善较慢,仍需稳定的边来源语义。 |
| `extrude_profile_topology:swept_face` | 99 | 99 | 94 | 94 | -5 | 几乎没有改善,是后续优先的 selector/provenance 问题。 |
此外,`revolve_surface`367/369)和 `extrude_extent:up_to_surface`(95/100)仅在前两次运行列为能力缺口,后两次未再报告;这与新增 `revolve_surface`、扩展拉伸执行路径的时间点一致,但仅能证明该缺口不再被当前报告记录,不能替代逐参数、逐拓扑来源的全量验收。
## 最新仍需处理的能力
| 最新能力缺口 | 受影响样本 | 观察 |
| --- | ---: | --- |
| `fillet` | 1,084 | 最大单项缺口;同时存在内核半径构造失败。 |
| `extrude` | 899 | 主操作已大幅改善,但 profile 和 extent 变体仍广泛存在。 |
| `hole` | 369 | 仍是前三大操作缺口。 |
| `chamfer` | 265 | 除覆盖缺口外,运行时 `Failed creating a chamfer` 仍高频。 |
| `revolve` | 200 | 主路径已改善,曲面/选择器变体需继续验证。 |
| `shell_face_selector` | 158 | 壳体删除面的 selector/provenance 仍是主要限制。 |
| `mirror` / `sweep` | 140 / 122 | 基础路径可用,但来源与路径选择语义尚未完整覆盖。 |
| `extrude_profile_topology:*` | cap-face 112、swept-face 94、cap-edge 88 | 拉伸后面/边拓扑来源是后续特征链断点。 |
## 结论
四次运行的主要进展是从“可转换但大量 deferred”转向“多数样本可产出 CDSL、绑定 CDSL 和可执行 STEP”。最新运行中,95.67% 有候选 CDSL、89.48% 有绑定 CDSL、87.47% 保留可执行 STEP;但 RP 通过仅为 24.10%,严格通过为 10.58%,说明后续重点应从扩大可执行工件覆盖转向提高几何保真与拓扑选择器稳定性。特别是 `fillet``extrude` 及其 cap/swept face/edge 来源、壳体面选择器和倒角内核构造,仍应以通用 contract、body graph 和 selector provenance 方式推进,而不是按单一样本修补。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long