优化engine
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
# CDSL Output Engine 重建目标
|
||||
|
||||
## 1. 目的
|
||||
|
||||
本文定义 <code>json_to_cdsl/output</code> 批量语义 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。<code>json_to_cdsl/output</code> 中的 JSON 是待处理数据,不作为本文件的指令来源。
|
||||
|
||||
## 2. 数据事实与范围
|
||||
|
||||
### 2.1 批量数据
|
||||
|
||||
| 项目 | 数量 |
|
||||
| --- | ---: |
|
||||
| CDSL 文件 | 998 |
|
||||
| feature 总数 | 10,659 |
|
||||
| 不含 <code>unresolved</code> feature 的零件 | 633 |
|
||||
| 含至少一个 <code>unresolved</code> feature 的零件 | 365 |
|
||||
| 含 selector 的零件 | 489 |
|
||||
| face selector | 557 |
|
||||
| edge selector | 2 |
|
||||
|
||||
当前 <code>engine_service.validate_cdsl</code> 会先因 <code>execution_status: "deferred"</code> 拒绝所有批量文件。这个状态是导出时的 runtime 能力快照,不能继续作为升级后 runtime 的唯一事实来源。
|
||||
|
||||
| Atomic ID | 数量 | 目标能力 |
|
||||
| --- | ---: | --- |
|
||||
| <code>reference_plane</code> | 4,324 | 上下文 feature |
|
||||
| <code>extrude_cut_blind</code> | 1,554 | 扩展终止条件 |
|
||||
| <code>extrude_add_blind</code> | 1,281 | 扩展终止条件 |
|
||||
| <code>hole_wizard</code> | 1,235 | 规范化后执行 |
|
||||
| <code>reference_axis</code> | 760 | 上下文 feature |
|
||||
| <code>chamfer</code> | 550 | selector 驱动修饰 |
|
||||
| <code>fillet</code> | 286 | selector 驱动修饰 |
|
||||
| <code>revolve_add</code> | 187 | 统一 primary executor |
|
||||
| <code>revolve_cut</code> | 170 | 统一 primary executor |
|
||||
| <code>pattern_linear</code> | 167 | feature 重放阵列 |
|
||||
| <code>pattern_mirror</code> | 98 | feature 重放镜像 |
|
||||
| <code>extrude_add_two_sided</code> | 47 | 双向 extent |
|
||||
|
||||
草图 profile 只有四类:<code>analytic_contours</code> 3,785、<code>circle</code> 504、<code>circles</code> 260、<code>annulus</code> 87。<code>analytic_contours</code> 中实际出现 16,352 条线、3,632 条圆弧和 313 个圆;本批没有需要作为首期阻塞项的 B-spline。
|
||||
|
||||
常见终止条件是 <code>blind</code> 2,456、<code>through_all</code> 763、<code>through_all_and_blind</code> 226、<code>through_all_both</code> 72。selector 依赖的 <code>up_to_surface</code> 70、<code>through_next</code> 7、<code>up_to_vertex</code> 3、<code>offset_from_surface</code> 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
|
||||
|
||||
<code>analytic_contours</code> 是必须补齐的 profile runtime。它必须产生 engine 中立的闭合 region,再由几何适配器生成面。
|
||||
|
||||
下列行为不属于精确重建:
|
||||
|
||||
- 未捕获的选择集不能通过“对所有边倒角或圆角”替代;
|
||||
- selector 有多个候选时不能取第一个候选;
|
||||
- B-spline 离散化不能标记为精确,除非输出近似误差;
|
||||
- 不允许重新启用 <code>compiler_context</code> 或 legacy translator 作为 CDSL-only 成功回退。
|
||||
|
||||
## 3. 成功定义
|
||||
|
||||
每份批量报告必须记录下列独立状态:
|
||||
|
||||
semantic_valid CDSL 符合 schema、ID 和依赖顺序
|
||||
runtime_eligible 所有 feature 当前可执行,且输入完整、无歧义
|
||||
compiled 已形成可执行 feature plan
|
||||
built 已成功输出 STEP
|
||||
geometry_verified 与 source truth 的数值几何比对通过
|
||||
topology_observed 面、边、顶点数量已记录,仅作诊断
|
||||
|
||||
<code>built</code> 不是 <code>geometry_verified</code> 的同义词。默认 strict 模式只有所有 feature 均 <code>runtime_eligible</code> 时才可标记为成功重建;调试模式可以输出部分 STEP,但必须标记为 <code>partial</code>。
|
||||
|
||||
数值验证至少比较 bounding box、volume、surface area 和 solid count。门槛应配置化,初始建议:bounding box 每个坐标绝对误差不超过 <code>0.01 mm</code>,volume 与 area 相对误差不超过 <code>0.1%</code>,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
|
||||
|
||||
建议在 <code>backend/engine/cdsl_engine</code> 内按以下职责拆分。文件名可调整,但依赖方向不得反转。
|
||||
|
||||
| 层 | 责任 | 不应承担的责任 |
|
||||
| --- | --- | --- |
|
||||
| <code>semantic_validation</code> | schema、ID、依赖顺序与自包含数据检查 | 判断当前内核是否支持 feature |
|
||||
| <code>capabilities</code> | 根据 registry、参数和 selector 条件计算可执行性 | 生成或修改 CDSL |
|
||||
| <code>sketch</code> | profile、闭环、outer/inner 分类,输出 <code>SketchRegion</code> | 直接修改实体 |
|
||||
| <code>planning</code> | feature DAG、归一化、执行顺序和 plan diagnostics | 复制 build123d 对象或做布尔运算 |
|
||||
| <code>runtime</code> | session、executor registry、结果与错误边界 | 解析原始 JSON 细节 |
|
||||
| <code>topology</code> | context、body、face、edge 注册与 selector 解析 | 私自补全缺失选择集 |
|
||||
| <code>adapters/build123d</code> | region 到 B-rep、布尔、修饰、STEP 导出 | 读取 CDSL schema 或决定依赖 |
|
||||
| <code>batch_rebuild</code> | 批量调度、报告、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): ...
|
||||
|
||||
<code>FeatureResult</code> 至少包含 <code>feature_id</code>、产生或修改的 body、context object、拓扑快照、可回放的执行定义和 feature 级诊断。任何 executor 都不得通过全局变量或 <code>BuildPart</code> 隐式上下文查找前序结果。
|
||||
|
||||
<code>HoleSpec</code> 是 runtime-neutral 的孔定义:包含孔径、深度、终止条件、位置以及可选 countersink/counterbore 尺寸,但不包含 OCC 对象或 host-face 推断。runtime 负责严格解析 host frame 和坐标,adapter 只将已解析的 <code>HoleSpec</code> 构造成切削工具。
|
||||
|
||||
runtime 对 B-rep 实体保持 opaque:包围盒、体积、STEP 导出及所有内核向量转换都只能经 adapter 返回;runtime 不得 import 或读取 build123d/OCC 对象属性。
|
||||
|
||||
### 4.2 现有代码的迁移约束
|
||||
|
||||
现有 <code>sketch_solver.py -> llm_compiler.py -> llm_engine.py</code> 流程可以逐步迁移,但必须保持单一路径:
|
||||
|
||||
- <code>sketch_solver.py</code> 输出中立 region/curve 数据,不能让 profile generator 拥有实体执行逻辑;
|
||||
- <code>llm_compiler.py</code> 只构建 feature plan。当前 <code>pattern_linear</code> 的坐标偏移克隆逻辑必须迁出 compiler,避免 pattern、selector 和 host face 语义被扁平化;
|
||||
- <code>llm_engine.py</code> 的大分支改为 executor registry;build123d import 只保留在 adapter 层;
|
||||
- <code>engine_service.validate_cdsl</code> 只在 atomic contract 声明 <code>requires_sketch: true</code> 时强制 <code>sketch_id</code>。reference、pattern、dress-up 与 Hole Wizard 都是合法的非草图 feature;
|
||||
- <code>profile_schema.json</code>、<code>cdsl_schema.json</code>、executor registry 和测试必须由同一 capability 声明校验,避免维护多个手写 supported set。
|
||||
|
||||
### 4.3 <code>execution_status</code> 的兼容策略
|
||||
|
||||
保留 CDSL 中的 <code>execution_status</code>,用于说明导出时的能力快照;不再仅因其为 <code>deferred</code> 而拒绝升级后的 runtime。实际执行前由 <code>CapabilityAnalyzer</code> 为每个 feature 产生:
|
||||
|
||||
declared_status CDSL 中的 execution_status
|
||||
resolved_status executable | blocked | unsupported
|
||||
required_capabilities 原子、profile、selector、extent 能力
|
||||
blockers 精确的缺参、歧义或内核前置条件
|
||||
|
||||
旧输出因此可以在 engine 升级后直接重跑;converter 重跑仍应更新导出状态,但不是重建前置条件。
|
||||
|
||||
## 5. 原子能力完成合同
|
||||
|
||||
### 5.1 Reference geometry
|
||||
|
||||
<code>reference_plane</code> 和 <code>reference_axis</code> 不生成实体,但必须作为正式 <code>FeatureResult</code> 写入 context registry,用于后续 workplane、revolve axis、mirror plane、pattern direction 和 hole host frame。
|
||||
|
||||
要求:
|
||||
|
||||
- 使用 canonical <code>PlaneSpec</code> 与 <code>AxisSpec</code>;
|
||||
- 校验零长度向量,正交化并记录修正后的坐标系;
|
||||
- 支持由显式参数、前序 reference、feature 或 sketch 推导;
|
||||
- 无法恢复的朝向缺失返回 <code>blocked: missing_reference_orientation</code>,不得默认 XY;
|
||||
- context feature 不修改 body,但可作为后续 feature 的依赖节点。
|
||||
|
||||
### 5.2 Analytic contour runtime
|
||||
|
||||
首期支持 <code>line</code>、<code>arc</code>、<code>circle</code>。固定流程为:二维 segment 归一化、端点容差拼接、闭环验证、workplane 映射、outer/inner 分类、生成 <code>SketchRegion</code>。一个 region 可以有一个 outer loop 和多个 hole loop。
|
||||
|
||||
必须诊断端点反转、退化边、自交、开放 loop、非共面输入,以及无法分类的 <code>unknown</code> contour。circle 是独立 loop,不能用零长度线模拟;嵌套环按奇偶包含关系分类。
|
||||
|
||||
对于四段等半径、90 度圆角组成的闭环,若导出逐段 <code>clockwise</code> 标记互相矛盾,runtime 可以依据闭环有向面积统一其短圆角方向;这是一种可证明的 rounded-rectangle 归一化。两段半圆或任意长圆弧无法仅由该规则恢复 sweep intent,必须保留原始证据或在 truth 验证中报告不匹配,不能根据目标 STEP 猜测方向。
|
||||
|
||||
B-spline 是后续扩展。采用采样近似时必须声明 chord tolerance 和最大偏差,且 <code>geometry_verified</code> 需使用对应容差策略。
|
||||
|
||||
### 5.3 Extrude 与 revolve
|
||||
|
||||
现有 add/cut/revolve atomic 保持原 ID,内部统一归一化为 <code>BooleanMode</code>、<code>ExtentSpec</code> 和 <code>AxisSpec</code>。执行器先生成 region 面,再调用 adapter;不得根据“草图中有圆”改变 feature 语义。
|
||||
|
||||
| 层次 | 终止条件 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| A | <code>blind</code>、<code>mid_plane</code>、<code>through_all</code>、<code>through_all_both</code>、<code>through_all_and_blind</code> | 使用当前 body 的精确包围范围与 margin 计算 extent;双向值保持独立 |
|
||||
| B | <code>up_to_surface</code>、<code>up_to_vertex</code>、<code>offset_from_surface</code>、<code>through_next</code>、<code>up_to_body</code> | 先通过 selector resolver 得到唯一目标,再由 adapter 做射线或相交查询;<code>up_to_body</code> 使用 active B-rep 的 body record,不允许引用失效快照 |
|
||||
|
||||
<code>extrude_add_two_sided</code> 必须保留正反两侧的距离与终止条件,不能简化为单个对称距离;每一侧都要独立解析 blind、through 或 selector-dependent end condition。revolve axis 可以来自显式 <code>AxisSpec</code> 或 owner-qualified 的 <code>reference_axis</code> selector;无 owner 的 source stable ID 不能被当作 OCC 轴。axis 与 profile 的退化相交必须在 preflight 阶段诊断。
|
||||
|
||||
### 5.4 Hole Wizard
|
||||
|
||||
<code>hole_wizard</code> 是独立 atomic,不在 compiler 中改写成匿名多个 hole step。执行器先解析为中立 <code>HoleSpec</code>,再选择 blind/countersink/counterbore/tapped 子型并调用 hole adapter。plan 和报告保留原 <code>feature_id</code> 与 <code>atomic_id</code>。
|
||||
|
||||
要求:
|
||||
|
||||
- 由 host face selector 或明确 workplane 得到唯一孔位 frame;
|
||||
- 支持 <code>blind</code>、<code>through_all</code>、<code>through_all_both</code> 与 countersink/counterbore;
|
||||
- 将 SolidWorks 位置坐标转换为 host frame,不能将局部坐标当世界坐标;
|
||||
- thread、非标准钻尖或不支持孔型返回 <code>unsupported_hole_subtype</code>,不能静默退化为普通圆柱孔;
|
||||
- 源 STEP 的 <code>stable_id</code> 只是线索,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。这样 <code>owner_feature_id</code> 仍可约束当前实体上的有效对象,而不会把所有存活拓扑误标为最后一个变更 feature。
|
||||
|
||||
解析顺序固定为:<code>owner_feature_id</code> 限定候选集,显式 kind 限定面/边/轴,再以几何签名评分,最后应用置信度和唯一性阈值。0 个候选报 <code>selector_not_found</code>,多个同分候选报 <code>selector_ambiguous</code>。两种情况都不能继续 strict rebuild。
|
||||
|
||||
原始 <code>stable_id</code> 不能被当作 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 个 <code>chamfer</code> 与 286 个 <code>fillet</code> feature 均没有 selector;这不是仅新增 runtime 函数就能解决的问题。它们必须由 converter/exporter 提供选择集,或由针对 source truth 的独立 selector enrichment pass 产生唯一、可审计的 selector,才可进入严格重建池。
|
||||
|
||||
### 5.7 Feature-level pattern
|
||||
|
||||
<code>pattern_linear</code> 和 <code>pattern_mirror</code> 重放 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 后仍可唯一解析;显式 <code>host_face.frame</code> 与局部孔位是可直接变换的坐标 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 虽有 <code>host_face</code>,但其来自 source STEP 推断。enrichment pass 必须将其转换为可解析 selector contract,并在置信度不足时保留 <code>unresolved</code>。同一规则适用于 fillet/chamfer,不能为了提高覆盖率创造不稳定 ID。
|
||||
|
||||
## 7. 分阶段交付与覆盖目标
|
||||
|
||||
批量覆盖数字是静态“输入就绪池”,不是尚未实现内核下的通过率承诺。所有数字均要求无 <code>unresolved</code>、profile 限于本批四类、<code>analytic_contours</code> 仅含 line/arc/circle,且终止条件在当前阶段已支持。
|
||||
|
||||
| 阶段 | 交付物 | 阶段出口 | 静态就绪池 |
|
||||
| --- | --- | --- | ---: |
|
||||
| P0 | <code>batch_rebuild</code>、基线 manifest、feature 级报告 | 998 个输入均有机器可读结果;可复现首个阻断 feature | 998 |
|
||||
| P1 | capability analyzer、execution session、reference plane/axis、校验修正 | 非草图 atomic 可规划;旧 <code>deferred</code> 不再是唯一阻断理由 | - |
|
||||
| P2 | <code>analytic_contours</code> 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 个带 <code>unresolved</code> 的零件不能仅靠补 engine atomic 达到严格重建。P7 的目标是把每个零件归入“可通过数据补全解锁”或“当前证据不足”,而不是报告模糊失败。
|
||||
|
||||
## 8. 批量基准与 CI 门槛
|
||||
|
||||
新增单一入口,例如 <code>backend/engine/cdsl_engine/batch_rebuild.py</code>,接收 CDSL 目录、truth 目录和输出目录。每次运行至少产生:
|
||||
|
||||
manifest.json
|
||||
parts/<part_id>.report.json
|
||||
parts/<part_id>.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
|
||||
|
||||
报告还必须输出稳定的 <code>failure_category</code>:<code>input_incomplete</code>、<code>selector_resolution</code>、<code>unsupported_capability</code>、<code>occ_execution_failure</code>、<code>geometry_mismatch</code> 或 <code>coordinate_frame_mismatch_candidate</code>;<code>geometry_verified</code> 是唯一成功类别。未请求 <code>--build</code> 但已通过预检的报告标为 <code>runtime_eligible_not_built</code>,它是非终态,不是失败或 verified。该分类只归纳已有证据,不能将构建完成或坐标框候选计为 verified。
|
||||
|
||||
<code>numeric_comparison</code> 必须保留严格的 <code>passed</code> 判定,并把体积、面积、实体数及无序包围盒跨度均吻合、但绝对坐标框不吻合的情况标为 <code>coordinate_frame_mismatch_candidate</code>。该标记仅帮助定位 workplane/export frame 数据问题,不能替代 <code>geometry_verified</code>。
|
||||
|
||||
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。不得只把名称加入 <code>SUPPORTED_ATOMIC_IDS</code>。
|
||||
|
||||
### 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 份 <code>geometry_mismatch</code>、49 份 <code>coordinate_frame_mismatch_candidate</code>、6 份 <code>input_incomplete</code>、5 份 <code>occ_execution_failure</code>、2 份 <code>selector_resolution</code>。这是一份验收基线而非成功率承诺:坐标框候选和已构建 STEP 均没有并入 verified。
|
||||
|
||||
该命令和输出目录结构是可恢复的;CI 应将经过审查的报告摘要保存在持久化工件中,而不依赖本机 <code>/tmp</code> 的 STEP 临时文件。
|
||||
|
||||
同日使用相同的 CDSL-only、每件 15 秒隔离构建策略完成 P4 池:309/309 已完成、277 份 strict runtime eligible、258 份 built、59 份 geometry verified。其余终态为 150 份 <code>geometry_mismatch</code>、49 份 <code>coordinate_frame_mismatch_candidate</code>、8 份 <code>input_incomplete</code>、5 份 <code>occ_execution_failure</code>、12 份 <code>selector_resolution</code>、26 份 <code>unsupported_capability</code>。P4 的 5 个 OCC 失败均已出现于 P3 基础主体能力池,未发现 Hole Wizard 新增的 OCC 失败。
|
||||
|
||||
P6 池也已使用同一策略完成:341/341 已完成、290 份 strict runtime eligible、268 份 built、60 份 geometry verified。其余终态为 159 份 <code>geometry_mismatch</code>、49 份 <code>coordinate_frame_mismatch_candidate</code>、9 份 <code>input_incomplete</code>、6 份 <code>occ_execution_failure</code>、14 份 <code>selector_resolution</code>、44 份 <code>unsupported_capability</code>。其中嵌套 pattern source 已按可回放 feature definition 递归执行;不能产生实体的 context source 会在 capability preflight 以 <code>unsupported_pattern_source</code> 阻断,不会再被归为 OCC 执行失败。
|
||||
|
||||
P7 的全量 capability 审计也已完成:998/998 份 CDSL 均有 machine-readable report,全部 semantic valid;290 份 strict runtime eligible(未请求 <code>--build</code>,因此分类为 <code>runtime_eligible_not_built</code>)、612 份 <code>input_incomplete</code>、96 份 <code>unsupported_capability</code>。这证明所有当前输入均被审计和分类,但不将 preflight 通过等同于 STEP 构建或 geometry verified。
|
||||
|
||||
## 9. 完成判定
|
||||
|
||||
本目标完成需要同时满足:
|
||||
|
||||
1. schema 已声明的 16 个 feature atomic 都有 executor、preflight contract 和 feature 级诊断;
|
||||
2. <code>analytic_contours</code> 的 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 的扩展单位是可独立测试、可替换内核、可追溯失败原因的原子能力,而不是为某一批零件增加临时分支。
|
||||
Reference in New Issue
Block a user