优化engine
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user