Compare commits
4 Commits
c6d3bfddec
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 334eac5259 | |||
| 09a25cdc6b | |||
| 48cec45fee | |||
| de21ee8a5b |
@@ -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
|
||||
|
||||
@@ -127,6 +127,62 @@ For `max_z`/`min_z` placement intent, use an owner role such as
|
||||
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
|
||||
|
||||
Keep a sketch to exactly `workplane` and `profile`. The workplane declares its
|
||||
@@ -228,6 +284,9 @@ Before returning the document, check every feature against these invariants:
|
||||
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)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ state from registered atomic executors, profile support, and complete inputs.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
@@ -17,7 +18,23 @@ from .runtime_types import (
|
||||
pattern_instance_member_id, transform_copy_member_id,
|
||||
)
|
||||
from .operation_contracts import materialized_feature_contracts
|
||||
from .selector_capabilities import is_direct_blind_extrude_cap_output_role
|
||||
from .selector_capabilities import (
|
||||
is_direct_blind_extrude_cap_output_role,
|
||||
is_immediate_retained_source_prism_swept_face_extent,
|
||||
is_direct_prism_shell_offset_edge_tdd,
|
||||
is_direct_prism_shell_offset_edge_vertex,
|
||||
is_initial_direct_loft_cap_output_role,
|
||||
is_initial_direct_sweep_cap_edge,
|
||||
is_initial_direct_sweep_cap_output_role,
|
||||
is_initial_direct_sweep_swept_edge,
|
||||
is_initial_direct_sweep_swept_face,
|
||||
is_initial_two_sided_circle_shell_cap_output_role,
|
||||
is_planar_imprint_extrude_cap_output_role,
|
||||
is_primary_add_dressup_cap_output_role,
|
||||
is_primary_add_shell_cap_output_role,
|
||||
is_primary_add_up_to_surface_cap_output_role,
|
||||
is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair,
|
||||
)
|
||||
|
||||
|
||||
_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_", "sweep_")
|
||||
@@ -61,7 +78,7 @@ _OPEN_PROFILE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if
|
||||
_PRIMARY_ATOMICS = frozenset({
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face", "extrude_surface",
|
||||
"extrude_cut_through",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "hole_blind", "hole_countersink",
|
||||
"revolve_add", "revolve_cut", "revolve_surface", "sweep_cut", "hole_blind", "hole_countersink",
|
||||
"hole_counterbore", "sphere_add", "box_add", "cylinder_add",
|
||||
})
|
||||
_ACTIVE_BODY_REQUIRED = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["requires_active_body"])
|
||||
@@ -74,6 +91,148 @@ _BODY_MUTATING_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() i
|
||||
_REPLAYABLE_ATOMICS = frozenset(a for a, c in _RUNTIME_CAPABILITIES.items() if c["replayable"])
|
||||
|
||||
|
||||
def _sweep_segmented_path_error(segments: list[Any]) -> tuple[str, str] | None:
|
||||
"""Validate the self-contained, ordered open-wire CDSL path contract."""
|
||||
if len(segments) < 2 or not all(isinstance(item, dict) for item in segments):
|
||||
return "invalid_sweep_path", "Sweep segmented path requires at least two captured curve segments"
|
||||
|
||||
def point(value: Any) -> tuple[float, float] | None:
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
try:
|
||||
result = (float(value[0]), float(value[1]))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
def same(left: tuple[float, float], right: tuple[float, float]) -> bool:
|
||||
return math.dist(left, right) <= 1e-8
|
||||
|
||||
endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
for item in segments:
|
||||
kind = item.get("type")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
return "unsupported_sweep_path", "Sweep segmented path supports only line, arc, or B-spline segments"
|
||||
if kind == "bspline":
|
||||
points = item.get("points")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
return "invalid_sweep_path", "Sweep B-spline path requires at least two interpolation points"
|
||||
converted = [point(value) for value in points]
|
||||
if any(value is None for value in converted):
|
||||
return "invalid_sweep_path", "Sweep B-spline path requires two-dimensional interpolation points"
|
||||
if item.get("periodic"):
|
||||
return "invalid_sweep_path", "Sweep segmented path requires open, non-periodic B-spline segments"
|
||||
start, end = converted[0], converted[-1]
|
||||
has_start_tangent = item.get("start_tangent") is not None
|
||||
has_end_tangent = item.get("end_tangent") is not None
|
||||
if has_start_tangent != has_end_tangent:
|
||||
return "invalid_sweep_path", "Sweep B-spline path requires both endpoint tangents when either is provided"
|
||||
if has_start_tangent and (point(item.get("start_tangent")) is None or point(item.get("end_tangent")) is None):
|
||||
return "invalid_sweep_path", "Sweep B-spline path tangents require two finite coordinates"
|
||||
if len(converted) == 2 and not has_start_tangent:
|
||||
return "invalid_sweep_path", "A two-point B-spline sweep path requires both endpoint tangents"
|
||||
else:
|
||||
start, end = point(item.get("start")), point(item.get("end"))
|
||||
if start is None or end is None:
|
||||
return "invalid_sweep_path", "Sweep segmented line and arc paths require two-dimensional start and end points"
|
||||
if kind == "arc" and (
|
||||
point(item.get("center")) is None
|
||||
or not isinstance(item.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(item["radius_mm"]))
|
||||
or float(item["radius_mm"]) <= 0
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep arc path requires a finite center and positive source radius"
|
||||
if start is None or end is None or same(start, end):
|
||||
return "invalid_sweep_path", "Sweep segmented path contains a degenerate curve segment"
|
||||
endpoints.append((start, end))
|
||||
|
||||
if any(not same(left[1], right[0]) for left, right in zip(endpoints, endpoints[1:])):
|
||||
return "invalid_sweep_path", "Sweep segmented path must be source-ordered and connected"
|
||||
vertices = [endpoints[0][0], *(end for _start, end in endpoints)]
|
||||
if any(
|
||||
later > index + 1 and same(left, right)
|
||||
for index, left in enumerate(vertices)
|
||||
for later, right in enumerate(vertices[index + 1:], start=index + 1)
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep segmented path must be an open non-branching wire"
|
||||
return None
|
||||
|
||||
|
||||
def _spatial_sweep_segmented_path_error(segments: list[Any]) -> tuple[str, str] | None:
|
||||
"""Validate ordered global sweep curves from multiple source sketch frames."""
|
||||
if len(segments) < 2 or not all(isinstance(item, dict) for item in segments):
|
||||
return "invalid_sweep_path", "Sweep spatial path requires at least two captured curve segments"
|
||||
|
||||
def point(value: Any) -> tuple[float, float, float] | None:
|
||||
if not isinstance(value, list) or len(value) != 3:
|
||||
return None
|
||||
try:
|
||||
result = tuple(float(component) for component in value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
def same(left: tuple[float, float, float], right: tuple[float, float, float]) -> bool:
|
||||
return math.dist(left, right) <= 1e-8
|
||||
|
||||
endpoints: list[tuple[tuple[float, float, float], tuple[float, float, float]]] = []
|
||||
source_entities: set[tuple[str, str]] = set()
|
||||
for item in segments:
|
||||
if not isinstance(item.get("source_sketch_id"), str) or not item["source_sketch_id"] or not isinstance(item.get("source_entity_id"), str) or not item["source_entity_id"]:
|
||||
return "invalid_sweep_path", "Sweep spatial path requires explicit source sketch and entity identities"
|
||||
source_entity = (item["source_sketch_id"], item["source_entity_id"])
|
||||
if source_entity in source_entities:
|
||||
return "invalid_sweep_path", "Sweep spatial path must not repeat one source sketch entity"
|
||||
source_entities.add(source_entity)
|
||||
kind = item.get("type")
|
||||
if kind not in {"line", "arc", "bspline"}:
|
||||
return "unsupported_sweep_path", "Sweep spatial path supports only line, arc, or B-spline segments"
|
||||
if kind == "bspline":
|
||||
points = item.get("points_mm")
|
||||
if not isinstance(points, list) or len(points) < 2:
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path requires at least two interpolation points"
|
||||
converted = [point(value) for value in points]
|
||||
if any(value is None for value in converted):
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path requires three-dimensional interpolation points"
|
||||
if item.get("periodic"):
|
||||
return "invalid_sweep_path", "Sweep spatial path requires open, non-periodic B-spline segments"
|
||||
start, end = converted[0], converted[-1]
|
||||
has_start_tangent = item.get("start_tangent_mm") is not None
|
||||
has_end_tangent = item.get("end_tangent_mm") is not None
|
||||
if has_start_tangent != has_end_tangent:
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path requires both endpoint tangents when either is provided"
|
||||
if has_start_tangent and (point(item.get("start_tangent_mm")) is None or point(item.get("end_tangent_mm")) is None):
|
||||
return "invalid_sweep_path", "Sweep spatial B-spline path tangents require three finite coordinates"
|
||||
if len(converted) == 2 and not has_start_tangent:
|
||||
return "invalid_sweep_path", "A two-point spatial B-spline sweep path requires both endpoint tangents"
|
||||
else:
|
||||
start, end = point(item.get("start_mm")), point(item.get("end_mm"))
|
||||
if start is None or end is None:
|
||||
return "invalid_sweep_path", "Sweep spatial line and arc paths require three-dimensional start and end points"
|
||||
if kind == "arc" and (
|
||||
point(item.get("center_mm")) is None
|
||||
or point(item.get("normal")) is None
|
||||
or not isinstance(item.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(item["radius_mm"]))
|
||||
or float(item["radius_mm"]) <= 0
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep spatial arc path requires a finite center, normal, and positive source radius"
|
||||
if start is None or end is None or same(start, end):
|
||||
return "invalid_sweep_path", "Sweep spatial path contains a degenerate curve segment"
|
||||
endpoints.append((start, end))
|
||||
|
||||
if any(not same(left[1], right[0]) for left, right in zip(endpoints, endpoints[1:])):
|
||||
return "invalid_sweep_path", "Sweep spatial path must be source-ordered and connected"
|
||||
vertices = [endpoints[0][0], *(end for _start, end in endpoints)]
|
||||
if any(
|
||||
later > index + 1 and same(left, right)
|
||||
for index, left in enumerate(vertices)
|
||||
for later, right in enumerate(vertices[index + 1:], start=index + 1)
|
||||
):
|
||||
return "invalid_sweep_path", "Sweep spatial path must be an open non-branching wire"
|
||||
return None
|
||||
|
||||
|
||||
def _mappings(value: Any):
|
||||
"""Yield nested feature mappings for capability-only contract checks."""
|
||||
if isinstance(value, dict):
|
||||
@@ -85,6 +244,17 @@ def _mappings(value: Any):
|
||||
yield from _mappings(child)
|
||||
|
||||
|
||||
def _query_selector_leaves(selector: dict[str, Any]):
|
||||
"""Yield only executable leaves of a recursive QUERY_SET selector."""
|
||||
operands = selector.get("query_operands")
|
||||
if not isinstance(operands, list) or not operands:
|
||||
yield selector
|
||||
return
|
||||
for operand in operands:
|
||||
if isinstance(operand, dict):
|
||||
yield from _query_selector_leaves(operand)
|
||||
|
||||
|
||||
def _contract_selectors(node: FeaturePlanNode, contract: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||
"""Read only the selector slot declared by the operation contract."""
|
||||
slot = str((contract or {}).get("selector_slot") or "")
|
||||
@@ -105,7 +275,7 @@ def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[s
|
||||
not isinstance(policy, dict)
|
||||
or policy.get("end_condition_type") != "up_to_surface"
|
||||
or policy.get("token_kind") != "face"
|
||||
or policy.get("output_role_contract") != "direct_blind_extrude_cap"
|
||||
or policy.get("output_role_contract") != "direct_or_primary_add_blind_extrude_cap"
|
||||
or policy.get("requires_immediate_owner") is not True
|
||||
):
|
||||
return None
|
||||
@@ -116,6 +286,63 @@ def _up_to_surface_output_role_reference(node: FeaturePlanNode, contract: dict[s
|
||||
return None
|
||||
|
||||
|
||||
def _retained_source_prism_swept_face_extent_reference(node: FeaturePlanNode) -> dict[str, Any] | None:
|
||||
"""Return the one non-output-role wall selector admitted for an extent."""
|
||||
end_condition = node.params.get("end_condition") or {}
|
||||
reference = end_condition.get("reference") if isinstance(end_condition, dict) else None
|
||||
intent = reference.get("selector_intent") if isinstance(reference, dict) else None
|
||||
if (
|
||||
end_condition.get("type") == "up_to_surface"
|
||||
and isinstance(intent, dict)
|
||||
and intent.get("consumer_contract") == "immediate_retained_source_prism_swept_face_up_to_surface"
|
||||
):
|
||||
return reference
|
||||
return None
|
||||
|
||||
|
||||
def _two_sided_up_to_surface_cap_pair_references(node: FeaturePlanNode, contract: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
"""Return the complete forward/reverse pair for the symmetric CAP contract."""
|
||||
policies = ((contract or {}).get("nested_selector_policies") or {})
|
||||
required = "symmetric_direct_prism_two_sided_up_to_surface_cap_pair"
|
||||
forward_policy = policies.get("params.end_condition.reference")
|
||||
reverse_policy = policies.get("params.reverse_end_condition.reference")
|
||||
if not (
|
||||
isinstance(forward_policy, dict)
|
||||
and isinstance(reverse_policy, dict)
|
||||
and forward_policy.get("output_role_contract") == required
|
||||
and reverse_policy.get("output_role_contract") == required
|
||||
and forward_policy.get("requires_immediate_owner") is True
|
||||
and reverse_policy.get("requires_immediate_owner") is True
|
||||
):
|
||||
return None
|
||||
forward = node.params.get("end_condition") or {}
|
||||
reverse = node.params.get("reverse_end_condition") or {}
|
||||
forward_reference = forward.get("reference") if isinstance(forward, dict) else None
|
||||
reverse_reference = reverse.get("reference") if isinstance(reverse, dict) else None
|
||||
if (
|
||||
forward.get("type") == "up_to_surface"
|
||||
and reverse.get("type") == "up_to_surface"
|
||||
and isinstance(forward_reference, dict)
|
||||
and isinstance(reverse_reference, dict)
|
||||
and forward_reference.get("output_role") is not None
|
||||
and reverse_reference.get("output_role") is not None
|
||||
):
|
||||
return forward_reference, reverse_reference
|
||||
return None
|
||||
|
||||
|
||||
def _transform_copy_member_sources(params: dict[str, Any], parameter: str) -> set[str]:
|
||||
"""Project source-qualified multi-body COPY refs to body-member keys."""
|
||||
return {
|
||||
transform_copy_member_id(
|
||||
str(reference.get("transform_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
)
|
||||
for reference in params.get(parameter) or ()
|
||||
if isinstance(reference, dict)
|
||||
}
|
||||
|
||||
|
||||
def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
"""Return internal body-member keys named by a transform contract."""
|
||||
source_ids = {str(value) for value in params.get("source_feature_ids") or ()}
|
||||
@@ -128,14 +355,7 @@ def _transform_member_sources(params: dict[str, Any]) -> set[str]:
|
||||
for reference in params.get("pattern_instance_refs") or ()
|
||||
if isinstance(reference, dict)
|
||||
)
|
||||
source_ids.update(
|
||||
transform_copy_member_id(
|
||||
str(reference.get("transform_feature_id") or ""),
|
||||
str(reference.get("source_feature_id") or ""),
|
||||
)
|
||||
for reference in params.get("transform_copy_refs") or ()
|
||||
if isinstance(reference, dict)
|
||||
)
|
||||
source_ids.update(_transform_copy_member_sources(params, "transform_copy_refs"))
|
||||
return source_ids
|
||||
|
||||
|
||||
@@ -172,8 +392,10 @@ def _next_body_graph(
|
||||
if atomic_id == "boolean_bodies":
|
||||
target_ids = {str(value) for value in node.params.get("target_feature_ids") or ()}
|
||||
target_ids.update(_pattern_instance_member_sources(node.params, "target_pattern_instance_refs"))
|
||||
target_ids.update(_transform_copy_member_sources(node.params, "target_transform_copy_refs"))
|
||||
tool_ids = {str(value) for value in node.params.get("tool_feature_ids") or ()}
|
||||
tool_ids.update(_pattern_instance_member_sources(node.params, "tool_pattern_instance_refs"))
|
||||
tool_ids.update(_transform_copy_member_sources(node.params, "tool_transform_copy_refs"))
|
||||
next_members = members - target_ids - tool_ids
|
||||
next_members.add(feature_id)
|
||||
if bool(node.params.get("keep_tools")):
|
||||
@@ -370,7 +592,7 @@ def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
if feature.get("sketch_id") is not None and (contracts.get(atomic_id) or {}).get("requires_sketch"):
|
||||
required.add(str(feature["sketch_id"]))
|
||||
if atomic_id in {"loft_add", "loft_add_with_cap_face"}:
|
||||
if atomic_id in {"loft_add", "loft_add_with_cap_face", "loft_surface"}:
|
||||
for sketch_id in (feature.get("params") or {}).get("profile_sketch_ids") or ():
|
||||
required.add(str(sketch_id))
|
||||
return frozenset(required)
|
||||
@@ -514,7 +736,14 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
if profile_type not in self.profile_types:
|
||||
blockers.append(self._blocker(node.feature_id, "unsupported_profile", "The current runtime cannot resolve the sketch profile", profile_type=profile_type))
|
||||
elif not resolution_error and not _has_closed_region(sketches[node.sketch_id]):
|
||||
elif (
|
||||
not resolution_error
|
||||
and not _has_closed_region(sketches[node.sketch_id])
|
||||
and not (
|
||||
node.atomic_id == "extrude_surface"
|
||||
and bool(sketches[node.sketch_id].get("surface_wires_mm"))
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "profile_no_closed_region",
|
||||
"The resolved sketch contains no closed profile region",
|
||||
@@ -531,7 +760,7 @@ class CapabilityAnalyzer:
|
||||
sketch_id=node.sketch_id,
|
||||
atomic_id=node.atomic_id,
|
||||
))
|
||||
if node.atomic_id in {"loft_add", "loft_add_with_cap_face"}:
|
||||
if node.atomic_id in {"loft_add", "loft_add_with_cap_face", "loft_surface"}:
|
||||
profile_ids = params.get("profile_sketch_ids")
|
||||
minimum_profiles = 1 if node.atomic_id == "loft_add_with_cap_face" else 2
|
||||
if not isinstance(profile_ids, list) or len(profile_ids) < minimum_profiles:
|
||||
@@ -582,10 +811,27 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
contract_selectors = _contract_selectors(node, contract)
|
||||
extent_selector = _up_to_surface_output_role_reference(node, contract)
|
||||
two_sided_extent_pair = _two_sided_up_to_surface_cap_pair_references(node, contract)
|
||||
output_role_selectors = [
|
||||
*contract_selectors,
|
||||
*([extent_selector] if extent_selector is not None else []),
|
||||
*(list(two_sided_extent_pair) if two_sided_extent_pair is not None else []),
|
||||
]
|
||||
retained_source_swept_face_extent = _retained_source_prism_swept_face_extent_reference(node)
|
||||
if retained_source_swept_face_extent is not None:
|
||||
required.append("selector:retained_source_prism_swept_face_extent")
|
||||
if (
|
||||
previous_node is None
|
||||
or retained_source_swept_face_extent.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_immediate_retained_source_prism_swept_face_extent(
|
||||
retained_source_swept_face_extent, previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_retained_source_prism_swept_face_extent",
|
||||
"up_to_surface SWEPT_FACE requires the immediately preceding direct new_body prism with one exact retained source edge",
|
||||
))
|
||||
contract_selector_ids = {id(selector) for selector in output_role_selectors}
|
||||
selector_intent_ids = {
|
||||
id(selector.get("selector_intent"))
|
||||
@@ -609,16 +855,10 @@ class CapabilityAnalyzer:
|
||||
continue
|
||||
required.append("selector:feature_output_role")
|
||||
is_extent_reference = selector is extent_selector
|
||||
if (
|
||||
not is_extent_reference
|
||||
and (contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face")
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector",
|
||||
"This feature contract cannot consume a feature output role selector",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
is_two_sided_extent_reference = (
|
||||
two_sided_extent_pair is not None
|
||||
and any(selector is reference for reference in two_sided_extent_pair)
|
||||
)
|
||||
if selector.get("kind") != "face" or not isinstance(selector.get("owner_feature_id"), str):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
@@ -642,30 +882,93 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
role_source = selector.get("output_role_source")
|
||||
selector_intent = selector.get("selector_intent")
|
||||
is_retained_shell_cap_offset_profile = (
|
||||
node.atomic_id == "extrude_from_face"
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_FACE"
|
||||
and selector_intent.get("consumer_contract")
|
||||
== "shell_retained_direct_prism_cap_offset_face_profile"
|
||||
and selector.get("output_role") == "shell.offset_face"
|
||||
and previous_node is not None
|
||||
and selector.get("owner_feature_id") == previous_node.feature_id
|
||||
)
|
||||
is_shell_cap_face_output_role = (
|
||||
node.atomic_id == "shell"
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
and selector.get("output_role") in {
|
||||
"extrude.start", "extrude.end", "loft.start", "loft.end", "sweep.start", "sweep.end",
|
||||
}
|
||||
)
|
||||
if is_extent_reference and (
|
||||
is_imprint_dressup_cap_output_role = (
|
||||
node.atomic_id in {"fillet", "chamfer"}
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
and selector_intent.get("consumer_contract") != "primary_add_dressup_union_continuation"
|
||||
)
|
||||
is_primary_add_dressup_cap_role = (
|
||||
node.atomic_id in {"fillet", "chamfer"}
|
||||
and isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_FACE"
|
||||
and selector.get("output_role") in {"extrude.start", "extrude.end"}
|
||||
and is_primary_add_dressup_cap_output_role(
|
||||
selector, previous_node.source_feature if previous_node is not None else None, sketches,
|
||||
)
|
||||
)
|
||||
if (
|
||||
not is_extent_reference
|
||||
and not is_two_sided_extent_reference
|
||||
and not is_imprint_dressup_cap_output_role
|
||||
and not is_primary_add_dressup_cap_role
|
||||
and (contract is None or not contract.get("selector_slot") or contract.get("selector_token_kind") != "face")
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_output_role_selector",
|
||||
"This feature contract cannot consume a feature output role selector",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if is_two_sided_extent_reference and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
or not is_symmetric_direct_prism_two_sided_up_to_surface_cap_pair(
|
||||
two_sided_extent_pair[0], two_sided_extent_pair[1], previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_two_sided_extent_output_role_pair",
|
||||
"two-sided up_to_surface CAP roles require the immediately preceding direct symmetric new_body prism pair",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_extent_reference and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not (
|
||||
is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
or is_primary_add_up_to_surface_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_extent_output_role_selector",
|
||||
"up_to_surface output roles require the immediately preceding direct new_body blind extrusion cap",
|
||||
"up_to_surface output roles require the immediately preceding direct new_body or primary ADD blind extrusion cap",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_shell_cap_face_output_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_direct_blind_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
or not (
|
||||
is_direct_blind_extrude_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_primary_add_shell_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_initial_direct_loft_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_initial_direct_sweep_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
or is_initial_two_sided_circle_shell_cap_output_role(selector, previous_node.source_feature, sketches)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
@@ -674,6 +977,139 @@ class CapabilityAnalyzer:
|
||||
"CAP_FACE output roles require the immediately preceding direct new_body blind extrusion cap",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_primary_add_dressup_cap_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_cap_face_output_role_selector",
|
||||
"primary ADD CAP_FACE output roles require the immediately preceding blind extrusion",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_imprint_dressup_cap_output_role and (
|
||||
previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_planar_imprint_extrude_cap_output_role(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_cap_face_output_role_selector",
|
||||
"IMPRINT CAP_FACE output roles require the immediately preceding complete new_body blind prism",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif is_primary_add_dressup_cap_role or is_imprint_dressup_cap_output_role:
|
||||
# The normal fillet/chamfer contract consumes edges, but a
|
||||
# complete CAP_FACE set is intentionally expanded into its
|
||||
# physical boundary edges by the shared dress-up executor.
|
||||
pass
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_tdd(
|
||||
selector,
|
||||
previous_node.source_feature,
|
||||
nodes_by_id.get(str(selector.get("owner_feature_id") or "")).source_feature
|
||||
if nodes_by_id.get(str(selector.get("owner_feature_id") or "")) is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_tdd_selector",
|
||||
"OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_vertex(
|
||||
selector,
|
||||
previous_node.source_feature,
|
||||
nodes_by_id.get(str(selector.get("owner_feature_id") or "")).source_feature
|
||||
if nodes_by_id.get(str(selector.get("owner_feature_id") or "")) is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_vertex_selector",
|
||||
"OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "CAP_EDGE"
|
||||
and selector_intent.get("lineage_role") in {"sweep.start", "sweep.end"}
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_initial_direct_sweep_cap_edge(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_sweep_cap_edge_selector",
|
||||
"CAP_EDGE sweep roles require the immediately preceding direct new_body one-edge sweep",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "SWEPT_FACE"
|
||||
and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_path"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_initial_direct_sweep_swept_face(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_sweep_swept_face_selector",
|
||||
"SWEPT_FACE sweep relations require the immediately preceding direct new_body analytic-profile sweep",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "SWEPT_EDGE"
|
||||
and (selector_intent.get("disambiguation") or {}).get("type") == "sweep_profile_vertex_path"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or selector.get("owner_feature_id") != previous_node.feature_id
|
||||
or not is_initial_direct_sweep_swept_edge(
|
||||
selector, previous_node.source_feature, sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_sweep_swept_edge_selector",
|
||||
"SWEPT_EDGE sweep relations require the immediately preceding direct new_body analytic-profile sweep",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if role_source is not None:
|
||||
source_owner = role_source.get("owner_feature_id") if isinstance(role_source, dict) else None
|
||||
source_role = role_source.get("output_role") if isinstance(role_source, dict) else None
|
||||
@@ -685,10 +1121,13 @@ class CapabilityAnalyzer:
|
||||
"An output role source requires owner_feature_id and output_role",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif selector.get("output_role") != "shell.offset_face" or node.atomic_id != "shell":
|
||||
elif (
|
||||
selector.get("output_role") != "shell.offset_face"
|
||||
or not (node.atomic_id == "shell" or is_retained_shell_cap_offset_profile)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_output_role_source",
|
||||
"Output role sources are currently supported only for shell.offset_face",
|
||||
"Output role sources are currently supported only for a shell offset-face contract",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
elif source_role not in {"extrude.start", "extrude.end"} or (
|
||||
@@ -702,14 +1141,110 @@ class CapabilityAnalyzer:
|
||||
"shell.offset_face requires a direct new_body blind extrusion cap source",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if node.atomic_id == "sweep_add":
|
||||
elif is_retained_shell_cap_offset_profile:
|
||||
shell = previous_node.source_feature or {}
|
||||
shell_selectors = shell.get("selectors") or []
|
||||
shell_params = shell.get("params") or {}
|
||||
removed = shell_selectors[0] if len(shell_selectors) == 1 else {}
|
||||
removed_intent = removed.get("selector_intent") if isinstance(removed, dict) else {}
|
||||
disambiguation = selector_intent.get("disambiguation") or {}
|
||||
source_ids = disambiguation.get("source_profile_entity_ids") if isinstance(disambiguation, dict) else None
|
||||
expected_retained = "extrude.end" if removed.get("output_role") == "extrude.start" else "extrude.start"
|
||||
if (
|
||||
shell.get("atomic_id") != "shell"
|
||||
or shell.get("depends_on") != [source_owner]
|
||||
or not isinstance(removed, dict)
|
||||
or removed.get("owner_feature_id") != source_owner
|
||||
or removed.get("output_role") not in {"extrude.start", "extrude.end"}
|
||||
or not isinstance(removed_intent, dict)
|
||||
or removed_intent.get("query_family") != "CAP_FACE"
|
||||
or source_role != expected_retained
|
||||
or not isinstance(source_ids, list)
|
||||
or not source_ids
|
||||
or len(set(source_ids)) != len(source_ids)
|
||||
or disambiguation.get("removed_cap_role") != removed.get("output_role")
|
||||
or params.get("operation") != "add"
|
||||
or params.get("result_mode") != "new_body"
|
||||
or (params.get("end_condition") or {}).get("type") != "blind"
|
||||
or params.get("two_sided")
|
||||
or params.get("draft") is not None
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_retained_shell_cap_offset_profile",
|
||||
"Retained shell cap profiles require one immediate opposite direct-prism cap removal",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
# Query-set parents carry no producer themselves. Their leaves
|
||||
# still need the same lifecycle proof as a direct selector before
|
||||
# runtime can attempt any recursive set evaluation.
|
||||
for selector_index, selector in enumerate(output_role_selectors):
|
||||
for leaf in _query_selector_leaves(selector):
|
||||
if leaf is selector:
|
||||
continue
|
||||
selector_intent = leaf.get("selector_intent")
|
||||
producer_node = nodes_by_id.get(str(leaf.get("owner_feature_id") or ""))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_tdd"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_tdd(
|
||||
leaf, previous_node.source_feature,
|
||||
producer_node.source_feature if producer_node is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_tdd_selector",
|
||||
"OFFSET_EDGE TDD requires the immediately preceding direct-prism shell retained-cap continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if (
|
||||
isinstance(selector_intent, dict)
|
||||
and selector_intent.get("query_family") == "OFFSET_EDGE"
|
||||
and selector_intent.get("consumer_contract") == "direct_prism_shell_offset_edge_vertex"
|
||||
and (
|
||||
node.atomic_id not in {"fillet", "chamfer"}
|
||||
or previous_node is None
|
||||
or not isinstance(selector_intent.get("disambiguation"), dict)
|
||||
or selector_intent["disambiguation"].get("shell_feature_id") != previous_node.feature_id
|
||||
or not is_direct_prism_shell_offset_edge_vertex(
|
||||
leaf, previous_node.source_feature,
|
||||
producer_node.source_feature if producer_node is not None else None,
|
||||
sketches,
|
||||
)
|
||||
)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"unsupported_offset_edge_vertex_selector",
|
||||
"OFFSET_EDGE vertex requires the immediately preceding direct-prism shell continuation",
|
||||
selector_index=selector_index,
|
||||
))
|
||||
if node.atomic_id in {"sweep_add", "sweep_cut"}:
|
||||
path = params.get("path")
|
||||
segment = path.get("segment") if isinstance(path, dict) else None
|
||||
segments = path.get("segments") if isinstance(path, dict) else None
|
||||
kind = segment.get("type") if isinstance(segment, dict) else None
|
||||
if kind not in {"line", "bspline"}:
|
||||
if isinstance(segments, list):
|
||||
spatial = not isinstance(path.get("workplane"), dict) if isinstance(path, dict) else False
|
||||
error = _spatial_sweep_segmented_path_error(segments) if spatial else _sweep_segmented_path_error(segments)
|
||||
if error is not None:
|
||||
code, message = error
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, code, message,
|
||||
))
|
||||
elif kind not in {"line", "arc", "circle", "bspline"}:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_sweep_path",
|
||||
"Sweep requires one captured line or B-spline path",
|
||||
"Sweep requires one captured line, arc, circle, or B-spline path",
|
||||
))
|
||||
elif kind == "line" and not all(
|
||||
isinstance(segment.get(key), list) and len(segment[key]) == 2
|
||||
@@ -719,6 +1254,34 @@ class CapabilityAnalyzer:
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep line path requires two-dimensional start and end points",
|
||||
))
|
||||
elif kind == "arc" and (
|
||||
not all(
|
||||
isinstance(segment.get(key), list)
|
||||
and len(segment[key]) == 2
|
||||
and all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in segment[key])
|
||||
for key in ("start", "end", "center")
|
||||
)
|
||||
or not isinstance(segment.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(segment["radius_mm"]))
|
||||
or float(segment["radius_mm"]) <= 0
|
||||
or not isinstance(segment.get("clockwise"), bool)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep arc path requires finite start, end, center, positive radius, and direction",
|
||||
))
|
||||
elif kind == "circle" and (
|
||||
not isinstance(segment.get("center"), list)
|
||||
or len(segment["center"]) != 2
|
||||
or not all(isinstance(value, (int, float)) and math.isfinite(float(value)) for value in segment["center"])
|
||||
or not isinstance(segment.get("radius_mm"), (int, float))
|
||||
or not math.isfinite(float(segment["radius_mm"]))
|
||||
or float(segment["radius_mm"]) <= 0
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_sweep_path",
|
||||
"Sweep circle path requires a finite center and positive radius",
|
||||
))
|
||||
elif kind == "bspline" and (
|
||||
not isinstance(segment.get("points"), list)
|
||||
or len(segment.get("points") or []) < 2
|
||||
@@ -744,8 +1307,10 @@ class CapabilityAnalyzer:
|
||||
if node.atomic_id == "boolean_bodies":
|
||||
target_ids = params.get("target_feature_ids")
|
||||
target_instance_refs = params.get("target_pattern_instance_refs")
|
||||
target_transform_refs = params.get("target_transform_copy_refs")
|
||||
tool_ids = params.get("tool_feature_ids")
|
||||
tool_instance_refs = params.get("tool_pattern_instance_refs")
|
||||
tool_transform_refs = params.get("tool_transform_copy_refs")
|
||||
operation = params.get("operation")
|
||||
if operation not in {"union", "subtract", "intersect"}:
|
||||
blockers.append(self._blocker(
|
||||
@@ -754,15 +1319,17 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
target_members: set[str] = set()
|
||||
tool_members: set[str] = set()
|
||||
for parameter, instance_parameter, feature_ids, instance_refs, selected_members in (
|
||||
("target_feature_ids", "target_pattern_instance_refs", target_ids, target_instance_refs, target_members),
|
||||
("tool_feature_ids", "tool_pattern_instance_refs", tool_ids, tool_instance_refs, tool_members),
|
||||
for parameter, instance_parameter, transform_parameter, feature_ids, instance_refs, transform_refs, selected_members in (
|
||||
("target_feature_ids", "target_pattern_instance_refs", "target_transform_copy_refs", target_ids, target_instance_refs, target_transform_refs, target_members),
|
||||
("tool_feature_ids", "tool_pattern_instance_refs", "tool_transform_copy_refs", tool_ids, tool_instance_refs, tool_transform_refs, tool_members),
|
||||
):
|
||||
if feature_ids is None:
|
||||
feature_ids = []
|
||||
if instance_refs is None:
|
||||
instance_refs = []
|
||||
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not (feature_ids or instance_refs):
|
||||
if transform_refs is None:
|
||||
transform_refs = []
|
||||
if not isinstance(feature_ids, list) or not isinstance(instance_refs, list) or not isinstance(transform_refs, list) or not (feature_ids or instance_refs or transform_refs):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_boolean_bodies",
|
||||
"booleanBodies requires explicit target and tool body references", parameter=parameter,
|
||||
@@ -818,6 +1385,45 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
continue
|
||||
selected_members.add(member_id)
|
||||
for index, reference in enumerate(transform_refs):
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "invalid_transform_copy_ref",
|
||||
"Transform COPY body reference must be an object", parameter=transform_parameter, index=index,
|
||||
))
|
||||
continue
|
||||
transform_id = str(reference.get("transform_feature_id") or "")
|
||||
source_id = str(reference.get("source_feature_id") or "")
|
||||
transform = nodes_by_id.get(transform_id)
|
||||
if transform is None or transform.atomic_id != "transform_bodies" or transform.feature_id not in completed:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY owner is not an executable preceding body transform",
|
||||
transform_feature_id=transform_id,
|
||||
))
|
||||
continue
|
||||
transform_sources = transform.params.get("source_feature_ids") or []
|
||||
if (
|
||||
not bool(transform.params.get("make_copy"))
|
||||
or not isinstance(transform_sources, list)
|
||||
or len(transform_sources) < 2
|
||||
or source_id not in {str(value) for value in transform_sources}
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY reference is not a source-qualified multi-body copy",
|
||||
transform_feature_id=transform_id, source_feature_id=source_id,
|
||||
))
|
||||
continue
|
||||
member_id = transform_copy_member_id(transform_id, source_id)
|
||||
if member_id not in body_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "transform_copy_unavailable",
|
||||
"Transform COPY source has no independently selectable body output",
|
||||
transform_feature_id=transform_id, source_feature_id=source_id,
|
||||
))
|
||||
continue
|
||||
selected_members.add(member_id)
|
||||
if target_members & tool_members:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "boolean_body_overlap",
|
||||
@@ -865,12 +1471,6 @@ class CapabilityAnalyzer:
|
||||
"hole scope body is no longer an independently selectable body output",
|
||||
scope_feature_id=scope_feature_id,
|
||||
))
|
||||
elif len(body_members) != 1:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "hole_scope_body_ambiguous",
|
||||
"hole scope body must be the sole active member",
|
||||
scope_feature_id=scope_feature_id,
|
||||
))
|
||||
if node.atomic_id in {"transform_bodies", "delete_bodies"}:
|
||||
parameter = "source_feature_ids" if node.atomic_id == "transform_bodies" else "target_feature_ids"
|
||||
source_ids = params.get(parameter)
|
||||
@@ -1041,19 +1641,23 @@ class CapabilityAnalyzer:
|
||||
blockers.append(self._blocker(node.feature_id, "unsupported_extent", "The extent needs a resolved topology selector or is not implemented", extent=end_type))
|
||||
target_kind = _EXTENT_TARGET_KINDS.get(end_type or "")
|
||||
if target_kind:
|
||||
required.append(f"selector:extent_target:{target_kind}")
|
||||
reference = end_condition.get("reference")
|
||||
if not isinstance(reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_extent_reference",
|
||||
"This end condition requires a captured target selector", extent=end_type,
|
||||
))
|
||||
elif end_type == "up_to_vertex" and reference.get("kind") == "source_vertex":
|
||||
required.append("source_vertex_extent")
|
||||
elif reference.get("kind") != target_kind:
|
||||
required.append(f"selector:extent_target:{target_kind}")
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_extent_target",
|
||||
"The captured target kind is incompatible with this end condition",
|
||||
extent=end_type, expected_kind=target_kind, actual_kind=reference.get("kind"),
|
||||
))
|
||||
else:
|
||||
required.append(f"selector:extent_target:{target_kind}")
|
||||
if end_type == "offset_from_surface" and abs(float(params.get("distance_mm") or 0.0)) <= 1e-12:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_offset_distance",
|
||||
@@ -1070,20 +1674,24 @@ class CapabilityAnalyzer:
|
||||
))
|
||||
reverse_target_kind = _EXTENT_TARGET_KINDS.get(reverse_type or "")
|
||||
if reverse_target_kind:
|
||||
required.append(f"selector:reverse_extent_target:{reverse_target_kind}")
|
||||
reverse_reference = reverse_condition.get("reference")
|
||||
if not isinstance(reverse_reference, dict):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_reverse_extent_reference",
|
||||
"This reverse end condition requires a captured target selector", extent=reverse_type,
|
||||
))
|
||||
elif reverse_type == "up_to_vertex" and reverse_reference.get("kind") == "source_vertex":
|
||||
required.append("source_vertex_reverse_extent")
|
||||
elif reverse_reference.get("kind") != reverse_target_kind:
|
||||
required.append(f"selector:reverse_extent_target:{reverse_target_kind}")
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "unsupported_reverse_extent_target",
|
||||
"The reverse target kind is incompatible with this end condition",
|
||||
extent=reverse_type, expected_kind=reverse_target_kind,
|
||||
actual_kind=reverse_reference.get("kind"),
|
||||
))
|
||||
else:
|
||||
required.append(f"selector:reverse_extent_target:{reverse_target_kind}")
|
||||
if reverse_type == "offset_from_surface" and abs(float(params.get("reverse_distance_mm") or 0.0)) <= 1e-12:
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id, "missing_reverse_offset_distance",
|
||||
@@ -1129,6 +1737,33 @@ class CapabilityAnalyzer:
|
||||
blockers.append(self._blocker(node.feature_id, "missing_reference_orientation", "Reference plane requires an explicit plane frame"))
|
||||
if node.atomic_id == "reference_plane" and isinstance(params.get("plane"), dict) and params["plane"].get("unresolved"):
|
||||
blockers.append(self._blocker(node.feature_id, "missing_reference_orientation", "Reference plane orientation was not captured"))
|
||||
if node.atomic_id == "reference_point":
|
||||
point = 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)
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_reference_point",
|
||||
"Reference point requires one finite three-dimensional coordinate",
|
||||
))
|
||||
if node.atomic_id == "assign_variable":
|
||||
value = params.get("value")
|
||||
if (
|
||||
not isinstance(params.get("name"), str)
|
||||
or not params["name"]
|
||||
or not isinstance(value, (int, float))
|
||||
or isinstance(value, bool)
|
||||
or not math.isfinite(float(value))
|
||||
or params.get("value_kind") not in {"any", "length"}
|
||||
):
|
||||
blockers.append(self._blocker(
|
||||
node.feature_id,
|
||||
"invalid_assign_variable",
|
||||
"Source variable requires one finite named scalar value",
|
||||
))
|
||||
if node.atomic_id == "reference_axis":
|
||||
axis = params.get("axis") or {}
|
||||
if not (axis.get("origin_mm") and axis.get("direction")):
|
||||
@@ -1195,17 +1830,21 @@ class CapabilityAnalyzer:
|
||||
if status == "executable":
|
||||
completed.add(node.feature_id)
|
||||
body_members, body_available = _next_body_graph(node, body_members, body_available, nodes_by_id)
|
||||
previous_node = node
|
||||
# Source variables affect lowering expressions only. They are not
|
||||
# topology producers and must not break an immediate selector's
|
||||
# producer/lifecycle relationship.
|
||||
if node.atomic_id != "assign_variable":
|
||||
previous_node = node
|
||||
body_producers = {
|
||||
"extrude_add_blind", "extrude_add_blind_with_hole", "extrude_add_two_sided", "extrude_cut_blind", "extrude_cut_two_sided", "extrude_from_face",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "boolean_bodies",
|
||||
"extrude_cut_through", "loft_add", "loft_add_with_cap_face", "sweep_add", "sweep_cut", "boolean_bodies",
|
||||
"revolve_add", "revolve_cut", "sphere_add", "box_add", "cylinder_add",
|
||||
"thread_add", "bend_add", "gear_add", "rack_add",
|
||||
# thread_cut 与 extrude_cut_blind/revolve_cut 一致:无宿主时由
|
||||
# active_body 前置阻止,文档含该类特征即视为携带可执行几何。
|
||||
"thread_cut",
|
||||
}
|
||||
surface_producers = {"extrude_surface", "revolve_surface"}
|
||||
surface_producers = {"extrude_surface", "revolve_surface", "loft_surface"}
|
||||
document_blockers: list[RuntimeDiagnostic] = []
|
||||
if not any(node.atomic_id in body_producers | surface_producers for node in plan):
|
||||
document_blockers.append(RuntimeDiagnostic(
|
||||
|
||||
@@ -24,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},
|
||||
@@ -95,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"},
|
||||
@@ -140,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"],
|
||||
@@ -160,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
|
||||
@@ -405,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
|
||||
},
|
||||
@@ -453,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"}
|
||||
},
|
||||
@@ -533,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": {
|
||||
@@ -588,6 +720,132 @@
|
||||
"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": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -595,6 +853,7 @@
|
||||
"kind": {"enum": ["face", "edge", "axis", "plane", "feature", "vertex", "body"]},
|
||||
"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": {
|
||||
@@ -606,7 +865,7 @@
|
||||
},
|
||||
"source_entities": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "object",
|
||||
@@ -642,9 +901,81 @@
|
||||
"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": {
|
||||
@@ -674,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"},
|
||||
@@ -715,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": {
|
||||
@@ -738,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"],
|
||||
@@ -767,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": {
|
||||
@@ -787,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": {
|
||||
@@ -805,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,
|
||||
@@ -819,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"}}}},
|
||||
@@ -846,15 +1248,18 @@
|
||||
{"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"}, "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"}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,41 @@ 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(
|
||||
@@ -82,12 +104,14 @@ def _can_register_primary_cut_tool_history(
|
||||
"""Return whether a primary REMOVE can retain a transient tool snapshot.
|
||||
|
||||
The implicit CADFS primary boolean normally has no independently active
|
||||
tool body. It may contribute selector provenance only when the target,
|
||||
tool, profile anchors, and prism builder history are all singular and
|
||||
exact. This gate applies only to the tool-side transient snapshot used by
|
||||
source-qualified section queries. The cut builder can independently
|
||||
prove a target-side continuation even when a trimmed or fallback tool has
|
||||
no direct-prism history.
|
||||
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
|
||||
@@ -96,8 +120,6 @@ def _can_register_primary_cut_tool_history(
|
||||
or topology_delta.history_status != "proven"
|
||||
or not topology_delta.relations
|
||||
or not topology_anchors
|
||||
or len(session.body_members) != 1
|
||||
or len(session.adapter.body_solids(session.body)) != 1
|
||||
or len(session.adapter.body_solids(tool)) != 1
|
||||
):
|
||||
return False
|
||||
@@ -115,6 +137,7 @@ def _extruded_tool(
|
||||
session: "ExecutionSession",
|
||||
*,
|
||||
record_multiface_prism_history: bool = False,
|
||||
use_operation_wide_prism_history: bool = False,
|
||||
) -> tuple[Any, TopologyDelta | None]:
|
||||
"""Build an extrude tool, retaining complete direct builder history."""
|
||||
extents = _extent_vectors_from_normal(node, faces, profile_normal, session)
|
||||
@@ -124,21 +147,38 @@ def _extruded_tool(
|
||||
taper_deg = float(draft["angle_deg"])
|
||||
if not bool(draft["pull_direction"]):
|
||||
taper_deg = -taper_deg
|
||||
topology_deltas: list[TopologyDelta] = []
|
||||
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(topology_delta)
|
||||
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(extents) == 1 and (
|
||||
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
|
||||
@@ -147,8 +187,19 @@ def _extruded_tool(
|
||||
# multi-face profiles keep the established general-extrude
|
||||
# path; forcing them through MakePrism can make a previously
|
||||
# executable profile invalid without adding usable evidence.
|
||||
solid, topology_delta = session.adapter.extrude_with_topology_delta(face, extent.vector)
|
||||
topology_deltas.append(topology_delta)
|
||||
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))
|
||||
@@ -161,9 +212,21 @@ def _extruded_tool(
|
||||
raise ValueError("extrude produced no solid")
|
||||
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(relation for delta in topology_deltas for relation in delta.relations),
|
||||
relations=tuple(relations),
|
||||
)
|
||||
|
||||
|
||||
@@ -181,7 +244,7 @@ def _apply_primary_tool(
|
||||
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)
|
||||
@@ -190,12 +253,10 @@ def _apply_primary_tool(
|
||||
retain_transient_tool = _can_register_primary_cut_tool_history(
|
||||
session, tool, tool_delta, topology_anchors,
|
||||
)
|
||||
if (
|
||||
len(session.body_members) == 1
|
||||
and len(session.adapter.body_solids(session.body)) == 1
|
||||
and len(session.adapter.body_solids(tool)) == 1
|
||||
):
|
||||
body, cut_delta = session.adapter.cut_with_topology_delta(session.body, tool)
|
||||
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
|
||||
@@ -219,23 +280,64 @@ def _apply_primary_tool(
|
||||
topology_delta = cut_delta
|
||||
else:
|
||||
topology_delta = None
|
||||
member_id = next(iter(session.body_members))
|
||||
members = {member_id: body}
|
||||
else:
|
||||
body = session.adapter.cut(session.body, tool)
|
||||
topology_delta = None
|
||||
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
|
||||
topology_anchors = None
|
||||
session.register_body(
|
||||
node.feature_id, body, replay_node=node, body_members=members, topology_delta=topology_delta,
|
||||
topology_predecessors=topology_predecessors, topology_anchors=topology_anchors,
|
||||
@@ -252,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, source_anchor_specs = session.adapter.faces_for_sketch_with_source_anchors(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":
|
||||
@@ -274,6 +380,7 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
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_"):
|
||||
# 拉伸:先按终止条件(盲孔/贯穿/至面/双侧等)求出位移向量,
|
||||
@@ -282,7 +389,15 @@ def _shape_from_primary(node: FeaturePlanNode, session: "ExecutionSession", *, s
|
||||
# 拉伸:穿透后与目标面求交,只保留可达部分(issue #5)。
|
||||
tool, topology_delta = _extruded_tool(
|
||||
node, faces, _normal_from_sketch(selected_sketch), session,
|
||||
record_multiface_prism_history=direct_all_circle_profile and len(source_anchor_specs) >= len(faces),
|
||||
# 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):
|
||||
@@ -318,10 +433,44 @@ 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,
|
||||
@@ -374,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 "")
|
||||
@@ -411,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]
|
||||
@@ -430,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,
|
||||
)
|
||||
|
||||
@@ -536,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)
|
||||
|
||||
|
||||
|
||||
@@ -32,22 +32,16 @@ def _execute_hole(node: FeaturePlanNode, session: "ExecutionSession", *, wizard:
|
||||
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")
|
||||
if len(session.body_members) != 1:
|
||||
raise ValueError("hole scope body is no longer the sole active member")
|
||||
scoped_body = session.body_members.get(scope_feature_id)
|
||||
if scoped_body is None:
|
||||
raise ValueError("hole scope body is no longer an independently selectable member")
|
||||
scoped_solids = session.adapter.body_solids(scoped_body)
|
||||
active_solids = session.adapter.body_solids(session.body)
|
||||
if (
|
||||
len(scoped_solids) != 1
|
||||
or len(active_solids) != 1
|
||||
or not scoped_solids[0].is_same(active_solids[0])
|
||||
):
|
||||
raise ValueError("hole scope body does not match the active body")
|
||||
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):
|
||||
@@ -62,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
|
||||
@@ -78,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 的螺纹孔
|
||||
@@ -91,8 +92,21 @@ 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,
|
||||
))
|
||||
result_body = session.adapter.cut(session.body, tool)
|
||||
members = {scope_feature_id: result_body} if scope_feature_id is not None else None
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 与目标面非均匀相交(部分采样点未
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["sketch_workplane","profile_non_self_intersecting"],"candidate_verifiers":["single_connected_body"],"runtime_capability":{"body_mutating":true,"requires_active_body":false,"replayable":true,"requires_selector":false,"open_profile_ok":false}},
|
||||
"extrude_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},"nested_selector_policies":{"params.end_condition.reference":{"end_condition_type":"up_to_surface","token_kind":"face","output_role_contract":"direct_blind_extrude_cap","requires_immediate_owner":true}},"server_injected_paths":[],"reference_policy":{"mode":"none"},"semantic_preflight":["requires_active_solid","sketch_workplane","profile_non_self_intersecting","cut_exit_distance"],"candidate_verifiers":["single_connected_body","volume_decreased"],"runtime_capability":{"body_mutating":true,"requires_active_body":true,"replayable":true,"requires_selector":false,"open_profile_ok":true}},
|
||||
"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."
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -169,6 +169,8 @@ class IncrementalCdslExecution:
|
||||
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)
|
||||
|
||||
@@ -43,6 +43,7 @@ from .topology import (
|
||||
SelectorResolution,
|
||||
TopologyDelta,
|
||||
TopologyDeltaRelation,
|
||||
TopologyBlendRelation,
|
||||
TopologySectionRelation,
|
||||
TopologyLineage,
|
||||
TopologyRecord,
|
||||
@@ -65,6 +66,7 @@ __all__ = [
|
||||
"ThreadSpec",
|
||||
"TopologyDelta",
|
||||
"TopologyDeltaRelation",
|
||||
"TopologyBlendRelation",
|
||||
"TopologySectionRelation",
|
||||
"TopologyLineage",
|
||||
"TopologyRecord",
|
||||
|
||||
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": "设计基准轴:同上,强指纹。"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
@@ -38,18 +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_with_source_anchors(self, sketch: dict[str, Any]) -> tuple[list[Any], list[dict[str, 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: ...
|
||||
@@ -57,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]: ...
|
||||
@@ -82,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]: ...
|
||||
@@ -104,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,
|
||||
@@ -123,6 +209,8 @@ class ExecutionSession:
|
||||
# 拉伸)。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}
|
||||
@@ -135,11 +223,27 @@ class ExecutionSession:
|
||||
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=predecessors,
|
||||
additional_predecessors=predecessors, member_preservations=member_preservations,
|
||||
)
|
||||
else:
|
||||
# 一个 Compound 的全部成员共享同一个前置 body snapshot。逐个登记会让
|
||||
@@ -152,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=predecessors,
|
||||
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,
|
||||
@@ -160,6 +264,7 @@ 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,
|
||||
@@ -198,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()
|
||||
|
||||
@@ -489,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] = []
|
||||
@@ -516,6 +559,16 @@ def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[
|
||||
))
|
||||
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:
|
||||
@@ -537,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):
|
||||
@@ -552,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.
|
||||
|
||||
@@ -571,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 [], []
|
||||
@@ -582,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
|
||||
@@ -589,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"},
|
||||
}
|
||||
|
||||
|
||||
@@ -609,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)
|
||||
@@ -627,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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -10,15 +10,211 @@ 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 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `CAP_FACE` | `1511` | CADFS FeatureScript 1511 exported query history | Direct `extrude.start` / `extrude.end` builder output role; an immediate `up_to_surface` reference or a shell face-removal selector may consume that role | The consumer must immediately follow a direct `extrude_add_blind` `new_body` blind producer, with a complete/proven cap role and no stable-ID, binding-ID, or geometry fallback. `00925274` F3 resolves `extrude.start` for `up_to_surface`; `00212904` F2 and `00789939` F2 resolve immediate shell removals through `extrude.start` and `extrude.end`, respectively. Fresh pipelines preserve the distinction between execution and similarity: `00212904` executes through F5 but is RP-rejected, while `00789939` executes F2/F5 then preserves the F5 checkpoint when F6 remains unsupported. Later mutations, draft-with-holes, fused/multi-region results and generic CAP queries remain unsupported. |
|
||||
| `CAP_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; `BRepPrimAPI_MakePrism.FirstShape(source_edge)` / `LastShape(source_edge)` exact cap-edge handles | A direct fillet/chamfer consumer of an independent, undrafted, `new_body` blind prism: one exact direct source-profile edge -> one role-qualified `extrude.start` or `extrude.end` cap edge, followed only by complete/proven one-to-one continuations to the active body | The selector must name one retained direct source entity and explicit CAP side. The adapter accepts the cap handle only after `IsSame` verifies it is present in the final snapshot; resolver follows the source anchor and role through `boundary`, and accepts subsequent `continuation` only when every relation is complete/proven and operation cardinality remains one-to-one. A direct all-circle annulus with one contained circle is included when both source wires retain exact final-face membership; outer/inner roles remain distinct. A direct analytic region with a solver-split circular hole is included only when all four arcs carry the same explicit logical-circle source marker and that source maps uniquely to one unsplit profile circle; the adapter reconstructs one native wire before the same final-face check. Corpus evidence: `00021014` F2 resolves nine non-hole start/end selectors and is strict/RP passing; hole matrix `00479470`, `00501170`, `00526649` is RP passing and `00621329` is strict/RP passing. `00694309` F4 resolves F1's start CAP edge through F3's proven primary-cut continuation and is strict/RP passing. `00566233` resolves both F2 selectors before an OCC chamfer feasibility failure, while `00614954`/`00678961` resolve F2 before later unsupported queries. `00735367` resolves F2 from its line-outer/circular-hole region and executes F3 before an unrelated F4 unsupported query. Incomplete/mixed logical-circle markers do not create a circular-hole anchor; partial/branched continuation, trimmed/split profiles, multi-region-with-hole profiles, draft, multiple/two-sided extents, fused results, and unproven boolean/SPLIT/COPY/pattern continuations are rejected. This is not general CAP_EDGE replay. |
|
||||
| `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. |
|
||||
| `OFFSET_FACE` | `1511` | CADFS FeatureScript 1511 exported query history | `shell.offset_face` builder output role plus one TDD source cap | Immediate direct shell owner and one explicit `extrude.start` or `extrude.end` true dependency. |
|
||||
| `SWEPT_FACE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(edge)` relation plus final-snapshot `BRepAlgoAPI_Cut.Modified/Preserved` target history | An immediate fillet/chamfer consumer or shell removal, or a one-sided `up_to_surface` extent: one retained direct source-profile edge -> its generated side face, followed only by complete/proven kernel continuations to one active face | Limited end-to-end contract. The source prism must be independent, undrafted, `new_body`, blind, and retain one exact direct wire/face construction anchor. The extent variant carries no output role, stable ID, binding ID, or geometry hint. For a single-solid primary cut, target-side builder history is registered independently of whether its tool has direct-prism history; transient tool topology remains restricted to the separate source-qualified `INTERSECT` contract. Resolver traverses only the requested result topology kind, measures cardinality over complete/proven final-snapshot relations of that source/result kind, and still rejects any bound partial, split, merge, or inactive branch. Non-final intermediate handles and cross-type section diagnostics remain diagnostic only. `00925274` resolves F1 E0 through F3, F6, F9 and F12 target continuations and is strict/RP passing. The shell variant accepts only original non-construction lines. Direct all-circle construction includes one annular hole; independently constructed direct circle regions retain one delta per source face only when every generated result remains in the final snapshot. IMPRINT/split source profiles, draft, multiple/two-sided extents, fused source results, unproven/split continuations, revolve, sweep, loft, copy and pattern are rejected. Fresh corpus evidence: `00594348` and `00925274` are strict in `output/swept-face-target-continuation-20260910`; `00111611` and `00974931` retain rebuild failures. This is not family completion. |
|
||||
| `SWEPT_EDGE` | `1511` | CADFS FeatureScript 1511 exported query history; direct-prism `BRepPrimAPI_MakePrism.Generated(vertex)` relation | One immediate downstream consumer of an independent, undrafted, `new_body` blind prism: a source profile vertex, identified by its complete incident source-edge set -> its generated vertical edge | Limited end-to-end contract. The source vertex must retain two exact direct-profile edge anchors and the generated relation must be complete and active. One analytic contour region may contain hole wires: all wires are passed once to `BRepBuilderAPI_MakeFace`, then each retained anchor is checked against the finished face with `IsSame`. A profile with multiple regions and any hole stays on the established `Face.make_holes` path with no anchors, preserving its executable geometry. IMPRINT/split or otherwise mutated profiles, draft, multiple/two-sided extents, fused results, boolean/SPLIT successors, revolve, sweep, loft, copy and pattern are rejected; this is not family completion. Corpus evidence: `00000715` F3; `00007264` F2 resolves four selectors by kernel lineage but is comparison-rejected; `00039669` F2 is strict/RP passing; `00151159` F2 is RP passing. |
|
||||
| `INTERSECT` | `1511` | CADFS FeatureScript 1511 exported query history; OCC boolean `SectionEdges()` plus exact `Generated(face)` handles from both inputs | One `INTERSECT EDGE` of exactly two source-qualified direct-prism `CAP_FACE`/`SWEPT_FACE` inputs from either one explicit `boolean_bodies` target/tool pair, or one immediate primary `extrude_cut_blind` target plus its transient direct-prism tool snapshot | The adapter creates a relation only when the same section edge is returned by `Generated(face)` for exactly one face in each boolean input and `IsSame` binds all three handles to the input/final snapshots. A primary cut records its tool only as transient historical topology: exactly one active target solid/member, one tool solid, complete direct-prism anchors/history and one immediate consumer are required; the tool itself cannot be selected. Resolver requires one complete relation, the `intersection`/`one` policy and final active member. Unqualified `SectionEdges()`, duplicate output edges, incomplete source snapshots, keep-tools, multiple target/tool members, copied/transformed/patterned/TDD/IMPRINT inputs, non-direct prisms, primary query `disambiguationData` whose FeatureScript semantics are unverified, and unknown versions remain rejected. This is a contract test boundary; `00020311`/`00029250` retain their F3 checkpoints because their `OD(0/1)` selectors are deferred. Real-corpus RP evidence is not yet sufficient to mark general `INTERSECT` replay complete. |
|
||||
| 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)
|
||||
|
||||
@@ -29,10 +225,29 @@ geometry while lowering a `sweep` path. It therefore creates no
|
||||
|
||||
| Source query / consumer | FeatureScript version | Source evidence | Lowering and runtime boundary | Evidence |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `qUnion([qConstructionFilter(qBodyType(qCreatedBy(sketch, EDGE), WIRE), NO)])` as `sweep.path` | `1511` + direct `onshape/std/geometry.fs@1511.0` | Local Onshape standard-library mirror `query.fs`: `BodyType.WIRE` describes sketch curves (lines or curves), `qBodyType` retains entities owned by that body type, and `qConstructionFilter(..., NO)` retains only non-construction entities. The repository does not vendor the exact 1511 standard-library snapshot, so this is source-semantics evidence rather than a claim that all revisions are compatible. | The owner must resolve to a source sketch with exactly one non-construction entity, whose exact source type is `line` or `bspline`; the outer query must be the exact singleton `qUnion` wrapper. `line` paths retain their endpoints. `bspline` paths retain the exported interpolation points, parameters, and endpoint derivatives; a two-point B-spline is executable only when both derivatives are present. Multiple entities, arcs/circles, construction-only geometry, omitted wrapper/filter, other query composition, other language/library revisions, loft guides, surface/profile consumers, and all runtime-body uses remain rejected or deferred. | The fresh seven-sample RP matrix `output/qbodytype-direct-sketch-wire-matrix-20260910-v1` rebuilds all direct-path cases: `00191739` is strict, `00726304` is RP-only approximate, `00227428`/`00287471`/`00500952`/`00816123` are comparison-rejected, and two-point B-spline `00896761` is `comparison_timeout` at 60 seconds. These are execution classifications, not a claim that rejected/timeout models are similar. `00786708` F2 remains deferred because F0 contains multiple non-construction path entities; its independent F5 single-line path still lowers. |
|
||||
| `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
|
||||
@@ -42,6 +257,36 @@ 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`
|
||||
|
||||
@@ -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,781(19.05%)**;strict 接受 = **744(7.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,159(12.40%) | 578(6.18%) | 1,812 | 8,108 | 1,773 | 1,719 |
|
||||
| 2026-09-07 (`20260907-235209`) | 2,829 | 4,283 | 2,235 | 1,358(14.53%) | 654(7.00%) | 5,554 | 7,112 | 5,554 | 5,250 |
|
||||
| 2026-09-08 (`20260908-215959`) | 5,367 | 3,266 | 693 | 2,031(21.73%) | 905(9.68%) | 6,134 | 8,633 | 6,134 | 6,090 |
|
||||
| 2026-09-09 (`20260909-191531`) | 6,232 | 2,710 | 396 | 2,253(24.10%) | 989(10.58%) | 8,176 | 8,942 | 8,364 | 6,007 |
|
||||
| 2026-09-11(最新 `output`) | **6,626** | **2,325** | **366** | **1,781(19.05%)** | **744(7.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 shape(157)、`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` contract;direct `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` 变体完成 rebuilt;38 项 selector provenance 测试及 lowering 联跑 156 passed。 | 全量 corpus 暂无 native `qOwnerBody`;不支持多 member、multi-solid、later successor、其它 consumer 或 current aggregate/geometry fallback。 |
|
||||
| fallback 与 replay 收紧 | `selector_intent.version` 成为唯一 canonical version;production `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 relation,deleted/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` selector;closed/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 coverage;unbounded 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,626(70.89%) | 2,829(30.27%) | +3,797(+40.62 pp) |
|
||||
| 生成可执行 STEP | 8,126 | 5,554 | +2,572 |
|
||||
| RP 通过 | 1,781(19.05%) | 1,358(14.53%) | +423(+4.52 pp) |
|
||||
| 严格通过 | 744(7.96%) | 654(7.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 replay;COPY/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` 等已拆分为新子项,不能直接与首次单项相减。
|
||||
@@ -49,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)
|
||||
@@ -151,6 +157,20 @@ 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)
|
||||
@@ -175,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
|
||||
|
||||
+5287
-242
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -49,6 +49,42 @@ 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, finalize_cdsl_execution
|
||||
from .selector_binding import bind_and_execute_candidate_selectors
|
||||
@@ -56,7 +92,11 @@ def rebuild_candidate(cdsl: dict[str, Any], output: Path) -> dict[str, Any]:
|
||||
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}
|
||||
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:
|
||||
replay = bind_and_execute_candidate_selectors(cdsl)
|
||||
result = finalize_cdsl_execution(replay.execution, output)
|
||||
|
||||
@@ -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'}`",
|
||||
"",
|
||||
|
||||
@@ -123,7 +123,10 @@ def _selector_roots(feature: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
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):
|
||||
# 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
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
from copy import deepcopy
|
||||
import json
|
||||
import math
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -29,10 +30,19 @@ def strip_provenance_intents(value: Any) -> int:
|
||||
"""Remove provenance-only selector fields from one copied CDSL value."""
|
||||
removed = 0
|
||||
if isinstance(value, dict):
|
||||
removed_intent = False
|
||||
for key in ("selector_intent", "selector_intent_version"):
|
||||
if key in value:
|
||||
value.pop(key)
|
||||
removed += 1
|
||||
removed_intent = True
|
||||
# Feature output roles are semantic builder evidence, just like a
|
||||
# selector intent. The geometry-only probe must not leave an output
|
||||
# role in place after discarding the evidence that validates it.
|
||||
if removed_intent:
|
||||
for key in ("output_role", "output_role_source"):
|
||||
if key in value:
|
||||
value.pop(key)
|
||||
for child in value.values():
|
||||
removed += strip_provenance_intents(child)
|
||||
elif isinstance(value, list):
|
||||
@@ -63,7 +73,7 @@ def _compare_worker(gold: str, rebuilt: str, result: str) -> None:
|
||||
write_json(Path(result), compare_steps(Path(gold), Path(rebuilt)))
|
||||
|
||||
|
||||
def _isolated(target: Any, args: tuple[str, ...], result_path: Path, timeout_seconds: float) -> str:
|
||||
def _isolated(target: Any, args: tuple[Any, ...], result_path: Path, timeout_seconds: float) -> str:
|
||||
"""Bound an OCC experiment so one candidate cannot stall the demo."""
|
||||
result_path.unlink(missing_ok=True)
|
||||
process = multiprocessing.get_context("spawn").Process(target=target, args=args)
|
||||
@@ -134,6 +144,41 @@ def _strict_passed(comparison: dict[str, Any] | None) -> bool:
|
||||
return bool(isinstance(comparison, dict) and (comparison.get("strict") or {}).get("passed"))
|
||||
|
||||
|
||||
_SEARCH_TOKEN = "_selector_search_token"
|
||||
|
||||
|
||||
def _selector_search_token(feature_id: str, location: str) -> str:
|
||||
"""Use a structural token while a group expands a public selector list."""
|
||||
return f"{feature_id}:{location}"
|
||||
|
||||
|
||||
def _annotate_selector_search_tokens(cdsl: dict[str, Any]) -> int:
|
||||
"""Mark copied diagnostic selectors without creating a durable CDSL field."""
|
||||
count = 0
|
||||
for feature in cdsl.get("features") or []:
|
||||
if not isinstance(feature, dict) or not isinstance(feature.get("id"), str):
|
||||
continue
|
||||
for location, selector in _selector_sites(feature):
|
||||
selector[_SEARCH_TOKEN] = _selector_search_token(feature["id"], location)
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _strip_selector_search_tokens(value: Any) -> int:
|
||||
"""Remove experiment-only branch tokens before a CDSL is persisted."""
|
||||
removed = 0
|
||||
if isinstance(value, dict):
|
||||
if _SEARCH_TOKEN in value:
|
||||
value.pop(_SEARCH_TOKEN)
|
||||
removed += 1
|
||||
for child in value.values():
|
||||
removed += _strip_selector_search_tokens(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
removed += _strip_selector_search_tokens(child)
|
||||
return removed
|
||||
|
||||
|
||||
def _selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""Return the public selector positions of one CDSL feature.
|
||||
|
||||
@@ -270,6 +315,8 @@ def _emit_strict_selector_record(
|
||||
*,
|
||||
rebuild_timeout_seconds: float,
|
||||
comparison_timeout_seconds: float,
|
||||
query_groups: list[dict[str, Any]] | None = None,
|
||||
candidate_replay_kind: str = "geometry_probe",
|
||||
) -> dict[str, Any]:
|
||||
"""Create and independently verify a compiled CDSL only after strict proof."""
|
||||
if not _strict_passed(geometry_comparison):
|
||||
@@ -286,6 +333,7 @@ def _emit_strict_selector_record(
|
||||
}
|
||||
|
||||
compiled_candidate = deepcopy(bound_cdsl)
|
||||
_strip_selector_search_tokens(compiled_candidate)
|
||||
compiled_path = directory / "selector_record.compiled.cdsl.json"
|
||||
write_json(compiled_path, compiled_candidate)
|
||||
compiled = _run_rebuild(
|
||||
@@ -322,20 +370,29 @@ def _emit_strict_selector_record(
|
||||
result["reason"] = "no_featurescript_selector_binding"
|
||||
return result
|
||||
record_path = directory / "selector-record.json"
|
||||
write_json(record_path, {
|
||||
verification = {
|
||||
"compiled_cdsl_strict": True,
|
||||
"compiled_cdsl": str(compiled_path),
|
||||
}
|
||||
if candidate_replay_kind == "geometry_probe":
|
||||
verification["geometry_probe_strict"] = True
|
||||
else:
|
||||
verification["selector_search_branch_strict"] = True
|
||||
record_payload: dict[str, Any] = {
|
||||
"schema": "cadfs_to_cdsl.selector_record_set.v1",
|
||||
"sample_id": sample.sample_id,
|
||||
"source": {
|
||||
"featurescript": sample.files.get("featurescript"),
|
||||
"step": sample.files.get("step"),
|
||||
},
|
||||
"verification": {
|
||||
"geometry_probe_strict": True,
|
||||
"compiled_cdsl_strict": True,
|
||||
"compiled_cdsl": str(compiled_path),
|
||||
},
|
||||
"verification": verification,
|
||||
"records": records,
|
||||
})
|
||||
}
|
||||
if query_groups:
|
||||
# These are the query-to-record *sets* selected by the offline search.
|
||||
# A FeatureScript query is not necessarily a single OCC record.
|
||||
record_payload["query_groups"] = deepcopy(query_groups)
|
||||
write_json(record_path, record_payload)
|
||||
result.update({
|
||||
"status": "strict_replayed",
|
||||
"record_count": len(records),
|
||||
@@ -359,41 +416,58 @@ def _searchable_selector_sites(feature: dict[str, Any]) -> list[tuple[str, dict[
|
||||
]
|
||||
|
||||
|
||||
def _replace_selector_site(cdsl: dict[str, Any], feature_id: str, location: str, selector: dict[str, Any]) -> None:
|
||||
"""Replace one public direct selector site in a copied candidate CDSL."""
|
||||
def _replace_selector_group(
|
||||
cdsl: dict[str, Any],
|
||||
feature_id: str,
|
||||
token: str,
|
||||
selectors: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Expand one selector root to an explicit branch-local record set."""
|
||||
feature = next(
|
||||
(item for item in cdsl.get("features") or [] if isinstance(item, dict) and item.get("id") == feature_id),
|
||||
None,
|
||||
)
|
||||
if feature is None:
|
||||
raise ValueError(f"search branch cannot find feature {feature_id}")
|
||||
if location.startswith("selectors[") and location.endswith("]"):
|
||||
index = int(location[len("selectors["):-1])
|
||||
selectors = feature.get("selectors") or []
|
||||
if index < 0 or index >= len(selectors) or not isinstance(selectors[index], dict):
|
||||
raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}")
|
||||
selectors[index] = deepcopy(selector)
|
||||
roots = feature.get("selectors") or []
|
||||
index = next(
|
||||
(
|
||||
item_index
|
||||
for item_index, selector in enumerate(roots)
|
||||
if isinstance(selector, dict) and selector.get(_SEARCH_TOKEN) == token
|
||||
),
|
||||
None,
|
||||
)
|
||||
if index is not None:
|
||||
roots[index:index + 1] = [deepcopy(selector) for selector in selectors]
|
||||
feature["selectors"] = roots
|
||||
return
|
||||
prefix = "params."
|
||||
suffix = ".reference"
|
||||
if location.startswith(prefix) and location.endswith(suffix):
|
||||
name = location[len(prefix):-len(suffix)]
|
||||
for name in ("end_condition", "reverse_end_condition"):
|
||||
condition = (feature.get("params") or {}).get(name)
|
||||
if not isinstance(condition, dict) or not isinstance(condition.get("reference"), dict):
|
||||
raise ValueError(f"search branch selector site is unavailable: {feature_id}:{location}")
|
||||
condition["reference"] = deepcopy(selector)
|
||||
return
|
||||
raise ValueError(f"search branch does not support nested selector site: {feature_id}:{location}")
|
||||
reference = condition.get("reference") if isinstance(condition, dict) else None
|
||||
if isinstance(reference, dict) and reference.get(_SEARCH_TOKEN) == token:
|
||||
if len(selectors) != 1:
|
||||
raise ValueError("search group cannot expand an extent reference")
|
||||
condition["reference"] = deepcopy(selectors[0])
|
||||
return
|
||||
raise ValueError(f"search branch selector token is unavailable: {feature_id}:{token}")
|
||||
|
||||
|
||||
def _forced_candidate_selector(placeholder: dict[str, Any], candidate: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Turn one public runtime record into a branch-local explicit selector."""
|
||||
def _forced_candidate_selectors(placeholder: dict[str, Any], candidates: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Turn a query's runtime record set into explicit branch-local roots."""
|
||||
from .selector_binding import _bound_selector
|
||||
|
||||
selector, _bound = _bound_selector(placeholder, [candidate])
|
||||
if not isinstance(selector, dict):
|
||||
raise ValueError("search branch produced an invalid explicit selector")
|
||||
return selector
|
||||
selectors: list[dict[str, Any]] = []
|
||||
for candidate in candidates:
|
||||
selector, _bound = _bound_selector(placeholder, [candidate])
|
||||
if not isinstance(selector, dict):
|
||||
raise ValueError("search branch produced an invalid explicit selector")
|
||||
if placeholder.get(_SEARCH_TOKEN) is not None:
|
||||
selector[_SEARCH_TOKEN] = placeholder[_SEARCH_TOKEN]
|
||||
selectors.append(selector)
|
||||
if not selectors:
|
||||
raise ValueError("search group has no runtime records")
|
||||
return selectors
|
||||
|
||||
|
||||
def _resolve_search_selector(placeholder: dict[str, Any], *, registry: Any, active_body_id: str | None) -> Any:
|
||||
@@ -432,12 +506,552 @@ def _search_candidate_records(
|
||||
return [deepcopy(item) for item in candidates[:maximum]], len(candidates)
|
||||
|
||||
|
||||
def _search_replay_worker(candidate: str, step: str, result: str, maximum_candidates: int) -> None:
|
||||
def _vector(value: Any) -> list[float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 3:
|
||||
return None
|
||||
try:
|
||||
result = [float(component) for component in value]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return result if all(math.isfinite(component) for component in result) else None
|
||||
|
||||
|
||||
def _sub(left: list[float], right: list[float]) -> list[float]:
|
||||
return [left[index] - right[index] for index in range(3)]
|
||||
|
||||
|
||||
def _dot(left: list[float], right: list[float]) -> float:
|
||||
return sum(left[index] * right[index] for index in range(3))
|
||||
|
||||
|
||||
def _cross(left: list[float], right: list[float]) -> list[float]:
|
||||
return [
|
||||
left[1] * right[2] - left[2] * right[1],
|
||||
left[2] * right[0] - left[0] * right[2],
|
||||
left[0] * right[1] - left[1] * right[0],
|
||||
]
|
||||
|
||||
|
||||
def _norm(value: list[float]) -> float:
|
||||
return math.sqrt(_dot(value, value))
|
||||
|
||||
|
||||
def _unit(value: list[float] | None) -> list[float] | None:
|
||||
if value is None or len(value) != 3:
|
||||
return None
|
||||
length = _norm(value)
|
||||
if length <= 1e-9:
|
||||
return None
|
||||
return [component / length for component in value]
|
||||
|
||||
|
||||
def _distance(left: list[float], right: list[float]) -> float:
|
||||
return _norm(_sub(left, right))
|
||||
|
||||
|
||||
def _score_distance(distance_mm: float, *, tolerance_mm: float = 0.05) -> float:
|
||||
return max(0.0, 1.0 - distance_mm / tolerance_mm)
|
||||
|
||||
|
||||
def _source_selector_map(provenance_candidate: dict[str, Any]) -> dict[str, dict[str, Any]]:
|
||||
"""Index source selectors by an experiment-only structural token."""
|
||||
selectors: dict[str, dict[str, Any]] = {}
|
||||
for feature in provenance_candidate.get("features") or []:
|
||||
if not isinstance(feature, dict) or not isinstance(feature.get("id"), str):
|
||||
continue
|
||||
for location, selector in _selector_sites(feature):
|
||||
if isinstance(selector.get("selector_intent"), dict):
|
||||
selectors[_selector_search_token(feature["id"], location)] = deepcopy(selector)
|
||||
return selectors
|
||||
|
||||
|
||||
def _source_feature(provenance_candidate: dict[str, Any], feature_id: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(feature_id, str):
|
||||
return None
|
||||
return next(
|
||||
(
|
||||
feature
|
||||
for feature in provenance_candidate.get("features") or []
|
||||
if isinstance(feature, dict) and feature.get("id") == feature_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _source_sketch(provenance_candidate: dict[str, Any], producer: dict[str, Any], source_sketch_id: str | None) -> dict[str, Any] | None:
|
||||
sketches = (provenance_candidate.get("geometry") or {}).get("sketches") or []
|
||||
sketch_id = producer.get("sketch_id")
|
||||
if isinstance(sketch_id, str):
|
||||
sketch = next((item for item in sketches if isinstance(item, dict) and item.get("id") == sketch_id), None)
|
||||
if isinstance(sketch, dict):
|
||||
return sketch
|
||||
if isinstance(source_sketch_id, str):
|
||||
sketch = next(
|
||||
(
|
||||
item
|
||||
for item in sketches
|
||||
if isinstance(item, dict) and item.get("source_sketch_id") == source_sketch_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if isinstance(sketch, dict):
|
||||
return sketch
|
||||
return None
|
||||
|
||||
|
||||
def _source_curve(sketch: dict[str, Any], entity_id: str) -> dict[str, Any] | None:
|
||||
profile = sketch.get("profile") or {}
|
||||
for entry in profile.get("source_entities") or []:
|
||||
if isinstance(entry, dict) and entry.get("id") == entity_id and isinstance(entry.get("curve"), dict):
|
||||
return entry["curve"]
|
||||
for contour in profile.get("contours") or []:
|
||||
if not isinstance(contour, dict):
|
||||
continue
|
||||
for segment in contour.get("segments") or []:
|
||||
if isinstance(segment, dict) and segment.get("source_entity_id") == entity_id:
|
||||
return segment
|
||||
for segment in profile.get("construction") or []:
|
||||
if isinstance(segment, dict) and segment.get("source_entity_id") == entity_id:
|
||||
return segment
|
||||
return None
|
||||
|
||||
|
||||
def _producer_frame(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
) -> tuple[dict[str, Any], dict[str, Any], list[float], float] | None:
|
||||
"""Recover only the direct sketch/extrude frame needed for candidate rank."""
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
source_entity = intent.get("source_entity") if isinstance(intent.get("source_entity"), dict) else None
|
||||
source_entities = intent.get("source_entities") or []
|
||||
source_sketch_id = (
|
||||
source_entity.get("sketch_id")
|
||||
if isinstance(source_entity, dict)
|
||||
else next(
|
||||
(
|
||||
item.get("sketch_id")
|
||||
for item in source_entities
|
||||
if isinstance(item, dict) and isinstance(item.get("sketch_id"), str)
|
||||
),
|
||||
None,
|
||||
)
|
||||
)
|
||||
producer = _source_feature(provenance_candidate, source_selector.get("owner_feature_id"))
|
||||
if producer is None or not str(producer.get("atomic_id") or "").startswith("extrude_"):
|
||||
return None
|
||||
sketch = _source_sketch(provenance_candidate, producer, source_sketch_id)
|
||||
if sketch is None:
|
||||
return None
|
||||
plane = sketch.get("workplane") or {}
|
||||
origin = _vector(plane.get("origin_mm"))
|
||||
normal = _unit(_vector(plane.get("normal")) or [])
|
||||
if origin is None or normal is None:
|
||||
return None
|
||||
params = producer.get("params") or {}
|
||||
try:
|
||||
span = abs(float(params["distance_mm"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if span <= 1e-9:
|
||||
return None
|
||||
if bool(params.get("reverse") or params.get("opposite_direction")):
|
||||
normal = [-component for component in normal]
|
||||
return producer, sketch, normal, span
|
||||
|
||||
|
||||
def _world_point(sketch: dict[str, Any], point: Any) -> list[float] | None:
|
||||
local = point if isinstance(point, (list, tuple)) and len(point) in {2, 3} else None
|
||||
if local is None:
|
||||
return None
|
||||
try:
|
||||
coordinates = [float(value) for value in local]
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
plane = sketch.get("workplane") or {}
|
||||
origin = _vector(plane.get("origin_mm"))
|
||||
x_axis = _unit(_vector(plane.get("x_dir")) or [])
|
||||
normal = _unit(_vector(plane.get("normal")) or [])
|
||||
if origin is None or x_axis is None or normal is None:
|
||||
return None
|
||||
y_axis = _unit(_cross(normal, x_axis))
|
||||
if y_axis is None:
|
||||
return None
|
||||
z = coordinates[2] if len(coordinates) == 3 else 0.0
|
||||
return [
|
||||
origin[index] + x_axis[index] * coordinates[0] + y_axis[index] * coordinates[1] + normal[index] * z
|
||||
for index in range(3)
|
||||
]
|
||||
|
||||
|
||||
def _source_entity_ids(source_selector: dict[str, Any]) -> list[str]:
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
entity = intent.get("source_entity")
|
||||
if isinstance(entity, dict) and isinstance(entity.get("entity_id"), str):
|
||||
return [entity["entity_id"]]
|
||||
return sorted({
|
||||
item["entity_id"]
|
||||
for item in intent.get("source_entities") or []
|
||||
if isinstance(item, dict) and isinstance(item.get("entity_id"), str)
|
||||
})
|
||||
|
||||
|
||||
def _cap_role_hint(source_selector: dict[str, Any]) -> str | None:
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
role = source_selector.get("output_role") or intent.get("output_role")
|
||||
if isinstance(role, str) and role in {"extrude.start", "extrude.end"}:
|
||||
return role.rsplit(".", 1)[1]
|
||||
ast = ((intent.get("source_query") or {}).get("ast") or {})
|
||||
args = ast.get("args") if isinstance(ast, dict) else None
|
||||
options = args[3] if isinstance(args, list) and len(args) >= 4 and isinstance(args[3], dict) else {}
|
||||
is_start = options.get("isStart")
|
||||
if is_start in {True, "true"}:
|
||||
return "start"
|
||||
if is_start in {False, "false"}:
|
||||
return "end"
|
||||
return None
|
||||
|
||||
|
||||
def _query_group_descriptor(token: str, source_selector: dict[str, Any]) -> dict[str, Any]:
|
||||
intent = source_selector.get("selector_intent") or {}
|
||||
policy = intent.get("derivation_policy") or {}
|
||||
return {
|
||||
"token": token,
|
||||
"feature_id": token.split(":", 1)[0],
|
||||
"source_location": token.split(":", 1)[1] if ":" in token else token,
|
||||
"kind": source_selector.get("kind"),
|
||||
"owner_feature_id": source_selector.get("owner_feature_id"),
|
||||
"query_family": intent.get("query_family"),
|
||||
"multiplicity": policy.get("multiplicity", "one"),
|
||||
"source_entity_ids": _source_entity_ids(source_selector),
|
||||
"source_make_query": deepcopy((intent.get("source_query") or {}).get("ast")),
|
||||
}
|
||||
|
||||
|
||||
def _rank_cap_face(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[float, dict[str, Any]] | None:
|
||||
frame = _producer_frame(provenance_candidate, source_selector)
|
||||
role = _cap_role_hint(source_selector)
|
||||
if frame is None or role is None:
|
||||
return None
|
||||
_producer, sketch, direction, span = frame
|
||||
origin = _vector((sketch.get("workplane") or {}).get("origin_mm"))
|
||||
geometry = candidate.get("geometry") or {}
|
||||
actual_normal = _unit(_vector(geometry.get("plane_normal") or geometry.get("normal")) or [])
|
||||
if origin is None or actual_normal is None or geometry.get("surface_type") != "plane":
|
||||
return None
|
||||
point = origin if role == "start" else [origin[index] + direction[index] * span for index in range(3)]
|
||||
expected_offset = _dot(direction, point)
|
||||
actual_offset = geometry.get("plane_offset_mm")
|
||||
try:
|
||||
actual_offset = float(actual_offset)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
alignment = abs(_dot(direction, actual_normal))
|
||||
signed_offset = actual_offset if _dot(direction, actual_normal) >= 0 else -actual_offset
|
||||
offset_error = abs(expected_offset - signed_offset)
|
||||
return (alignment + _score_distance(offset_error)) / 2.0, {
|
||||
"method": "extrude_cap_plane",
|
||||
"cap_role": role,
|
||||
"normal_alignment": round(alignment, 6),
|
||||
"plane_offset_error_mm": round(offset_error, 9),
|
||||
}
|
||||
|
||||
|
||||
def _rank_swept_face(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[float, dict[str, Any]] | None:
|
||||
frame = _producer_frame(provenance_candidate, source_selector)
|
||||
entity_ids = _source_entity_ids(source_selector)
|
||||
if frame is None or len(entity_ids) != 1:
|
||||
return None
|
||||
_producer, sketch, direction, _span = frame
|
||||
curve = _source_curve(sketch, entity_ids[0])
|
||||
geometry = candidate.get("geometry") or {}
|
||||
if not isinstance(curve, dict):
|
||||
return None
|
||||
if curve.get("type") == "line":
|
||||
start, end = _world_point(sketch, curve.get("start")), _world_point(sketch, curve.get("end"))
|
||||
actual_normal = _unit(_vector(geometry.get("plane_normal") or geometry.get("normal")) or [])
|
||||
if start is None or end is None or actual_normal is None or geometry.get("surface_type") != "plane":
|
||||
return None
|
||||
expected_normal = _unit(_cross(_sub(end, start), direction))
|
||||
if expected_normal is None:
|
||||
return None
|
||||
try:
|
||||
actual_offset = float(geometry["plane_offset_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
alignment = abs(_dot(expected_normal, actual_normal))
|
||||
expected_offset = _dot(expected_normal, start)
|
||||
signed_offset = actual_offset if _dot(expected_normal, actual_normal) >= 0 else -actual_offset
|
||||
offset_error = abs(expected_offset - signed_offset)
|
||||
return (alignment + _score_distance(offset_error)) / 2.0, {
|
||||
"method": "source_line_supporting_plane",
|
||||
"normal_alignment": round(alignment, 6),
|
||||
"plane_offset_error_mm": round(offset_error, 9),
|
||||
}
|
||||
if curve.get("type") not in {"arc", "circle"}:
|
||||
return None
|
||||
center = _world_point(sketch, curve.get("center"))
|
||||
actual_origin = _vector(geometry.get("axis_origin_mm"))
|
||||
actual_direction = _unit(_vector(geometry.get("axis_direction")) or [])
|
||||
try:
|
||||
radius_error = abs(float(curve["radius_mm"]) - float(geometry["radius_mm"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
if center is None or actual_origin is None or actual_direction is None or geometry.get("surface_type") != "cylinder":
|
||||
return None
|
||||
alignment = abs(_dot(direction, actual_direction))
|
||||
radial_offset = _norm(_cross(_sub(center, actual_origin), actual_direction))
|
||||
return (
|
||||
alignment + _score_distance(radial_offset) + _score_distance(radius_error)
|
||||
) / 3.0, {
|
||||
"method": "source_arc_cylinder",
|
||||
"axis_alignment": round(alignment, 6),
|
||||
"axis_offset_mm": round(radial_offset, 9),
|
||||
"radius_error_mm": round(radius_error, 9),
|
||||
}
|
||||
|
||||
|
||||
def _point2(value: Any) -> tuple[float, float] | None:
|
||||
if not isinstance(value, (list, tuple)) or len(value) < 2:
|
||||
return None
|
||||
try:
|
||||
point = float(value[0]), float(value[1])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return point if all(math.isfinite(component) for component in point) else None
|
||||
|
||||
|
||||
def _curve_contains_point(curve: dict[str, Any], point: tuple[float, float], *, tolerance_mm: float = 0.05) -> bool:
|
||||
curve_type = curve.get("type")
|
||||
if curve_type == "line":
|
||||
start, end = _point2(curve.get("start")), _point2(curve.get("end"))
|
||||
if start is None or end is None:
|
||||
return False
|
||||
direction = end[0] - start[0], end[1] - start[1]
|
||||
length = math.hypot(*direction)
|
||||
if length <= 1e-9:
|
||||
return False
|
||||
offset = point[0] - start[0], point[1] - start[1]
|
||||
distance = abs(direction[0] * offset[1] - direction[1] * offset[0]) / length
|
||||
parameter = (direction[0] * offset[0] + direction[1] * offset[1]) / (length * length)
|
||||
return distance <= tolerance_mm and -tolerance_mm / length <= parameter <= 1.0 + tolerance_mm / length
|
||||
center = _point2(curve.get("center"))
|
||||
try:
|
||||
radius = float(curve["radius_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if center is None or radius <= 1e-9 or abs(math.dist(center, point) - radius) > tolerance_mm:
|
||||
return False
|
||||
if curve_type == "circle":
|
||||
return True
|
||||
if curve_type != "arc":
|
||||
return False
|
||||
start, end = _point2(curve.get("start")), _point2(curve.get("end"))
|
||||
if start is None or end is None:
|
||||
return False
|
||||
angle = math.atan2(point[1] - center[1], point[0] - center[0])
|
||||
start_angle = math.atan2(start[1] - center[1], start[0] - center[0])
|
||||
end_angle = math.atan2(end[1] - center[1], end[0] - center[0])
|
||||
full_turn = 2.0 * math.pi
|
||||
if bool(curve.get("clockwise")):
|
||||
travelled = (start_angle - angle) % full_turn
|
||||
total = (start_angle - end_angle) % full_turn
|
||||
else:
|
||||
travelled = (angle - start_angle) % full_turn
|
||||
total = (end_angle - start_angle) % full_turn
|
||||
angular_tolerance = tolerance_mm / radius
|
||||
return travelled <= total + angular_tolerance
|
||||
|
||||
|
||||
def _curve_intersections_2d(left: dict[str, Any], right: dict[str, Any]) -> list[tuple[float, float]]:
|
||||
"""Intersect line/arc/circle supports, retaining only source curve spans."""
|
||||
left_type, right_type = left.get("type"), right.get("type")
|
||||
points: list[tuple[float, float]] = []
|
||||
if left_type == "line" and right_type == "line":
|
||||
left_start, left_end = _point2(left.get("start")), _point2(left.get("end"))
|
||||
right_start, right_end = _point2(right.get("start")), _point2(right.get("end"))
|
||||
if None not in (left_start, left_end, right_start, right_end):
|
||||
left_dx, left_dy = left_end[0] - left_start[0], left_end[1] - left_start[1]
|
||||
right_dx, right_dy = right_end[0] - right_start[0], right_end[1] - right_start[1]
|
||||
determinant = left_dx * right_dy - left_dy * right_dx
|
||||
if abs(determinant) > 1e-9:
|
||||
offset_x, offset_y = right_start[0] - left_start[0], right_start[1] - left_start[1]
|
||||
parameter = (offset_x * right_dy - offset_y * right_dx) / determinant
|
||||
points.append((left_start[0] + parameter * left_dx, left_start[1] + parameter * left_dy))
|
||||
else:
|
||||
line, circular = (left, right) if left_type == "line" else (right, left)
|
||||
if line.get("type") == "line" and circular.get("type") in {"arc", "circle"}:
|
||||
start, end, center = _point2(line.get("start")), _point2(line.get("end")), _point2(circular.get("center"))
|
||||
try:
|
||||
radius = float(circular["radius_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
radius = 0.0
|
||||
if start is not None and end is not None and center is not None and radius > 1e-9:
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
ox, oy = start[0] - center[0], start[1] - center[1]
|
||||
quadratic_a = dx * dx + dy * dy
|
||||
quadratic_b = 2.0 * (dx * ox + dy * oy)
|
||||
quadratic_c = ox * ox + oy * oy - radius * radius
|
||||
discriminant = quadratic_b * quadratic_b - 4.0 * quadratic_a * quadratic_c
|
||||
if quadratic_a > 1e-12 and discriminant >= -1e-9:
|
||||
root = math.sqrt(max(0.0, discriminant))
|
||||
for parameter in ((-quadratic_b - root) / (2.0 * quadratic_a), (-quadratic_b + root) / (2.0 * quadratic_a)):
|
||||
points.append((start[0] + parameter * dx, start[1] + parameter * dy))
|
||||
elif left_type in {"arc", "circle"} and right_type in {"arc", "circle"}:
|
||||
left_center, right_center = _point2(left.get("center")), _point2(right.get("center"))
|
||||
try:
|
||||
left_radius, right_radius = float(left["radius_mm"]), float(right["radius_mm"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
left_radius = right_radius = 0.0
|
||||
if left_center is not None and right_center is not None and left_radius > 1e-9 and right_radius > 1e-9:
|
||||
dx, dy = right_center[0] - left_center[0], right_center[1] - left_center[1]
|
||||
center_distance = math.hypot(dx, dy)
|
||||
if 1e-9 < center_distance <= left_radius + right_radius + 0.05:
|
||||
along = (left_radius * left_radius - right_radius * right_radius + center_distance * center_distance) / (2.0 * center_distance)
|
||||
height_squared = left_radius * left_radius - along * along
|
||||
if height_squared >= -1e-9:
|
||||
height = math.sqrt(max(0.0, height_squared))
|
||||
base_x, base_y = left_center[0] + along * dx / center_distance, left_center[1] + along * dy / center_distance
|
||||
offset_x, offset_y = -dy * height / center_distance, dx * height / center_distance
|
||||
points.extend(((base_x + offset_x, base_y + offset_y), (base_x - offset_x, base_y - offset_y)))
|
||||
valid: list[tuple[float, float]] = []
|
||||
for point in points:
|
||||
if not _curve_contains_point(left, point) or not _curve_contains_point(right, point):
|
||||
continue
|
||||
if not any(math.dist(point, existing) <= 1e-6 for existing in valid):
|
||||
valid.append(point)
|
||||
return valid
|
||||
|
||||
|
||||
def _source_curve_intersection(sketch: dict[str, Any], entity_ids: list[str]) -> list[float] | None:
|
||||
if len(entity_ids) != 2:
|
||||
return None
|
||||
curves = [_source_curve(sketch, entity_id) for entity_id in entity_ids]
|
||||
if any(not isinstance(curve, dict) for curve in curves):
|
||||
return None
|
||||
intersections = _curve_intersections_2d(curves[0], curves[1])
|
||||
if len(intersections) != 1:
|
||||
return None
|
||||
return _world_point(sketch, intersections[0])
|
||||
|
||||
|
||||
def _rank_swept_edge(
|
||||
provenance_candidate: dict[str, Any],
|
||||
source_selector: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
) -> tuple[float, dict[str, Any]] | None:
|
||||
frame = _producer_frame(provenance_candidate, source_selector)
|
||||
entity_ids = _source_entity_ids(source_selector)
|
||||
if frame is None:
|
||||
return None
|
||||
_producer, sketch, direction, _span = frame
|
||||
source_point = _source_curve_intersection(sketch, entity_ids)
|
||||
geometry = candidate.get("geometry") or {}
|
||||
start, end = _vector(geometry.get("start_mm")), _vector(geometry.get("end_mm"))
|
||||
if source_point is None or start is None or end is None or geometry.get("curve_type") != "line":
|
||||
return None
|
||||
edge_direction = _unit(_sub(end, start))
|
||||
if edge_direction is None:
|
||||
return None
|
||||
alignment = abs(_dot(direction, edge_direction))
|
||||
supporting_line_error = _norm(_cross(_sub(source_point, start), direction))
|
||||
return (alignment + _score_distance(supporting_line_error)) / 2.0, {
|
||||
"method": "source_pair_extrusion_vertex",
|
||||
"axis_alignment": round(alignment, 6),
|
||||
"source_vertex_offset_mm": round(supporting_line_error, 9),
|
||||
}
|
||||
|
||||
|
||||
def _query_candidate_groups(
|
||||
provenance_candidate: dict[str, Any],
|
||||
token: str,
|
||||
source_selector: dict[str, Any] | None,
|
||||
resolution: Any,
|
||||
*,
|
||||
maximum: int,
|
||||
) -> tuple[dict[str, Any], list[dict[str, Any]], int]:
|
||||
"""Rank branch alternatives as FeatureScript query result sets.
|
||||
|
||||
This is deliberately an offline geometric heuristic. It gets one chance
|
||||
to reduce a query to record *sets*, then final STEP strict replay decides
|
||||
whether that set is useful. It never adds a geometry relation to the
|
||||
production selector graph.
|
||||
"""
|
||||
normal_candidates, normal_count = _search_candidate_records(resolution, maximum=maximum)
|
||||
if not isinstance(source_selector, dict):
|
||||
descriptor = {"token": token, "ranking_status": "source_query_missing"}
|
||||
return descriptor, [
|
||||
{"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}}
|
||||
for candidate in normal_candidates
|
||||
], normal_count
|
||||
descriptor = _query_group_descriptor(token, source_selector)
|
||||
family = descriptor.get("query_family")
|
||||
ranker = {
|
||||
"CAP_FACE": _rank_cap_face,
|
||||
"SWEPT_FACE": _rank_swept_face,
|
||||
"SWEPT_EDGE": _rank_swept_edge,
|
||||
}.get(family)
|
||||
if ranker is None:
|
||||
descriptor["ranking_status"] = "query_family_not_ranked"
|
||||
return descriptor, [
|
||||
{"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}}
|
||||
for candidate in normal_candidates
|
||||
], normal_count
|
||||
ranked: list[tuple[float, dict[str, Any], dict[str, Any]]] = []
|
||||
for candidate in resolution.candidates:
|
||||
if not isinstance(candidate, dict) or not candidate.get("record_id") or float(candidate.get("score") or 0.0) < 0.8:
|
||||
continue
|
||||
result = ranker(provenance_candidate, source_selector, candidate)
|
||||
if result is None:
|
||||
continue
|
||||
score, reason = result
|
||||
if score >= 0.98:
|
||||
ranked.append((score, deepcopy(candidate), reason))
|
||||
ranked.sort(key=lambda item: (-item[0], str(item[1].get("record_id"))))
|
||||
if not ranked:
|
||||
descriptor["ranking_status"] = "source_geometry_unavailable"
|
||||
return descriptor, [
|
||||
{"records": [candidate], "score": candidate["score"], "ranking": {"method": "resolver_candidate"}}
|
||||
for candidate in normal_candidates
|
||||
], normal_count
|
||||
descriptor["ranking_status"] = "source_geometry_ranked"
|
||||
descriptor["ranked_record_count"] = len(ranked)
|
||||
if descriptor.get("multiplicity") == "all_fragments":
|
||||
records = [candidate for _score, candidate, _reason in ranked]
|
||||
descriptor["selected_set_cardinality"] = len(records)
|
||||
return descriptor, [{
|
||||
"records": records,
|
||||
"score": round(sum(score for score, _candidate, _reason in ranked) / len(ranked), 6),
|
||||
"ranking": {"method": "all_fragments_source_geometry", "records": [reason for _score, _candidate, reason in ranked]},
|
||||
}], 1
|
||||
groups = [
|
||||
{"records": [candidate], "score": round(score, 6), "ranking": reason}
|
||||
for score, candidate, reason in ranked[:maximum]
|
||||
]
|
||||
return descriptor, groups, len(ranked)
|
||||
|
||||
|
||||
def _search_replay_worker(
|
||||
candidate: str,
|
||||
provenance: str,
|
||||
step: str,
|
||||
result: str,
|
||||
maximum_candidates: int,
|
||||
) -> None:
|
||||
"""Replay one branch until it builds or exposes its next selector decision."""
|
||||
from engine.cdsl_engine.runtime import finalize_cdsl_execution, prepare_cdsl_execution
|
||||
from .selector_binding import _bound_selector, _selector_key
|
||||
|
||||
bound = read_json(Path(candidate))
|
||||
provenance_candidate = read_json(Path(provenance))
|
||||
source_selectors = _source_selector_map(provenance_candidate)
|
||||
execution = prepare_cdsl_execution(bound)
|
||||
if not execution.analysis.runtime_eligible:
|
||||
first = next((item for item in execution.analysis.feature_results if not item.executable), None)
|
||||
@@ -485,19 +1099,25 @@ def _search_replay_worker(candidate: str, step: str, result: str, maximum_candid
|
||||
active_body_id=active_body_id,
|
||||
)
|
||||
if resolution.status != "resolved":
|
||||
candidates, eligible_count = _search_candidate_records(
|
||||
token = placeholder.get(_SEARCH_TOKEN)
|
||||
descriptor, candidate_groups, group_count = _query_candidate_groups(
|
||||
provenance_candidate,
|
||||
str(token or f"{node.feature_id}:{location}"),
|
||||
source_selectors.get(str(token)),
|
||||
resolution,
|
||||
maximum=maximum_candidates,
|
||||
)
|
||||
if candidates:
|
||||
if candidate_groups:
|
||||
write_json(Path(result), {
|
||||
"status": "branchable_selector",
|
||||
"status": "branchable_query_group",
|
||||
"feature_id": node.feature_id,
|
||||
"source_location": location,
|
||||
"selector_token": token,
|
||||
"resolution_status": resolution.status,
|
||||
"diagnostic": resolution.diagnostic.as_dict() if resolution.diagnostic is not None else None,
|
||||
"candidates": candidates,
|
||||
"candidate_count": eligible_count,
|
||||
"query_group": descriptor,
|
||||
"candidate_groups": candidate_groups,
|
||||
"candidate_group_count": group_count,
|
||||
"bound_cdsl": bound,
|
||||
"selector_binding": evidence,
|
||||
})
|
||||
@@ -572,11 +1192,18 @@ def _search_replay_worker(candidate: str, step: str, result: str, maximum_candid
|
||||
})
|
||||
|
||||
|
||||
def _run_search_replay(candidate_path: Path, step: Path, *, timeout_seconds: float, maximum_candidates: int) -> dict[str, Any]:
|
||||
def _run_search_replay(
|
||||
candidate_path: Path,
|
||||
provenance_path: Path,
|
||||
step: Path,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
maximum_candidates: int,
|
||||
) -> dict[str, Any]:
|
||||
worker = step.with_suffix(".worker.json")
|
||||
outcome = _isolated(
|
||||
_search_replay_worker,
|
||||
(str(candidate_path), str(step), str(worker), maximum_candidates),
|
||||
(str(candidate_path), str(provenance_path), str(step), str(worker), maximum_candidates),
|
||||
worker,
|
||||
timeout_seconds,
|
||||
)
|
||||
@@ -596,6 +1223,8 @@ def _public_branch_summary(result: dict[str, Any], choices: list[dict[str, Any]]
|
||||
"status": result.get("status"),
|
||||
"feature_id": result.get("feature_id"),
|
||||
"source_location": result.get("source_location"),
|
||||
"query_group": result.get("query_group"),
|
||||
"candidate_group_count": result.get("candidate_group_count"),
|
||||
"reason": result.get("reason"),
|
||||
"resolution_status": result.get("resolution_status"),
|
||||
"choice_path": choices,
|
||||
@@ -630,7 +1259,10 @@ def _run_selector_search(
|
||||
return {"status": "disabled"}, None
|
||||
search_dir = directory / "selector-search"
|
||||
search_dir.mkdir(parents=True, exist_ok=True)
|
||||
pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(deepcopy(geometry_candidate), [])]
|
||||
search_candidate = deepcopy(geometry_candidate)
|
||||
_annotate_selector_search_tokens(search_candidate)
|
||||
provenance_path = directory / "provenance.candidate.cdsl.json"
|
||||
pending: list[tuple[dict[str, Any], list[dict[str, Any]]]] = [(search_candidate, [])]
|
||||
seen: set[str] = set()
|
||||
terminals: list[tuple[dict[str, Any], dict[str, Any], list[dict[str, Any]], dict[str, Any] | None]] = []
|
||||
branch_summaries: list[dict[str, Any]] = []
|
||||
@@ -653,32 +1285,43 @@ def _run_selector_search(
|
||||
write_json(candidate_path, candidate)
|
||||
outcome = _run_search_replay(
|
||||
candidate_path,
|
||||
provenance_path,
|
||||
step_path,
|
||||
timeout_seconds=rebuild_timeout_seconds,
|
||||
maximum_candidates=maximum_candidates,
|
||||
)
|
||||
_write_rebuild_artifacts(search_dir, branch_name, outcome)
|
||||
if outcome.get("status") == "branchable_selector":
|
||||
candidates = list(outcome.get("candidates") or [])
|
||||
total = int(outcome.get("candidate_count") or len(candidates))
|
||||
if total > len(candidates):
|
||||
if outcome.get("status") == "branchable_query_group":
|
||||
groups = [item for item in outcome.get("candidate_groups") or [] if isinstance(item, dict)]
|
||||
total = int(outcome.get("candidate_group_count") or len(groups))
|
||||
if total > len(groups):
|
||||
candidates_truncated = True
|
||||
branch_summaries.append(_public_branch_summary(outcome, choices))
|
||||
for candidate_record in candidates:
|
||||
for group in groups:
|
||||
child = deepcopy(outcome["bound_cdsl"])
|
||||
feature_id = str(outcome["feature_id"])
|
||||
location = str(outcome["source_location"])
|
||||
source_feature = next(item for item in child["features"] if item.get("id") == feature_id)
|
||||
source_selector = dict(_searchable_selector_sites(source_feature)[
|
||||
next(index for index, (name, _selector) in enumerate(_searchable_selector_sites(source_feature)) if name == location)
|
||||
][1])
|
||||
forced = _forced_candidate_selector(source_selector, candidate_record)
|
||||
_replace_selector_site(child, feature_id, location, forced)
|
||||
token = outcome.get("selector_token")
|
||||
source_selector = next(
|
||||
(
|
||||
selector
|
||||
for _location, selector in _searchable_selector_sites(source_feature)
|
||||
if selector.get(_SEARCH_TOKEN) == token
|
||||
),
|
||||
None,
|
||||
)
|
||||
if not isinstance(source_selector, dict):
|
||||
raise ValueError(f"search branch selector token is unavailable: {feature_id}:{token}")
|
||||
records = [item for item in group.get("records") or [] if isinstance(item, dict)]
|
||||
forced = _forced_candidate_selectors(source_selector, records)
|
||||
_replace_selector_group(child, feature_id, str(token), forced)
|
||||
pending.append((child, [*choices, {
|
||||
"feature_id": feature_id,
|
||||
"source_location": location,
|
||||
"record_id": candidate_record.get("record_id"),
|
||||
"score": candidate_record.get("score"),
|
||||
"source_location": outcome.get("source_location"),
|
||||
"query_group": deepcopy(outcome.get("query_group") or {}),
|
||||
"resolved_runtime_records": records,
|
||||
"score": group.get("score"),
|
||||
"ranking": deepcopy(group.get("ranking") or {}),
|
||||
}]))
|
||||
continue
|
||||
comparison = None
|
||||
@@ -731,6 +1374,8 @@ def _run_selector_search(
|
||||
gold_step,
|
||||
rebuild_timeout_seconds=rebuild_timeout_seconds,
|
||||
comparison_timeout_seconds=comparison_timeout_seconds,
|
||||
query_groups=choices,
|
||||
candidate_replay_kind="selector_search_branch",
|
||||
)
|
||||
report["selector_record"] = selector_record
|
||||
return report, selector_record if selector_record.get("status") == "strict_replayed" else None
|
||||
|
||||
@@ -59,7 +59,8 @@ class IntegrationTests(unittest.TestCase):
|
||||
])
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
|
||||
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"]],
|
||||
@@ -123,7 +124,10 @@ 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")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -140,7 +144,8 @@ class IntegrationTests(unittest.TestCase):
|
||||
|
||||
candidate = lower_model(parse_featurescript(feature.read_text(), "00925274"), {}).cdsl
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
|
||||
rebuilt = Path(tmp) / "rebuild.step"
|
||||
outcome = rebuild_candidate(candidate, rebuilt)
|
||||
|
||||
self.assertEqual(outcome["status"], "rebuilt")
|
||||
result = outcome["result"]
|
||||
@@ -166,9 +171,13 @@ class IntegrationTests(unittest.TestCase):
|
||||
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(
|
||||
selector["selector_intent"]["query_family"] == "SWEPT_EDGE"
|
||||
for selector in fillet["selectors"]
|
||||
operand["selector_intent"]["query_family"] == "SWEPT_EDGE"
|
||||
for operand in query_set["query_operands"]
|
||||
))
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
@@ -176,6 +185,8 @@ class IntegrationTests(unittest.TestCase):
|
||||
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"]
|
||||
@@ -287,11 +298,13 @@ class IntegrationTests(unittest.TestCase):
|
||||
self.assertNotIn("stable_id", selector)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
|
||||
rebuilt = Path(tmp) / "rebuild.step"
|
||||
outcome = rebuild_candidate(candidate, rebuilt)
|
||||
self.assertTrue(rebuilt.exists())
|
||||
|
||||
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||
self.assertEqual(outcome["error"]["message"], "f_F4: selector_query_unsupported during incremental replay")
|
||||
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")
|
||||
@@ -359,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]])
|
||||
@@ -367,6 +380,7 @@ 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"
|
||||
@@ -390,17 +404,18 @@ class IntegrationTests(unittest.TestCase):
|
||||
self.assertEqual(outcome["status"], "rebuilt")
|
||||
self.assertEqual([item["feature_id"] for item in outcome["result"]["feature_results"]], ["f_F2"])
|
||||
|
||||
def test_multi_entity_sketch_wire_qbodytype_path_remains_deferred_00786708(self):
|
||||
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.assertIn({
|
||||
"code": "unsupported_engine_capability", "feature_id": "F2", "operation": "sweep", "capability": "sweep_path_query",
|
||||
"message": "current CDSL sweep requires one direct sketch line or B-spline path",
|
||||
}, result.diagnostics)
|
||||
self.assertNotIn("f_F2", {item["id"] for item in result.cdsl["features"]})
|
||||
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")
|
||||
|
||||
@@ -417,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_preserve_shell_prefix_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")
|
||||
@@ -433,9 +447,11 @@ class IntegrationTests(unittest.TestCase):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
outcome = rebuild_candidate(candidate, Path(tmp) / "rebuild.step")
|
||||
|
||||
self.assertEqual(outcome["status"], "rebuild_failed")
|
||||
self.assertEqual(outcome["error"]["message"], "f_F7: selector_query_unsupported during incremental replay")
|
||||
self.assertEqual(outcome["last_executable_prefix"]["last_feature_id"], "f_F6")
|
||||
self.assertEqual(outcome["status"], "rebuilt")
|
||||
self.assertEqual(
|
||||
[item["feature_id"] for item in outcome["result"]["feature_results"]],
|
||||
["f_F1", "f_F3", "f_F5", "f_F6", "f_F10"],
|
||||
)
|
||||
|
||||
def test_face_chamfer_source_query_is_not_geometry_bound_00111611(self):
|
||||
root = Path(__file__).parents[2] / "data/cadfs-sample/CADFS_test"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,6 +67,43 @@ 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");
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -6,7 +6,13 @@ from pathlib import Path
|
||||
|
||||
from cadfs_to_cdsl.dataset import Sample
|
||||
from cadfs_to_cdsl.reports import read_json
|
||||
from cadfs_to_cdsl.selector_candidate_demo import _search_candidate_records, run_geometry_probe, strip_provenance_intents
|
||||
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:
|
||||
@@ -18,6 +24,39 @@ def _contains_selector_intent(value: object) -> bool:
|
||||
|
||||
|
||||
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": (
|
||||
@@ -34,11 +73,85 @@ class SelectorCandidateDemoTests(unittest.TestCase):
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user