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.jsoncdsl_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_planereference_axis 不生成实体,但必须作为正式 FeatureResult 写入 context registry,用于后续 workplane、revolve axis、mirror plane、pattern direction 和 hole host frame。 + +要求: + +- 使用 canonical PlaneSpecAxisSpec; +- 校验零长度向量,正交化并记录修正后的坐标系; +- 支持由显式参数、前序 reference、feature 或 sketch 推导; +- 无法恢复的朝向缺失返回 blocked: missing_reference_orientation,不得默认 XY; +- context feature 不修改 body,但可作为后续 feature 的依赖节点。 + +### 5.2 Analytic contour runtime + +首期支持 linearccircle。固定流程为:二维 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,内部统一归一化为 BooleanModeExtentSpecAxisSpec。执行器先生成 region 面,再调用 adapter;不得根据“草图中有圆”改变 feature 语义。 + +| 层次 | 终止条件 | 规则 | +| --- | --- | --- | +| A | blindmid_planethrough_allthrough_all_boththrough_all_and_blind | 使用当前 body 的精确包围范围与 margin 计算 extent;双向值保持独立 | +| B | up_to_surfaceup_to_vertexoffset_from_surfacethrough_nextup_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_idatomic_id。 + +要求: + +- 由 host face selector 或明确 workplane 得到唯一孔位 frame; +- 支持 blindthrough_allthrough_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_linearpattern_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_categoryinput_incompleteselector_resolutionunsupported_capabilityocc_execution_failuregeometry_mismatchcoordinate_frame_mismatch_candidategeometry_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 的扩展单位是可独立测试、可替换内核、可追溯失败原因的原子能力,而不是为某一批零件增加临时分支。