diff --git a/backend/app/services/engine_service.py b/backend/app/services/engine_service.py
index 820954f9..8afb1771 100644
--- a/backend/app/services/engine_service.py
+++ b/backend/app/services/engine_service.py
@@ -105,15 +105,13 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not fid or fid in feature_ids:
raise ValueError("Feature ids must be unique")
feature_ids.add(fid)
- if feature.get("execution_status") not in (None, "supported"):
- raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
- if str(feature.get("sketch_id") or "") not in sketch_ids:
- raise ValueError(f"Feature {fid} refers to a missing sketch")
atomic_id = str(feature.get("atomic_id") or "")
if not atomic_id:
raise ValueError(f"Feature {fid} has no atomic_id")
contract = atomic_contracts.get(atomic_id)
if atomic_id not in supported_atomic_ids or not isinstance(contract, dict):
+ if feature.get("execution_status") == "deferred":
+ raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
raise ValueError(
f"Unsupported CDSL atomic_id: {atomic_id}. Supported: {', '.join(supported_atomic_ids)}"
)
@@ -145,9 +143,15 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
try:
- engine.compile_cdsl(copy.deepcopy(cdsl))
+ analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
- raise ValueError(f"CDSL engine compile preflight failed: {error}") from error
+ raise ValueError(f"CDSL engine runtime preflight failed: {error}") from error
+ if not analysis.runtime_eligible:
+ first = next((result for result in analysis.feature_results if not result.executable), None)
+ if first is None:
+ raise ValueError(f"CDSL engine runtime preflight failed: {analysis.document_blockers[0].code}")
+ blockers = ", ".join(blocker.code for blocker in first.blockers)
+ raise ValueError(f"CDSL engine runtime preflight failed: feature {first.feature_id}: {blockers}")
def _parameter_id(path: list[str]) -> str:
@@ -355,7 +359,10 @@ def build_revision(
write_json(parameters_path, contract)
try:
- engine_result = engine.run_rebuild(cdsl_copy, step_path)
+ # Product revisions are semantic CDSL artifacts. Do not route them
+ # through the legacy rebuild entry point, which is allowed to use
+ # compiler_context/translator compatibility fallbacks.
+ engine_result = engine.run_cdsl_only(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
diff --git a/backend/engine/cdsl_engine/README.md b/backend/engine/cdsl_engine/README.md
index fa61b984..fe1789aa 100644
--- a/backend/engine/cdsl_engine/README.md
+++ b/backend/engine/cdsl_engine/README.md
@@ -2,11 +2,17 @@
This package rebuilds `cad.cdsl.llm.v1` models through the CDSL-only path:
-`sketch_solver -> llm_compiler -> llm_engine -> STEP`
+`semantic validation -> capability analysis -> sketch resolution -> session runtime -> STEP`
+
+`runtime.py` owns the executor registry, an `ExecutionSession`, and the
+feature/topology lifecycle. `build123d_adapter.py` is the only layer that
+creates or mutates B-rep objects. `runtime_types.py` owns runtime-neutral
+feature, context, selector, and topology contracts. `llm_compiler.py` and
+`llm_engine.py` remain available for legacy engine-plan compatibility but are
+not used by `run_cdsl_only`.
Supported profiles are defined by `SHAPE_GENERATORS` in `sketch_solver.py`.
-Supported feature atomic operations are defined by the dispatch in `llm_engine.py`
-and their required parameters are defined by `REQUIRED` in `llm_compiler.py`.
+Supported feature atomic operations are defined by `EXECUTORS` in `runtime.py`.
Their human-readable contract is in `profile_schema.json`; the complete,
machine-enforced CDSL object contract is in `cdsl_schema.json`.
The Studio only accepts self-contained profile data and requires successful
@@ -17,8 +23,38 @@ The Studio only accepts self-contained profile data and requires successful
`profile_schema.json` and `cdsl_schema.json` together are the source of truth
for the engine contract exposed to the CAD Agent and the backend validator.
-Any addition, removal, rename, or
-parameter-contract change in `sketch_solver.py`, `llm_compiler.py`, or
-`llm_engine.py` must update both files in the same change.
+Any addition, removal, rename, or parameter-contract change in
+`sketch_solver.py`, `runtime.py`, or the build adapter must update both files
+in the same change.
`backend/tests/test_profile_schema.py` fails when the registered profiles or
supported atomic operations diverge from the document.
+
+## Batch baseline
+
+Use the resumable batch entry point to produce feature-level eligibility and
+rebuild reports. `--build` invokes only the session-based CDSL runtime; it
+never falls back to `compiler_context` or the legacy translator.
+
+```bash
+PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
+ json_to_cdsl/output --out /tmp/cdsl-batch --build --build-timeout 15
+```
+
+The output directory contains `manifest.json`, one report per part under
+`parts/`, `summary-by-atomic.json`, and `summary-by-blocker.json`. Re-run the
+same command to resume completed work; use `--max-parts` to run a bounded CI
+shard.
+
+Use `--part-ids 046112,053393` to run an exact, comma-separated regression
+subset. Unknown ids are rejected so a phase baseline cannot silently omit a
+requested part.
+
+Use `--phase p3`, `--phase p4`, or `--phase p6` to run a documented
+strict-closed static pool. The
+selector lives in `phase_pools.py`; its membership is regression-tested
+against the committed exports rather than copied into a shell command.
+
+The runtime foundation test suite also analyzes every committed export without
+building STEP, so CI verifies that the full corpus always yields one
+machine-readable capability result per input. Use the batch command above for
+the slower, resumable truth-build layer.
diff --git a/backend/engine/cdsl_engine/__init__.py b/backend/engine/cdsl_engine/__init__.py
index c748a8f2..71adaff9 100644
--- a/backend/engine/cdsl_engine/__init__.py
+++ b/backend/engine/cdsl_engine/__init__.py
@@ -8,10 +8,15 @@ from __future__ import annotations
from .convert_to_cdsl import convert_sw_json_to_cdsl, write_cdsl_outputs
from .llm_compiler import compile_cdsl
-from .llm_engine import SUPPORTED_ATOMIC_IDS, run_engine_plan
-from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_engine, run_rebuild
+from .llm_engine import run_engine_plan
+from .rebuild import compare_with_gold, compile_cdsl_to_pack, run_cdsl_only, run_engine, run_rebuild
from .semantic_validation import validate_semantic_cdsl
-from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches
+from .sketch_solver import SHAPE_GENERATORS, resolve_all_sketches, resolve_required_sketches
+from .runtime import ALL_ATOMIC_IDS, EXECUTORS, RuntimeExecutionError, analyze_cdsl, rebuild_cdsl
+
+# The package-level runtime contract is the session executor registry. The
+# older llm_engine dispatcher remains available only for legacy engine packs.
+SUPPORTED_ATOMIC_IDS = ALL_ATOMIC_IDS
__all__ = [
"convert_sw_json_to_cdsl",
@@ -20,12 +25,19 @@ __all__ = [
"compile_cdsl_to_pack",
"run_engine_plan",
"run_engine",
+ "run_cdsl_only",
"run_rebuild",
"compare_with_gold",
"resolve_all_sketches",
+ "resolve_required_sketches",
"SHAPE_GENERATORS",
"SUPPORTED_ATOMIC_IDS",
"validate_semantic_cdsl",
+ "ALL_ATOMIC_IDS",
+ "EXECUTORS",
+ "rebuild_cdsl",
+ "analyze_cdsl",
+ "RuntimeExecutionError",
]
__version__ = "1.0.0"
diff --git a/backend/engine/cdsl_engine/batch_rebuild.py b/backend/engine/cdsl_engine/batch_rebuild.py
new file mode 100644
index 00000000..7260a356
--- /dev/null
+++ b/backend/engine/cdsl_engine/batch_rebuild.py
@@ -0,0 +1,422 @@
+"""Batch CDSL rebuild baseline and machine-readable reporting."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import signal
+import subprocess
+import sys
+import time
+from collections import Counter
+from pathlib import Path
+from typing import Any, Iterable
+
+from .semantic_validation import validate_semantic_cdsl
+
+
+def _write_json(path: Path, value: Any) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+
+
+class BatchBuildError(RuntimeError):
+ def __init__(self, error: dict[str, Any], selector_resolution: list[dict[str, Any]] | None = None) -> None:
+ super().__init__(str(error.get("message") or error.get("code") or "build failed"))
+ self.error = error
+ self.selector_resolution = selector_resolution or []
+
+
+def _failure_category(report: dict[str, Any]) -> str:
+ """Classify the terminal rebuild state without weakening any gate."""
+ if report.get("geometry_verified"):
+ return "geometry_verified"
+ if report.get("runtime_eligible") and not report.get("build_attempted"):
+ return "runtime_eligible_not_built"
+ comparison = report.get("numeric_comparison") or {}
+ if comparison.get("classification") == "coordinate_frame_mismatch_candidate":
+ return "coordinate_frame_mismatch_candidate"
+ if report.get("built"):
+ return "geometry_mismatch"
+ blocker = report.get("first_blocker") or {}
+ code = str(blocker.get("code") or "")
+ if code in {"selector_not_found", "selector_ambiguous"}:
+ return "selector_resolution"
+ if code.startswith("unsupported_") or code in {"unknown_atomic", "unsupported_atomic"}:
+ return "unsupported_capability"
+ if code in {
+ "unresolved_input", "missing_parameter", "missing_sketch", "missing_selector",
+ "missing_host_face", "missing_hole_positions", "missing_extent_reference",
+ "missing_reverse_extent_reference", "missing_revolve_axis", "missing_reference_axis_geometry",
+ "missing_reference_orientation", "profile_no_closed_region", "profile_resolution_failed",
+ "semantic_validation_failed", "no_solid_feature", "extent_target_not_reached", "missing_active_body",
+ "invalid_hole_spec", "extent_target_not_in_direction", "non_uniform_extent_target", "invalid_extent_offset",
+ }:
+ return "input_incomplete"
+ if code in {"build_timeout", "build_failed", "execution_failed"}:
+ return "occ_execution_failure"
+ return "preflight_blocked"
+
+
+def _runtime_atomic_ids() -> frozenset[str]:
+ from .runtime import ALL_ATOMIC_IDS
+
+ return ALL_ATOMIC_IDS
+
+
+def _truth_metrics(cdsl: dict[str, Any]) -> dict[str, Any] | None:
+ truth = (cdsl.get("meta") or {}).get("document_truth") or {}
+ mass = truth.get("mass_properties") or {}
+ geometry = truth.get("geometry") or {}
+ if not mass.get("available"):
+ return None
+ box_m = geometry.get("bounding_box")
+ volume_m3 = mass.get("volume")
+ area_m2 = mass.get("surface_area")
+ solid_count = geometry.get("solid_body_count")
+ if not isinstance(box_m, list) or len(box_m) != 6 or volume_m3 is None or area_m2 is None or solid_count is None:
+ return None
+ return {
+ "bounding_box_mm": [float(value) * 1000.0 for value in box_m],
+ "volume_mm3": float(volume_m3) * 1_000_000_000.0,
+ "surface_area_mm2": float(area_m2) * 1_000_000.0,
+ "solid_count": int(solid_count),
+ }
+
+
+def _bounding_box_spans(box: list[float]) -> list[float]:
+ return [box[index + 3] - box[index] for index in range(3)]
+
+
+def _verification_classification(
+ *,
+ actual_box: list[float],
+ truth: dict[str, Any],
+ box_delta: float,
+ volume_relative_error: float,
+ area_relative_error: float,
+ solid_count_matches: bool,
+) -> str:
+ """Classify a strict truth comparison without weakening its threshold.
+
+ Some exports carry internally consistent feature dimensions in a frame
+ that differs from the source truth frame. Matching volume, area, solid
+ count, and unordered bounding-box spans is useful evidence for diagnosing
+ that condition, but is not enough to call a rebuild geometry-verified.
+ """
+ metrics_match = volume_relative_error <= 0.001 and area_relative_error <= 0.001 and solid_count_matches
+ spans_match = max(
+ abs(actual - expected)
+ for actual, expected in zip(
+ sorted(_bounding_box_spans(actual_box)),
+ sorted(_bounding_box_spans(truth["bounding_box_mm"])),
+ )
+ ) <= 0.01
+ if box_delta <= 0.01 and metrics_match:
+ return "verified"
+ if metrics_match and spans_match:
+ return "coordinate_frame_mismatch_candidate"
+ return "geometry_mismatch"
+
+
+def _compare_truth(result: dict[str, Any], truth: dict[str, Any] | None) -> dict[str, Any] | None:
+ if truth is None:
+ return None
+ actual_box = [*result["bbox_mm"]["min"], *result["bbox_mm"]["max"]]
+ box_delta = max(abs(actual - expected) for actual, expected in zip(actual_box, truth["bounding_box_mm"]))
+ volume = float(result["volume_mm3"])
+ volume_expected = float(truth["volume_mm3"])
+ volume_relative_error = abs(volume - volume_expected) / max(abs(volume_expected), 1e-9)
+ from build123d import import_step
+
+ rebuilt = import_step(str(result["out_step"]))
+ area = float(rebuilt.area)
+ area_expected = float(truth["surface_area_mm2"])
+ area_relative_error = abs(area - area_expected) / max(abs(area_expected), 1e-9)
+ solid_count = len(rebuilt.solids())
+ solid_count_matches = solid_count == truth["solid_count"]
+ classification = _verification_classification(
+ actual_box=actual_box,
+ truth=truth,
+ box_delta=box_delta,
+ volume_relative_error=volume_relative_error,
+ area_relative_error=area_relative_error,
+ solid_count_matches=solid_count_matches,
+ )
+ return {
+ "expected": truth,
+ "actual": {
+ "bounding_box_mm": actual_box,
+ "volume_mm3": volume,
+ "surface_area_mm2": area,
+ "solid_count": solid_count,
+ },
+ "bounding_box_max_delta_mm": box_delta,
+ "volume_relative_error": volume_relative_error,
+ "surface_area_relative_error": area_relative_error,
+ "solid_count_matches": solid_count_matches,
+ "classification": classification,
+ "passed": classification == "verified",
+ }
+
+
+def _run_isolated_build(cdsl_path: Path, out_step: Path, *, timeout_s: float) -> dict[str, Any]:
+ """Keep an OCC boolean timeout local to one batch part."""
+ source = (
+ "import json\n"
+ "from pathlib import Path\n"
+ "from cdsl_engine.runtime import rebuild_cdsl, RuntimeExecutionError\n"
+ "try:\n"
+ f" result=rebuild_cdsl(json.loads(Path({str(cdsl_path)!r}).read_text(encoding='utf-8')), Path({str(out_step)!r}))\n"
+ " result['engine']='cdsl_only'\n"
+ " print(json.dumps(result))\n"
+ "except RuntimeExecutionError as error:\n"
+ " print(json.dumps({'_build_error': error.diagnostic.as_dict(), 'selector_resolution': error.selector_resolutions}))"
+ )
+ process = subprocess.Popen(
+ [sys.executable, "-c", source], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ text=True, start_new_session=True,
+ )
+ try:
+ stdout, stderr = process.communicate(timeout=timeout_s)
+ except subprocess.TimeoutExpired as error:
+ # Boolean operations can leave helper processes behind on some OCC
+ # versions. Killing the dedicated process group prevents one part
+ # from consuming a later shard's timeout budget.
+ os.killpg(process.pid, signal.SIGKILL)
+ process.communicate()
+ raise RuntimeError(f"build_timeout: exceeded {timeout_s:g}s") from error
+ if process.returncode:
+ raise RuntimeError((stderr or stdout or "build subprocess failed").strip())
+ try:
+ output = json.loads(stdout)
+ except json.JSONDecodeError as error:
+ raise RuntimeError(f"build subprocess returned invalid JSON: {stdout[-500:]}") from error
+ if output.get("_build_error"):
+ raise BatchBuildError(output["_build_error"], output.get("selector_resolution"))
+ return output
+
+
+def analyze_document(
+ path: Path,
+ *,
+ atomic_ids: frozenset[str] | None = None,
+ out_step: Path | None = None,
+ build_timeout_s: float | None = None,
+) -> dict[str, Any]:
+ started = time.monotonic()
+ report: dict[str, Any] = {
+ "part_id": path.name.removesuffix(".cdsl.json"),
+ "cdsl_path": str(path),
+ "semantic_valid": False,
+ "runtime_eligible": False,
+ "compiled": False,
+ "built": False,
+ "geometry_verified": False,
+ "topology_observed": False,
+ "feature_results": [],
+ "unsupported_atomic_ids": [],
+ "unsupported_profile_types": [],
+ "unresolved_input": [],
+ "selector_resolution": [],
+ "numeric_comparison": None,
+ "build_attempted": out_step is not None,
+ "failure_category": "preflight_blocked",
+ }
+ try:
+ cdsl = json.loads(path.read_text(encoding="utf-8"))
+ report["part_id"] = str(cdsl.get("part_id") or report["part_id"])
+ semantic = validate_semantic_cdsl(cdsl)
+ report["semantic_valid"] = True
+ report["unresolved_input"] = semantic["unresolved"]
+ from .runtime import analyze_cdsl
+
+ # Profile resolution is runtime preflight, not a build-time surprise.
+ # The optional atomic override keeps this function useful for focused
+ # capability tests without changing the production runtime contract.
+ if atomic_ids is None:
+ analysis = analyze_cdsl(cdsl)
+ else:
+ from .capabilities import CapabilityAnalyzer, sketch_ids_required_by_contract
+ from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches
+
+ sketch_errors: dict[str, str] = {}
+ resolved = resolve_required_sketches(
+ cdsl, sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
+ )
+ analysis = CapabilityAnalyzer(atomic_ids=atomic_ids, profile_types=SHAPE_GENERATORS).analyze(
+ resolved, sketch_errors=sketch_errors,
+ )
+ report["runtime_eligible"] = analysis.runtime_eligible
+ report["feature_results"] = [item.as_dict() for item in analysis.feature_results]
+ blockers = [blocker for item in analysis.feature_results for blocker in item.blockers]
+ blockers.extend(analysis.document_blockers)
+ report["unsupported_atomic_ids"] = sorted({item.atomic_id for item in analysis.feature_results if item.resolved_status == "unsupported"})
+ report["unsupported_profile_types"] = sorted({blocker.detail.get("profile_type") for blocker in blockers if blocker.code == "unsupported_profile"})
+ if blockers:
+ report["first_blocker"] = blockers[0].as_dict()
+ if analysis.runtime_eligible:
+ report["compiled"] = True
+ if out_step is not None:
+ from .rebuild import run_cdsl_only
+
+ try:
+ build_result = (
+ _run_isolated_build(path, out_step, timeout_s=build_timeout_s)
+ if build_timeout_s is not None
+ else run_cdsl_only(cdsl, out_step)
+ )
+ report["built"] = True
+ report["build_result"] = build_result
+ report["selector_resolution"] = build_result.get("selector_resolution") or []
+ comparison = _compare_truth(build_result, _truth_metrics(cdsl))
+ report["numeric_comparison"] = comparison
+ report["geometry_verified"] = bool(comparison and comparison["passed"])
+ from build123d import import_step
+
+ rebuilt = import_step(str(out_step))
+ report["topology_observed"] = True
+ report["topology"] = {
+ "face_count": len(rebuilt.faces()),
+ "edge_count": len(rebuilt.edges()),
+ "vertex_count": len(rebuilt.vertices()),
+ }
+ except BatchBuildError as error:
+ report["first_blocker"] = error.error
+ report["selector_resolution"] = error.selector_resolution
+ except Exception as error:
+ code = "build_timeout" if str(error).startswith("build_timeout:") else "build_failed"
+ report["first_blocker"] = {"code": code, "message": str(error)}
+ except Exception as error:
+ code = "profile_resolution_failed" if "analytic_contours" in str(error) or "profile" in str(error) else "semantic_validation_failed"
+ report["first_blocker"] = {"code": code, "message": str(error)}
+ report["failure_category"] = _failure_category(report)
+ report["timings"] = {"analysis_s": round(time.monotonic() - started, 6)}
+ return report
+
+
+def _write_summaries(out_dir: Path, reports: list[dict[str, Any]], *, input_directory: Path, total_count: int) -> dict[str, Any]:
+ atomic_counter = Counter()
+ blocker_counter = Counter()
+ for report in reports:
+ for feature in report.get("feature_results") or ():
+ atomic_counter[feature["atomic_id"]] += 1
+ for blocker in feature["blockers"]:
+ blocker_counter[blocker["code"]] += 1
+ first = report.get("first_blocker") or {}
+ if not (report.get("feature_results") or ()) and first.get("code"):
+ blocker_counter[first["code"]] += 1
+ manifest = {
+ "schema": "cdsl.engine.batch-rebuild.v1",
+ "input_directory": str(input_directory),
+ "part_count": total_count,
+ "completed_count": len(reports),
+ "complete": len(reports) == total_count,
+ "semantic_valid_count": sum(bool(report.get("semantic_valid")) for report in reports),
+ "runtime_eligible_count": sum(bool(report.get("runtime_eligible")) for report in reports),
+ "compiled_count": sum(bool(report.get("compiled")) for report in reports),
+ "built_count": sum(bool(report.get("built")) for report in reports),
+ "geometry_verified_count": sum(bool(report.get("geometry_verified")) for report in reports),
+ "failure_category_counts": dict(sorted(Counter(report.get("failure_category") or "unknown" for report in reports).items())),
+ "results": [{"part_id": report["part_id"], "report": f"parts/{report['part_id']}.report.json"} for report in reports],
+ }
+ _write_json(out_dir / "manifest.json", manifest)
+ _write_json(out_dir / "summary-by-atomic.json", dict(sorted(atomic_counter.items())))
+ _write_json(out_dir / "summary-by-blocker.json", dict(sorted(blocker_counter.items())))
+ return manifest
+
+
+def batch_analyze(
+ cdsl_dir: Path,
+ out_dir: Path,
+ *,
+ atomic_ids: frozenset[str] | None = None,
+ build: bool = False,
+ overwrite: bool = False,
+ max_parts: int | None = None,
+ build_timeout_s: float | None = None,
+ part_ids: Iterable[str] | None = None,
+) -> dict[str, Any]:
+ files = sorted(cdsl_dir.glob("*.cdsl.json"))
+ if not files:
+ raise ValueError(f"No *.cdsl.json files found in {cdsl_dir}")
+ if part_ids is not None:
+ requested = {str(part_id).strip() for part_id in part_ids if str(part_id).strip()}
+ available = {path.name.removesuffix(".cdsl.json") for path in files}
+ unknown = sorted(requested - available)
+ if unknown:
+ raise ValueError(f"Requested part ids do not exist in {cdsl_dir}: {', '.join(unknown)}")
+ files = [path for path in files if path.name.removesuffix(".cdsl.json") in requested]
+ if not files:
+ raise ValueError("Part-id filter selected no CDSL documents")
+ reports_by_part: dict[str, dict[str, Any]] = {}
+ pending: list[Path] = []
+ for path in files:
+ part_id = path.name.removesuffix(".cdsl.json")
+ report_path = out_dir / "parts" / f"{part_id}.report.json"
+ if report_path.exists() and not overwrite:
+ existing = json.loads(report_path.read_text(encoding="utf-8"))
+ if not build or existing.get("build_attempted"):
+ # Reports are resumable artifacts. Derived classifications may
+ # be added after an earlier checkpoint, so migrate them in
+ # place without needlessly rebuilding the STEP artifact.
+ category = _failure_category(existing)
+ if existing.get("failure_category") != category:
+ existing["failure_category"] = category
+ _write_json(report_path, existing)
+ reports_by_part[part_id] = existing
+ continue
+ pending.append(path)
+ if max_parts is not None:
+ pending = pending[:max(0, max_parts)]
+ newly_analyzed = 0
+ for path in pending:
+ part_id = path.name.removesuffix(".cdsl.json")
+ report_path = out_dir / "parts" / f"{part_id}.report.json"
+ report = analyze_document(
+ path,
+ atomic_ids=atomic_ids,
+ out_step=(out_dir / "parts" / f"{part_id}.step") if build else None,
+ build_timeout_s=build_timeout_s,
+ )
+ _write_json(report_path, report)
+ reports_by_part[part_id] = report
+ newly_analyzed += 1
+ # Keep every successfully analyzed part recoverable during a long
+ # build. A later invocation resumes from per-part report files; the
+ # aggregate checkpoint is intentionally periodic to avoid O(n^2)
+ # JSON writes during a thousand-part run.
+ if newly_analyzed % 25 == 0:
+ _write_summaries(out_dir, list(reports_by_part.values()), input_directory=cdsl_dir, total_count=len(files))
+ return _write_summaries(out_dir, list(reports_by_part.values()), input_directory=cdsl_dir, total_count=len(files))
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="Analyze semantic CDSL rebuild eligibility in batch")
+ parser.add_argument("cdsl_dir", type=Path)
+ parser.add_argument("--out", type=Path, required=True)
+ parser.add_argument("--build", action="store_true", help="Rebuild every strict-runtime-eligible part via CDSL-only runtime")
+ parser.add_argument("--overwrite", action="store_true", help="Ignore existing per-part reports and rerun the batch")
+ parser.add_argument("--max-parts", type=int, default=None, help="Process at most this many pending parts; useful for resumable CI shards")
+ parser.add_argument("--part-ids", default=None, help="Comma-separated exact part ids to include; omitted means every CDSL document")
+ parser.add_argument("--phase", choices=("p3", "p4", "p6"), default=None, help="Run a documented static phase pool instead of every CDSL document")
+ parser.add_argument("--build-timeout", type=float, default=10.0, help="Per-part build timeout in seconds when --build is used")
+ args = parser.parse_args()
+ if args.phase and args.part_ids:
+ parser.error("--phase and --part-ids are mutually exclusive")
+ selected_part_ids = args.part_ids.split(",") if args.part_ids else None
+ if args.phase:
+ from .phase_pools import select_static_phase_pool
+
+ selected_part_ids = select_static_phase_pool(args.cdsl_dir, args.phase)
+ manifest = batch_analyze(
+ args.cdsl_dir, args.out, build=args.build, overwrite=args.overwrite,
+ max_parts=args.max_parts, build_timeout_s=args.build_timeout if args.build else None,
+ part_ids=selected_part_ids,
+ )
+ print(json.dumps({key: value for key, value in manifest.items() if key != "results"}, ensure_ascii=False))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/backend/engine/cdsl_engine/build123d_adapter.py b/backend/engine/cdsl_engine/build123d_adapter.py
new file mode 100644
index 00000000..0e81abcd
--- /dev/null
+++ b/backend/engine/cdsl_engine/build123d_adapter.py
@@ -0,0 +1,387 @@
+"""build123d/OCC implementation of the runtime-neutral geometry adapter."""
+
+from __future__ import annotations
+
+import math
+from typing import Any, Iterable
+
+from build123d import Axis, Edge, Face, Plane, Solid, Vector, Wire, export_step
+
+from .runtime_types import AxisSpec, HoleSpec, PlaneSpec, TopologyRecord, Vector3, canonical_plane_signature
+
+
+def _vector(value: list[float] | tuple[float, float, float]) -> Vector:
+ return Vector(float(value[0]), float(value[1]), float(value[2]))
+
+
+def _arc_midpoint(edge: dict[str, Any], start: Vector, end: Vector, center: Vector) -> Vector:
+ radius = float(edge.get("radius_mm") or (start - center).length)
+ first = start - center
+ second = end - center
+ if first.length <= 1e-9 or second.length <= 1e-9:
+ return (start + end) / 2
+ normal = _vector(edge.get("normal") or [0, 0, 1])
+ if normal.length <= 1e-9:
+ normal = first.cross(second)
+ if normal.length <= 1e-9:
+ normal = Vector(0, 0, 1)
+ normal = normal.normalized()
+ if "clockwise" not in edge:
+ bisector = first.normalized() + second.normalized()
+ if bisector.length <= 1e-9:
+ bisector = normal.cross(first)
+ return center + bisector.normalized() * radius
+ sweep = math.atan2(normal.dot(first.cross(second)), first.dot(second))
+ if bool(edge["clockwise"]):
+ if sweep >= 0:
+ sweep -= math.tau
+ elif sweep <= 0:
+ sweep += math.tau
+ half = sweep / 2
+ radius_vector = first.normalized() * radius
+ return center + radius_vector * math.cos(half) + normal.cross(radius_vector) * math.sin(half)
+
+
+class Build123dGeometryAdapter:
+ """All B-rep construction and mutation lives in this adapter."""
+
+ @staticmethod
+ def plane(spec: PlaneSpec) -> Plane:
+ return Plane(origin=_vector(spec.origin_mm), x_dir=_vector(spec.x_dir), z_dir=_vector(spec.normal))
+
+ @staticmethod
+ def axis(spec: AxisSpec) -> Axis:
+ return Axis(origin=_vector(spec.origin_mm), direction=_vector(spec.direction))
+
+ @staticmethod
+ def _wire(edges: list[dict[str, Any]]) -> Wire:
+ built: list[Edge] = []
+ for edge in edges:
+ start = _vector(edge["start_mm"])
+ end = _vector(edge["end_mm"])
+ if edge.get("type") == "arc" and edge.get("center_mm") is not None:
+ center = _vector(edge["center_mm"])
+ built.append(Edge.make_three_point_arc(start, _arc_midpoint(edge, start, end, center), end))
+ else:
+ built.append(Edge.make_line(start, end))
+ return Wire(built)
+
+ def _circle_wire(self, center: list[float], radius: float, plane_spec: PlaneSpec) -> Wire:
+ origin = Vector(*plane_spec.origin_mm) + Vector(*plane_spec.x_dir) * float(center[0]) + Vector(*plane_spec.y_dir) * float(center[1])
+ circle_plane = Plane(origin=origin, x_dir=Vector(*plane_spec.x_dir), z_dir=Vector(*plane_spec.normal))
+ return Wire.make_circle(radius, circle_plane)
+
+ def _faces_from_circles(self, entities: list[dict[str, Any]], plane_spec: PlaneSpec) -> list[Face]:
+ circles = [item for item in entities if item.get("type") == "circle" and not item.get("construction")]
+ if not circles:
+ return []
+ entries = []
+ for item in circles:
+ radius = float(item.get("radius_mm") or 0)
+ if radius <= 0:
+ continue
+ center = [float(value) for value in item.get("center") or [0, 0]]
+ entries.append({"center": center, "radius": radius, "wire": self._circle_wire(center, radius, plane_spec)})
+ faces: list[Face] = []
+ for entry in entries:
+ containing = sum(
+ math.dist(entry["center"], other["center"]) + entry["radius"] < other["radius"] - 1e-8
+ for other in entries
+ if other is not entry
+ )
+ if containing % 2:
+ continue
+ holes = [
+ other["wire"]
+ for other in entries
+ if math.dist(entry["center"], other["center"]) + other["radius"] < entry["radius"] - 1e-8
+ and sum(
+ math.dist(other["center"], candidate["center"]) + other["radius"] < candidate["radius"] - 1e-8
+ for candidate in entries
+ if candidate is not other
+ ) == containing + 1
+ ]
+ face = Face(entry["wire"])
+ faces.append(face.make_holes(holes) if holes else face)
+ return faces
+
+ def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Face]:
+ regions = sketch.get("contour_regions_mm") or []
+ if regions:
+ result: list[Face] = []
+ for region in regions:
+ outer = region.get("outer") or []
+ if len(outer) < 2:
+ continue
+ face = Face(self._wire(outer))
+ holes = [self._wire(hole) for hole in region.get("holes") or [] if len(hole) >= 2]
+ result.append(face.make_holes(holes) if holes else face)
+ return result
+ edges = sketch.get("contour_edges_mm") or []
+ if len(edges) >= 2:
+ return [Face(self._wire(edges))]
+ plane = PlaneSpec.from_mapping(sketch.get("workplane") or {})
+ return self._faces_from_circles(sketch.get("entities") or [], plane)
+
+ @staticmethod
+ def extrude(face: Face, direction: Vector3) -> Solid:
+ return Solid.extrude(face, _vector(direction))
+
+ @staticmethod
+ def body_center(body: Any) -> Vector3:
+ bbox = body.bounding_box()
+ return ((bbox.min.X + bbox.max.X) / 2, (bbox.min.Y + bbox.max.Y) / 2, (bbox.min.Z + bbox.max.Z) / 2)
+
+ @staticmethod
+ def body_span(body: Any, direction: Vector3) -> float:
+ unit = _vector(direction).normalized()
+ bbox = body.bounding_box()
+ values = [
+ Vector(x, y, z).dot(unit)
+ for x in (bbox.min.X, bbox.max.X)
+ for y in (bbox.min.Y, bbox.max.Y)
+ for z in (bbox.min.Z, bbox.max.Z)
+ ]
+ return max(values) - min(values)
+
+ @staticmethod
+ def vertex_coordinates(vertex: Any) -> Vector3:
+ return (float(vertex.X), float(vertex.Y), float(vertex.Z))
+
+ @staticmethod
+ def profile_sample_points(face: Face) -> list[Vector]:
+ """Sample a profile face before a selector-dependent termination.
+
+ A simple vector extrusion is exact only when the selected target is
+ reached at one common distance over the complete profile. Center and
+ boundary samples let the runtime prove that precondition instead of
+ silently constructing a wrong prismatic solid.
+ """
+ points = [face.center()]
+ for edge in face.edges():
+ for fraction in (0.0, 0.25, 0.5, 0.75):
+ points.append(edge.position_at(fraction))
+ unique: list[Vector] = []
+ for point in points:
+ if not any((point - current).length <= 1e-6 for current in unique):
+ unique.append(point)
+ return unique
+
+ @staticmethod
+ def _forward_intersection_distance(target: Any, point: Vector, direction: Vector) -> float | None:
+ try:
+ intersections = target.find_intersection_points(Axis(point, direction)) or []
+ except Exception as error:
+ raise ValueError("extent target does not support ray intersection") from error
+ distances = [
+ (hit_point - point).dot(direction)
+ for hit_point, _normal in intersections
+ if (hit_point - point).dot(direction) > 1e-6
+ ]
+ return min(distances) if distances else None
+
+ def uniform_intersection_distance(self, target: Any, faces: Iterable[Face], direction: Vector3) -> float:
+ """Return a proven uniform positive target distance for a profile set."""
+ unit_direction = _vector(direction).normalized()
+ distances: list[float] = []
+ for face in faces:
+ for point in self.profile_sample_points(face):
+ distance = self._forward_intersection_distance(target, point, unit_direction)
+ if distance is None:
+ raise ValueError("extent target is not reached by every profile ray")
+ distances.append(distance)
+ if not distances:
+ raise ValueError("extent feature has no profile samples")
+ minimum, maximum = min(distances), max(distances)
+ if maximum - minimum > 1e-5:
+ raise ValueError("extent target requires non-uniform profile trimming")
+ return sum(distances) / len(distances)
+
+ @staticmethod
+ def revolve(face: Face, angle_deg: float, axis: AxisSpec) -> Solid:
+ return Solid.revolve(face, angle_deg, Build123dGeometryAdapter.axis(axis))
+
+ @staticmethod
+ def fuse(body: Any | None, solid: Solid) -> Any:
+ return solid if body is None else body.fuse(solid)
+
+ @staticmethod
+ def cut(body: Any, tool: Any) -> Any:
+ return body.cut(tool)
+
+ @staticmethod
+ def sphere(radius_mm: float, center_mm: Vector3) -> Solid:
+ return Solid.make_sphere(radius_mm, Plane(origin=_vector(center_mm)))
+
+ def hole_tool(self, spec: HoleSpec, starts: Iterable[Vector3], inward: Vector3, through_depth_mm: float) -> Solid:
+ """Build a neutral ``HoleSpec`` into one OCC cutting tool."""
+ depth = through_depth_mm if spec.end_condition != "blind" else spec.depth_mm
+ result: Solid | None = None
+ for start in starts:
+ plane = Plane(origin=_vector(start), z_dir=_vector(inward))
+ tool = Solid.make_cylinder(spec.diameter_mm / 2, depth, plane)
+ if spec.counterbore:
+ diameter, bore_depth = spec.counterbore
+ tool = tool.fuse(Solid.make_cylinder(diameter / 2, bore_depth, plane))
+ if spec.countersink:
+ diameter, angle = spec.countersink
+ sink_depth = ((diameter - spec.diameter_mm) / 2) / math.tan(angle / 2)
+ tool = tool.fuse(Solid.make_cone(diameter / 2, spec.diameter_mm / 2, sink_depth, plane))
+ result = self.fuse(result, tool)
+ if result is None:
+ raise ValueError("hole has no positions")
+ return result
+
+ @staticmethod
+ def fillet(body: Any, radius_mm: float, edges: Iterable[Edge]) -> Any:
+ return body.fillet(radius_mm, list(edges))
+
+ @staticmethod
+ def tangent_edges(body: Any, seeds: Iterable[Edge], *, angular_tolerance: float = 1e-6) -> list[Edge]:
+ """Expand selected edges through actual tangent, vertex-adjacent chains.
+
+ The expansion is based solely on the current B-rep. It never uses a
+ global edge set or source stable IDs, and is consequently safe after a
+ body mutation invalidates earlier topology objects.
+ """
+ edges = list(body.edges())
+ selected = [edge for edge in seeds]
+ selected_indexes = {
+ index
+ for index, edge in enumerate(edges)
+ if any(edge.is_same(seed) for seed in selected)
+ }
+ if not selected_indexes:
+ return []
+
+ def shared_vertex(first: Edge, second: Edge) -> tuple[float, float] | None:
+ first_ends = [(0.0, vertex) for vertex in first.vertices()[:1]] + [(1.0, vertex) for vertex in first.vertices()[-1:]]
+ second_ends = [(0.0, vertex) for vertex in second.vertices()[:1]] + [(1.0, vertex) for vertex in second.vertices()[-1:]]
+ for first_parameter, first_vertex in first_ends:
+ for second_parameter, second_vertex in second_ends:
+ if first_vertex.is_same(second_vertex):
+ return first_parameter, second_parameter
+ return None
+
+ # Edges sharing a vertex whose tangents are parallel (orientation is
+ # irrelevant) are a tangent-continuous chain.
+ pending = list(selected_indexes)
+ while pending:
+ current_index = pending.pop()
+ for candidate_index, candidate in enumerate(edges):
+ if candidate_index in selected_indexes:
+ continue
+ shared = shared_vertex(edges[current_index], candidate)
+ if shared is None:
+ continue
+ first_tangent = edges[current_index].tangent_at(shared[0]).normalized()
+ second_tangent = candidate.tangent_at(shared[1]).normalized()
+ if abs(abs(first_tangent.dot(second_tangent)) - 1.0) <= angular_tolerance:
+ selected_indexes.add(candidate_index)
+ pending.append(candidate_index)
+ return [edge for index, edge in enumerate(edges) if index in selected_indexes]
+
+ @staticmethod
+ def chamfer(body: Any, distance_mm: float, distance_2_mm: float | None, edges: Iterable[Edge], face: Face | None = None) -> Any:
+ return body.chamfer(distance_mm, distance_2_mm, list(edges), face=face)
+
+ @staticmethod
+ def mirror(body: Any, plane: PlaneSpec) -> Any:
+ return body.mirror(Build123dGeometryAdapter.plane(plane))
+
+ @staticmethod
+ def export(body: Any, path: str) -> None:
+ export_step(body, path)
+
+ @staticmethod
+ def body_geometry(body: Any) -> dict[str, Any]:
+ bbox = body.bounding_box()
+ return {
+ "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
+ "volume_mm3": float(body.volume),
+ }
+
+ @staticmethod
+ def topology_records(body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]:
+ records: list[TopologyRecord] = []
+ faces = list(body.faces())
+ edges = list(body.edges())
+ vertices = list(body.vertices())
+
+ def index_for(shape: Any, candidates: list[Any]) -> int | None:
+ """Map a subshape returned by a face/edge back to body topology."""
+ for index, candidate in enumerate(candidates):
+ if shape.is_same(candidate):
+ return index
+ return None
+
+ edge_faces: list[set[int]] = [set() for _edge in edges]
+ for face_index, face in enumerate(faces):
+ for edge in face.edges():
+ edge_index = index_for(edge, edges)
+ if edge_index is not None:
+ edge_faces[edge_index].add(face_index)
+ vertex_edges: list[set[int]] = [set() for _vertex in vertices]
+ for edge_index, edge in enumerate(edges):
+ for vertex in edge.vertices():
+ vertex_index = index_for(vertex, vertices)
+ if vertex_index is not None:
+ vertex_edges[vertex_index].add(edge_index)
+
+ def edge_signature(edge_index: int) -> str:
+ edge = edges[edge_index]
+ return ":".join((
+ str(edge.geom_type).split(".")[-1].lower(),
+ f"{float(edge.length):.6f}",
+ str(len(edge_faces[edge_index])),
+ ))
+
+ for index, face in enumerate(faces):
+ bbox = face.bounding_box()
+ center = face.center()
+ normal = face.normal_at()
+ boundary_edge_indexes = [
+ edge_index
+ for edge in face.edges()
+ if (edge_index := index_for(edge, edges)) is not None
+ ]
+ geometry = {
+ "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
+ "center_mm": [center.X, center.Y, center.Z], "normal": [normal.X, normal.Y, normal.Z],
+ "area_mm2": float(face.area), "surface_type": str(face.geom_type).split(".")[-1].lower(),
+ "adjacency_signature": sorted(edge_signature(edge_index) for edge_index in boundary_edge_indexes),
+ }
+ if geometry["surface_type"] == "plane":
+ plane_normal, plane_offset = canonical_plane_signature(
+ (normal.X, normal.Y, normal.Z), (center.X, center.Y, center.Z),
+ )
+ geometry["plane_normal"] = list(plane_normal)
+ geometry["plane_offset_mm"] = plane_offset
+ records.append(TopologyRecord(
+ record_id=f"{body_id}:face:{index}", kind="face", feature_id=feature_id, body_id=body_id, value=face,
+ geometry=geometry,
+ ))
+ for index, edge in enumerate(edges):
+ bbox = edge.bounding_box()
+ center = edge.center()
+ vertices = edge.vertices()
+ geometry = {
+ "bbox_mm": [bbox.min.X, bbox.min.Y, bbox.min.Z, bbox.max.X, bbox.max.Y, bbox.max.Z],
+ "center_mm": [center.X, center.Y, center.Z], "length_mm": float(edge.length),
+ "curve_type": str(edge.geom_type).split(".")[-1].lower(),
+ "adjacent_face_count": len(edge_faces[index]),
+ }
+ if vertices:
+ geometry["start_mm"] = list(vertices[0])
+ geometry["end_mm"] = list(vertices[-1])
+ records.append(TopologyRecord(
+ record_id=f"{body_id}:edge:{index}", kind="edge", feature_id=feature_id, body_id=body_id, value=edge,
+ geometry=geometry,
+ ))
+ for index, vertex in enumerate(vertices):
+ point = [vertex.X, vertex.Y, vertex.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])},
+ ))
+ return records
diff --git a/backend/engine/cdsl_engine/capabilities.py b/backend/engine/cdsl_engine/capabilities.py
new file mode 100644
index 00000000..c73c217a
--- /dev/null
+++ b/backend/engine/cdsl_engine/capabilities.py
@@ -0,0 +1,378 @@
+"""Capability analysis and feature planning for semantic CDSL.
+
+The analyzer is intentionally independent of the geometry kernel. It treats
+the CDSL ``execution_status`` as provenance, then derives current executable
+state from registered atomic executors, profile support, and complete inputs.
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Iterable
+
+from .runtime_types import CapabilityResult, FeaturePlanNode, HoleSpec, RuntimeDiagnostic
+
+
+_SELECTOR_REQUIRED = frozenset({"fillet", "chamfer"})
+_SKETCH_ATOM_PREFIXES = ("extrude_", "revolve_")
+_PRIMARY_ATOMICS = frozenset({
+ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
+ "revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
+ "hole_counterbore", "sphere_add",
+})
+_HOLE_ATOMICS = frozenset({"hole_blind", "hole_countersink", "hole_counterbore", "hole_wizard"})
+_ACTIVE_BODY_REQUIRED = frozenset({
+ "extrude_cut_blind", "revolve_cut", *_HOLE_ATOMICS, "fillet", "chamfer",
+})
+_BODY_MUTATING_ATOMICS = frozenset({
+ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
+ "revolve_add", "revolve_cut", "sphere_add", *_HOLE_ATOMICS, "fillet", "chamfer",
+})
+# A pattern may replay a previous pattern as well as a direct body mutation.
+# Context-only features have no geometry definition to instance.
+_REPLAYABLE_ATOMICS = _BODY_MUTATING_ATOMICS | frozenset({"pattern_linear", "pattern_mirror"})
+_SUPPORTED_EXTENTS = frozenset({
+ "blind", "mid_plane", "through_all", "through_all_both", "through_all_and_blind",
+ "up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body",
+})
+_EXTENT_TARGET_KINDS = {
+ "up_to_surface": "face",
+ "offset_from_surface": "face",
+ "up_to_vertex": "vertex",
+ "up_to_body": "body",
+}
+
+
+def _has_explicit_axis(axis: Any) -> bool:
+ return isinstance(axis, dict) and axis.get("origin_mm") is not None and axis.get("direction") is not None
+
+
+def _has_resolvable_axis_selector(node: FeaturePlanNode) -> bool:
+ axis = node.params.get("axis") or {}
+ selector = axis.get("selector") if isinstance(axis, dict) else None
+ if not isinstance(selector, dict):
+ selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
+ # A source stable id does not survive SolidWorks -> OCC. An axis selector
+ # must therefore name the preceding context feature explicitly.
+ return isinstance(selector, dict) and selector.get("kind") == "axis" and bool(selector.get("owner_feature_id"))
+
+
+def _has_explicit_host_frame(params: dict[str, Any]) -> bool:
+ host = params.get("host_face")
+ frame = host.get("frame") if isinstance(host, dict) else None
+ return isinstance(frame, dict) and all(frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal"))
+
+
+def pattern_transform_blocker(source: FeaturePlanNode) -> str | None:
+ """Return the selector dependency that cannot be transformed exactly.
+
+ An explicit host frame is coordinate data, not a topology guess. It can
+ be transformed with a patterned instance while preserving local hole
+ positions. All topology selectors and selector-dependent extents remain
+ blocked until their geometry transform contract is implemented.
+ """
+ if source.selectors:
+ return "feature selector"
+ host = source.params.get("host_face")
+ if host is not None and not _has_explicit_host_frame(source.params):
+ return "host face selector"
+ end_condition = source.params.get("end_condition") or {}
+ if isinstance(end_condition, dict) and isinstance(end_condition.get("reference"), dict):
+ return "extent target selector"
+ return None
+
+
+def _schema_contract() -> dict[str, dict[str, Any]]:
+ path = Path(__file__).with_name("profile_schema.json")
+ return json.loads(path.read_text(encoding="utf-8"))["feature_atomic_ids"]
+
+
+def sketch_ids_required_by_contract(cdsl: dict[str, Any]) -> frozenset[str]:
+ """Return only sketches consumed by a feature with a sketch contract.
+
+ CAD documents commonly preserve construction or abandoned sketches whose
+ contours are incomplete. They are semantic data, but must not make an
+ otherwise independent feature history ineligible for execution.
+ """
+ contracts = _schema_contract()
+ return frozenset(
+ str(feature["sketch_id"])
+ for feature in cdsl.get("features") or ()
+ if feature.get("sketch_id") is not None
+ and (contracts.get(str(feature.get("atomic_id") or "")) or {}).get("requires_sketch")
+ )
+
+
+def _has_closed_region(sketch: dict[str, Any]) -> bool:
+ """Mirror the adapter's input contract without importing the geometry kernel."""
+ regions = sketch.get("contour_regions_mm") or []
+ if any(len(region.get("outer") or []) >= 2 for region in regions if isinstance(region, dict)):
+ return True
+ if len(sketch.get("contour_edges_mm") or []) >= 2:
+ return True
+ return any(
+ entity.get("type") == "circle" and not entity.get("construction")
+ and float(entity.get("radius_mm") or 0.0) > 0
+ for entity in sketch.get("entities") or []
+ if isinstance(entity, dict)
+ )
+
+
+@dataclass(frozen=True)
+class CapabilityAnalysis:
+ plan: tuple[FeaturePlanNode, ...]
+ feature_results: tuple[CapabilityResult, ...]
+ document_blockers: tuple[RuntimeDiagnostic, ...] = ()
+
+ @property
+ def runtime_eligible(self) -> bool:
+ return not self.document_blockers and all(result.executable for result in self.feature_results)
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "runtime_eligible": self.runtime_eligible,
+ "feature_results": [result.as_dict() for result in self.feature_results],
+ "document_blockers": [blocker.as_dict() for blocker in self.document_blockers],
+ }
+
+
+class CapabilityAnalyzer:
+ """Determine whether a semantic document is executable by this runtime."""
+
+ def __init__(self, *, atomic_ids: Iterable[str], profile_types: Iterable[str]) -> None:
+ self.atomic_ids = frozenset(atomic_ids)
+ self.profile_types = frozenset(profile_types)
+ self.contracts = _schema_contract()
+
+ def _blocker(self, feature_id: str, code: str, message: str, **detail: Any) -> RuntimeDiagnostic:
+ return RuntimeDiagnostic(code=code, message=message, feature_id=feature_id, detail=detail)
+
+ def _plan(self, cdsl: dict[str, Any]) -> tuple[FeaturePlanNode, ...]:
+ return tuple(
+ FeaturePlanNode(
+ feature_id=str(feature.get("id") or ""),
+ atomic_id=str(feature.get("atomic_id") or ""),
+ name=feature.get("name"),
+ depends_on=tuple(feature.get("depends_on") or ()),
+ params=dict(feature.get("params") or {}),
+ selectors=tuple(feature.get("selectors") or ()),
+ sketch_id=feature.get("sketch_id"),
+ declared_status=feature.get("execution_status"),
+ source_feature=feature,
+ )
+ for feature in cdsl.get("features") or ()
+ )
+
+ def analyze(
+ self,
+ cdsl: dict[str, Any],
+ *,
+ sketch_errors: dict[str, str] | None = None,
+ ) -> CapabilityAnalysis:
+ sketches = {str(sketch.get("id")): sketch for sketch in (cdsl.get("geometry") or {}).get("sketches") or ()}
+ sketch_errors = sketch_errors or {}
+ plan = self._plan(cdsl)
+ nodes_by_id = {node.feature_id: node for node in plan}
+ results: list[CapabilityResult] = []
+ completed: set[str] = set()
+ body_available = False
+ for node in plan:
+ blockers: list[RuntimeDiagnostic] = []
+ contract = self.contracts.get(node.atomic_id)
+ required = [f"atomic:{node.atomic_id}"]
+ if not contract:
+ blockers.append(self._blocker(node.feature_id, "unknown_atomic", "The semantic schema has no atomic contract", atomic_id=node.atomic_id))
+ elif node.atomic_id not in self.atomic_ids:
+ blockers.append(self._blocker(node.feature_id, "unsupported_atomic", "The current runtime has no registered executor", atomic_id=node.atomic_id))
+ for unresolved in node.source_feature.get("unresolved") or ():
+ blockers.append(self._blocker(node.feature_id, "unresolved_input", str(unresolved)))
+ for dependency in node.depends_on:
+ if dependency not in completed:
+ blockers.append(self._blocker(node.feature_id, "dependency_unavailable", "Feature dependency did not become executable", dependency=dependency))
+ if node.atomic_id in _ACTIVE_BODY_REQUIRED:
+ required.append("active_body")
+ if not body_available:
+ blockers.append(self._blocker(
+ node.feature_id, "missing_active_body",
+ "This feature mutates an existing body, but no preceding executable feature created one",
+ ))
+ params = node.params
+ if contract:
+ for parameter in contract.get("required_params") or ():
+ if params.get(parameter) is None:
+ blockers.append(self._blocker(node.feature_id, "missing_parameter", "Required parameter is missing", parameter=parameter))
+ if contract.get("requires_sketch"):
+ if not node.sketch_id or node.sketch_id not in sketches:
+ blockers.append(self._blocker(node.feature_id, "missing_sketch", "Feature requires an existing sketch", sketch_id=node.sketch_id))
+ else:
+ profile_type = str((sketches[node.sketch_id].get("profile") or {}).get("type") or "")
+ required.append(f"profile:{profile_type}")
+ resolution_error = sketch_errors.get(str(node.sketch_id))
+ if resolution_error:
+ blockers.append(self._blocker(
+ node.feature_id,
+ "profile_resolution_failed",
+ "The feature's sketch could not be resolved into executable regions",
+ sketch_id=node.sketch_id,
+ reason=resolution_error,
+ ))
+ 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]):
+ blockers.append(self._blocker(
+ node.feature_id, "profile_no_closed_region",
+ "The resolved sketch contains no closed profile region",
+ sketch_id=node.sketch_id,
+ ))
+ if node.atomic_id.startswith(_SKETCH_ATOM_PREFIXES):
+ end_condition = params.get("end_condition") or {"type": "blind"}
+ end_type = end_condition.get("type")
+ required.append(f"extent:{end_type}")
+ if end_type not in _SUPPORTED_EXTENTS:
+ 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)
+ 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 reference.get("kind") != 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"),
+ ))
+ 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",
+ "Offset-from-surface requires a non-zero captured offset distance",
+ ))
+ if node.atomic_id == "extrude_add_two_sided":
+ reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
+ reverse_type = reverse_condition.get("type")
+ required.append(f"extent:reverse:{reverse_type}")
+ if reverse_type not in _SUPPORTED_EXTENTS:
+ blockers.append(self._blocker(
+ node.feature_id, "unsupported_reverse_extent",
+ "The reverse extent is not implemented", extent=reverse_type,
+ ))
+ reverse_target_kind = _EXTENT_TARGET_KINDS.get(reverse_type)
+ 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_reference.get("kind") != 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"),
+ ))
+ 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",
+ "Reverse offset-from-surface requires a non-zero captured offset distance",
+ ))
+ if node.atomic_id in _SELECTOR_REQUIRED and not node.selectors:
+ blockers.append(self._blocker(node.feature_id, "missing_selector", "Dress-up features require an explicit selector"))
+ if node.atomic_id in _HOLE_ATOMICS:
+ required.append("selector:host_face")
+ if not params.get("host_face"):
+ blockers.append(self._blocker(
+ node.feature_id, "missing_host_face",
+ "Hole operations require a host face selector or an explicit host frame",
+ ))
+ try:
+ HoleSpec.from_feature(node.atomic_id, params, wizard=node.atomic_id == "hole_wizard")
+ except ValueError as error:
+ blockers.append(self._blocker(node.feature_id, "invalid_hole_spec", str(error)))
+ if node.atomic_id == "hole_wizard":
+ if params.get("thread"):
+ blockers.append(self._blocker(node.feature_id, "unsupported_hole_subtype", "Threaded Hole Wizard geometry is not represented by the current CDSL runtime"))
+ hole_extent = (params.get("end_condition") or {"type": "blind"}).get("type")
+ if hole_extent not in {"blind", "through_all", "through_all_both"}:
+ blockers.append(self._blocker(
+ node.feature_id, "unsupported_hole_extent",
+ "The current Hole Wizard runtime supports blind and through-all extents only",
+ extent=hole_extent,
+ ))
+ if not params.get("positions"):
+ blockers.append(self._blocker(node.feature_id, "missing_hole_positions", "Hole Wizard requires captured positions"))
+ if node.atomic_id == "reference_plane" and not isinstance(params.get("plane"), dict):
+ 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_axis":
+ axis = params.get("axis") or {}
+ if not (axis.get("origin_mm") and axis.get("direction")):
+ plane_selectors = [selector for selector in node.selectors if selector.get("kind") == "plane"]
+ if len(plane_selectors) < 2:
+ blockers.append(self._blocker(node.feature_id, "missing_reference_axis_geometry", "Reference axis requires explicit geometry or two reference planes"))
+ if node.atomic_id.startswith("revolve_"):
+ axis = params.get("axis")
+ if not _has_explicit_axis(axis) and not _has_resolvable_axis_selector(node):
+ blockers.append(self._blocker(
+ node.feature_id, "missing_revolve_axis",
+ "Revolve requires an explicit axis or an owner-qualified reference-axis selector",
+ ))
+ if node.atomic_id.startswith("pattern_"):
+ required.append("feature_replay")
+ sources = params.get("source_feature_ids") or []
+ if not sources:
+ blockers.append(self._blocker(node.feature_id, "missing_pattern_source", "Pattern has no source features"))
+ for source_id in sources:
+ source = nodes_by_id.get(str(source_id))
+ if source is None:
+ blockers.append(self._blocker(
+ node.feature_id, "missing_pattern_source",
+ "Pattern source feature does not exist", source_feature_id=source_id,
+ ))
+ continue
+ if source.atomic_id not in _REPLAYABLE_ATOMICS:
+ blockers.append(self._blocker(
+ node.feature_id, "unsupported_pattern_source",
+ "Pattern source has no replayable body definition",
+ source_feature_id=source_id, atomic_id=source.atomic_id,
+ ))
+ continue
+ if source.feature_id not in completed:
+ blockers.append(self._blocker(
+ node.feature_id, "pattern_source_unavailable",
+ "Pattern source did not become executable before this pattern",
+ source_feature_id=source_id,
+ ))
+ continue
+ transform_dependency = pattern_transform_blocker(source) if source else None
+ if transform_dependency:
+ blockers.append(self._blocker(
+ node.feature_id, "unsupported_pattern_selector_transform",
+ "Pattern source uses a topology dependency that cannot yet be transformed",
+ source_feature_id=source_id, dependency=transform_dependency,
+ ))
+ if node.atomic_id == "pattern_mirror" and not params.get("mirror_plane"):
+ blockers.append(self._blocker(node.feature_id, "missing_mirror_plane", "Mirror pattern has no mirror plane"))
+ status = "executable" if not blockers else ("unsupported" if any(b.code.startswith("unsupported") or b.code == "unknown_atomic" for b in blockers) else "blocked")
+ results.append(CapabilityResult(node.feature_id, node.atomic_id, status, tuple(required), tuple(blockers)))
+ if status == "executable":
+ completed.add(node.feature_id)
+ if node.atomic_id in _BODY_MUTATING_ATOMICS:
+ body_available = True
+ body_producers = {
+ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
+ "revolve_add", "revolve_cut", "sphere_add",
+ }
+ document_blockers: list[RuntimeDiagnostic] = []
+ if not any(node.atomic_id in body_producers for node in plan):
+ document_blockers.append(RuntimeDiagnostic(
+ "no_solid_feature", "CDSL contains no feature capable of creating a solid body",
+ ))
+ return CapabilityAnalysis(plan=plan, feature_results=tuple(results), document_blockers=tuple(document_blockers))
diff --git a/backend/engine/cdsl_engine/llm_engine.py b/backend/engine/cdsl_engine/llm_engine.py
index 8cff7da3..bc913eeb 100644
--- a/backend/engine/cdsl_engine/llm_engine.py
+++ b/backend/engine/cdsl_engine/llm_engine.py
@@ -51,6 +51,8 @@ SUPPORTED_ATOMIC_IDS = frozenset({
"hole_countersink",
"hole_counterbore",
"sphere_add",
+ "reference_plane",
+ "reference_axis",
})
@@ -103,6 +105,40 @@ def _ordered_profile_points(sketch: dict[str, Any]) -> list[tuple[float, float]]
return pts
+def _arc_midpoint(edge: dict[str, Any], p1: Vector, p2: Vector, center: Vector, radius: float) -> Vector:
+ """Return a point on the intended directed arc for ``make_three_point_arc``.
+
+ Legacy contour data has no sweep direction and retains its prior shortest
+ arc behavior. Evidence-v2 analytic contours carry ``clockwise`` so a
+ major arc or a clockwise arc cannot be silently inverted by the adapter.
+ """
+ v1 = p1 - center
+ v2 = p2 - center
+ if v1.length < 1e-9 or v2.length < 1e-9:
+ return (p1 + p2) / 2
+ n = Vector(*(edge.get("normal") or [0, 0, 1]))
+ if n.length < 1e-9:
+ n = v1.cross(v2)
+ if n.length < 1e-9:
+ n = Vector(0, 0, 1)
+ n = n.normalized()
+ v1n = v1.normalized() * radius
+ if "clockwise" not in edge:
+ bisector = v1n + v2.normalized() * radius
+ if bisector.length < 1e-9:
+ bisector = n.cross(v1n)
+ return center + bisector.normalized() * radius
+ sweep = math.atan2(n.dot(v1.cross(v2)), v1.dot(v2))
+ if bool(edge["clockwise"]):
+ if sweep >= 0:
+ sweep -= math.tau
+ elif sweep <= 0:
+ sweep += math.tau
+ half = sweep / 2
+ midpoint_vector = v1n * math.cos(half) + n.cross(v1n) * math.sin(half)
+ return center + midpoint_vector
+
+
def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal: list[float] | None = None) -> Face:
b123_edges: list[Edge] = []
for e in edges_mm:
@@ -116,18 +152,7 @@ def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal:
if v1.length < 1e-9 or v2.length < 1e-9:
b123_edges.append(Edge.make_line(p1, p2))
continue
- n = Vector(*(e.get("normal") or [0, 0, 1]))
- if n.length < 1e-9:
- n = v1.cross(v2)
- if n.length < 1e-9:
- n = Vector(0, 0, 1)
- n = n.normalized()
- v1n = v1.normalized() * r
- v2n = v2.normalized() * r
- bis = v1n + v2n
- if bis.length < 1e-9:
- bis = n.cross(v1n)
- mid = center + bis.normalized() * r
+ mid = _arc_midpoint(e, p1, p2, center, r)
try:
b123_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
@@ -154,18 +179,7 @@ def _face_from_contour_edges(edges_mm: list[dict[str, Any]], *, desired_normal:
if v1.length < 1e-9 or v2.length < 1e-9:
rev_edges.append(Edge.make_line(p1, p2))
continue
- n = Vector(*(e.get("normal") or [0, 0, 1]))
- if n.length < 1e-9:
- n = v1.cross(v2)
- if n.length < 1e-9:
- n = Vector(0, 0, 1)
- n = n.normalized()
- v1n = v1.normalized() * r
- v2n = v2.normalized() * r
- bis = v1n + v2n
- if bis.length < 1e-9:
- bis = n.cross(v1n)
- mid = center + bis.normalized() * r
+ mid = _arc_midpoint(e, p1, p2, center, r)
try:
rev_edges.append(Edge.make_three_point_arc(p1, mid, p2))
except Exception:
@@ -267,7 +281,22 @@ def run_engine_plan(
sketch = step.get("sketch")
sid = step.get("step_id")
- if atomic == "sphere_add":
+ if atomic == "reference_plane":
+ # Context features deliberately produce no solid. They remain
+ # executable plan steps so their dependencies are preserved and
+ # can be registered by the session-based runtime.
+ plane = _plane_from_workplane(params.get("plane") or {})
+ log.append(
+ f"{sid}: reference_plane origin={tuple(plane.origin)} normal={tuple(plane.z_dir)}"
+ )
+
+ elif atomic == "reference_axis":
+ axis = _axis_from_params(params.get("axis") or {})
+ log.append(
+ f"{sid}: reference_axis origin={tuple(axis.position)} direction={tuple(axis.direction)}"
+ )
+
+ elif atomic == "sphere_add":
radius = _f(params.get("radius_mm") or 0)
center = params.get("center_mm") or [0, 0, 0]
if radius <= 0 or len(center) != 3:
diff --git a/backend/engine/cdsl_engine/phase_pools.py b/backend/engine/cdsl_engine/phase_pools.py
new file mode 100644
index 00000000..a2118c32
--- /dev/null
+++ b/backend/engine/cdsl_engine/phase_pools.py
@@ -0,0 +1,95 @@
+"""Deterministic phase-pool selection for CDSL runtime baselines.
+
+Pool membership is intentionally input-based. It does not claim a part is
+truth-verified; that remains the responsibility of ``batch_rebuild --build``.
+"""
+
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any
+
+from .capabilities import sketch_ids_required_by_contract
+from .semantic_validation import validate_semantic_cdsl
+from .sketch_solver import resolve_required_sketches
+
+
+P3_ATOMIC_IDS = frozenset({
+ "reference_plane", "reference_axis", "extrude_add_blind", "extrude_add_two_sided",
+ "extrude_cut_blind", "revolve_add", "revolve_cut",
+})
+P4_ATOMIC_IDS = P3_ATOMIC_IDS | frozenset({"hole_wizard"})
+P6_ATOMIC_IDS = P4_ATOMIC_IDS | frozenset({"pattern_linear", "pattern_mirror"})
+P3_PROFILE_TYPES = frozenset({"analytic_contours", "circle", "circles", "annulus"})
+# Static pool membership asks whether the exported history is an extrude/
+# revolve history. Whether a first cut has a preceding active body remains a
+# runtime preflight question, not a reason to erase it from the input pool.
+_P3_PRIMARY_ATOMICS = frozenset({
+ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut",
+})
+
+
+def _p3_profile_ready(cdsl: dict[str, Any]) -> bool:
+ required_ids = sketch_ids_required_by_contract(cdsl)
+ errors: dict[str, str] = {}
+ try:
+ resolved = resolve_required_sketches(cdsl, required_ids, errors=errors)
+ except ValueError:
+ return False
+ if errors:
+ return False
+ sketches = {str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or ()}
+ for sketch_id in required_ids:
+ sketch = sketches.get(sketch_id) or {}
+ profile = sketch.get("profile") or {}
+ if profile.get("type") not in P3_PROFILE_TYPES:
+ return False
+ if not (
+ any(len(region.get("outer") or []) >= 2 for region in sketch.get("contour_regions_mm") or () if isinstance(region, dict))
+ or len(sketch.get("contour_edges_mm") or ()) >= 2
+ or any(
+ entity.get("type") == "circle" and not entity.get("construction") and float(entity.get("radius_mm") or 0.0) > 0
+ for entity in sketch.get("entities") or ()
+ if isinstance(entity, dict)
+ )
+ ):
+ return False
+ return True
+
+
+_PHASE_ATOMIC_IDS = {"p3": P3_ATOMIC_IDS, "p4": P4_ATOMIC_IDS, "p6": P6_ATOMIC_IDS}
+
+
+def is_static_phase_ready(cdsl: dict[str, Any], phase: str) -> bool:
+ """Return whether CDSL belongs to a documented static phase input pool."""
+ allowed = _PHASE_ATOMIC_IDS.get(phase)
+ if allowed is None:
+ raise ValueError(f"Unknown CDSL runtime phase {phase!r}")
+ semantic = validate_semantic_cdsl(cdsl)
+ if semantic["unresolved"]:
+ return False
+ atoms = {str(feature.get("atomic_id") or "") for feature in cdsl.get("features") or ()}
+ if not atoms <= allowed:
+ return False
+ if not any(atom in _P3_PRIMARY_ATOMICS for atom in atoms):
+ return False
+ return _p3_profile_ready(cdsl)
+
+
+def select_static_phase_pool(cdsl_dir: Path, phase: str) -> list[str]:
+ """Return sorted part ids in one documented static phase input pool."""
+ part_ids: list[str] = []
+ for path in sorted(cdsl_dir.glob("*.cdsl.json")):
+ document: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
+ if is_static_phase_ready(document, phase):
+ part_ids.append(str(document.get("part_id") or path.name.removesuffix(".cdsl.json")))
+ return part_ids
+
+
+def is_p3_static_ready(cdsl: dict[str, Any]) -> bool:
+ return is_static_phase_ready(cdsl, "p3")
+
+
+def select_p3_static_pool(cdsl_dir: Path) -> list[str]:
+ return select_static_phase_pool(cdsl_dir, "p3")
diff --git a/backend/engine/cdsl_engine/profile_schema.json b/backend/engine/cdsl_engine/profile_schema.json
index 793b8d31..14ba81d4 100644
--- a/backend/engine/cdsl_engine/profile_schema.json
+++ b/backend/engine/cdsl_engine/profile_schema.json
@@ -2,27 +2,27 @@
"schema": "cdsl.engine.schema.v1",
"schema_version": "1.3.0",
"cdsl_json_schema_file": "cdsl_schema.json",
- "maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with sketch_solver.py, llm_compiler.py and llm_engine.py; deferred entries describe future engine work.",
+ "maintenance_rule": "The semantic CDSL contract is a superset of the current runtime. runtime_supported_atomic_ids and runtime_supported_profiles must stay synchronized with runtime.py EXECUTORS, sketch_solver.py SHAPE_GENERATORS, and the package-level capability tests. Legacy llm_compiler.py and llm_engine.py are not the CDSL-only runtime contract.",
"coordinate_convention": "All profile dimensions use millimetres. Two-dimensional points are [u, v] in the sketch workplane.",
- "runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add"],
- "runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "complex_arc_shape", "unknown_shape"],
+ "runtime_supported_atomic_ids": ["extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind", "revolve_add", "revolve_cut", "hole_blind", "hole_countersink", "hole_counterbore", "sphere_add", "reference_plane", "reference_axis", "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror"],
+ "runtime_supported_profiles": ["circle", "annulus", "circles", "circle_grid", "rectangle", "rectangle_with_circles", "rectangle_with_fillets", "obround", "polygon", "ibone", "rectangle_with_symmetric_notches", "revolve_chamfer", "revolve_chamfer_slanted", "circle_with_arc_notches", "circular_sector_slot", "circle_with_radial_tabs", "filleted_rect_side_slots", "d_shape", "partial_ring", "partial_ring_with_arc_island", "arc_chain", "radial_slot", "patterned_cutouts", "compound_patterned_cutouts", "analytic_contours", "complex_arc_shape", "unknown_shape"],
"feature_atomic_ids": {
"extrude_add_blind": {"summary": "Add the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true},
- "extrude_add_two_sided": {"summary": "Add the closed profile symmetrically on both sides of its workplane.", "required_params": ["distance_mm"], "optional_params": [], "requires_sketch": true},
+ "extrude_add_two_sided": {"summary": "Add the closed profile with independently captured forward and reverse terminations.", "required_params": ["distance_mm", "reverse_distance_mm"], "optional_params": ["reverse", "end_condition", "reverse_end_condition"], "requires_sketch": true},
"extrude_cut_blind": {"summary": "Remove the closed profile by one signed extrusion distance.", "required_params": ["distance_mm"], "optional_params": ["reverse"], "requires_sketch": true},
"revolve_add": {"summary": "Add the closed profile by revolving it around an axis.", "required_params": ["angle_deg", "axis"], "optional_params": ["reverse"], "requires_sketch": true},
"revolve_cut": {"summary": "Remove the closed profile by revolving it around an axis.", "required_params": ["angle_deg", "axis"], "optional_params": ["reverse"], "requires_sketch": true},
- "hole_blind": {"summary": "Cut one or more blind cylindrical holes in the current body.", "required_params": ["diameter_mm", "depth_mm", "positions"], "optional_params": ["host_face", "drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid.", "requires_sketch": true},
- "hole_countersink": {"summary": "Cut one or more blind holes with countersink dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "countersink_diameter_mm", "countersink_angle_rad"], "optional_params": ["host_face", "drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid.", "requires_sketch": true},
- "hole_counterbore": {"summary": "Cut one or more blind holes with counterbore dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "counterbore_diameter_mm", "counterbore_depth_mm"], "optional_params": ["host_face", "drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid.", "requires_sketch": true},
+ "hole_blind": {"summary": "Cut one or more blind cylindrical holes in the current body.", "required_params": ["diameter_mm", "depth_mm", "positions", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face must provide a strict selector or an explicit frame.", "requires_sketch": true},
+ "hole_countersink": {"summary": "Cut one or more blind holes with countersink dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "countersink_diameter_mm", "countersink_angle_rad", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face must provide a strict selector or an explicit frame.", "requires_sketch": true},
+ "hole_counterbore": {"summary": "Cut one or more blind holes with counterbore dimensions.", "required_params": ["diameter_mm", "depth_mm", "positions", "counterbore_diameter_mm", "counterbore_depth_mm", "host_face"], "optional_params": ["drill_angle_rad"], "position_format": "positions is a non-empty array of objects: [{\"mm\":[u_mm,v_mm,w_mm]}]. A bare coordinate array is invalid. host_face must provide a strict selector or an explicit frame.", "requires_sketch": true},
"sphere_add": {"summary": "Add one spherical solid at an explicit model-space center.", "required_params": ["radius_mm", "center_mm"], "optional_params": [], "requires_sketch": true},
- "fillet": {"summary": "Apply a radius to selected edges or faces.", "required_params": ["radius_mm"], "optional_params": ["tangent_propagation"], "requires_sketch": false, "execution_status": "deferred"},
- "chamfer": {"summary": "Apply an equal-distance or angle-distance chamfer to selected edges or faces.", "required_params": ["distance_mm"], "optional_params": ["distance_2_mm", "angle_rad"], "requires_sketch": false, "execution_status": "deferred"},
- "pattern_linear": {"summary": "Repeat source features along one or two directions.", "required_params": ["source_feature_ids", "direction_1", "spacing_1_mm", "pattern_count_1"], "optional_params": ["direction_2", "spacing_2_mm", "pattern_count_2"], "requires_sketch": false, "execution_status": "deferred"},
- "pattern_mirror": {"summary": "Mirror source features about a selected plane.", "required_params": ["source_feature_ids", "mirror_plane"], "optional_params": [], "requires_sketch": false, "execution_status": "deferred"},
- "reference_plane": {"summary": "A named reference plane used by sketches or patterns.", "required_params": ["plane"], "optional_params": [], "requires_sketch": false, "execution_status": "deferred"},
- "reference_axis": {"summary": "A named reference axis used by revolve or pattern features.", "required_params": ["axis"], "optional_params": [], "requires_sketch": false, "execution_status": "deferred"},
- "hole_wizard": {"summary": "A SolidWorks Hole Wizard feature including its typed dimensional contract and placement selectors.", "required_params": ["hole_type", "diameter_mm", "depth_mm"], "optional_params": ["positions", "host_face", "thread", "countersink", "counterbore"], "requires_sketch": false, "execution_status": "deferred"}
+ "fillet": {"summary": "Apply a radius to selected edges or faces.", "required_params": ["radius_mm"], "optional_params": ["tangent_propagation"], "requires_sketch": false},
+ "chamfer": {"summary": "Apply an equal-distance or angle-distance chamfer to selected edges or faces.", "required_params": ["distance_mm"], "optional_params": ["distance_2_mm", "angle_rad"], "requires_sketch": false},
+ "pattern_linear": {"summary": "Repeat source features along one or two directions.", "required_params": ["source_feature_ids", "direction_1", "spacing_1_mm", "pattern_count_1"], "optional_params": ["direction_2", "spacing_2_mm", "pattern_count_2"], "requires_sketch": false},
+ "pattern_mirror": {"summary": "Mirror source features about a selected plane.", "required_params": ["source_feature_ids", "mirror_plane"], "optional_params": [], "requires_sketch": false},
+ "reference_plane": {"summary": "A named reference plane used by sketches or patterns.", "required_params": ["plane"], "optional_params": [], "requires_sketch": false},
+ "reference_axis": {"summary": "A named reference axis used by revolve or pattern features.", "required_params": ["axis"], "optional_params": [], "requires_sketch": false},
+ "hole_wizard": {"summary": "A SolidWorks Hole Wizard feature including its typed dimensional contract and placement selectors.", "required_params": ["hole_type", "diameter_mm", "depth_mm"], "optional_params": ["positions", "host_face", "thread", "countersink", "counterbore"], "requires_sketch": false}
},
"profiles": {
"circle": {
@@ -190,7 +190,7 @@
},
"analytic_contours": {
"agent_allowed": false,
- "summary": "Exact analytic line, arc, circle and B-spline contours emitted by the Evidence v2 converter. Future engines must consume this profile without relying on compiler_context."
+ "summary": "Exact analytic line, arc and circle contours emitted by the Evidence v2 converter. The runtime resolves them into closed regions without compiler_context; B-spline currently produces an explicit unsupported diagnostic."
},
"unknown_shape": {
"agent_allowed": false,
diff --git a/backend/engine/cdsl_engine/rebuild.py b/backend/engine/cdsl_engine/rebuild.py
index b28726c2..57b9e7ee 100644
--- a/backend/engine/cdsl_engine/rebuild.py
+++ b/backend/engine/cdsl_engine/rebuild.py
@@ -1,7 +1,7 @@
"""
CDSL → STEP 重建管道
====================
-优先: CDSL → sketch_solver → llm_compiler → llm_engine (engine=cdsl_only)
+优先: CDSL → capability planner → session runtime → STEP (engine=cdsl_only)
回退: CDSL + compiler_context → translator
"""
@@ -41,7 +41,7 @@ def run_rebuild(cdsl: dict[str, Any], out_step: Path, ctx_file: Path | None = No
cdsl_only_error: Exception | None = None
if all_drawable and not force_exact:
try:
- return _run_cdsl_only(cdsl, out_step, gold_step=gold_step)
+ return run_cdsl_only(cdsl, out_step, gold_step=gold_step)
except Exception as e:
cdsl_only_error = e
@@ -106,23 +106,24 @@ def _sketch_is_cdsl_drawable(sketch: dict[str, Any]) -> bool:
return ptype in SHAPE_GENERATORS
-def _run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
- """纯 Learning-IR 路径:CDSL → sketch_solver → llm_compiler → llm_engine。"""
- t0 = time.time()
- slim = {k: v for k, v in cdsl.items() if k != "compiler_context"}
- pack = compile_cdsl(slim)
- pack.pop("compiler_context", None)
- result = run_engine_plan(pack, out_step)
- result["engine"] = "cdsl_only"
- result["elapsed_s"] = round(time.time() - t0, 1)
- result.setdefault("log", [])
- result["log"].append("cdsl_only: sketch_solver + llm_compiler + llm_engine (no compiler_context)")
+def run_cdsl_only(cdsl: dict[str, Any], out_step: Path, gold_step: Path | None = None) -> dict[str, Any]:
+ """Pure semantic CDSL path with no compiler_context fallback."""
+ t0 = time.time()
+ try:
+ from .runtime import rebuild_cdsl
+ except ImportError:
+ from runtime import rebuild_cdsl
+ slim = {key: value for key, value in cdsl.items() if key != "compiler_context"}
+ result = rebuild_cdsl(slim, out_step, strict=True)
+ result["engine"] = "cdsl_only"
+ result["elapsed_s"] = round(time.time() - t0, 1)
+ result.setdefault("log", []).append("cdsl_only: capability planner + session runtime (no compiler_context)")
if gold_step and gold_step.exists():
result["gold_step"] = str(gold_step)
return result
-def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
+def compile_cdsl_to_pack(cdsl: dict[str, Any]) -> dict[str, Any]:
pack = compile_cdsl({k: v for k, v in cdsl.items() if k != "compiler_context"})
pack.pop("compiler_context", None)
return pack
diff --git a/backend/engine/cdsl_engine/runtime.py b/backend/engine/cdsl_engine/runtime.py
new file mode 100644
index 00000000..060d4208
--- /dev/null
+++ b/backend/engine/cdsl_engine/runtime.py
@@ -0,0 +1,828 @@
+"""Session-based CDSL execution with atomic executor registry."""
+
+from __future__ import annotations
+
+from copy import deepcopy
+from dataclasses import dataclass, field
+import math
+from pathlib import Path
+from typing import Any, Callable, Protocol
+
+from .build123d_adapter import Build123dGeometryAdapter
+from .capabilities import CapabilityAnalyzer, pattern_transform_blocker, sketch_ids_required_by_contract
+from .runtime_types import (
+ AxisSpec, CapabilityResult, FeaturePlanNode, FeatureResult, HoleSpec, PlaneSpec, Vector3,
+ RuntimeDiagnostic, SelectorResolution, TopologyRecord, TopologyRegistry,
+ vector_add, vector_cross, vector_dot, vector_scale, vector_subtract, vector_unit,
+)
+from .sketch_solver import SHAPE_GENERATORS, resolve_required_sketches
+
+
+ALL_ATOMIC_IDS = frozenset({
+ "extrude_add_blind", "extrude_add_two_sided", "extrude_cut_blind",
+ "revolve_add", "revolve_cut", "hole_blind", "hole_countersink",
+ "hole_counterbore", "sphere_add", "reference_plane", "reference_axis",
+ "hole_wizard", "fillet", "chamfer", "pattern_linear", "pattern_mirror",
+})
+
+
+class RuntimeExecutionError(RuntimeError):
+ """A feature execution failure with serializable runtime evidence."""
+
+ def __init__(self, diagnostic: RuntimeDiagnostic, selector_resolutions: list[dict[str, Any]]) -> None:
+ super().__init__(diagnostic.message)
+ self.diagnostic = diagnostic
+ self.selector_resolutions = selector_resolutions
+
+
+class FeatureExecutionError(RuntimeError):
+ """An expected feature-level execution rejection with a stable code."""
+
+ def __init__(self, code: str, message: str, **detail: Any) -> None:
+ super().__init__(message)
+ self.code = code
+ self.detail = detail
+
+
+class AtomicExecutor(Protocol):
+ atomic_id: str
+
+ def preflight(self, node: FeaturePlanNode, session: "ExecutionSession") -> CapabilityResult: ...
+ def execute(self, node: FeaturePlanNode, session: "ExecutionSession") -> FeatureResult: ...
+
+
+class GeometryAdapter(Protocol):
+ """Kernel boundary consumed by the session runtime.
+
+ Geometry values remain opaque here. A future adapter may use a different
+ B-rep kernel as long as it preserves these construction/query contracts.
+ """
+
+ def topology_records(self, body: Any, feature_id: str, body_id: str) -> list[TopologyRecord]: ...
+ def body_geometry(self, body: Any) -> dict[str, Any]: ...
+ def faces_for_sketch(self, sketch: dict[str, Any]) -> list[Any]: ...
+ def extrude(self, face: Any, direction: Vector3) -> Any: ...
+ def revolve(self, face: Any, angle_deg: float, axis: AxisSpec) -> Any: ...
+ def fuse(self, body: Any | None, solid: Any) -> Any: ...
+ def cut(self, body: Any, tool: Any) -> Any: ...
+ def sphere(self, radius_mm: float, center_mm: Vector3) -> Any: ...
+ def hole_tool(self, spec: HoleSpec, starts: list[Vector3], inward: Vector3, through_depth_mm: float) -> Any: ...
+ def body_center(self, body: Any) -> Vector3: ...
+ def body_span(self, body: Any, direction: Vector3) -> float: ...
+ def vertex_coordinates(self, vertex: Any) -> Vector3: ...
+ def profile_sample_points(self, face: Any) -> list[Any]: ...
+ def uniform_intersection_distance(self, target: Any, faces: list[Any], direction: Vector3) -> float: ...
+ def fillet(self, body: Any, radius_mm: float, edges: list[Any]) -> Any: ...
+ def tangent_edges(self, body: Any, seeds: list[Any]) -> list[Any]: ...
+ def chamfer(self, body: Any, distance_mm: float, distance_2_mm: float | None, edges: list[Any], face: Any | None = None) -> Any: ...
+ def export(self, body: Any, path: str) -> None: ...
+
+
+@dataclass
+class ExecutionSession:
+ sketches: dict[str, dict[str, Any]]
+ nodes: dict[str, FeaturePlanNode]
+ adapter: GeometryAdapter = field(default_factory=Build123dGeometryAdapter)
+ topology: TopologyRegistry = field(default_factory=TopologyRegistry)
+ body: Any | None = None
+ body_id: str | None = None
+ results: dict[str, FeatureResult] = field(default_factory=dict)
+ replay_definitions: dict[str, FeaturePlanNode] = field(default_factory=dict)
+ selector_resolutions: list[dict[str, Any]] = field(default_factory=list)
+
+ def register_body(self, feature_id: str, body: Any, *, replay_node: FeaturePlanNode | None = None) -> None:
+ self.body = body
+ self.body_id = f"body:{feature_id}"
+ self.topology.replace_body_topology(feature_id, self.body_id, self.adapter.topology_records(body, feature_id, self.body_id))
+ self.topology.register(TopologyRecord(
+ record_id=self.body_id, kind="body", feature_id=feature_id, body_id=self.body_id,
+ geometry=self.adapter.body_geometry(body), value=body, owner_feature_ids=(feature_id,),
+ ))
+ if replay_node is not None:
+ self.replay_definitions[feature_id] = replay_node
+
+ def resolve(self, selector: dict[str, Any]) -> SelectorResolution:
+ resolution = self.topology.resolve(selector, active_body_id=self.body_id)
+ self.selector_resolutions.append(resolution.as_dict())
+ return resolution
+
+ def result(self, node: FeaturePlanNode, *, context: PlaneSpec | AxisSpec | None = None, diagnostics: list[RuntimeDiagnostic] | None = None) -> FeatureResult:
+ result = FeatureResult(
+ feature_id=node.feature_id, atomic_id=node.atomic_id, status="executed", body_id=self.body_id,
+ context=context, replay_definition={"atomic_id": node.atomic_id, "params": deepcopy(node.params), "sketch_id": node.sketch_id},
+ diagnostics=diagnostics or [],
+ )
+ self.results[node.feature_id] = result
+ return result
+
+ def replay_sources(self, source_feature_ids: list[Any]) -> list[FeaturePlanNode]:
+ """Return selected source features in their original history order.
+
+ A pattern's exported selection order is not an execution order. In
+ particular, a boolean cut may appear before its parent boss in the
+ raw selection array. The CDSL feature list is dependency-ordered by
+ semantic validation, so it is the stable order for replay.
+ """
+ requested = {str(feature_id) for feature_id in source_feature_ids}
+ sources = [
+ feature
+ for feature_id, feature in self.nodes.items()
+ if feature_id in requested and feature_id in self.replay_definitions
+ ]
+ if len(sources) != len(requested):
+ missing = sorted(requested - {source.feature_id for source in sources})
+ raise ValueError(f"pattern source features have no replay definitions: {', '.join(missing)}")
+ return sources
+
+
+def _normal_from_sketch(sketch: dict[str, Any]) -> Vector3:
+ return PlaneSpec.from_mapping(sketch.get("workplane") or {}).normal
+
+
+def _extent_reference(node: FeaturePlanNode, condition: dict[str, Any] | None = None) -> dict[str, Any]:
+ condition = condition or node.params.get("end_condition") or {}
+ reference = condition.get("reference")
+ if not isinstance(reference, dict):
+ raise FeatureExecutionError(
+ "missing_extent_reference",
+ "This end condition requires a captured target selector",
+ extent=condition.get("type"),
+ )
+ return reference
+
+
+def _targeted_extent_vector(
+ node: FeaturePlanNode,
+ faces: list[Any],
+ direction: Vector3,
+ session: ExecutionSession,
+ condition: str,
+ *,
+ end_condition: dict[str, Any] | None = None,
+ offset_mm: float | None = None,
+) -> Vector3:
+ if 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
+ else:
+ reference = _extent_reference(node, end_condition)
+ 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")
+ expected_kind = {"up_to_vertex": "vertex", "up_to_body": "body"}.get(condition, "face")
+ if resolution.record.kind != expected_kind:
+ raise FeatureExecutionError(
+ "unsupported_extent_target",
+ "The resolved target kind is incompatible with this end condition",
+ extent=condition, expected_kind=expected_kind, actual_kind=resolution.record.kind,
+ )
+ target = resolution.record.value
+ if condition == "up_to_vertex":
+ target_point = session.adapter.vertex_coordinates(target)
+ projections = [
+ vector_dot(vector_subtract(target_point, point), direction)
+ for face in faces
+ for point in session.adapter.profile_sample_points(face)
+ ]
+ if not projections or min(projections) <= 1e-6:
+ raise FeatureExecutionError("extent_target_not_in_direction", "The target vertex is not ahead of the profile", extent=condition)
+ if max(projections) - min(projections) > 1e-5:
+ raise FeatureExecutionError("non_uniform_extent_target", "The target vertex does not define one extrusion distance", extent=condition)
+ distance = sum(projections) / len(projections)
+ else:
+ try:
+ distance = session.adapter.uniform_intersection_distance(target, faces, direction)
+ except ValueError as error:
+ code = "non_uniform_extent_target" if "non-uniform" in str(error) else "extent_target_not_reached"
+ raise FeatureExecutionError(code, str(error), extent=condition) from error
+ if condition == "offset_from_surface":
+ offset = abs(float(offset_mm if offset_mm is not None else node.params.get("distance_mm") or 0.0))
+ distance -= offset
+ if distance <= 1e-6:
+ raise FeatureExecutionError(
+ "invalid_extent_offset",
+ "Offset distance reaches or passes the target surface",
+ extent=condition, offset_mm=offset,
+ )
+ return vector_scale(direction, distance)
+
+
+def _side_extent_vectors(
+ node: FeaturePlanNode,
+ faces: list[Any],
+ direction: Vector3,
+ session: ExecutionSession,
+ *,
+ end_condition: dict[str, Any],
+ distance_mm: float,
+) -> list[Vector3]:
+ """Resolve one directional extent without borrowing the opposite side.
+
+ ``extrude_add_two_sided`` calls this once for each independently captured
+ termination. The regular one-sided executor also uses it for all simple
+ termination modes, keeping the geometry adapter interface uniform.
+ """
+ condition = str(end_condition.get("type") or "blind")
+ distance = abs(float(distance_mm or 0.0))
+ if condition == "blind":
+ if distance <= 0:
+ raise ValueError("blind extent requires distance_mm > 0")
+ return [vector_scale(direction, distance)]
+ if condition == "mid_plane":
+ if distance <= 0:
+ raise ValueError("mid_plane extent requires distance_mm > 0")
+ return [vector_scale(direction, distance / 2), vector_scale(direction, -distance / 2)]
+ if condition == "through_all":
+ if session.body is None:
+ if distance <= 0:
+ raise ValueError("through_all on an initial feature has no body and no fallback distance")
+ return [direction * distance]
+ return [vector_scale(direction, max(session.adapter.body_span(session.body, direction), 1.0) + 2.0)]
+ if condition in {"up_to_surface", "up_to_vertex", "offset_from_surface", "through_next", "up_to_body"}:
+ return [
+ _targeted_extent_vector(
+ node, faces, direction, session, condition,
+ end_condition=end_condition, offset_mm=distance,
+ )
+ ]
+ raise ValueError(f"unsupported directional extent {condition!r}")
+
+
+def _extent_vectors(
+ node: FeaturePlanNode,
+ faces: list[Any],
+ sketch: dict[str, Any],
+ session: ExecutionSession,
+) -> list[Vector3]:
+ params = node.params
+ normal = vector_unit(_normal_from_sketch(sketch), field_name="sketch normal")
+ if bool(params.get("reverse")):
+ normal = vector_scale(normal, -1)
+ end_condition = params.get("end_condition") or {"type": "blind"}
+ condition = end_condition.get("type", "blind")
+ distance = abs(float(params.get("distance_mm") or 0.0))
+ if node.atomic_id == "extrude_add_two_sided":
+ reverse_condition = params.get("reverse_end_condition") or {"type": "blind"}
+ reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
+ if reverse_distance <= 0:
+ raise ValueError("two-sided extrusion requires reverse_distance_mm > 0")
+ return [
+ *_side_extent_vectors(
+ node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
+ ),
+ *_side_extent_vectors(
+ node, faces, vector_scale(normal, -1), session,
+ end_condition=reverse_condition, distance_mm=reverse_distance,
+ ),
+ ]
+ if condition in {"through_all", "through_all_both", "through_all_and_blind"}:
+ if session.body is None:
+ # A first feature with through-all has no body to terminate
+ # against. The source must provide a usable blind component.
+ if distance <= 0:
+ raise ValueError("through_all on an initial feature has no body and no fallback distance")
+ return [vector_scale(normal, distance)]
+ span = max(session.adapter.body_span(session.body, normal), 1.0) + 2.0
+ if condition == "through_all":
+ return [vector_scale(normal, span)]
+ if condition == "through_all_both":
+ return [vector_scale(normal, span), vector_scale(normal, -span)]
+ # Through-all-and-blind is represented by a through direction plus
+ # its captured opposite blind direction when available.
+ reverse_distance = abs(float(params.get("reverse_distance_mm") or 0.0))
+ return [vector_scale(normal, span), vector_scale(normal, -(reverse_distance or span))]
+ return _side_extent_vectors(
+ node, faces, normal, session, end_condition=end_condition, distance_mm=distance,
+ )
+
+
+def _revolve_axis(node: FeaturePlanNode, session: ExecutionSession) -> AxisSpec:
+ raw_axis = node.params.get("axis") or {}
+ if raw_axis.get("origin_mm") is not None and raw_axis.get("direction") is not None:
+ return AxisSpec.from_mapping(raw_axis)
+ selector = raw_axis.get("selector") if isinstance(raw_axis, dict) else None
+ if not isinstance(selector, dict):
+ selector = next((item for item in node.selectors if item.get("kind") == "axis"), None)
+ if not isinstance(selector, dict):
+ raise FeatureExecutionError(
+ "missing_revolve_axis",
+ "Revolve requires an explicit axis or an owner-qualified reference-axis selector",
+ )
+ resolution = session.resolve(selector)
+ if resolution.status != "resolved" or resolution.record is None:
+ raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "revolve axis was not resolved")
+ if not isinstance(resolution.record.value, AxisSpec):
+ raise FeatureExecutionError(
+ "unsupported_revolve_axis", "The resolved context is not an axis", actual_kind=resolution.record.kind,
+ )
+ return resolution.record.value
+
+
+def _shape_from_primary(node: FeaturePlanNode, session: ExecutionSession, *, sketch: dict[str, Any] | None = None) -> FeatureResult:
+ selected_sketch = sketch or session.sketches.get(str(node.sketch_id))
+ if selected_sketch is None:
+ raise ValueError("primary feature has no resolved sketch")
+ faces = session.adapter.faces_for_sketch(selected_sketch)
+ if not faces:
+ raise ValueError("sketch does not create a closed profile region")
+ if node.atomic_id.startswith("extrude_"):
+ vectors = _extent_vectors(node, faces, selected_sketch, session)
+ solids = [session.adapter.extrude(face, vector) for face in faces for vector in vectors]
+ else:
+ axis = _revolve_axis(node, session)
+ angle = float(node.params.get("angle_deg") or 0.0)
+ if angle <= 0:
+ raise ValueError("revolve requires angle_deg > 0")
+ solids = [session.adapter.revolve(face, angle, axis) for face in faces]
+ tool = None
+ for solid in solids:
+ tool = session.adapter.fuse(tool, solid)
+ if tool is None:
+ raise ValueError("primary feature produced no solid")
+ if "cut" in node.atomic_id:
+ if session.body is None:
+ raise ValueError("cut feature has no body")
+ body = session.adapter.cut(session.body, tool)
+ else:
+ body = session.adapter.fuse(session.body, tool)
+ session.register_body(node.feature_id, body, replay_node=node)
+ return session.result(node)
+
+
+def _execute_reference_plane(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
+ plane = PlaneSpec.from_mapping(node.params.get("plane") or {})
+ session.topology.register_context(node.feature_id, plane)
+ return session.result(node, context=plane)
+
+
+def _execute_reference_axis(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
+ params = node.params.get("axis") or {}
+ if params.get("origin_mm") and params.get("direction"):
+ axis = AxisSpec.from_mapping(params)
+ else:
+ planes = [session.resolve(selector) for selector in node.selectors if selector.get("kind") == "plane"]
+ resolved = [item.record.value for item in planes if item.status == "resolved" and isinstance(item.record.value, PlaneSpec)]
+ if len(resolved) < 2:
+ raise ValueError("reference axis requires two uniquely resolved planes")
+ first, second = resolved[0], resolved[1]
+ n1, n2 = first.normal, second.normal
+ direction = vector_cross(n1, n2)
+ squared_length = vector_dot(direction, direction)
+ if squared_length <= 1e-18:
+ raise ValueError("reference planes are parallel and cannot define an axis")
+ d1 = vector_dot(n1, first.origin_mm)
+ d2 = vector_dot(n2, second.origin_mm)
+ point = vector_scale(vector_add(vector_scale(vector_cross(n2, direction), d1), vector_scale(vector_cross(direction, n1), d2)), 1 / squared_length)
+ axis = AxisSpec(origin_mm=point, direction=vector_unit(direction, field_name="reference axis"))
+ session.topology.register_context(node.feature_id, axis)
+ return session.result(node, context=axis)
+
+
+def _execute_sphere(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
+ radius = float(node.params.get("radius_mm") or 0.0)
+ center = node.params.get("center_mm") or []
+ if radius <= 0 or len(center) != 3:
+ raise ValueError("sphere_add requires radius_mm and a three-dimensional center_mm")
+ solid = session.adapter.sphere(radius, (float(center[0]), float(center[1]), float(center[2])))
+ session.register_body(node.feature_id, session.adapter.fuse(session.body, solid), replay_node=node)
+ return session.result(node)
+
+
+def _host_plane(resolution: SelectorResolution) -> 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"],
+ })
+
+
+def _hole_starts(
+ spec: HoleSpec,
+ *,
+ host_plane: PlaneSpec,
+ positions_are_local: bool,
+) -> list[Vector3]:
+ starts: list[Vector3] = []
+ for point in spec.positions_mm:
+ if positions_are_local:
+ start = vector_add(
+ vector_add(
+ vector_add(host_plane.origin_mm, vector_scale(host_plane.x_dir, point[0])),
+ vector_scale(host_plane.y_dir, point[1]),
+ ),
+ vector_scale(host_plane.normal, point[2]),
+ )
+ else:
+ start = point
+ starts.append(start)
+ return starts
+
+
+def _execute_hole(node: FeaturePlanNode, session: ExecutionSession, *, wizard: bool = False) -> FeatureResult:
+ if session.body is None:
+ raise ValueError("hole feature has no body")
+ host_selector = node.params.get("host_face")
+ if isinstance(host_selector, dict) and isinstance(host_selector.get("frame"), dict):
+ host = PlaneSpec.from_mapping(host_selector["frame"])
+ positions_are_local = True
+ else:
+ selectors = list(node.selectors)
+ if isinstance(host_selector, dict):
+ selectors.append(host_selector)
+ 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
+ spec = HoleSpec.from_feature(node.atomic_id, node.params, wizard=wizard)
+ normal = host.normal
+ inward = normal if vector_dot(vector_subtract(session.adapter.body_center(session.body), host.origin_mm), normal) >= 0 else vector_scale(normal, -1)
+ tool = session.adapter.hole_tool(
+ spec,
+ _hole_starts(spec, host_plane=host, positions_are_local=positions_are_local),
+ inward,
+ session.adapter.body_span(session.body, inward) + 2.0,
+ )
+ session.register_body(node.feature_id, session.adapter.cut(session.body, tool), replay_node=node)
+ return session.result(node)
+
+
+def _selector_edges(node: FeaturePlanNode, session: ExecutionSession, *, tangent_propagation: bool = False) -> list[Any]:
+ resolved: list[SelectorResolution] = [session.resolve(selector) for selector in node.selectors]
+ failed = next((item for item in resolved if item.status != "resolved"), None)
+ if failed:
+ raise ValueError(failed.diagnostic.message if failed.diagnostic else "selector resolution failed")
+ edges: list[Any] = []
+ for item in resolved:
+ if item.record.kind == "edge":
+ edges.append(item.record.value)
+ elif item.record.kind == "face":
+ edges.extend(item.record.value.edges())
+ if not edges:
+ raise ValueError("selectors did not resolve any edges")
+ return session.adapter.tangent_edges(session.body, edges) if tangent_propagation else edges
+
+
+def _execute_fillet(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
+ if session.body is None:
+ raise ValueError("fillet has no body")
+ radius = float(node.params.get("radius_mm") or 0)
+ if radius <= 0:
+ raise ValueError("fillet radius_mm must be > 0")
+ body = session.adapter.fillet(
+ session.body, radius, _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
+ )
+ session.register_body(node.feature_id, body, replay_node=node)
+ return session.result(node)
+
+
+def _execute_chamfer(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
+ if session.body is None:
+ raise ValueError("chamfer has no body")
+ distance = float(node.params.get("distance_mm") or 0)
+ if distance <= 0:
+ raise ValueError("chamfer distance_mm must be > 0")
+ body = session.adapter.chamfer(
+ session.body, distance, node.params.get("distance_2_mm"),
+ _selector_edges(node, session, tangent_propagation=bool(node.params.get("tangent_propagation"))),
+ )
+ session.register_body(node.feature_id, body, replay_node=node)
+ return session.result(node)
+
+
+def _translated_sketch(sketch: dict[str, Any], offset: Vector3) -> dict[str, Any]:
+ output = deepcopy(sketch)
+ components = offset
+ workplane = output.get("workplane") or {}
+ origin = workplane.get("origin_mm") or [0, 0, 0]
+ workplane["origin_mm"] = [float(origin[index]) + components[index] for index in range(3)]
+ output["workplane"] = workplane
+ for key in ("contour_edges_mm", "contour_regions_mm"):
+ def translate(value: Any) -> None:
+ if isinstance(value, dict):
+ for point_key in ("start_mm", "end_mm", "center_mm"):
+ if point_key in value:
+ value[point_key] = [float(value[point_key][index]) + components[index] for index in range(3)]
+ for child in value.values():
+ translate(child)
+ elif isinstance(value, list):
+ for child in value:
+ translate(child)
+ translate(output.get(key))
+ return output
+
+
+def _translated_node(node: FeaturePlanNode, instance_id: str, offset: Vector3) -> FeaturePlanNode:
+ params = deepcopy(node.params)
+ components = offset
+ if isinstance(params.get("plane"), dict) and params["plane"].get("origin_mm"):
+ params["plane"]["origin_mm"] = [float(params["plane"]["origin_mm"][index]) + components[index] for index in range(3)]
+ 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(
+ host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
+ )
+ if positions_are_local and host_frame.get("origin_mm"):
+ host_frame["origin_mm"] = [float(host_frame["origin_mm"][index]) + components[index] for index in range(3)]
+ if not positions_are_local:
+ for position in params.get("positions") or []:
+ if position.get("mm"):
+ position["mm"] = [float(position["mm"][index]) + components[index] for index in range(3)]
+ axis = params.get("axis") or {}
+ if axis.get("origin_mm"):
+ axis["origin_mm"] = [float(axis["origin_mm"][index]) + components[index] for index in range(3)]
+ return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
+
+
+def _execute_linear_pattern(node: FeaturePlanNode, session: ExecutionSession, execute: Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]) -> FeatureResult:
+ params = node.params
+ sources = session.replay_sources(params.get("source_feature_ids") or [])
+ if not sources:
+ raise ValueError("pattern source features have no replay definitions")
+ count_1 = int(params.get("pattern_count_1") or 1)
+ count_2 = int(params.get("pattern_count_2") or 1)
+ direction_1 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_1") or [1, 0, 0])), field_name="pattern direction_1"), float(params.get("spacing_1_mm") or 0))
+ direction_2 = vector_scale(vector_unit(tuple(float(value) for value in (params.get("direction_2") or [0, 1, 0])), field_name="pattern direction_2"), float(params.get("spacing_2_mm") or 0))
+ for first in range(count_1):
+ for second in range(count_2):
+ if first == 0 and second == 0:
+ continue
+ offset = vector_add(vector_scale(direction_1, first), vector_scale(direction_2, second))
+ for source in sources:
+ dependency = pattern_transform_blocker(source)
+ if dependency:
+ raise ValueError(f"pattern source uses an unsupported {dependency}")
+ cloned = _translated_node(source, f"{node.feature_id}.p{first}_{second}.{source.feature_id}", offset)
+ sketch = session.sketches.get(str(source.sketch_id))
+ execute(cloned, session, _translated_sketch(sketch, offset) if sketch else None)
+ # A later pattern may select this pattern feature. The definition is
+ # replayed recursively, never approximated by copying the current body.
+ session.replay_definitions[node.feature_id] = node
+ return session.result(node)
+
+
+def _reflect_point(point: list[float] | tuple[float, float, float], plane: PlaneSpec, *, vector: bool = False) -> list[float]:
+ value = tuple(float(component) for component in point)
+ offset = value if vector else vector_subtract(value, plane.origin_mm)
+ mirrored = vector_subtract(value, vector_scale(plane.normal, 2 * vector_dot(offset, plane.normal)))
+ return list(mirrored)
+
+
+def _mirrored_sketch(sketch: dict[str, Any], plane: PlaneSpec) -> dict[str, Any]:
+ output = deepcopy(sketch)
+ workplane = output.get("workplane") or {}
+ if workplane.get("origin_mm"):
+ workplane["origin_mm"] = _reflect_point(workplane["origin_mm"], plane)
+ for key in ("x_dir", "y_dir", "normal"):
+ if workplane.get(key):
+ workplane[key] = _reflect_point(workplane[key], plane, vector=True)
+ output["workplane"] = workplane
+
+ # A reflection reverses handedness. ``PlaneSpec`` reconstructs its local
+ # y direction as normal x x, so keeping the reflected normal means that
+ # local y is the inverse of the reflected source y. Profiles represented
+ # as local circles (rather than already-transformed contour edges) must
+ # therefore invert v to remain at their actual reflected world position.
+ def mirror_local_coordinates(value: Any) -> None:
+ if isinstance(value, dict):
+ for point_key in ("center", "start", "end"):
+ point = value.get(point_key)
+ if isinstance(point, list) and len(point) == 2:
+ value[point_key] = [float(point[0]), -float(point[1])]
+ for child in value.values():
+ mirror_local_coordinates(child)
+ elif isinstance(value, list):
+ for child in value:
+ mirror_local_coordinates(child)
+
+ mirror_local_coordinates(output.get("entities"))
+ # This is not consumed after sketch resolution, but retaining the same
+ # local semantics makes an overridden sketch safe to inspect or replay.
+ mirror_local_coordinates(output.get("profile"))
+
+ def mirror(value: Any) -> None:
+ if isinstance(value, dict):
+ for point_key in ("start_mm", "end_mm", "center_mm"):
+ if point_key in value:
+ value[point_key] = _reflect_point(value[point_key], plane)
+ if value.get("normal"):
+ value["normal"] = _reflect_point(value["normal"], plane, vector=True)
+ for child in value.values():
+ mirror(child)
+ elif isinstance(value, list):
+ for child in value:
+ mirror(child)
+ mirror(output.get("contour_edges_mm"))
+ mirror(output.get("contour_regions_mm"))
+ return output
+
+
+def _mirrored_node(node: FeaturePlanNode, instance_id: str, plane: PlaneSpec) -> FeaturePlanNode:
+ params = deepcopy(node.params)
+ if isinstance(params.get("plane"), dict):
+ for key in ("origin_mm", "x_dir", "y_dir", "normal"):
+ if params["plane"].get(key):
+ params["plane"][key] = _reflect_point(params["plane"][key], plane, vector=key != "origin_mm")
+ 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(
+ host_frame.get(key) is not None for key in ("origin_mm", "x_dir", "normal")
+ )
+ if positions_are_local:
+ for key in ("origin_mm", "x_dir", "y_dir", "normal"):
+ if host_frame.get(key):
+ host_frame[key] = _reflect_point(host_frame[key], plane, vector=key != "origin_mm")
+ # See _mirrored_sketch: the canonical reflected plane reverses local
+ # y, so local hole coordinates must do the same.
+ for position in params.get("positions") or []:
+ point = position.get("mm")
+ if isinstance(point, list) and len(point) == 3:
+ position["mm"] = [float(point[0]), -float(point[1]), float(point[2])]
+ else:
+ for position in params.get("positions") or []:
+ if position.get("mm"):
+ position["mm"] = _reflect_point(position["mm"], plane)
+ axis = params.get("axis") or {}
+ if axis.get("origin_mm"):
+ axis["origin_mm"] = _reflect_point(axis["origin_mm"], plane)
+ if axis.get("direction"):
+ axis["direction"] = _reflect_point(axis["direction"], plane, vector=True)
+ return FeaturePlanNode(instance_id, node.atomic_id, node.name, (), params, node.selectors, node.sketch_id, node.declared_status, node.source_feature)
+
+
+def _execute_mirror_pattern(node: FeaturePlanNode, session: ExecutionSession) -> FeatureResult:
+ mirror = node.params.get("mirror_plane") or {}
+ resolution = session.resolve(mirror)
+ if resolution.status != "resolved" or not isinstance(resolution.record.value, PlaneSpec):
+ raise ValueError(resolution.diagnostic.message if resolution.diagnostic else "mirror plane was not resolved")
+ sources = session.replay_sources(node.params.get("source_feature_ids") or [])
+ if not sources:
+ raise ValueError("mirror pattern source features have no replay definitions")
+ for source in sources:
+ dependency = pattern_transform_blocker(source)
+ if dependency:
+ raise ValueError(f"mirror pattern source uses an unsupported {dependency}")
+ cloned = _mirrored_node(source, f"{node.feature_id}.m.{source.feature_id}", resolution.record.value)
+ sketch = session.sketches.get(str(source.sketch_id))
+ _execute_node(cloned, session, _mirrored_sketch(sketch, resolution.record.value) if sketch else None)
+ session.replay_definitions[node.feature_id] = node
+ return session.result(node)
+
+
+def _execute_node(node: FeaturePlanNode, session: ExecutionSession, sketch_override: dict[str, Any] | None = None) -> FeatureResult:
+ executor = EXECUTORS.get(node.atomic_id)
+ if executor is None:
+ raise ValueError(f"No executor registered for {node.atomic_id!r}")
+ return executor(node, session, sketch_override)
+
+
+ExecutorFunction = Callable[[FeaturePlanNode, ExecutionSession, dict[str, Any] | None], FeatureResult]
+
+
+def _primary_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ return _shape_from_primary(node, session, sketch=sketch)
+
+
+def _reference_plane_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_reference_plane(node, session)
+
+
+def _reference_axis_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_reference_axis(node, session)
+
+
+def _sphere_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_sphere(node, session)
+
+
+def _hole_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_hole(node, session)
+
+
+def _hole_wizard_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_hole(node, session, wizard=True)
+
+
+def _fillet_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_fillet(node, session)
+
+
+def _chamfer_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_chamfer(node, session)
+
+
+def _linear_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_linear_pattern(node, session, _execute_node)
+
+
+def _mirror_pattern_executor(node: FeaturePlanNode, session: ExecutionSession, sketch: dict[str, Any] | None) -> FeatureResult:
+ del sketch
+ return _execute_mirror_pattern(node, session)
+
+
+EXECUTORS: dict[str, ExecutorFunction] = {
+ "reference_plane": _reference_plane_executor,
+ "reference_axis": _reference_axis_executor,
+ "sphere_add": _sphere_executor,
+ "extrude_add_blind": _primary_executor,
+ "extrude_add_two_sided": _primary_executor,
+ "extrude_cut_blind": _primary_executor,
+ "revolve_add": _primary_executor,
+ "revolve_cut": _primary_executor,
+ "hole_blind": _hole_executor,
+ "hole_countersink": _hole_executor,
+ "hole_counterbore": _hole_executor,
+ "hole_wizard": _hole_wizard_executor,
+ "fillet": _fillet_executor,
+ "chamfer": _chamfer_executor,
+ "pattern_linear": _linear_pattern_executor,
+ "pattern_mirror": _mirror_pattern_executor,
+}
+
+
+def analyze_cdsl(cdsl: dict[str, Any]):
+ """Resolve profiles and return the current runtime capability analysis."""
+ sketch_errors: dict[str, str] = {}
+ resolved = resolve_required_sketches(
+ deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
+ )
+ analyzer = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS)
+ return analyzer.analyze(resolved, sketch_errors=sketch_errors)
+
+
+def rebuild_cdsl(cdsl: dict[str, Any], out_step: Path, *, strict: bool = True) -> dict[str, Any]:
+ """Rebuild CDSL through session-scoped atomic executors only."""
+ sketch_errors: dict[str, str] = {}
+ resolved = resolve_required_sketches(
+ deepcopy(cdsl), sketch_ids_required_by_contract(cdsl), errors=sketch_errors,
+ )
+ analysis = CapabilityAnalyzer(atomic_ids=EXECUTORS, profile_types=SHAPE_GENERATORS).analyze(
+ resolved, sketch_errors=sketch_errors,
+ )
+ if strict and not analysis.runtime_eligible:
+ first = next((result for result in analysis.feature_results if not result.executable), None)
+ if first is None:
+ raise ValueError(analysis.document_blockers[0].code)
+ if any(blocker.code == "unknown_atomic" for blocker in first.blockers):
+ raise ValueError(f"unsupported atomic_id: {first.atomic_id}")
+ detail = "; ".join(blocker.code for blocker in first.blockers)
+ raise ValueError(f"Feature {first.feature_id} is not runtime eligible: {detail}")
+ session = ExecutionSession(
+ sketches={str(sketch.get("id")): sketch for sketch in (resolved.get("geometry") or {}).get("sketches") or []},
+ nodes={node.feature_id: node for node in analysis.plan},
+ )
+ diagnostics: list[RuntimeDiagnostic] = []
+ for node, preflight in zip(analysis.plan, analysis.feature_results):
+ if not preflight.executable:
+ diagnostics.extend(preflight.blockers)
+ if strict:
+ break
+ continue
+ try:
+ _execute_node(node, session)
+ except Exception as error:
+ failed_resolution = next(
+ (item for item in reversed(session.selector_resolutions) if item["status"] != "resolved"), None,
+ )
+ diagnostic = (
+ RuntimeDiagnostic(error.code, str(error), feature_id=node.feature_id, detail=error.detail)
+ if isinstance(error, FeatureExecutionError)
+ else
+ RuntimeDiagnostic(
+ failed_resolution["diagnostic"]["code"], failed_resolution["diagnostic"]["message"],
+ feature_id=node.feature_id, detail=failed_resolution["diagnostic"].get("detail") or {},
+ )
+ if failed_resolution and failed_resolution.get("diagnostic")
+ else RuntimeDiagnostic("execution_failed", str(error), feature_id=node.feature_id)
+ )
+ diagnostics.append(diagnostic)
+ if strict:
+ raise RuntimeExecutionError(diagnostic, list(session.selector_resolutions)) from error
+ if session.body is None:
+ raise ValueError("CDSL execution produced no body")
+ out_step.parent.mkdir(parents=True, exist_ok=True)
+ session.adapter.export(session.body, str(out_step))
+ geometry = session.adapter.body_geometry(session.body)
+ bbox = geometry["bbox_mm"]
+ return {
+ "engine": "cdsl_session_runtime",
+ "out_step": str(out_step),
+ "volume_mm3": float(geometry["volume_mm3"]),
+ "bbox_mm": {"min": bbox[:3], "max": bbox[3:]},
+ "feature_results": [result.as_dict() for result in session.results.values()],
+ "runtime_diagnostics": [diagnostic.as_dict() for diagnostic in diagnostics],
+ "topology_records": [record.public_dict() for record in session.topology.records()],
+ "selector_resolution": session.selector_resolutions,
+ }
diff --git a/backend/engine/cdsl_engine/runtime_types.py b/backend/engine/cdsl_engine/runtime_types.py
new file mode 100644
index 00000000..de753079
--- /dev/null
+++ b/backend/engine/cdsl_engine/runtime_types.py
@@ -0,0 +1,625 @@
+"""Runtime-neutral CDSL planning, diagnostics, and topology contracts.
+
+This module deliberately has no build123d dependency. The planner and
+selector resolver can therefore be used by validation, batch reporting, and
+any geometry adapter without importing OCC objects.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass, field
+from math import sqrt
+from typing import Any, Iterable
+
+
+Vector3 = tuple[float, float, float]
+
+
+def _vector3(value: Any, *, field_name: str) -> Vector3:
+ if not isinstance(value, (list, tuple)) or len(value) != 3:
+ raise ValueError(f"{field_name} must contain three coordinates")
+ try:
+ return (float(value[0]), float(value[1]), float(value[2]))
+ except (TypeError, ValueError) as error:
+ raise ValueError(f"{field_name} must contain numeric coordinates") from error
+
+
+def _length(value: Vector3) -> float:
+ return sqrt(sum(component * component for component in value))
+
+
+def _unit(value: Vector3, *, field_name: str) -> Vector3:
+ magnitude = _length(value)
+ if magnitude <= 1e-12:
+ raise ValueError(f"{field_name} must be non-zero")
+ return tuple(component / magnitude for component in value) # type: ignore[return-value]
+
+
+def _dot(left: Vector3, right: Vector3) -> float:
+ return sum(a * b for a, b in zip(left, right))
+
+
+def _cross(left: Vector3, right: Vector3) -> Vector3:
+ 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 vector_add(left: Vector3, right: Vector3) -> Vector3:
+ return tuple(a + b for a, b in zip(left, right)) # type: ignore[return-value]
+
+
+def vector_subtract(left: Vector3, right: Vector3) -> Vector3:
+ return tuple(a - b for a, b in zip(left, right)) # type: ignore[return-value]
+
+
+def vector_scale(value: Vector3, factor: float) -> Vector3:
+ return tuple(component * factor for component in value) # type: ignore[return-value]
+
+
+def vector_dot(left: Vector3, right: Vector3) -> float:
+ return _dot(left, right)
+
+
+def vector_cross(left: Vector3, right: Vector3) -> Vector3:
+ return _cross(left, right)
+
+
+def vector_unit(value: Vector3, *, field_name: str = "vector") -> Vector3:
+ return _unit(value, field_name=field_name)
+
+
+def canonical_plane_signature(normal: Vector3, point_mm: Vector3) -> tuple[Vector3, float]:
+ """Normalize a plane sign so source and OCC face orientations compare."""
+ unit_normal = _unit(normal, field_name="plane.normal")
+ offset = _dot(unit_normal, point_mm)
+ for component in unit_normal:
+ if abs(component) <= 1e-12:
+ continue
+ if component < 0:
+ unit_normal = tuple(-value for value in unit_normal) # type: ignore[assignment]
+ offset = -offset
+ break
+ return unit_normal, offset
+
+
+def normalize_selector_geometry(geometry: Any) -> dict[str, Any]:
+ """Convert legacy SolidWorks selector evidence into runtime-neutral units.
+
+ Current CDSL records may already carry ``*_mm`` fields. Older exported
+ evidence instead stores SolidWorks surface parameters, boxes, and areas in
+ SI units. The selector remains the source of truth; this function only
+ makes its geometric signature comparable to an OCC topology snapshot.
+ """
+ if not isinstance(geometry, dict):
+ return {}
+ result = dict(geometry)
+ surface = geometry.get("surface")
+ if isinstance(surface, dict):
+ surface_type = str(surface.get("type") or "").lower()
+ if surface_type:
+ result.setdefault("surface_type", surface_type)
+ parameters = surface.get("parameters")
+ if surface_type == "plane" and isinstance(parameters, list) and len(parameters) >= 6:
+ try:
+ raw_normal = _vector3(parameters[:3], field_name="selector surface normal")
+ # SolidWorks evidence uses metres for surface locations.
+ point_mm = tuple(float(value) * 1000.0 for value in parameters[3:6])
+ plane_normal, plane_offset = canonical_plane_signature(raw_normal, point_mm) # type: ignore[arg-type]
+ result.setdefault("plane_normal", list(plane_normal))
+ result.setdefault("plane_offset_mm", plane_offset)
+ except (TypeError, ValueError):
+ pass
+ curve = geometry.get("curve")
+ if isinstance(curve, dict) and curve.get("type"):
+ result.setdefault("curve_type", str(curve["type"]).lower())
+ raw_box = geometry.get("box")
+ if isinstance(raw_box, list) and len(raw_box) == 6:
+ try:
+ result.setdefault("bbox_mm", [float(value) * 1000.0 for value in raw_box])
+ except (TypeError, ValueError):
+ pass
+ raw_area = geometry.get("area")
+ if raw_area is not None:
+ try:
+ result.setdefault("area_mm2", float(raw_area) * 1_000_000.0)
+ except (TypeError, ValueError):
+ pass
+ for raw_key, normalized_key in (("start", "start_mm"), ("end", "end_mm")):
+ value = geometry.get(raw_key)
+ if isinstance(value, list) and len(value) == 3:
+ try:
+ result.setdefault(normalized_key, [float(component) * 1000.0 for component in value])
+ except (TypeError, ValueError):
+ pass
+ return result
+
+
+@dataclass(frozen=True)
+class AxisSpec:
+ """Canonical axis with a normalized direction."""
+
+ origin_mm: Vector3
+ direction: Vector3
+
+ @classmethod
+ def from_mapping(cls, value: dict[str, Any]) -> "AxisSpec":
+ return cls(
+ origin_mm=_vector3(value.get("origin_mm"), field_name="axis.origin_mm"),
+ direction=_unit(_vector3(value.get("direction"), field_name="axis.direction"), field_name="axis.direction"),
+ )
+
+ def as_dict(self) -> dict[str, list[float]]:
+ return {"origin_mm": list(self.origin_mm), "direction": list(self.direction)}
+
+
+@dataclass(frozen=True)
+class PlaneSpec:
+ """Canonical right-handed plane frame.
+
+ SolidWorks exports may contain a redundant or non-orthogonal y direction.
+ The runtime persists the orthonormalized frame so later features all use
+ the same coordinate system.
+ """
+
+ origin_mm: Vector3
+ x_dir: Vector3
+ y_dir: Vector3
+ normal: Vector3
+
+ @classmethod
+ def from_mapping(cls, value: dict[str, Any]) -> "PlaneSpec":
+ origin = _vector3(value.get("origin_mm"), field_name="plane.origin_mm")
+ normal = _unit(_vector3(value.get("normal"), field_name="plane.normal"), field_name="plane.normal")
+ x_raw = _vector3(value.get("x_dir"), field_name="plane.x_dir")
+ projected_x = tuple(x_raw[index] - _dot(x_raw, normal) * normal[index] for index in range(3))
+ x_dir = _unit(projected_x, field_name="plane.x_dir")
+ y_dir = _unit(_cross(normal, x_dir), field_name="plane.y_dir")
+ return cls(origin_mm=origin, x_dir=x_dir, y_dir=y_dir, normal=normal)
+
+ def as_dict(self) -> dict[str, list[float]]:
+ return {
+ "origin_mm": list(self.origin_mm),
+ "x_dir": list(self.x_dir),
+ "y_dir": list(self.y_dir),
+ "normal": list(self.normal),
+ }
+
+
+@dataclass(frozen=True)
+class HoleSpec:
+ """Runtime-neutral definition of a cylindrical Hole Wizard operation.
+
+ The spec deliberately contains no OCC planes or shapes. The runtime
+ resolves the host frame and the adapter turns this definition into a
+ cutting tool, keeping source-contract parsing separate from B-rep work.
+ """
+
+ diameter_mm: float
+ depth_mm: float
+ end_condition: str
+ positions_mm: tuple[Vector3, ...]
+ countersink: tuple[float, float] | None = None
+ counterbore: tuple[float, float] | None = None
+
+ @classmethod
+ def from_feature(cls, atomic_id: str, params: dict[str, Any], *, wizard: bool) -> "HoleSpec":
+ try:
+ diameter = float(params.get("diameter_mm") or 0.0)
+ depth = float(params.get("depth_mm") or 0.0)
+ except (TypeError, ValueError) as error:
+ raise ValueError("hole dimensions must be numeric") from error
+ if diameter <= 0 or depth <= 0:
+ raise ValueError("hole requires positive diameter_mm and depth_mm")
+ condition = str((params.get("end_condition") or {"type": "blind"}).get("type") or "blind")
+ if condition not in {"blind", "through_all", "through_all_both"}:
+ raise ValueError(f"unsupported hole extent {condition!r}")
+ raw_positions = params.get("positions") or []
+ positions = tuple(_vector3(item.get("mm"), field_name="hole position") for item in raw_positions if isinstance(item, dict))
+ if len(positions) != len(raw_positions) or not positions:
+ raise ValueError("hole requires non-empty positions with three-dimensional mm coordinates")
+
+ raw_sink: dict[str, Any] | None = params.get("countersink") if wizard else None
+ raw_bore: dict[str, Any] | None = params.get("counterbore") if wizard else None
+ if atomic_id == "hole_countersink":
+ raw_sink = {"diameter_mm": params.get("countersink_diameter_mm"), "angle_rad": params.get("countersink_angle_rad")}
+ if atomic_id == "hole_counterbore":
+ raw_bore = {"diameter_mm": params.get("counterbore_diameter_mm"), "depth_mm": params.get("counterbore_depth_mm")}
+
+ def dimensions(value: dict[str, Any] | None, second: str, label: str) -> tuple[float, float] | None:
+ if value is None:
+ return None
+ try:
+ first_value = float(value.get("diameter_mm") or 0.0)
+ second_value = float(value.get(second) or 0.0)
+ except (AttributeError, TypeError, ValueError) as error:
+ raise ValueError(f"{label} dimensions must be numeric") from error
+ if first_value <= diameter or second_value <= 0:
+ raise ValueError(f"{label} requires a diameter larger than the main hole and a positive {second}")
+ return first_value, second_value
+
+ return cls(
+ diameter_mm=diameter,
+ depth_mm=depth,
+ end_condition=condition,
+ positions_mm=positions,
+ countersink=dimensions(raw_sink, "angle_rad", "countersink"),
+ counterbore=dimensions(raw_bore, "depth_mm", "counterbore"),
+ )
+
+
+@dataclass(frozen=True)
+class RuntimeDiagnostic:
+ code: str
+ message: str
+ feature_id: str | None = None
+ detail: dict[str, Any] = field(default_factory=dict)
+
+ def as_dict(self) -> dict[str, Any]:
+ output: dict[str, Any] = {"code": self.code, "message": self.message}
+ if self.feature_id is not None:
+ output["feature_id"] = self.feature_id
+ if self.detail:
+ output["detail"] = self.detail
+ return output
+
+
+@dataclass(frozen=True)
+class CapabilityResult:
+ feature_id: str
+ atomic_id: str
+ resolved_status: str
+ required_capabilities: tuple[str, ...] = ()
+ blockers: tuple[RuntimeDiagnostic, ...] = ()
+
+ @property
+ def executable(self) -> bool:
+ return self.resolved_status == "executable"
+
+ def as_dict(self) -> dict[str, Any]:
+ return {
+ "feature_id": self.feature_id,
+ "atomic_id": self.atomic_id,
+ "resolved_status": self.resolved_status,
+ "required_capabilities": list(self.required_capabilities),
+ "blockers": [blocker.as_dict() for blocker in self.blockers],
+ }
+
+
+@dataclass(frozen=True)
+class FeaturePlanNode:
+ feature_id: str
+ atomic_id: str
+ name: str | None
+ depends_on: tuple[str, ...]
+ params: dict[str, Any]
+ selectors: tuple[dict[str, Any], ...]
+ sketch_id: str | None
+ declared_status: str | None
+ source_feature: dict[str, Any]
+
+
+@dataclass
+class FeatureResult:
+ feature_id: str
+ atomic_id: str
+ status: str
+ body_id: str | None = None
+ context: PlaneSpec | AxisSpec | None = None
+ replay_definition: dict[str, Any] | None = None
+ diagnostics: list[RuntimeDiagnostic] = field(default_factory=list)
+
+ def as_dict(self) -> dict[str, Any]:
+ output: dict[str, Any] = {
+ "feature_id": self.feature_id,
+ "atomic_id": self.atomic_id,
+ "status": self.status,
+ "diagnostics": [diagnostic.as_dict() for diagnostic in self.diagnostics],
+ }
+ if self.body_id is not None:
+ output["body_id"] = self.body_id
+ if self.context is not None:
+ output["context"] = self.context.as_dict()
+ if self.replay_definition is not None:
+ output["replay_definition"] = self.replay_definition
+ return output
+
+
+@dataclass(frozen=True)
+class TopologyRecord:
+ """Runtime-side signature of a topology item or context object.
+
+ ``feature_id`` identifies the feature that produced this *snapshot*.
+ ``owner_feature_ids`` is durable semantic provenance for an unchanged
+ current B-rep item. Boolean and dress-up operations replace OCC objects,
+ so keeping these concepts separate prevents a later mutation from making
+ every surviving face appear to be owned by that mutation.
+ """
+
+ record_id: str
+ kind: str
+ feature_id: str
+ body_id: str | None = None
+ geometry: dict[str, Any] = field(default_factory=dict)
+ value: Any = None
+ owner_feature_ids: tuple[str, ...] = ()
+
+ @property
+ def owners(self) -> tuple[str, ...]:
+ """Return durable provenance, retaining compatibility for contexts."""
+ return self.owner_feature_ids or (self.feature_id,)
+
+ def public_dict(self) -> dict[str, Any]:
+ output: dict[str, Any] = {
+ "record_id": self.record_id,
+ "kind": self.kind,
+ "feature_id": self.feature_id,
+ "geometry": self.geometry,
+ }
+ if self.body_id is not None:
+ output["body_id"] = self.body_id
+ if self.owner_feature_ids:
+ output["owner_feature_ids"] = list(self.owner_feature_ids)
+ return output
+
+
+@dataclass(frozen=True)
+class SelectorResolution:
+ selector: dict[str, Any]
+ status: str
+ record: TopologyRecord | None = None
+ candidates: tuple[dict[str, Any], ...] = ()
+ diagnostic: RuntimeDiagnostic | None = None
+
+ def as_dict(self) -> dict[str, Any]:
+ output = {
+ "selector": self.selector,
+ "status": self.status,
+ "candidates": list(self.candidates),
+ }
+ if self.record is not None:
+ output["record"] = self.record.public_dict()
+ if self.diagnostic is not None:
+ output["diagnostic"] = self.diagnostic.as_dict()
+ return output
+
+
+class TopologyRegistry:
+ """Feature-scoped context/topology registry with explainable matching."""
+
+ def __init__(self) -> None:
+ self._records: list[TopologyRecord] = []
+ self._by_feature: dict[str, list[TopologyRecord]] = {}
+ self._active_body_id: str | None = None
+
+ def register(self, record: TopologyRecord) -> None:
+ self._records.append(record)
+ self._by_feature.setdefault(record.feature_id, []).append(record)
+
+ def records_for_feature(self, feature_id: str) -> tuple[TopologyRecord, ...]:
+ return tuple(self._by_feature.get(feature_id, ()))
+
+ def records(self) -> tuple[TopologyRecord, ...]:
+ return tuple(self._records)
+
+ def register_context(self, feature_id: str, context: PlaneSpec | AxisSpec) -> TopologyRecord:
+ kind = "plane" if isinstance(context, PlaneSpec) else "axis"
+ record = TopologyRecord(
+ record_id=f"{feature_id}:{kind}",
+ kind=kind,
+ feature_id=feature_id,
+ geometry=context.as_dict(),
+ value=context,
+ )
+ self.register(record)
+ return record
+
+ def replace_body_topology(self, feature_id: str, body_id: str, records: Iterable[TopologyRecord]) -> None:
+ """Record a fresh B-rep snapshot after a feature mutates the body.
+
+ OCC topology object identity is invalidated by most body mutations.
+ We therefore keep old objects out of active selector resolution but
+ carry their semantic owners forward when, and only when, one current
+ object has one geometrically equivalent predecessor. A changed or
+ split object intentionally becomes owned by this feature instead of
+ being guessed as belonging to an older one.
+ """
+ previous = [
+ record for record in self._records
+ if self._active_body_id is not None and record.body_id == self._active_body_id
+ ]
+ consumed_predecessors: set[str] = set()
+ for record in records:
+ predecessor = self._unique_equivalent_predecessor(record, previous, consumed_predecessors)
+ owners = predecessor.owners if predecessor is not None else (feature_id,)
+ if predecessor is not None:
+ consumed_predecessors.add(predecessor.record_id)
+ self.register(
+ TopologyRecord(
+ record_id=record.record_id,
+ kind=record.kind,
+ feature_id=feature_id,
+ body_id=body_id,
+ geometry=dict(record.geometry),
+ value=record.value,
+ owner_feature_ids=owners,
+ )
+ )
+ self._active_body_id = body_id
+
+ @staticmethod
+ def _numbers_equal(left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
+ try:
+ return abs(float(left) - float(right)) <= tolerance
+ except (TypeError, ValueError):
+ return False
+
+ @classmethod
+ def _vectors_equal(cls, left: Any, right: Any, *, tolerance: float = 1e-6) -> bool:
+ try:
+ first = _vector3(left, field_name="prior topology geometry")
+ second = _vector3(right, field_name="current topology geometry")
+ except ValueError:
+ return False
+ return all(abs(a - b) <= tolerance for a, b in zip(first, second))
+
+ @classmethod
+ def _geometry_equivalent(cls, prior: TopologyRecord, current: TopologyRecord) -> bool:
+ """Check a complete, orientation-aware snapshot signature.
+
+ This is intentionally much stricter than selector scoring. Selector
+ scoring may compare partial source evidence; provenance transfer must
+ never manufacture ownership from a merely similar candidate.
+ """
+ if prior.kind != current.kind:
+ return False
+ left, right = prior.geometry, current.geometry
+ for key in ("surface_type", "curve_type"):
+ if left.get(key) != right.get(key):
+ return False
+ for key in ("bbox_mm", "center_mm", "normal", "plane_normal"):
+ if key in left or key in right:
+ if key not in left or key not in right:
+ return False
+ left_value, right_value = left[key], right[key]
+ if key == "bbox_mm":
+ if not isinstance(left_value, (list, tuple)) or not isinstance(right_value, (list, tuple)):
+ return False
+ if len(left_value) != 6 or len(right_value) != 6:
+ return False
+ if not all(cls._numbers_equal(a, b) for a, b in zip(left_value, right_value)):
+ return False
+ elif not cls._vectors_equal(left_value, right_value):
+ return False
+ for key in ("area_mm2", "length_mm", "plane_offset_mm"):
+ if key in left or key in right:
+ if key not in left or key not in right or not cls._numbers_equal(left[key], right[key]):
+ return False
+ for key in ("adjacency_signature", "adjacent_face_count", "incident_edge_count"):
+ if key in left or key in right:
+ if key not in left or key not in right or left[key] != right[key]:
+ return False
+ left_start, left_end = left.get("start_mm"), left.get("end_mm")
+ right_start, right_end = right.get("start_mm"), right.get("end_mm")
+ if any(value is not None for value in (left_start, left_end, right_start, right_end)):
+ if None in (left_start, left_end, right_start, right_end):
+ return False
+ same_direction = cls._vectors_equal(left_start, right_start) and cls._vectors_equal(left_end, right_end)
+ reverse_direction = cls._vectors_equal(left_start, right_end) and cls._vectors_equal(left_end, right_start)
+ if not same_direction and not reverse_direction:
+ return False
+ return True
+
+ @classmethod
+ def _unique_equivalent_predecessor(
+ cls,
+ current: TopologyRecord,
+ predecessors: Iterable[TopologyRecord],
+ consumed_predecessors: set[str],
+ ) -> TopologyRecord | None:
+ matches = [
+ record for record in predecessors
+ if record.record_id not in consumed_predecessors and cls._geometry_equivalent(record, current)
+ ]
+ return matches[0] if len(matches) == 1 else None
+
+ @staticmethod
+ def _vector_score(expected: Any, actual: Any, tolerance: float = 1e-4) -> float | None:
+ try:
+ left = _vector3(expected, field_name="selector geometry")
+ right = _vector3(actual, field_name="record geometry")
+ except ValueError:
+ return None
+ error = _length(tuple(a - b for a, b in zip(left, right)))
+ return max(0.0, 1.0 - error / tolerance)
+
+ @classmethod
+ def _geometry_score(cls, selector_geometry: dict[str, Any], record_geometry: dict[str, Any]) -> float | None:
+ if not selector_geometry:
+ return 0.0
+ scores: list[float] = []
+ for key in ("center_mm", "normal", "origin_mm", "direction", "plane_normal", "start_mm", "end_mm"):
+ if key in selector_geometry:
+ score = cls._vector_score(selector_geometry[key], record_geometry.get(key))
+ if score is None:
+ return None
+ scores.append(score)
+ for key in ("surface_type", "curve_type"):
+ if key in selector_geometry:
+ if record_geometry.get(key) != selector_geometry[key]:
+ return None
+ scores.append(1.0)
+ if "bbox_mm" in selector_geometry:
+ expected = selector_geometry["bbox_mm"]
+ actual = record_geometry.get("bbox_mm")
+ if not isinstance(expected, list) or not isinstance(actual, list) or len(expected) != len(actual):
+ return None
+ delta = max(abs(float(a) - float(b)) for a, b in zip(expected, actual))
+ scores.append(max(0.0, 1.0 - delta / 1e-4))
+ if "plane_offset_mm" in selector_geometry:
+ try:
+ delta = abs(float(selector_geometry["plane_offset_mm"]) - float(record_geometry.get("plane_offset_mm")))
+ except (TypeError, ValueError):
+ return None
+ scores.append(max(0.0, 1.0 - delta / 1e-4))
+ if "area_mm2" in selector_geometry:
+ try:
+ expected_area = float(selector_geometry["area_mm2"])
+ actual_area = float(record_geometry.get("area_mm2"))
+ except (TypeError, ValueError):
+ return None
+ relative_delta = abs(expected_area - actual_area) / max(abs(expected_area), 1e-9)
+ scores.append(max(0.0, 1.0 - relative_delta / 1e-4))
+ return sum(scores) / len(scores) if scores else 0.0
+
+ def resolve(
+ self,
+ selector: dict[str, Any],
+ *,
+ minimum_score: float = 0.8,
+ active_body_id: str | None = None,
+ ) -> SelectorResolution:
+ kind = selector.get("kind")
+ owner = selector.get("owner_feature_id")
+ candidates = [record for record in self._records if record.kind == kind]
+ if active_body_id and kind in {"face", "edge", "vertex", "body"}:
+ candidates = [record for record in candidates if record.body_id == active_body_id]
+ if owner:
+ candidates = [record for record in candidates if owner in record.owners]
+ geometry = normalize_selector_geometry(selector.get("geometry"))
+ scored: list[tuple[float, TopologyRecord]] = []
+ for candidate in candidates:
+ # An owner-qualified context selector is deterministic when it has
+ # a single runtime candidate even if its source stable_id cannot
+ # survive the SolidWorks -> OCC boundary.
+ score = 1.0 if not geometry else self._geometry_score(geometry, candidate.geometry)
+ if score is not None:
+ scored.append((score, candidate))
+ scored.sort(key=lambda item: (-item[0], item[1].record_id))
+ public_candidates = tuple({"score": round(score, 6), **record.public_dict()} for score, record in scored)
+ if not scored or scored[0][0] < minimum_score:
+ return SelectorResolution(
+ selector=selector,
+ status="not_found",
+ candidates=public_candidates,
+ diagnostic=RuntimeDiagnostic(
+ code="selector_not_found",
+ message="No runtime topology record satisfies the selector",
+ detail={"candidate_count": len(scored), "minimum_score": minimum_score},
+ ),
+ )
+ best_score, best_record = scored[0]
+ if len(scored) > 1 and abs(scored[1][0] - best_score) <= 1e-9:
+ return SelectorResolution(
+ selector=selector,
+ status="ambiguous",
+ candidates=public_candidates,
+ diagnostic=RuntimeDiagnostic(
+ code="selector_ambiguous",
+ message="More than one runtime topology record has the best selector score",
+ detail={"best_score": best_score, "candidate_count": len(scored)},
+ ),
+ )
+ return SelectorResolution(selector=selector, status="resolved", record=best_record, candidates=public_candidates)
diff --git a/backend/engine/cdsl_engine/sketch_solver.py b/backend/engine/cdsl_engine/sketch_solver.py
index 85fe3eab..4311d7dc 100644
--- a/backend/engine/cdsl_engine/sketch_solver.py
+++ b/backend/engine/cdsl_engine/sketch_solver.py
@@ -38,7 +38,7 @@ from __future__ import annotations
import math
from copy import deepcopy
-from typing import Any
+from typing import Any, Iterable
# ═══════════════════════════════════════════════════════════════
@@ -84,8 +84,9 @@ def _contour_arc(
end_mm: list[float],
center_mm: list[float],
radius_mm: float | None,
+ clockwise: bool | None = None,
) -> dict[str, Any]:
- return {
+ result = {
"type": "arc",
"start_mm": [
float(start_mm[0]),
@@ -104,6 +105,9 @@ def _contour_arc(
],
"radius_mm": float(radius_mm) if radius_mm is not None else None,
}
+ if clockwise is not None:
+ result["clockwise"] = bool(clockwise)
+ return result
# ═══════════════════════════════════════════════════════════════
@@ -1558,6 +1562,235 @@ def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ct
return [], []
+# ═══════════════════════════════════════════════════════════════
+# Evidence v2 analytic contours
+# ═══════════════════════════════════════════════════════════════
+
+_ANALYTIC_TOLERANCE_MM = 1e-5
+
+
+def _distance_2d(left: list[float], right: list[float]) -> float:
+ return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
+
+
+def _reverse_analytic_edge(edge: _Ctx) -> _Ctx:
+ result = deepcopy(edge)
+ result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"]
+ if result.get("type") == "arc" and "clockwise" in result:
+ result["clockwise"] = not bool(result["clockwise"])
+ return result
+
+
+def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]:
+ """Order/reorient a contour without depending on SolidWorks segment order."""
+ if not edges:
+ return []
+ pending = [deepcopy(edge) for edge in edges]
+ ordered = [pending.pop(0)]
+ while pending:
+ tail = ordered[-1]["end_mm"]
+ match_index = None
+ reverse = False
+ for index, edge in enumerate(pending):
+ if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM:
+ match_index = index
+ break
+ if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM:
+ match_index = index
+ reverse = True
+ break
+ if match_index is None:
+ raise ValueError("analytic_contours: segments do not form a connected contour")
+ edge = pending.pop(match_index)
+ ordered.append(_reverse_analytic_edge(edge) if reverse else edge)
+ if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM:
+ raise ValueError("analytic_contours: closed contour endpoints do not meet")
+ return ordered
+
+
+def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]:
+ center = segment.get("center") or [0.0, 0.0]
+ radius = float(segment.get("radius_mm") or 0.0)
+ if radius <= 0:
+ raise ValueError("analytic_contours: circle radius_mm must be > 0")
+ cx, cy = float(center[0]), float(center[1])
+ clockwise = bool(segment.get("clockwise", False))
+ angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
+ points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
+ return [
+ _contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise)
+ for index in range(4)
+ ]
+
+
+def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]:
+ segment_type = segment.get("type")
+ if segment_type == "line":
+ return [_contour_line(segment["start"], segment["end"])]
+ if segment_type == "arc":
+ return [
+ _contour_arc(
+ segment["start"], segment["end"], segment["center"],
+ segment.get("radius_mm"), segment.get("clockwise"),
+ )
+ ]
+ if segment_type == "circle":
+ return _analytic_circle_edges(segment)
+ if segment_type == "bspline":
+ raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
+ raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}")
+
+
+def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
+ """Create a deterministic planar sample only for containment classification."""
+ points: list[tuple[float, float]] = []
+ for edge in edges:
+ start = edge["start_mm"]
+ points.append((float(start[0]), float(start[1])))
+ if edge.get("type") != "arc":
+ continue
+ center = edge["center_mm"]
+ end = edge["end_mm"]
+ sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1])
+ ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1])
+ start_angle = math.atan2(sy, sx)
+ end_angle = math.atan2(ey, ex)
+ delta = end_angle - start_angle
+ if edge.get("clockwise"):
+ if delta >= 0:
+ delta -= math.tau
+ elif delta <= 0:
+ delta += math.tau
+ for fraction in (0.25, 0.5, 0.75):
+ angle = start_angle + delta * fraction
+ radius = float(edge.get("radius_mm") or math.hypot(sx, sy))
+ points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle)))
+ return points
+
+
+def _loop_area(points: list[tuple[float, float]]) -> float:
+ if len(points) < 3:
+ return 0.0
+ return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0)
+
+
+def _endpoint_signed_area(edges: list[_Ctx]) -> float:
+ points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges]
+ return sum(
+ points[index][0] * points[(index + 1) % len(points)][1]
+ - points[(index + 1) % len(points)][0] * points[index][1]
+ for index in range(len(points))
+ ) / 2.0
+
+
+def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None:
+ """Repair inconsistent sweep flags on a conventional rounded rectangle.
+
+ Evidence exports occasionally label one or more 90-degree corner arcs
+ with the opposite direction. Honouring those isolated flags creates
+ 270-degree loops. This normalizer applies only to the unambiguous shape:
+ exactly four equal-radius quarter arcs in one closed loop. Other arcs,
+ including annular sectors and long sweeps, retain their captured flags.
+ """
+ arcs = [edge for edge in edges if edge.get("type") == "arc"]
+ if len(arcs) != 4:
+ return
+ radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs]
+ if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM:
+ return
+ for edge in arcs:
+ center = edge.get("center_mm")
+ if not isinstance(center, list):
+ return
+ start, end = edge["start_mm"], edge["end_mm"]
+ first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1]))
+ second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1]))
+ angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1]))
+ if abs(angle - math.pi / 2) > 1e-4:
+ return
+ # A clockwise endpoint loop needs clockwise short corner arcs; a
+ # counter-clockwise loop needs their reverse. This preserves the actual
+ # rounded-rectangle boundary, independent of per-segment export noise.
+ clockwise = _endpoint_signed_area(edges) < 0.0
+ for edge in arcs:
+ edge["clockwise"] = clockwise
+
+
+def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
+ if len(loop) < 3:
+ return False
+ inside = False
+ x, y = point
+ previous = loop[-1]
+ for current in loop:
+ x1, y1 = current
+ x2, y2 = previous
+ if (y1 > y) != (y2 > y):
+ intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1
+ if x < intersect_x:
+ inside = not inside
+ previous = current
+ return inside
+
+
+def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
+ """Resolve Evidence v2 line/arc/circle loops into engine-neutral regions.
+
+ The returned regions preserve holes and islands. The build adapter owns
+ B-rep creation; this profile generator only reasons about sketch geometry.
+ """
+ loops: list[_Ctx] = []
+ entities: list[_Ctx] = []
+ for contour_index, contour in enumerate(profile.get("contours") or []):
+ if not contour.get("closed"):
+ raise ValueError(f"analytic_contours: contour {contour_index} is open")
+ segment_edges: list[_Ctx] = []
+ for segment in contour.get("segments") or []:
+ segment_type = segment.get("type")
+ if segment_type == "line":
+ entities.append(_line(segment["start"], segment["end"]))
+ elif segment_type == "circle":
+ entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
+ segment_edges.extend(_analytic_segment_edges(segment))
+ if not segment_edges:
+ continue
+ edges = _join_analytic_edges(segment_edges, closed=True)
+ _normalize_quarter_rounding_direction(edges)
+ points = _sample_analytic_loop(edges)
+ area = _loop_area(points)
+ if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM:
+ raise ValueError(f"analytic_contours: contour {contour_index} is degenerate")
+ loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area})
+
+ for segment in profile.get("construction") or []:
+ if segment.get("type") == "line":
+ entities.append(_line(segment["start"], segment["end"], construction=True))
+ elif segment.get("type") == "circle":
+ entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True))
+
+ if not loops:
+ return entities, []
+ for loop in loops:
+ # Role tags captured from the source sketch are useful provenance but
+ # not authoritative geometry. A number of exports label separate
+ # closed contours as ``inner`` although no outer contour contains
+ # them. The even-odd containment rule is deterministic for the
+ # supported analytic curves and preserves those independent regions.
+ contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop)
+ loop["role"] = "inner" if contained_by % 2 else "outer"
+ outers = [loop for loop in loops if loop["role"] == "outer"]
+ inners = [loop for loop in loops if loop["role"] == "inner"]
+ regions = [{"outer": outer["edges"], "holes": []} for outer in outers]
+ for inner in inners:
+ containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])]
+ if not containing:
+ raise ValueError("analytic_contours: inner contour has no containing outer contour")
+ selected = min(containing, key=lambda outer: outer["area"])
+ regions[outers.index(selected)]["holes"].append(inner["edges"])
+ meta["_regions"] = regions
+ return entities, []
+
+
# ═══════════════════════════════════════════════════════════════
# 生成器注册表 —— 唯一索引点
# ═══════════════════════════════════════════════════════════════
@@ -1588,6 +1821,7 @@ SHAPE_GENERATORS: dict[str, Any] = {
"radial_slot": _gen_radial_slot,
"patterned_cutouts": _gen_patterned_cutouts,
"compound_patterned_cutouts": _gen_compound_patterned_cutouts,
+ "analytic_contours": _gen_analytic_contours,
"arc_chain": _gen_arc_chain,
"complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建
"unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退
@@ -1748,3 +1982,72 @@ def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]:
out = deepcopy(cdsl)
out.setdefault("geometry", {})["sketches"] = result
return out
+
+
+def resolve_required_sketches(
+ cdsl: dict[str, Any],
+ sketch_ids: Iterable[str],
+ *,
+ errors: dict[str, str] | None = None,
+) -> dict[str, Any]:
+ """Resolve only profiles that an executable feature actually consumes.
+
+ ``profile_from`` dependencies are resolved recursively. Callers that
+ pass ``errors`` get feature-addressable failures without losing unrelated
+ resolved sketches; callers that omit it retain the strict exception
+ behavior useful to profile tooling.
+ """
+ sketches = list((cdsl.get("geometry") or {}).get("sketches") or [])
+ by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None}
+ resolved: dict[str, dict[str, Any]] = {}
+ resolving: set[str] = set()
+
+ def resolve_one(sketch_id: str) -> dict[str, Any]:
+ if sketch_id in resolved:
+ return resolved[sketch_id]
+ sketch = by_id.get(sketch_id)
+ if sketch is None:
+ raise ValueError(f"sketch {sketch_id!r} was not found")
+ if sketch_id in resolving:
+ raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle")
+ resolving.add(sketch_id)
+ try:
+ if "profile" in sketch:
+ output = resolve_profile(sketch)
+ elif sketch.get("profile_from"):
+ source_id = str(sketch["profile_from"])
+ source = resolve_one(source_id)
+ if not source.get("profile"):
+ raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile")
+ output = deepcopy(sketch)
+ output["profile"] = deepcopy(source["profile"])
+ output.pop("profile_from", None)
+ shift = sketch.get("profile_shift")
+ if shift and len(shift) == 2 and output["profile"].get("type") == "polygon":
+ du, dv = float(shift[0]), float(shift[1])
+ for vertex in output["profile"]["vertices"]:
+ vertex[0] = round(vertex[0] + du, 6)
+ vertex[1] = round(vertex[1] + dv, 6)
+ output.pop("profile_shift", None)
+ output = resolve_profile(output)
+ else:
+ output = deepcopy(sketch)
+ resolved[sketch_id] = output
+ return output
+ finally:
+ resolving.discard(sketch_id)
+
+ for sketch_id in {str(item) for item in sketch_ids}:
+ try:
+ resolve_one(sketch_id)
+ except ValueError as error:
+ if errors is None:
+ raise
+ errors[sketch_id] = str(error)
+
+ output = deepcopy(cdsl)
+ output.setdefault("geometry", {})["sketches"] = [
+ resolved.get(str(sketch.get("id")), deepcopy(sketch))
+ for sketch in sketches
+ ]
+ return output
diff --git a/backend/tests/test_engine_runtime_foundation.py b/backend/tests/test_engine_runtime_foundation.py
new file mode 100644
index 00000000..4d68e36c
--- /dev/null
+++ b/backend/tests/test_engine_runtime_foundation.py
@@ -0,0 +1,944 @@
+from __future__ import annotations
+
+import json
+import sys
+import tempfile
+import unittest
+from copy import deepcopy
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+sys.path.insert(0, str(ROOT / "backend" / "engine"))
+
+from cdsl_engine.batch_rebuild import _failure_category, _verification_classification, batch_analyze # noqa: E402
+from cdsl_engine.capabilities import CapabilityAnalyzer # noqa: E402
+from cdsl_engine.runtime_types import HoleSpec, PlaneSpec, TopologyRecord, TopologyRegistry # noqa: E402
+from cdsl_engine.sketch_solver import SHAPE_GENERATORS, resolve_all_sketches # noqa: E402
+
+
+def _workplane() -> dict:
+ return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
+
+
+class EngineRuntimeFoundationTests(unittest.TestCase):
+ def _base_block(self) -> dict:
+ return {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "runtime-block",
+ "meta": {"unit": "mm"},
+ "geometry": {"sketches": [{
+ "id": "base", "workplane": _workplane(),
+ "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
+ }]},
+ "features": [{
+ "id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
+ "params": {"distance_mm": 10}, "sketch_id": "base",
+ }],
+ }
+
+ def test_runtime_module_has_no_build123d_import(self) -> None:
+ runtime_source = (ROOT / "backend" / "engine" / "cdsl_engine" / "runtime.py").read_text(encoding="utf-8")
+ self.assertNotIn("from build123d", runtime_source)
+ self.assertNotIn("import build123d", runtime_source)
+
+ def test_execution_session_declares_a_kernel_neutral_adapter_protocol(self) -> None:
+ from cdsl_engine.runtime import ExecutionSession, GeometryAdapter
+
+ self.assertIn("adapter", ExecutionSession.__dataclass_fields__)
+ self.assertTrue(getattr(GeometryAdapter, "_is_protocol", False))
+ self.assertIn("export", GeometryAdapter.__dict__)
+
+ def test_hole_spec_normalizes_wizard_subtypes_without_occ_dependencies(self) -> None:
+ spec = HoleSpec.from_feature("hole_wizard", {
+ "diameter_mm": 2, "depth_mm": 6, "end_condition": {"type": "blind"},
+ "positions": [{"mm": [1, 2, 3]}],
+ "countersink": {"diameter_mm": 4, "angle_rad": 1.5707963267948966},
+ }, wizard=True)
+ self.assertEqual(spec.positions_mm, ((1.0, 2.0, 3.0),))
+ self.assertEqual(spec.countersink, (4.0, 1.5707963267948966))
+ with self.assertRaisesRegex(ValueError, "larger than the main hole"):
+ HoleSpec.from_feature("hole_wizard", {
+ "diameter_mm": 2, "depth_mm": 6, "positions": [{"mm": [0, 0, 0]}],
+ "counterbore": {"diameter_mm": 2, "depth_mm": 1},
+ }, wizard=True)
+
+ def test_analytic_contours_create_a_region_with_hole(self) -> None:
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1",
+ "geometry": {"sketches": [{
+ "id": "sketch", "workplane": _workplane(),
+ "profile": {"type": "analytic_contours", "contours": [
+ {"role": "outer", "closed": True, "segments": [
+ {"type": "line", "start": [0, 0], "end": [10, 0]},
+ {"type": "line", "start": [10, 0], "end": [10, 10]},
+ {"type": "line", "start": [10, 10], "end": [0, 10]},
+ {"type": "line", "start": [0, 10], "end": [0, 0]},
+ ]},
+ {"role": "inner", "closed": True, "segments": [
+ {"type": "circle", "center": [5, 5], "radius_mm": 2},
+ ]},
+ ]},
+ }]},
+ "features": [],
+ }
+ sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
+ region = sketch["contour_regions_mm"][0]
+ self.assertEqual(len(region["outer"]), 4)
+ self.assertEqual(len(region["holes"]), 1)
+ self.assertEqual(len(region["holes"][0]), 4)
+
+ def test_analytic_contours_normalize_disjoint_inner_roles(self) -> None:
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1",
+ "geometry": {"sketches": [{
+ "id": "sketch", "workplane": _workplane(),
+ "profile": {"type": "analytic_contours", "contours": [
+ {"role": "outer", "closed": True, "segments": [
+ {"type": "line", "start": [0, 0], "end": [2, 0]},
+ {"type": "line", "start": [2, 0], "end": [2, 2]},
+ {"type": "line", "start": [2, 2], "end": [0, 2]},
+ {"type": "line", "start": [0, 2], "end": [0, 0]},
+ ]},
+ {"role": "inner", "closed": True, "segments": [
+ {"type": "line", "start": [4, 0], "end": [6, 0]},
+ {"type": "line", "start": [6, 0], "end": [6, 2]},
+ {"type": "line", "start": [6, 2], "end": [4, 2]},
+ {"type": "line", "start": [4, 2], "end": [4, 0]},
+ ]},
+ ]},
+ }]},
+ "features": [],
+ }
+ sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
+ self.assertEqual(len(sketch["contour_regions_mm"]), 2)
+ self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"]))
+
+ def test_deferred_reference_is_currently_executable_without_a_sketch(self) -> None:
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1",
+ "geometry": {"sketches": [{"id": "s", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1}}]},
+ "features": [{
+ "id": "plane", "atomic_id": "reference_plane", "depends_on": [],
+ "execution_status": "deferred", "params": {"plane": _workplane()},
+ }],
+ }
+ analysis = CapabilityAnalyzer(atomic_ids={"reference_plane"}, profile_types=SHAPE_GENERATORS).analyze(cdsl)
+ self.assertFalse(analysis.runtime_eligible)
+ self.assertEqual(analysis.feature_results[0].resolved_status, "executable")
+ self.assertEqual(analysis.document_blockers[0].code, "no_solid_feature")
+
+ def test_unused_invalid_profile_does_not_block_runtime_preflight(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["geometry"]["sketches"].append({
+ "id": "abandoned", "workplane": _workplane(),
+ "profile": {"type": "analytic_contours", "contours": [{
+ "role": "outer", "closed": True,
+ "segments": [{"type": "line", "start": [0, 0], "end": [1, 0]}],
+ }]},
+ })
+ analysis = analyze_cdsl(cdsl)
+ self.assertTrue(analysis.runtime_eligible)
+
+ def test_used_invalid_profile_is_a_feature_level_blocker(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["geometry"]["sketches"].append({
+ "id": "broken", "workplane": _workplane(),
+ "profile": {"type": "analytic_contours", "contours": [{
+ "role": "outer", "closed": True,
+ "segments": [{"type": "line", "start": [0, 0], "end": [1, 0]}],
+ }]},
+ })
+ cdsl["features"].append({
+ "id": "broken_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
+ "params": {"distance_mm": 1}, "sketch_id": "broken",
+ })
+ analysis = analyze_cdsl(cdsl)
+ broken = next(item for item in analysis.feature_results if item.feature_id == "broken_cut")
+ self.assertEqual(broken.resolved_status, "blocked")
+ self.assertIn("profile_resolution_failed", [item.code for item in broken.blockers])
+
+ def test_used_empty_analytic_profile_is_a_feature_level_blocker(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["geometry"]["sketches"].append({
+ "id": "construction_only", "workplane": _workplane(),
+ "profile": {"type": "analytic_contours", "contours": []},
+ })
+ cdsl["features"].append({
+ "id": "empty_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
+ "params": {"distance_mm": 1}, "sketch_id": "construction_only",
+ })
+ analysis = analyze_cdsl(cdsl)
+ empty = next(item for item in analysis.feature_results if item.feature_id == "empty_cut")
+ self.assertIn("profile_no_closed_region", [item.code for item in empty.blockers])
+
+ def test_selector_resolver_refuses_equal_candidates(self) -> None:
+ registry = TopologyRegistry()
+ plane = PlaneSpec.from_mapping(_workplane())
+ registry.register(TopologyRecord("first", "plane", "f1", geometry=plane.as_dict(), value=plane))
+ registry.register(TopologyRecord("second", "plane", "f2", geometry=plane.as_dict(), value=plane))
+ resolution = registry.resolve({"kind": "plane", "geometry": plane.as_dict()})
+ self.assertEqual(resolution.status, "ambiguous")
+ self.assertEqual(resolution.diagnostic.code, "selector_ambiguous")
+
+ def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None:
+ registry = TopologyRegistry()
+ registry.register(TopologyRecord(
+ "face", "face", "f1", "body:f1",
+ geometry={
+ "surface_type": "plane", "plane_normal": [1, 0, 0],
+ "plane_offset_mm": 12.0, "bbox_mm": [12, -2, -3, 12, 2, 3], "area_mm2": 24,
+ },
+ ))
+ resolution = registry.resolve({
+ "kind": "face",
+ "geometry": {
+ "surface": {"type": "plane", "parameters": [-1, 0, 0, -0.012, 0, 0]},
+ "box": [0.012, -0.002, -0.003, 0.012, 0.002, 0.003], "area": 0.000024,
+ },
+ })
+ self.assertEqual(resolution.status, "resolved")
+ self.assertEqual(resolution.record.record_id, "face")
+
+ def test_owner_selector_survives_a_later_body_snapshot_when_geometry_is_unchanged(self) -> None:
+ registry = TopologyRegistry()
+ geometry = {
+ "bbox_mm": [0, 0, 0, 0, 0, 10],
+ "center_mm": [0, 0, 5],
+ "length_mm": 10.0,
+ "curve_type": "line",
+ "start_mm": [0, 0, 0],
+ "end_mm": [0, 0, 10],
+ }
+ registry.replace_body_topology("base_add", "body:base_add", [
+ TopologyRecord("body:base_add:edge:0", "edge", "base_add", "body:base_add", geometry, "old-edge"),
+ ])
+ registry.replace_body_topology("later_cut", "body:later_cut", [
+ TopologyRecord("body:later_cut:edge:3", "edge", "later_cut", "body:later_cut", geometry, "current-edge"),
+ ])
+
+ resolution = registry.resolve(
+ {"kind": "edge", "owner_feature_id": "base_add", "geometry": geometry},
+ active_body_id="body:later_cut",
+ )
+ self.assertEqual(resolution.status, "resolved")
+ self.assertEqual(resolution.record.value, "current-edge")
+ self.assertEqual(resolution.record.feature_id, "later_cut")
+ self.assertEqual(resolution.record.owner_feature_ids, ("base_add",))
+
+ def test_ambiguous_predecessors_do_not_invent_topology_ownership(self) -> None:
+ registry = TopologyRegistry()
+ geometry = {"center_mm": [1, 2, 3]}
+ registry.replace_body_topology("base_add", "body:base_add", [
+ TopologyRecord("body:base_add:vertex:0", "vertex", "base_add", "body:base_add", geometry),
+ TopologyRecord("body:base_add:vertex:1", "vertex", "base_add", "body:base_add", geometry),
+ ])
+ registry.replace_body_topology("later_cut", "body:later_cut", [
+ TopologyRecord("body:later_cut:vertex:0", "vertex", "later_cut", "body:later_cut", geometry),
+ ])
+
+ resolution = registry.resolve(
+ {"kind": "vertex", "owner_feature_id": "base_add", "geometry": geometry},
+ active_body_id="body:later_cut",
+ )
+ self.assertEqual(resolution.status, "not_found")
+
+ def test_changed_adjacency_prevents_owner_provenance_transfer(self) -> None:
+ registry = TopologyRegistry()
+ unchanged_geometry = {
+ "bbox_mm": [0, 0, 0, 1, 1, 0], "center_mm": [0.5, 0.5, 0],
+ "normal": [0, 0, 1], "area_mm2": 1, "surface_type": "plane",
+ }
+ registry.replace_body_topology("base_add", "body:base_add", [
+ TopologyRecord(
+ "body:base_add:face:0", "face", "base_add", "body:base_add",
+ {**unchanged_geometry, "adjacency_signature": ["line:1.000000:2"]},
+ ),
+ ])
+ registry.replace_body_topology("later_cut", "body:later_cut", [
+ TopologyRecord(
+ "body:later_cut:face:0", "face", "later_cut", "body:later_cut",
+ {**unchanged_geometry, "adjacency_signature": ["line:1.000000:2", "circle:1.000000:1"]},
+ ),
+ ])
+
+ resolution = registry.resolve(
+ {"kind": "face", "owner_feature_id": "base_add", "geometry": unchanged_geometry},
+ active_body_id="body:later_cut",
+ )
+ self.assertEqual(resolution.status, "not_found")
+
+ def test_batch_analysis_writes_one_report_per_input(self) -> None:
+ document = {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "batch-part",
+ "meta": {"unit": "mm"},
+ "geometry": {"sketches": [{"id": "s", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1}}]},
+ "features": [{
+ "id": "f", "atomic_id": "reference_plane", "depends_on": [], "execution_status": "deferred",
+ "params": {"plane": _workplane()},
+ }],
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ input_path = root / "input"
+ input_path.mkdir()
+ (input_path / "batch-part.cdsl.json").write_text(json.dumps(document), encoding="utf-8")
+ manifest = batch_analyze(input_path, root / "out", atomic_ids=frozenset({"reference_plane"}))
+ report = json.loads((root / "out" / "parts" / "batch-part.report.json").read_text(encoding="utf-8"))
+ self.assertEqual(manifest["part_count"], 1)
+ self.assertTrue(report["semantic_valid"])
+ self.assertFalse(report["runtime_eligible"])
+ self.assertEqual(report["first_blocker"]["code"], "no_solid_feature")
+
+ def test_batch_analysis_part_filter_is_exact_and_rejects_unknown_ids(self) -> None:
+ document = {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
+ "meta": {"unit": "mm"}, "geometry": {"sketches": []}, "features": [],
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ input_path = root / "input"
+ input_path.mkdir()
+ for part_id in ("first", "second"):
+ (input_path / f"{part_id}.cdsl.json").write_text(
+ json.dumps({**document, "part_id": part_id}), encoding="utf-8",
+ )
+ manifest = batch_analyze(
+ input_path, root / "out", atomic_ids=frozenset({"reference_plane"}), part_ids=["second"],
+ )
+ self.assertEqual(manifest["part_count"], 1)
+ self.assertEqual(manifest["results"], [{"part_id": "second", "report": "parts/second.report.json"}])
+ with self.assertRaisesRegex(ValueError, "do not exist"):
+ batch_analyze(input_path, root / "bad", part_ids=["missing"])
+
+ def test_batch_resume_migrates_derived_failure_category_without_rebuilding(self) -> None:
+ document = {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cached",
+ "meta": {"unit": "mm"}, "geometry": {"sketches": []}, "features": [],
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ source = root / "input"
+ source.mkdir()
+ (source / "cached.cdsl.json").write_text(json.dumps(document), encoding="utf-8")
+ report_path = root / "out" / "parts" / "cached.report.json"
+ report_path.parent.mkdir(parents=True)
+ report_path.write_text(json.dumps({
+ "part_id": "cached", "semantic_valid": True, "runtime_eligible": False, "compiled": False,
+ "build_attempted": False, "built": False, "geometry_verified": False, "feature_results": [],
+ "first_blocker": {"code": "missing_host_face"},
+ }), encoding="utf-8")
+ manifest = batch_analyze(source, root / "out")
+ report = json.loads(report_path.read_text(encoding="utf-8"))
+ self.assertEqual(manifest["failure_category_counts"], {"input_incomplete": 1})
+ self.assertEqual(report["failure_category"], "input_incomplete")
+
+ def test_full_export_batch_has_a_machine_readable_report_for_every_input(self) -> None:
+ """Keep the shipped 998-part corpus on the capability-report path.
+
+ STEP construction is intentionally left to the resumable build job;
+ this CI-sized pass proves that every current export gets a semantic
+ result and a first blocker rather than being silently skipped.
+ """
+ source = ROOT / "json_to_cdsl" / "output"
+ input_count = len(list(source.glob("*.cdsl.json")))
+ self.assertGreater(input_count, 0)
+ with tempfile.TemporaryDirectory() as directory:
+ out = Path(directory) / "baseline"
+ manifest = batch_analyze(source, out)
+ reports = [json.loads(path.read_text(encoding="utf-8")) for path in (out / "parts").glob("*.report.json")]
+ self.assertEqual(manifest["part_count"], input_count)
+ self.assertEqual(manifest["completed_count"], input_count)
+ self.assertTrue(manifest["complete"])
+ self.assertEqual(manifest["semantic_valid_count"], input_count)
+ self.assertEqual(len(reports), input_count)
+ self.assertTrue(all(report["runtime_eligible"] or report.get("first_blocker") for report in reports))
+
+ def test_p3_static_pool_is_explicit_and_reports_missing_capture_separately(self) -> None:
+ from cdsl_engine.phase_pools import select_p3_static_pool
+ from cdsl_engine.runtime import analyze_cdsl
+
+ source = ROOT / "json_to_cdsl" / "output"
+ part_ids = select_p3_static_pool(source)
+ self.assertEqual(len(part_ids), 266)
+ ineligible: dict[str, set[str]] = {}
+ for part_id in part_ids:
+ cdsl = json.loads((source / f"{part_id}.cdsl.json").read_text(encoding="utf-8"))
+ result = analyze_cdsl(cdsl)
+ if not result.runtime_eligible:
+ ineligible[part_id] = {
+ blocker.code
+ for feature in result.feature_results
+ for blocker in feature.blockers
+ }
+ self.assertEqual(len(part_ids) - len(ineligible), 262)
+ self.assertEqual(
+ ineligible,
+ {
+ "027784": {"missing_revolve_axis"},
+ "104237": {"missing_extent_reference"},
+ "239358": {"missing_extent_reference", "dependency_unavailable"},
+ "241720": {"missing_extent_reference"},
+ },
+ )
+
+ def test_p3_phase_pool_can_be_passed_to_batch_rebuild(self) -> None:
+ from cdsl_engine.phase_pools import select_p3_static_pool
+
+ source = ROOT / "json_to_cdsl" / "output"
+ pool = select_p3_static_pool(source)
+ with tempfile.TemporaryDirectory() as directory:
+ manifest = batch_analyze(source, Path(directory) / "p3", part_ids=pool, max_parts=1)
+ self.assertEqual(manifest["part_count"], len(pool))
+ self.assertEqual(manifest["completed_count"], 1)
+ self.assertFalse(manifest["complete"])
+
+ def test_phase_pool_membership_is_regressed_for_p4_and_p6(self) -> None:
+ from cdsl_engine.phase_pools import select_static_phase_pool
+
+ source = ROOT / "json_to_cdsl" / "output"
+ self.assertEqual(len(select_static_phase_pool(source, "p4")), 309)
+ self.assertEqual(len(select_static_phase_pool(source, "p6")), 341)
+ with self.assertRaisesRegex(ValueError, "Unknown CDSL runtime phase"):
+ select_static_phase_pool(source, "p5")
+
+ def test_nested_pattern_source_is_replayable_after_its_first_execution(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].extend([
+ {
+ "id": "first_pattern", "atomic_id": "pattern_linear", "depends_on": ["base_add"],
+ "params": {"source_feature_ids": ["base_add"], "direction_1": [1, 0, 0],
+ "spacing_1_mm": 20, "pattern_count_1": 2},
+ },
+ {
+ "id": "nested_pattern", "atomic_id": "pattern_linear", "depends_on": ["first_pattern"],
+ "params": {"source_feature_ids": ["first_pattern"], "direction_1": [0, 1, 0],
+ "spacing_1_mm": 20, "pattern_count_1": 2},
+ },
+ ])
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "nested.step")
+ self.assertEqual(result["engine"], "cdsl_session_runtime")
+
+ def test_pattern_source_without_body_definition_is_blocked_in_preflight(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].insert(0, {
+ "id": "context", "atomic_id": "reference_plane", "depends_on": [],
+ "params": {"plane": _workplane()},
+ })
+ cdsl["features"].append({
+ "id": "pattern", "atomic_id": "pattern_linear", "depends_on": ["base_add", "context"],
+ "params": {"source_feature_ids": ["context"], "direction_1": [1, 0, 0],
+ "spacing_1_mm": 20, "pattern_count_1": 2},
+ })
+ analysis = analyze_cdsl(cdsl)
+ pattern = next(result for result in analysis.feature_results if result.feature_id == "pattern")
+ self.assertEqual(pattern.resolved_status, "unsupported")
+ self.assertIn("unsupported_pattern_source", [blocker.code for blocker in pattern.blockers])
+
+ def test_truth_comparison_keeps_frame_mismatch_distinct_from_verified_geometry(self) -> None:
+ truth = {"bounding_box_mm": [-10, -200, -10, 10, 200, 10], "solid_count": 1}
+ common = {
+ "truth": truth,
+ "volume_relative_error": 1e-8,
+ "area_relative_error": 1e-8,
+ "solid_count_matches": True,
+ }
+ self.assertEqual(
+ _verification_classification(
+ actual_box=[-10, 990, 0, 10, 1010, 400], box_delta=1190, **common,
+ ),
+ "coordinate_frame_mismatch_candidate",
+ )
+ self.assertEqual(
+ _verification_classification(
+ actual_box=[-10, -200, -10, 10, 200, 10], box_delta=0, **common,
+ ),
+ "verified",
+ )
+ self.assertEqual(
+ _verification_classification(
+ actual_box=[-10, 990, 0, 10, 1010, 400], box_delta=1190,
+ truth=truth, volume_relative_error=0.1, area_relative_error=1e-8, solid_count_matches=True,
+ ),
+ "geometry_mismatch",
+ )
+
+ def test_batch_failure_categories_preserve_input_selector_capability_and_occ_boundaries(self) -> None:
+ self.assertEqual(_failure_category({"geometry_verified": True}), "geometry_verified")
+ self.assertEqual(_failure_category({"runtime_eligible": True, "build_attempted": False}), "runtime_eligible_not_built")
+ self.assertEqual(_failure_category({"built": True, "numeric_comparison": {"classification": "coordinate_frame_mismatch_candidate"}}), "coordinate_frame_mismatch_candidate")
+ self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "missing_host_face"}}), "input_incomplete")
+ self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "extent_target_not_reached"}}), "input_incomplete")
+ self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "selector_ambiguous"}}), "selector_resolution")
+ self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "unsupported_hole_subtype"}}), "unsupported_capability")
+ self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "build_timeout"}}), "occ_execution_failure")
+
+ def test_mirror_pattern_replays_a_selector_free_cut(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test",
+ "meta": {"unit": "mm"},
+ "geometry": {"sketches": [
+ {"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}},
+ {"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}},
+ ]},
+ "features": [
+ {"id": "ref", "atomic_id": "reference_plane", "depends_on": [], "params": {"plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}}},
+ {"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": ["ref"], "params": {"distance_mm": 2}, "sketch_id": "base"},
+ {"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 2}, "sketch_id": "cut"},
+ {
+ "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["cut_1", "ref"],
+ "params": {"source_feature_ids": ["cut_1"], "mirror_plane": {"kind": "plane", "stable_id": "source", "source": "solidworks", "confidence": 1, "owner_feature_id": "ref"}},
+ "selectors": [{"kind": "plane", "stable_id": "source", "source": "solidworks", "confidence": 1, "owner_feature_id": "ref"}],
+ },
+ ],
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "mirror.step")
+ self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5)
+ self.assertIn("mirror", [item["feature_id"] for item in result["feature_results"]])
+
+ def test_mirrored_local_circle_preserves_its_reflected_world_position(self) -> None:
+ from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
+ from cdsl_engine.runtime import _mirrored_sketch
+ from cdsl_engine.sketch_solver import resolve_all_sketches
+
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1",
+ "geometry": {"sketches": [{
+ "id": "circles", "workplane": _workplane(),
+ "profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]},
+ }]},
+ "features": [],
+ }
+ sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
+ mirror_plane = PlaneSpec.from_mapping({
+ "origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0],
+ })
+ mirrored = _mirrored_sketch(sketch, mirror_plane)
+ face = Build123dGeometryAdapter().faces_for_sketch(mirrored)[0]
+ center = face.center()
+ self.assertAlmostEqual(center.X, -2.0)
+ self.assertAlmostEqual(center.Y, 3.0)
+ self.assertAlmostEqual(center.Z, 0.0)
+
+ def test_fillet_and_hole_wizard_use_resolved_face_edge_selectors(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ base = self._base_block()
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ baseline = rebuild_cdsl(base, root / "baseline.step")
+ edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge")
+ top_face = next(
+ item for item in baseline["topology_records"]
+ if item["kind"] == "face" and item["geometry"]["surface_type"] == "plane" and item["geometry"]["normal"][2] > 0.9
+ )
+ with_fillet = deepcopy(base)
+ with_fillet["features"].append({
+ "id": "fillet", "atomic_id": "fillet", "depends_on": ["base_add"], "params": {"radius_mm": 1},
+ "selectors": [{"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1, "owner_feature_id": "base_add", "geometry": edge["geometry"]}],
+ })
+ filleted = rebuild_cdsl(with_fillet, root / "fillet.step")
+ with_hole = deepcopy(base)
+ with_hole["features"].append({
+ "id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
+ "params": {
+ "hole_type": "plain", "diameter_mm": 2, "depth_mm": 5,
+ "end_condition": {"type": "blind", "solidworks_code": 0}, "positions": [{"mm": [0, 0, 10]}],
+ "host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"]},
+ },
+ "selectors": [{"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"]}],
+ })
+ holed = rebuild_cdsl(with_hole, root / "hole.step")
+ self.assertLess(filleted["volume_mm3"], baseline["volume_mm3"])
+ self.assertAlmostEqual(holed["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
+
+ def test_hole_frame_uses_local_coordinates(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ base = self._base_block()
+ base["features"].append({
+ "id": "hole", "atomic_id": "hole_blind", "depends_on": ["base_add"],
+ "sketch_id": "base",
+ "params": {
+ "diameter_mm": 2, "depth_mm": 5, "positions": [{"mm": [0, 0, 0]}],
+ "host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}},
+ },
+ })
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(base, Path(directory) / "local-hole.step")
+ self.assertAlmostEqual(result["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
+
+ def test_pattern_with_selector_source_is_blocked_before_execution(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].extend([
+ {"id": "fillet", "atomic_id": "fillet", "depends_on": ["base_add"], "params": {"radius_mm": 1}, "selectors": [{"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1}]},
+ {"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["fillet"], "params": {"source_feature_ids": ["fillet"], "direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2}},
+ ])
+ analysis = analyze_cdsl(cdsl)
+ pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
+ self.assertFalse(pattern.executable)
+ self.assertIn("unsupported_pattern_selector_transform", [item.code for item in pattern.blockers])
+
+ def test_chamfer_and_linear_pattern_execute_without_selector_guessing(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ base = self._base_block()
+ base["geometry"]["sketches"].append({
+ "id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [-2, 0], "radius_mm": 1},
+ })
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ baseline = rebuild_cdsl(base, root / "baseline.step")
+ edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge")
+ chamfered = deepcopy(base)
+ chamfered["features"].append({
+ "id": "chamfer", "atomic_id": "chamfer", "depends_on": ["base_add"], "params": {"distance_mm": 1},
+ "selectors": [{"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1, "owner_feature_id": "base_add", "geometry": edge["geometry"]}],
+ })
+ chamfer_result = rebuild_cdsl(chamfered, root / "chamfer.step")
+ patterned = deepcopy(base)
+ patterned["features"].extend([
+ {"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10}, "sketch_id": "cut"},
+ {"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cut_1"], "params": {"source_feature_ids": ["cut_1"], "direction_1": [1, 0, 0], "spacing_1_mm": 2, "pattern_count_1": 3}},
+ ])
+ pattern_result = rebuild_cdsl(patterned, root / "pattern.step")
+ self.assertLess(chamfer_result["volume_mm3"], baseline["volume_mm3"])
+ self.assertAlmostEqual(pattern_result["volume_mm3"], 1000 - 3 * 10 * 3.141592653589793, places=5)
+
+ def test_linear_pattern_replays_hole_with_explicit_host_frame(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].extend([
+ {
+ "id": "hole_1", "atomic_id": "hole_blind", "depends_on": ["base_add"], "sketch_id": "base",
+ "params": {
+ "diameter_mm": 2, "depth_mm": 5, "positions": [{"mm": [0, 0, 0]}],
+ "host_face": {"frame": {
+ "origin_mm": [0, 0, 10], "x_dir": [1, 0, 0],
+ "y_dir": [0, 1, 0], "normal": [0, 0, 1],
+ }},
+ },
+ },
+ {
+ "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"],
+ "params": {
+ "source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0],
+ "spacing_1_mm": 4, "pattern_count_1": 2,
+ },
+ },
+ ])
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "patterned-holes.step")
+ self.assertAlmostEqual(result["volume_mm3"], 1000 - 2 * 5 * 3.141592653589793, places=5)
+
+ def test_mirror_pattern_replays_local_hole_coordinates_in_the_correct_quadrant(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].insert(0, {
+ "id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [],
+ "params": {"plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}},
+ })
+ cdsl["features"].extend([
+ {
+ "id": "hole_1", "atomic_id": "hole_blind", "depends_on": ["base_add"], "sketch_id": "base",
+ "params": {
+ "diameter_mm": 2, "depth_mm": 5, "positions": [{"mm": [2, 3, 0]}],
+ "host_face": {"frame": {
+ "origin_mm": [0, 0, 10], "x_dir": [1, 0, 0],
+ "y_dir": [0, 1, 0], "normal": [0, 0, 1],
+ }},
+ },
+ },
+ {
+ "id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["hole_1", "mirror_plane"],
+ "params": {
+ "source_feature_ids": ["hole_1"],
+ "mirror_plane": {"kind": "plane", "owner_feature_id": "mirror_plane"},
+ },
+ "selectors": [{"kind": "plane", "owner_feature_id": "mirror_plane"}],
+ },
+ ])
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "mirrored-holes.step")
+ mirrored_cylinder = next(
+ item for item in result["topology_records"]
+ if item["record_id"].startswith("body:mirror.m.hole_1:face")
+ and item["geometry"].get("surface_type") == "cylinder"
+ and item["geometry"]["bbox_mm"][0] < -2.9
+ )
+ self.assertEqual(mirrored_cylinder["geometry"]["bbox_mm"][:2], [-3.0, 2.0])
+ self.assertEqual(mirrored_cylinder["geometry"]["bbox_mm"][3:5], [-1.0, 4.0])
+
+ def test_pattern_replays_selected_sources_in_history_order(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = self._base_block()
+ cdsl["geometry"]["sketches"].append({
+ "id": "cut", "workplane": _workplane(),
+ "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
+ })
+ cdsl["features"].extend([
+ {
+ "id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
+ "params": {"distance_mm": 10}, "sketch_id": "cut",
+ },
+ {
+ "id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cut_1"],
+ # SolidWorks selection exports are not guaranteed to match
+ # history order; the executor must replay the boss before its cut.
+ "params": {
+ "source_feature_ids": ["cut_1", "base_add"],
+ "direction_1": [1, 0, 0], "spacing_1_mm": 20, "pattern_count_1": 2,
+ },
+ },
+ ])
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "ordered-pattern.step")
+ self.assertAlmostEqual(result["volume_mm3"], 2 * (1000 - 10 * 3.141592653589793), places=5)
+
+ def test_through_all_extent_uses_current_body(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = self._base_block()
+ cdsl["geometry"]["sketches"].append({
+ "id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
+ })
+ cdsl["features"].append({
+ "id": "cut_all", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
+ "params": {"distance_mm": 0, "end_condition": {"type": "through_all", "solidworks_code": 1}}, "sketch_id": "cut",
+ })
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "through.step")
+ self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * 3.141592653589793, places=5)
+
+ def test_two_sided_extrude_uses_independent_forward_and_reverse_distances(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = self._base_block()
+ feature = cdsl["features"][0]
+ feature["atomic_id"] = "extrude_add_two_sided"
+ feature["params"] = {
+ "distance_mm": 2, "reverse_distance_mm": 3,
+ "end_condition": {"type": "blind"}, "reverse_end_condition": {"type": "blind"},
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "two-sided.step")
+ self.assertAlmostEqual(result["volume_mm3"], 500.0)
+ self.assertEqual(result["bbox_mm"]["min"][2], -3.0)
+ self.assertEqual(result["bbox_mm"]["max"][2], 2.0)
+
+ def test_two_sided_extrude_requires_reverse_distance_contract(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ feature = cdsl["features"][0]
+ feature["atomic_id"] = "extrude_add_two_sided"
+ analysis = analyze_cdsl(cdsl)
+ self.assertIn("missing_parameter", [item.code for item in analysis.feature_results[0].blockers])
+
+ def test_revolve_can_resolve_an_owner_qualified_reference_axis(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "axis-revolve",
+ "meta": {"unit": "mm"},
+ "geometry": {"sketches": [{
+ "id": "profile", "workplane": _workplane(),
+ "profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]},
+ }]},
+ "features": [
+ {
+ "id": "axis", "atomic_id": "reference_axis", "depends_on": [],
+ "params": {"axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}},
+ },
+ {
+ "id": "revolve", "atomic_id": "revolve_add", "depends_on": ["axis"], "sketch_id": "profile",
+ "params": {
+ "angle_deg": 360, "axis": {"selector": {"kind": "axis", "owner_feature_id": "axis"}},
+ },
+ "selectors": [{"kind": "axis", "owner_feature_id": "axis"}],
+ },
+ ],
+ }
+ with tempfile.TemporaryDirectory() as directory:
+ result = rebuild_cdsl(cdsl, Path(directory) / "axis-revolve.step")
+ self.assertAlmostEqual(result["volume_mm3"], 6 * 3.141592653589793, places=5)
+
+ def test_unowned_revolve_feature_selector_is_preflight_blocked(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = json.loads((ROOT / "json_to_cdsl" / "output" / "027784.cdsl.json").read_text(encoding="utf-8"))
+ analysis = analyze_cdsl(cdsl)
+ revolve = next(item for item in analysis.feature_results if item.feature_id == "f_007")
+ self.assertIn("missing_revolve_axis", [item.code for item in revolve.blockers])
+
+ def test_tangent_propagation_does_not_expand_to_unrelated_box_edges(self) -> None:
+ from build123d import Box
+ from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
+
+ body = Box(10, 10, 10)
+ selected = body.edges()[0]
+ expanded = Build123dGeometryAdapter().tangent_edges(body, [selected])
+ self.assertEqual(len(expanded), 1)
+ self.assertTrue(expanded[0].is_same(selected))
+
+ def test_inconsistent_quarter_arc_flags_are_normalized_from_loop_orientation(self) -> None:
+ from cdsl_engine.batch_rebuild import analyze_document
+
+ fixture = ROOT / "json_to_cdsl" / "output" / "053393.cdsl.json"
+ with tempfile.TemporaryDirectory() as directory:
+ report = analyze_document(fixture, out_step=Path(directory) / "053393.step")
+ self.assertTrue(report["runtime_eligible"])
+ self.assertTrue(report["built"])
+ self.assertTrue(report["geometry_verified"])
+
+ def test_exported_two_sided_fixture_is_geometry_verified(self) -> None:
+ from cdsl_engine.batch_rebuild import analyze_document
+
+ fixture = ROOT / "json_to_cdsl" / "output" / "046112.cdsl.json"
+ with tempfile.TemporaryDirectory() as directory:
+ report = analyze_document(fixture, out_step=Path(directory) / "046112.step")
+ self.assertTrue(report["runtime_eligible"])
+ self.assertTrue(report["built"])
+ self.assertTrue(report["geometry_verified"])
+
+
+ def test_selector_driven_extrude_extents_require_unique_rebuilt_topology(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ base = self._base_block()
+ base["geometry"]["sketches"].append({
+ "id": "cut", "workplane": _workplane(),
+ "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
+ })
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ baseline = rebuild_cdsl(base, root / "baseline.step")
+ top_face = next(
+ item for item in baseline["topology_records"]
+ if item["kind"] == "face" and item["geometry"]["surface_type"] == "plane"
+ and item["geometry"]["normal"][2] > 0.9
+ )
+ top_vertex = next(
+ item for item in baseline["topology_records"]
+ if item["kind"] == "vertex" and item["geometry"]["center_mm"][2] > 9.9
+ )
+ for name, end_condition, expected_depth in (
+ ("surface", {"type": "up_to_surface", "reference": {"kind": "face", "owner_feature_id": "base_add", "geometry": top_face["geometry"]}}, 10.0),
+ ("vertex", {"type": "up_to_vertex", "reference": {"kind": "vertex", "owner_feature_id": "base_add", "geometry": top_vertex["geometry"]}}, 10.0),
+ ("offset", {"type": "offset_from_surface", "reference": {"kind": "face", "owner_feature_id": "base_add", "geometry": top_face["geometry"]}}, 8.0),
+ ("next", {"type": "through_next"}, 10.0),
+ ):
+ cdsl = deepcopy(base)
+ cdsl["features"].append({
+ "id": f"cut_{name}", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
+ "params": {"distance_mm": 2 if name == "offset" else 0, "end_condition": end_condition},
+ "sketch_id": "cut",
+ })
+ result = rebuild_cdsl(cdsl, root / f"{name}.step")
+ self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5)
+
+ def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None:
+ from cdsl_engine.runtime import rebuild_cdsl
+
+ base = self._base_block()
+ base["geometry"]["sketches"].append({
+ "id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1},
+ })
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ baseline = rebuild_cdsl(base, root / "baseline.step")
+ body = next(item for item in baseline["topology_records"] if item["kind"] == "body")
+ cdsl = deepcopy(base)
+ cdsl["features"].append({
+ "id": "cut_to_body", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
+ "params": {"distance_mm": 0, "end_condition": {
+ "type": "up_to_body", "reference": {
+ "kind": "body", "owner_feature_id": "base_add", "geometry": body["geometry"],
+ },
+ }},
+ "sketch_id": "cut",
+ })
+ result = rebuild_cdsl(cdsl, root / "up-to-body.step")
+ self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * 3.141592653589793, places=5)
+
+ def test_selector_dependent_extent_without_reference_is_preflight_blocked(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"][0]["params"]["end_condition"] = {"type": "up_to_surface"}
+ analysis = analyze_cdsl(cdsl)
+ result = analysis.feature_results[0]
+ self.assertIn("missing_extent_reference", [item.code for item in result.blockers])
+
+ def test_hole_wizard_unsupported_extent_is_preflight_blocked(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].append({
+ "id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
+ "params": {
+ "hole_type": "plain", "diameter_mm": 2, "depth_mm": 2,
+ "positions": [{"mm": [0, 0, 0]}], "host_face": {"kind": "face"},
+ "end_condition": {"type": "up_to_surface"},
+ },
+ })
+ analysis = analyze_cdsl(cdsl)
+ result = next(item for item in analysis.feature_results if item.feature_id == "hole")
+ self.assertIn("unsupported_hole_extent", [item.code for item in result.blockers])
+
+ def test_legacy_hole_atomics_have_the_same_host_and_shape_preflight_contract(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = self._base_block()
+ cdsl["features"].append({
+ "id": "hole", "atomic_id": "hole_counterbore", "depends_on": ["base_add"], "sketch_id": "base",
+ "params": {
+ "diameter_mm": 2, "depth_mm": 3, "positions": [{"mm": [0, 0, 0]}],
+ "counterbore_diameter_mm": 2, "counterbore_depth_mm": 1,
+ },
+ })
+ analysis = analyze_cdsl(cdsl)
+ result = next(item for item in analysis.feature_results if item.feature_id == "hole")
+ codes = {item.code for item in result.blockers}
+ self.assertIn("missing_host_face", codes)
+ self.assertIn("invalid_hole_spec", codes)
+
+ def test_body_mutation_without_a_preceding_solid_is_preflight_blocked(self) -> None:
+ from cdsl_engine.runtime import analyze_cdsl
+
+ cdsl = {
+ "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "no-body",
+ "meta": {"unit": "mm"}, "geometry": {"sketches": []},
+ "features": [{
+ "id": "hole", "atomic_id": "hole_wizard", "depends_on": [],
+ "params": {
+ "hole_type": "plain", "diameter_mm": 2, "depth_mm": 3,
+ "positions": [{"mm": [0, 0, 0]}],
+ "host_face": {"frame": _workplane()},
+ },
+ }],
+ }
+ result = analyze_cdsl(cdsl).feature_results[0]
+ self.assertIn("missing_active_body", [item.code for item in result.blockers])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/backend/tests/test_profile_schema.py b/backend/tests/test_profile_schema.py
index 06e519cf..0f33381b 100644
--- a/backend/tests/test_profile_schema.py
+++ b/backend/tests/test_profile_schema.py
@@ -53,7 +53,7 @@ class ProfileSchemaTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "features\\[0\\]\\.atomic_id"):
validate_cdsl(cdsl, self.engine)
- def test_semantic_validator_accepts_deferred_features_but_runtime_rejects_them(self) -> None:
+ def test_semantic_validator_preserves_deferred_features_but_runtime_checks_current_capabilities(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "part_id": "deferred-fillet",
"meta": {"unit": "mm"},
@@ -68,7 +68,7 @@ class ProfileSchemaTests(unittest.TestCase):
}
result = self.engine.validate_semantic_cdsl(cdsl)
self.assertEqual(result["deferred_feature_ids"], ["f01"])
- with self.assertRaisesRegex(ValueError, "deferred"):
+ with self.assertRaisesRegex(ValueError, "missing_selector"):
validate_cdsl(cdsl, self.engine)
def test_rejects_bare_hole_coordinate_arrays_before_rebuild(self) -> None:
@@ -105,6 +105,12 @@ class ProfileSchemaTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "CDSL-only rebuild failed: unsupported atomic_id: extrude"):
self.engine.run_rebuild(cdsl, out_step)
+ def test_product_revision_path_uses_the_cdsl_only_entry_point(self) -> None:
+ source = (Path(__file__).resolve().parents[2] / "backend" / "app" / "services" / "engine_service.py").read_text(encoding="utf-8")
+ build_revision_source = source[source.index("def build_revision"):]
+ self.assertIn("engine.run_cdsl_only(cdsl_copy, step_path)", build_revision_source)
+ self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source)
+
def test_all_official_samples_match_the_engine_schema(self) -> None:
samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json"))
self.assertGreater(len(samples), 0)
diff --git a/docs/cdsl-output-engine-target.md b/docs/cdsl-output-engine-target.md
new file mode 100644
index 00000000..5e6ba306
--- /dev/null
+++ b/docs/cdsl-output-engine-target.md
@@ -0,0 +1,339 @@
+# CDSL Output Engine 重建目标
+
+## 1. 目的
+
+本文定义 json_to_cdsl/output 批量语义 CDSL 的 engine 重建目标、架构边界和验收方式。目标不是让 runtime 对未知数据作猜测,而是:
+
+1. 补齐当前 CDSL v1.1 已声明的原子 feature 执行能力;
+2. 将可确定的语义草图和 feature history 可靠地重建为 STEP;
+3. 对输入数据缺失、selector 歧义或内核失败给出 feature 级诊断;
+4. 将批量重建率作为可重复测量的工程指标。
+
+本文是 [cdsl-output-engine-rebuild-plan.md](cdsl-output-engine-rebuild-plan.md) 的交付目标补充。前者记录现状与建议顺序;本文规定目标架构、阶段出口和成功判定。
+
+数据快照日期:2026-08-21。json_to_cdsl/output 中的 JSON 是待处理数据,不作为本文件的指令来源。
+
+## 2. 数据事实与范围
+
+### 2.1 批量数据
+
+| 项目 | 数量 |
+| --- | ---: |
+| CDSL 文件 | 998 |
+| feature 总数 | 10,659 |
+| 不含 unresolved feature 的零件 | 633 |
+| 含至少一个 unresolved feature 的零件 | 365 |
+| 含 selector 的零件 | 489 |
+| face selector | 557 |
+| edge selector | 2 |
+
+当前 engine_service.validate_cdsl 会先因 execution_status: "deferred" 拒绝所有批量文件。这个状态是导出时的 runtime 能力快照,不能继续作为升级后 runtime 的唯一事实来源。
+
+| Atomic ID | 数量 | 目标能力 |
+| --- | ---: | --- |
+| reference_plane | 4,324 | 上下文 feature |
+| extrude_cut_blind | 1,554 | 扩展终止条件 |
+| extrude_add_blind | 1,281 | 扩展终止条件 |
+| hole_wizard | 1,235 | 规范化后执行 |
+| reference_axis | 760 | 上下文 feature |
+| chamfer | 550 | selector 驱动修饰 |
+| fillet | 286 | selector 驱动修饰 |
+| revolve_add | 187 | 统一 primary executor |
+| revolve_cut | 170 | 统一 primary executor |
+| pattern_linear | 167 | feature 重放阵列 |
+| pattern_mirror | 98 | feature 重放镜像 |
+| extrude_add_two_sided | 47 | 双向 extent |
+
+草图 profile 只有四类:analytic_contours 3,785、circle 504、circles 260、annulus 87。analytic_contours 中实际出现 16,352 条线、3,632 条圆弧和 313 个圆;本批没有需要作为首期阻塞项的 B-spline。
+
+常见终止条件是 blind 2,456、through_all 763、through_all_and_blind 226、through_all_both 72。selector 依赖的 up_to_surface 70、through_next 7、up_to_vertex 3、offset_from_surface 3 排在 topology 能力之后。
+
+### 2.2 交付边界
+
+本目标覆盖现有 CDSL schema 中的全部 16 个 feature atomic ID。当前已执行的 9 个 atomic 也必须纳入统一架构:
+
+ extrude_add_blind extrude_add_two_sided
+ extrude_cut_blind revolve_add
+ revolve_cut hole_blind
+ hole_countersink hole_counterbore
+ sphere_add
+
+需新增或重构进 executor 框架的 7 个 atomic:
+
+ reference_plane reference_axis
+ hole_wizard fillet
+ chamfer pattern_linear
+ pattern_mirror
+
+analytic_contours 是必须补齐的 profile runtime。它必须产生 engine 中立的闭合 region,再由几何适配器生成面。
+
+下列行为不属于精确重建:
+
+- 未捕获的选择集不能通过“对所有边倒角或圆角”替代;
+- selector 有多个候选时不能取第一个候选;
+- B-spline 离散化不能标记为精确,除非输出近似误差;
+- 不允许重新启用 compiler_context 或 legacy translator 作为 CDSL-only 成功回退。
+
+## 3. 成功定义
+
+每份批量报告必须记录下列独立状态:
+
+ semantic_valid CDSL 符合 schema、ID 和依赖顺序
+ runtime_eligible 所有 feature 当前可执行,且输入完整、无歧义
+ compiled 已形成可执行 feature plan
+ built 已成功输出 STEP
+ geometry_verified 与 source truth 的数值几何比对通过
+ topology_observed 面、边、顶点数量已记录,仅作诊断
+
+built 不是 geometry_verified 的同义词。默认 strict 模式只有所有 feature 均 runtime_eligible 时才可标记为成功重建;调试模式可以输出部分 STEP,但必须标记为 partial。
+
+数值验证至少比较 bounding box、volume、surface area 和 solid count。门槛应配置化,初始建议:bounding box 每个坐标绝对误差不超过 0.01 mm,volume 与 area 相对误差不超过 0.1%,solid count 必须相同。拓扑数量因 STEP/OCC 表达差异不是首期硬门槛。
+
+## 4. 目标架构
+
+目标 pipeline 的依赖方向如下:
+
+ Semantic CDSL
+ -> Semantic Validator
+ -> Capability Analyzer
+ -> Feature Planner
+ -> Execution Session -> Atomic Executor Registry -> Geometry Adapter -> build123d / OCC
+ <-> Topology and Context Registry
+ -> STEP + Build Report
+
+建议在 backend/engine/cdsl_engine 内按以下职责拆分。文件名可调整,但依赖方向不得反转。
+
+| 层 | 责任 | 不应承担的责任 |
+| --- | --- | --- |
+| semantic_validation | schema、ID、依赖顺序与自包含数据检查 | 判断当前内核是否支持 feature |
+| capabilities | 根据 registry、参数和 selector 条件计算可执行性 | 生成或修改 CDSL |
+| sketch | profile、闭环、outer/inner 分类,输出 SketchRegion | 直接修改实体 |
+| planning | feature DAG、归一化、执行顺序和 plan diagnostics | 复制 build123d 对象或做布尔运算 |
+| runtime | session、executor registry、结果与错误边界 | 解析原始 JSON 细节 |
+| topology | context、body、face、edge 注册与 selector 解析 | 私自补全缺失选择集 |
+| adapters/build123d | region 到 B-rep、布尔、修饰、STEP 导出 | 读取 CDSL schema 或决定依赖 |
+| batch_rebuild | 批量调度、报告、truth 对比、回归基线 | 实现几何算法 |
+
+### 4.1 核心接口
+
+每个 feature 必须经由同一类接口运行:
+
+ class AtomicExecutor(Protocol):
+ atomic_id: str
+
+ def preflight(self, node: FeaturePlanNode,
+ session: ExecutionSession) -> CapabilityResult: ...
+ def execute(self, node: FeaturePlanNode,
+ session: ExecutionSession) -> FeatureResult: ...
+
+ class GeometryAdapter(Protocol):
+ def make_regions(self, regions: list[SketchRegion]) -> GeometryResult: ...
+ def extrude(self, body, regions, extent: ExtentSpec, mode: BooleanMode): ...
+ def revolve(self, body, regions, axis: AxisSpec,
+ angle_deg: float, mode: BooleanMode): ...
+ def hole_tool(self, spec: HoleSpec, starts, inward,
+ through_depth_mm: float): ...
+ def apply_fillet(self, body, edges, radius_mm: float): ...
+ def apply_chamfer(self, body, targets, spec: ChamferSpec): ...
+ def body_geometry(self, body) -> BodyGeometry: ...
+ def export(self, body, path: str): ...
+
+FeatureResult 至少包含 feature_id、产生或修改的 body、context object、拓扑快照、可回放的执行定义和 feature 级诊断。任何 executor 都不得通过全局变量或 BuildPart 隐式上下文查找前序结果。
+
+HoleSpec 是 runtime-neutral 的孔定义:包含孔径、深度、终止条件、位置以及可选 countersink/counterbore 尺寸,但不包含 OCC 对象或 host-face 推断。runtime 负责严格解析 host frame 和坐标,adapter 只将已解析的 HoleSpec 构造成切削工具。
+
+runtime 对 B-rep 实体保持 opaque:包围盒、体积、STEP 导出及所有内核向量转换都只能经 adapter 返回;runtime 不得 import 或读取 build123d/OCC 对象属性。
+
+### 4.2 现有代码的迁移约束
+
+现有 sketch_solver.py -> llm_compiler.py -> llm_engine.py 流程可以逐步迁移,但必须保持单一路径:
+
+- sketch_solver.py 输出中立 region/curve 数据,不能让 profile generator 拥有实体执行逻辑;
+- llm_compiler.py 只构建 feature plan。当前 pattern_linear 的坐标偏移克隆逻辑必须迁出 compiler,避免 pattern、selector 和 host face 语义被扁平化;
+- llm_engine.py 的大分支改为 executor registry;build123d import 只保留在 adapter 层;
+- engine_service.validate_cdsl 只在 atomic contract 声明 requires_sketch: true 时强制 sketch_id。reference、pattern、dress-up 与 Hole Wizard 都是合法的非草图 feature;
+- profile_schema.json、cdsl_schema.json、executor registry 和测试必须由同一 capability 声明校验,避免维护多个手写 supported set。
+
+### 4.3 execution_status 的兼容策略
+
+保留 CDSL 中的 execution_status,用于说明导出时的能力快照;不再仅因其为 deferred 而拒绝升级后的 runtime。实际执行前由 CapabilityAnalyzer 为每个 feature 产生:
+
+ declared_status CDSL 中的 execution_status
+ resolved_status executable | blocked | unsupported
+ required_capabilities 原子、profile、selector、extent 能力
+ blockers 精确的缺参、歧义或内核前置条件
+
+旧输出因此可以在 engine 升级后直接重跑;converter 重跑仍应更新导出状态,但不是重建前置条件。
+
+## 5. 原子能力完成合同
+
+### 5.1 Reference geometry
+
+reference_plane 和 reference_axis 不生成实体,但必须作为正式 FeatureResult 写入 context registry,用于后续 workplane、revolve axis、mirror plane、pattern direction 和 hole host frame。
+
+要求:
+
+- 使用 canonical PlaneSpec 与 AxisSpec;
+- 校验零长度向量,正交化并记录修正后的坐标系;
+- 支持由显式参数、前序 reference、feature 或 sketch 推导;
+- 无法恢复的朝向缺失返回 blocked: missing_reference_orientation,不得默认 XY;
+- context feature 不修改 body,但可作为后续 feature 的依赖节点。
+
+### 5.2 Analytic contour runtime
+
+首期支持 line、arc、circle。固定流程为:二维 segment 归一化、端点容差拼接、闭环验证、workplane 映射、outer/inner 分类、生成 SketchRegion。一个 region 可以有一个 outer loop 和多个 hole loop。
+
+必须诊断端点反转、退化边、自交、开放 loop、非共面输入,以及无法分类的 unknown contour。circle 是独立 loop,不能用零长度线模拟;嵌套环按奇偶包含关系分类。
+
+对于四段等半径、90 度圆角组成的闭环,若导出逐段 clockwise 标记互相矛盾,runtime 可以依据闭环有向面积统一其短圆角方向;这是一种可证明的 rounded-rectangle 归一化。两段半圆或任意长圆弧无法仅由该规则恢复 sweep intent,必须保留原始证据或在 truth 验证中报告不匹配,不能根据目标 STEP 猜测方向。
+
+B-spline 是后续扩展。采用采样近似时必须声明 chord tolerance 和最大偏差,且 geometry_verified 需使用对应容差策略。
+
+### 5.3 Extrude 与 revolve
+
+现有 add/cut/revolve atomic 保持原 ID,内部统一归一化为 BooleanMode、ExtentSpec 和 AxisSpec。执行器先生成 region 面,再调用 adapter;不得根据“草图中有圆”改变 feature 语义。
+
+| 层次 | 终止条件 | 规则 |
+| --- | --- | --- |
+| A | blind、mid_plane、through_all、through_all_both、through_all_and_blind | 使用当前 body 的精确包围范围与 margin 计算 extent;双向值保持独立 |
+| B | up_to_surface、up_to_vertex、offset_from_surface、through_next、up_to_body | 先通过 selector resolver 得到唯一目标,再由 adapter 做射线或相交查询;up_to_body 使用 active B-rep 的 body record,不允许引用失效快照 |
+
+extrude_add_two_sided 必须保留正反两侧的距离与终止条件,不能简化为单个对称距离;每一侧都要独立解析 blind、through 或 selector-dependent end condition。revolve axis 可以来自显式 AxisSpec 或 owner-qualified 的 reference_axis selector;无 owner 的 source stable ID 不能被当作 OCC 轴。axis 与 profile 的退化相交必须在 preflight 阶段诊断。
+
+### 5.4 Hole Wizard
+
+hole_wizard 是独立 atomic,不在 compiler 中改写成匿名多个 hole step。执行器先解析为中立 HoleSpec,再选择 blind/countersink/counterbore/tapped 子型并调用 hole adapter。plan 和报告保留原 feature_id 与 atomic_id。
+
+要求:
+
+- 由 host face selector 或明确 workplane 得到唯一孔位 frame;
+- 支持 blind、through_all、through_all_both 与 countersink/counterbore;
+- 将 SolidWorks 位置坐标转换为 host frame,不能将局部坐标当世界坐标;
+- thread、非标准钻尖或不支持孔型返回 unsupported_hole_subtype,不能静默退化为普通圆柱孔;
+- 源 STEP 的 stable_id 只是线索,host face 必须在重建中间体上重新解析。
+
+### 5.5 Selector 与 topology registry
+
+selector 是 dress-up、Hole Wizard、pattern 和 selector-dependent extent 的共同前置能力。每完成一个 feature,registry 记录:
+
+ feature_id, parent feature ids, body id, context objects,
+ generated/owned faces and edges, bbox, center, area, normal,
+ surface or curve type, adjacency signature, local feature signature
+
+每次实体变更都产生新的 active B-rep snapshot,旧 OCC 对象不能继续用于 selector。若且唯若新旧拓扑项存在唯一、完整且几何等价的签名匹配(包括 bbox、中心、面积/长度、朝向与邻接签名),才可将其 durable owner provenance 继承到新对象;分裂、合并、修改或多候选匹配不得继承 owner。这样 owner_feature_id 仍可约束当前实体上的有效对象,而不会把所有存活拓扑误标为最后一个变更 feature。
+
+解析顺序固定为:owner_feature_id 限定候选集,显式 kind 限定面/边/轴,再以几何签名评分,最后应用置信度和唯一性阈值。0 个候选报 selector_not_found,多个同分候选报 selector_ambiguous。两种情况都不能继续 strict rebuild。
+
+原始 stable_id 不能被当作 OCC 持久名称。解析器必须输出候选、评分和最终原因,而不是隐藏地选择拓扑对象。
+
+### 5.6 Fillet 与 chamfer
+
+实现顺序为:edge selector、face selector 展开到边、单距离 chamfer、双距离或角度 chamfer、tangent propagation。tangent propagation 只能从已解析 edge 出发,沿当前 B-rep 中共享顶点且切向连续的 edge chain 扩展,不能退化为全局边集合。每次 OCC 修饰后都重新扫描 registry,因为此前 B-rep 对象可能失效。
+
+不得提供“没有 selector 时对全部边应用”的回退。当前 550 个 chamfer 与 286 个 fillet feature 均没有 selector;这不是仅新增 runtime 函数就能解决的问题。它们必须由 converter/exporter 提供选择集,或由针对 source truth 的独立 selector enrichment pass 产生唯一、可审计的 selector,才可进入严格重建池。
+
+### 5.7 Feature-level pattern
+
+pattern_linear 和 pattern_mirror 重放 source feature 的语义执行定义,不复制完整 body,也不在 compiler 中仅平移三维坐标。每个实例有独立 transform scope,并通过相同 atomic executor 在当前 body 上执行。
+
+- linear pattern 支持一维和二维 direction/spacing/count,并保留 direction reverse;
+- mirror pattern 使用 canonical plane,支持显式 plane 或 registry reference;
+- source feature 必须已成功执行,且其 selector/reference 经 transform 后仍可唯一解析;显式 host_face.frame 与局部孔位是可直接变换的坐标 contract,未显式 frame 的 host selector、dress-up selector 和 B 层终止 selector 在具备完整 transform contract 前必须阻断;
+- 无 source feature、不可变换 selector 或布尔失败时,整个 pattern 阻断并给出实例级错误。
+
+## 6. 数据补全责任
+
+engine 负责执行确定的 CDSL,不负责虚构 source intent。下列数据问题必须由 exporter/converter 或可审计 enrichment pass 解决:
+
+| 数据缺口 | 当前数量 | 所需动作 |
+| --- | ---: | --- |
+| Hole Wizard 缺少 semantic selections | 761 | 捕获 host face、placement frame 和位置关联 |
+| 缺 source sketch parent | 153 | 记录草图 parent feature/reference plane |
+| reference plane 朝向未捕获 | 21 | 导出 origin、normal、x direction 或稳定派生关系 |
+| hole 直径或深度缺失 | 3 | 捕获原始尺寸及单位 |
+| revolve 缺 selection 或 axis | 2 | 捕获 revolve axis/reference |
+| fillet 尺寸无效 | 1 | 导出有效 radius/distance |
+| chamfer 缺 semantic selections | 1 | 捕获 edge/face 选择集 |
+
+部分 Hole Wizard 虽有 host_face,但其来自 source STEP 推断。enrichment pass 必须将其转换为可解析 selector contract,并在置信度不足时保留 unresolved。同一规则适用于 fillet/chamfer,不能为了提高覆盖率创造不稳定 ID。
+
+## 7. 分阶段交付与覆盖目标
+
+批量覆盖数字是静态“输入就绪池”,不是尚未实现内核下的通过率承诺。所有数字均要求无 unresolved、profile 限于本批四类、analytic_contours 仅含 line/arc/circle,且终止条件在当前阶段已支持。
+
+| 阶段 | 交付物 | 阶段出口 | 静态就绪池 |
+| --- | --- | --- | ---: |
+| P0 | batch_rebuild、基线 manifest、feature 级报告 | 998 个输入均有机器可读结果;可复现首个阻断 feature | 998 |
+| P1 | capability analyzer、execution session、reference plane/axis、校验修正 | 非草图 atomic 可规划;旧 deferred 不再是唯一阻断理由 | - |
+| P2 | analytic_contours region resolver | line/arc/circle、洞与闭环测试通过 | - |
+| P3 | unified extrude/revolve 与 A 层终止条件 | 基础实体池全部通过 runtime preflight,并以 truth 运行回归 | 266 current / 271 historical estimate |
+| P4 | topology/selector registry 与 Hole Wizard | 有 position、唯一 host face 的孔可严格执行 | 309 current / 312 historical estimate |
+| P5 | fillet/chamfer 和 selector enrichment 闭环 | 只接受唯一 selector;所有缺 selector 输入明确阻断 | 取决于 enrichment |
+| P6 | linear/mirror feature replay | source feature、context、transform 和实例诊断完整 | 341 current / 343 historical estimate |
+| P7 | B 层终止条件、剩余数据补全和回归收敛 | 633 个无 unresolved 零件按实际 feature 组合进入全量目标池 | 633 |
+
+P3 的 271、P4 的 312 与 P6 的 343 都是初始导出统计的历史估计。按当前 998 份 CDSL 的严格闭合-region contract 复算后,P3/P4/P6 静态池分别为 266/309/341:差额来自仅 reference history 或 consumed profile 无闭合 region,不能由 runtime 补齐。P3 的 266 件中另有 4 件缺 captured extent reference 或 revolve axis,因而当前 strict runtime-eligible 为 262;P4/P6 当前 strict runtime-eligible 分别为 277/290,主要阻断是 threaded Hole、缺 active body/reference 及不可变换的 selector。它们不是简单相加。
+
+运行时以 `phase_pools.select_static_phase_pool` 固化 P3/P4/P6 输入定义:无 `unresolved`、仅该阶段允许的 atomic、至少一个 primary feature,且所有 consumed sketch 都是四种可解析 profile 的闭合 region。静态池不代表每个 feature 的 selector/reference 已完整捕获;当前缺 axis、extent reference 或 history 中无 active body 的文件必须在 strict preflight 报具体 blocker,不能被计入 runtime-eligible。
+
+剩余 365 个带 unresolved 的零件不能仅靠补 engine atomic 达到严格重建。P7 的目标是把每个零件归入“可通过数据补全解锁”或“当前证据不足”,而不是报告模糊失败。
+
+## 8. 批量基准与 CI 门槛
+
+新增单一入口,例如 backend/engine/cdsl_engine/batch_rebuild.py,接收 CDSL 目录、truth 目录和输出目录。每次运行至少产生:
+
+ manifest.json
+ parts/.report.json
+ parts/.step
+ summary-by-atomic.json
+ summary-by-blocker.json
+
+每份报告包含:
+
+ part_id, cdsl_path, semantic_valid, runtime_eligible, compiled, built,
+ geometry_verified, topology_observed, first_blocker, feature_results,
+ unsupported_atomic_ids, unsupported_profile_types, unresolved_input,
+ selector_resolution, numeric_comparison, timings
+
+报告还必须输出稳定的 failure_category:input_incomplete、selector_resolution、unsupported_capability、occ_execution_failure、geometry_mismatch 或 coordinate_frame_mismatch_candidate;geometry_verified 是唯一成功类别。未请求 --build 但已通过预检的报告标为 runtime_eligible_not_built,它是非终态,不是失败或 verified。该分类只归纳已有证据,不能将构建完成或坐标框候选计为 verified。
+
+numeric_comparison 必须保留严格的 passed 判定,并把体积、面积、实体数及无序包围盒跨度均吻合、但绝对坐标框不吻合的情况标为 coordinate_frame_mismatch_candidate。该标记仅帮助定位 workplane/export frame 数据问题,不能替代 geometry_verified。
+
+CI 分三层运行:
+
+1. 单元与 contract 测试:每个 atomic executor、profile resolver、selector 歧义、extent 计算与 capability/schema 同步;
+2. 小型集成 fixture:reference -> sketch -> primary feature -> dress-up/pattern 跨 feature 路径;
+3. 批量回归:保存按零件和 atomic 分组的基线,禁止已验证零件退化,新增通过必须附带数值比对。
+
+每次新增 atomic 或终止条件,必须同时更新 schema contract、capability registry、executor、诊断、单元测试和至少一个批量 fixture。不得只把名称加入 SUPPORTED_ATOMIC_IDS。
+
+### 8.1 当前 P3 严格基线
+
+2026-08-23 已按当前静态 P3 pool 运行以下 CDSL-only 基线;每个零件在独立进程中构建,单件 timeout 为 15 秒:
+
+ PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
+ json_to_cdsl/output --out /tmp/cdsl-p3-current-baseline \
+ --phase p3 --build --build-timeout 15
+
+最终结果为 266/266 已完成、262 份 strict runtime eligible、253 份 built、57 份 geometry verified。其余终态分类为 147 份 geometry_mismatch、49 份 coordinate_frame_mismatch_candidate、6 份 input_incomplete、5 份 occ_execution_failure、2 份 selector_resolution。这是一份验收基线而非成功率承诺:坐标框候选和已构建 STEP 均没有并入 verified。
+
+该命令和输出目录结构是可恢复的;CI 应将经过审查的报告摘要保存在持久化工件中,而不依赖本机 /tmp 的 STEP 临时文件。
+
+同日使用相同的 CDSL-only、每件 15 秒隔离构建策略完成 P4 池:309/309 已完成、277 份 strict runtime eligible、258 份 built、59 份 geometry verified。其余终态为 150 份 geometry_mismatch、49 份 coordinate_frame_mismatch_candidate、8 份 input_incomplete、5 份 occ_execution_failure、12 份 selector_resolution、26 份 unsupported_capability。P4 的 5 个 OCC 失败均已出现于 P3 基础主体能力池,未发现 Hole Wizard 新增的 OCC 失败。
+
+P6 池也已使用同一策略完成:341/341 已完成、290 份 strict runtime eligible、268 份 built、60 份 geometry verified。其余终态为 159 份 geometry_mismatch、49 份 coordinate_frame_mismatch_candidate、9 份 input_incomplete、6 份 occ_execution_failure、14 份 selector_resolution、44 份 unsupported_capability。其中嵌套 pattern source 已按可回放 feature definition 递归执行;不能产生实体的 context source 会在 capability preflight 以 unsupported_pattern_source 阻断,不会再被归为 OCC 执行失败。
+
+P7 的全量 capability 审计也已完成:998/998 份 CDSL 均有 machine-readable report,全部 semantic valid;290 份 strict runtime eligible(未请求 --build,因此分类为 runtime_eligible_not_built)、612 份 input_incomplete、96 份 unsupported_capability。这证明所有当前输入均被审计和分类,但不将 preflight 通过等同于 STEP 构建或 geometry verified。
+
+## 9. 完成判定
+
+本目标完成需要同时满足:
+
+1. schema 已声明的 16 个 feature atomic 都有 executor、preflight contract 和 feature 级诊断;
+2. analytic_contours 的 line/arc/circle 可构成带洞 region,并由统一 adapter 执行;
+3. 266 个当前基础静态就绪零件均已由 phase-pool 回归审计;其中 262 个在现有输入下通过 strict runtime preflight,剩余 4 个以缺 axis/reference 的 feature-level blocker 报告;当前 CDSL-only P3 基线为 253 built、57 geometry verified,且所有非 verified 结果已有严格终态分类;
+4. Hole、dress-up、pattern 与 selector-dependent extent 不再依赖 compiler 内特例或全局 build123d 状态;
+5. 每个无法严格重建的零件都能区分为输入缺失、selector 歧义、未支持能力或 OCC 执行失败;
+6. 批量报告和 CI 基线持续追踪 998 个输入,且 CDSL-only 成功路径不调用 legacy translator。
+
+这样,engine 的扩展单位是可独立测试、可替换内核、可追溯失败原因的原子能力,而不是为某一批零件增加临时分支。