优化engine

This commit is contained in:
2026-08-24 10:01:21 +08:00
parent 91a443d990
commit fd8c0c37ad
16 changed files with 4486 additions and 74 deletions
+14 -7
View File
@@ -105,15 +105,13 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not fid or fid in feature_ids:
raise ValueError("Feature ids must be unique")
feature_ids.add(fid)
if feature.get("execution_status") not in (None, "supported"):
raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
if str(feature.get("sketch_id") or "") not in sketch_ids:
raise ValueError(f"Feature {fid} refers to a missing sketch")
atomic_id = str(feature.get("atomic_id") or "")
if not atomic_id:
raise ValueError(f"Feature {fid} has no atomic_id")
contract = atomic_contracts.get(atomic_id)
if atomic_id not in supported_atomic_ids or not isinstance(contract, dict):
if feature.get("execution_status") == "deferred":
raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
raise ValueError(
f"Unsupported CDSL atomic_id: {atomic_id}. Supported: {', '.join(supported_atomic_ids)}"
)
@@ -145,9 +143,15 @@ def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
try:
engine.compile_cdsl(copy.deepcopy(cdsl))
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
raise ValueError(f"CDSL engine compile preflight failed: {error}") from error
raise ValueError(f"CDSL engine runtime preflight failed: {error}") from error
if not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(f"CDSL engine runtime preflight failed: {analysis.document_blockers[0].code}")
blockers = ", ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"CDSL engine runtime preflight failed: feature {first.feature_id}: {blockers}")
def _parameter_id(path: list[str]) -> str:
@@ -355,7 +359,10 @@ def build_revision(
write_json(parameters_path, contract)
try:
engine_result = engine.run_rebuild(cdsl_copy, step_path)
# Product revisions are semantic CDSL artifacts. Do not route them
# through the legacy rebuild entry point, which is allowed to use
# compiler_context/translator compatibility fallbacks.
engine_result = engine.run_cdsl_only(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
+42 -6
View File
@@ -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.
+15 -3
View File
@@ -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"
+422
View File
@@ -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
+378
View File
@@ -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))
+54 -25
View File
@@ -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:
+95
View File
@@ -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")
+15 -15
View File
@@ -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,
+15 -14
View File
@@ -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
+828
View File
@@ -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,
}
+625
View File
@@ -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)
+305 -2
View File
@@ -38,7 +38,7 @@ from __future__ import annotations
import math
from copy import deepcopy
from typing import Any
from typing import Any, Iterable
# ═══════════════════════════════════════════════════════════════
@@ -84,8 +84,9 @@ def _contour_arc(
end_mm: list[float],
center_mm: list[float],
radius_mm: float | None,
clockwise: bool | None = None,
) -> dict[str, Any]:
return {
result = {
"type": "arc",
"start_mm": [
float(start_mm[0]),
@@ -104,6 +105,9 @@ def _contour_arc(
],
"radius_mm": float(radius_mm) if radius_mm is not None else None,
}
if clockwise is not None:
result["clockwise"] = bool(clockwise)
return result
# ═══════════════════════════════════════════════════════════════
@@ -1558,6 +1562,235 @@ def _gen_compound_patterned_cutouts(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ct
return [], []
# ═══════════════════════════════════════════════════════════════
# Evidence v2 analytic contours
# ═══════════════════════════════════════════════════════════════
_ANALYTIC_TOLERANCE_MM = 1e-5
def _distance_2d(left: list[float], right: list[float]) -> float:
return math.hypot(float(left[0]) - float(right[0]), float(left[1]) - float(right[1]))
def _reverse_analytic_edge(edge: _Ctx) -> _Ctx:
result = deepcopy(edge)
result["start_mm"], result["end_mm"] = result["end_mm"], result["start_mm"]
if result.get("type") == "arc" and "clockwise" in result:
result["clockwise"] = not bool(result["clockwise"])
return result
def _join_analytic_edges(edges: list[_Ctx], *, closed: bool) -> list[_Ctx]:
"""Order/reorient a contour without depending on SolidWorks segment order."""
if not edges:
return []
pending = [deepcopy(edge) for edge in edges]
ordered = [pending.pop(0)]
while pending:
tail = ordered[-1]["end_mm"]
match_index = None
reverse = False
for index, edge in enumerate(pending):
if _distance_2d(tail, edge["start_mm"]) <= _ANALYTIC_TOLERANCE_MM:
match_index = index
break
if _distance_2d(tail, edge["end_mm"]) <= _ANALYTIC_TOLERANCE_MM:
match_index = index
reverse = True
break
if match_index is None:
raise ValueError("analytic_contours: segments do not form a connected contour")
edge = pending.pop(match_index)
ordered.append(_reverse_analytic_edge(edge) if reverse else edge)
if closed and _distance_2d(ordered[0]["start_mm"], ordered[-1]["end_mm"]) > _ANALYTIC_TOLERANCE_MM:
raise ValueError("analytic_contours: closed contour endpoints do not meet")
return ordered
def _analytic_circle_edges(segment: _Ctx) -> list[_Ctx]:
center = segment.get("center") or [0.0, 0.0]
radius = float(segment.get("radius_mm") or 0.0)
if radius <= 0:
raise ValueError("analytic_contours: circle radius_mm must be > 0")
cx, cy = float(center[0]), float(center[1])
clockwise = bool(segment.get("clockwise", False))
angles = [0.0, -90.0, -180.0, -270.0, -360.0] if clockwise else [0.0, 90.0, 180.0, 270.0, 360.0]
points = [[cx + radius * math.cos(math.radians(angle)), cy + radius * math.sin(math.radians(angle)), 0.0] for angle in angles]
return [
_contour_arc(points[index], points[index + 1], [cx, cy, 0.0], radius, clockwise)
for index in range(4)
]
def _analytic_segment_edges(segment: _Ctx) -> list[_Ctx]:
segment_type = segment.get("type")
if segment_type == "line":
return [_contour_line(segment["start"], segment["end"])]
if segment_type == "arc":
return [
_contour_arc(
segment["start"], segment["end"], segment["center"],
segment.get("radius_mm"), segment.get("clockwise"),
)
]
if segment_type == "circle":
return _analytic_circle_edges(segment)
if segment_type == "bspline":
raise ValueError("analytic_contours: bspline requires an explicit approximation capability")
raise ValueError(f"analytic_contours: unsupported segment type {segment_type!r}")
def _sample_analytic_loop(edges: list[_Ctx]) -> list[tuple[float, float]]:
"""Create a deterministic planar sample only for containment classification."""
points: list[tuple[float, float]] = []
for edge in edges:
start = edge["start_mm"]
points.append((float(start[0]), float(start[1])))
if edge.get("type") != "arc":
continue
center = edge["center_mm"]
end = edge["end_mm"]
sx, sy = float(start[0]) - float(center[0]), float(start[1]) - float(center[1])
ex, ey = float(end[0]) - float(center[0]), float(end[1]) - float(center[1])
start_angle = math.atan2(sy, sx)
end_angle = math.atan2(ey, ex)
delta = end_angle - start_angle
if edge.get("clockwise"):
if delta >= 0:
delta -= math.tau
elif delta <= 0:
delta += math.tau
for fraction in (0.25, 0.5, 0.75):
angle = start_angle + delta * fraction
radius = float(edge.get("radius_mm") or math.hypot(sx, sy))
points.append((float(center[0]) + radius * math.cos(angle), float(center[1]) + radius * math.sin(angle)))
return points
def _loop_area(points: list[tuple[float, float]]) -> float:
if len(points) < 3:
return 0.0
return abs(sum(points[index][0] * points[(index + 1) % len(points)][1] - points[(index + 1) % len(points)][0] * points[index][1] for index in range(len(points))) / 2.0)
def _endpoint_signed_area(edges: list[_Ctx]) -> float:
points = [(float(edge["start_mm"][0]), float(edge["start_mm"][1])) for edge in edges]
return sum(
points[index][0] * points[(index + 1) % len(points)][1]
- points[(index + 1) % len(points)][0] * points[index][1]
for index in range(len(points))
) / 2.0
def _normalize_quarter_rounding_direction(edges: list[_Ctx]) -> None:
"""Repair inconsistent sweep flags on a conventional rounded rectangle.
Evidence exports occasionally label one or more 90-degree corner arcs
with the opposite direction. Honouring those isolated flags creates
270-degree loops. This normalizer applies only to the unambiguous shape:
exactly four equal-radius quarter arcs in one closed loop. Other arcs,
including annular sectors and long sweeps, retain their captured flags.
"""
arcs = [edge for edge in edges if edge.get("type") == "arc"]
if len(arcs) != 4:
return
radii = [float(edge.get("radius_mm") or 0.0) for edge in arcs]
if min(radii) <= _ANALYTIC_TOLERANCE_MM or max(radii) - min(radii) > _ANALYTIC_TOLERANCE_MM:
return
for edge in arcs:
center = edge.get("center_mm")
if not isinstance(center, list):
return
start, end = edge["start_mm"], edge["end_mm"]
first = (float(start[0]) - float(center[0]), float(start[1]) - float(center[1]))
second = (float(end[0]) - float(center[0]), float(end[1]) - float(center[1]))
angle = abs(math.atan2(first[0] * second[1] - first[1] * second[0], first[0] * second[0] + first[1] * second[1]))
if abs(angle - math.pi / 2) > 1e-4:
return
# A clockwise endpoint loop needs clockwise short corner arcs; a
# counter-clockwise loop needs their reverse. This preserves the actual
# rounded-rectangle boundary, independent of per-segment export noise.
clockwise = _endpoint_signed_area(edges) < 0.0
for edge in arcs:
edge["clockwise"] = clockwise
def _point_in_loop(point: tuple[float, float], loop: list[tuple[float, float]]) -> bool:
if len(loop) < 3:
return False
inside = False
x, y = point
previous = loop[-1]
for current in loop:
x1, y1 = current
x2, y2 = previous
if (y1 > y) != (y2 > y):
intersect_x = (x2 - x1) * (y - y1) / (y2 - y1) + x1
if x < intersect_x:
inside = not inside
previous = current
return inside
def _gen_analytic_contours(profile: _Ctx, meta: _Ctx) -> tuple[list[_Ctx], list[_Ctx]]:
"""Resolve Evidence v2 line/arc/circle loops into engine-neutral regions.
The returned regions preserve holes and islands. The build adapter owns
B-rep creation; this profile generator only reasons about sketch geometry.
"""
loops: list[_Ctx] = []
entities: list[_Ctx] = []
for contour_index, contour in enumerate(profile.get("contours") or []):
if not contour.get("closed"):
raise ValueError(f"analytic_contours: contour {contour_index} is open")
segment_edges: list[_Ctx] = []
for segment in contour.get("segments") or []:
segment_type = segment.get("type")
if segment_type == "line":
entities.append(_line(segment["start"], segment["end"]))
elif segment_type == "circle":
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0)))
segment_edges.extend(_analytic_segment_edges(segment))
if not segment_edges:
continue
edges = _join_analytic_edges(segment_edges, closed=True)
_normalize_quarter_rounding_direction(edges)
points = _sample_analytic_loop(edges)
area = _loop_area(points)
if area <= _ANALYTIC_TOLERANCE_MM * _ANALYTIC_TOLERANCE_MM:
raise ValueError(f"analytic_contours: contour {contour_index} is degenerate")
loops.append({"role": contour.get("role", "unknown"), "edges": edges, "points": points, "area": area})
for segment in profile.get("construction") or []:
if segment.get("type") == "line":
entities.append(_line(segment["start"], segment["end"], construction=True))
elif segment.get("type") == "circle":
entities.append(_circle(segment.get("center") or [0.0, 0.0], float(segment.get("radius_mm") or 0.0), construction=True))
if not loops:
return entities, []
for loop in loops:
# Role tags captured from the source sketch are useful provenance but
# not authoritative geometry. A number of exports label separate
# closed contours as ``inner`` although no outer contour contains
# them. The even-odd containment rule is deterministic for the
# supported analytic curves and preserves those independent regions.
contained_by = sum(_point_in_loop(loop["points"][0], other["points"]) for other in loops if other is not loop)
loop["role"] = "inner" if contained_by % 2 else "outer"
outers = [loop for loop in loops if loop["role"] == "outer"]
inners = [loop for loop in loops if loop["role"] == "inner"]
regions = [{"outer": outer["edges"], "holes": []} for outer in outers]
for inner in inners:
containing = [outer for outer in outers if _point_in_loop(inner["points"][0], outer["points"])]
if not containing:
raise ValueError("analytic_contours: inner contour has no containing outer contour")
selected = min(containing, key=lambda outer: outer["area"])
regions[outers.index(selected)]["holes"].append(inner["edges"])
meta["_regions"] = regions
return entities, []
# ═══════════════════════════════════════════════════════════════
# 生成器注册表 —— 唯一索引点
# ═══════════════════════════════════════════════════════════════
@@ -1588,6 +1821,7 @@ SHAPE_GENERATORS: dict[str, Any] = {
"radial_slot": _gen_radial_slot,
"patterned_cutouts": _gen_patterned_cutouts,
"compound_patterned_cutouts": _gen_compound_patterned_cutouts,
"analytic_contours": _gen_analytic_contours,
"arc_chain": _gen_arc_chain,
"complex_arc_shape": _gen_polygon, # 从 compiler_context entities 重建
"unknown_shape": _gen_polygon, # 未分类形状也走 compiler_context 回退
@@ -1748,3 +1982,72 @@ def resolve_all_sketches(cdsl: dict[str, Any]) -> dict[str, Any]:
out = deepcopy(cdsl)
out.setdefault("geometry", {})["sketches"] = result
return out
def resolve_required_sketches(
cdsl: dict[str, Any],
sketch_ids: Iterable[str],
*,
errors: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Resolve only profiles that an executable feature actually consumes.
``profile_from`` dependencies are resolved recursively. Callers that
pass ``errors`` get feature-addressable failures without losing unrelated
resolved sketches; callers that omit it retain the strict exception
behavior useful to profile tooling.
"""
sketches = list((cdsl.get("geometry") or {}).get("sketches") or [])
by_id = {str(sketch.get("id")): sketch for sketch in sketches if sketch.get("id") is not None}
resolved: dict[str, dict[str, Any]] = {}
resolving: set[str] = set()
def resolve_one(sketch_id: str) -> dict[str, Any]:
if sketch_id in resolved:
return resolved[sketch_id]
sketch = by_id.get(sketch_id)
if sketch is None:
raise ValueError(f"sketch {sketch_id!r} was not found")
if sketch_id in resolving:
raise ValueError(f"sketch {sketch_id}: profile_from contains a cycle")
resolving.add(sketch_id)
try:
if "profile" in sketch:
output = resolve_profile(sketch)
elif sketch.get("profile_from"):
source_id = str(sketch["profile_from"])
source = resolve_one(source_id)
if not source.get("profile"):
raise ValueError(f"sketch {sketch_id}: profile_from={source_id!r} has no profile")
output = deepcopy(sketch)
output["profile"] = deepcopy(source["profile"])
output.pop("profile_from", None)
shift = sketch.get("profile_shift")
if shift and len(shift) == 2 and output["profile"].get("type") == "polygon":
du, dv = float(shift[0]), float(shift[1])
for vertex in output["profile"]["vertices"]:
vertex[0] = round(vertex[0] + du, 6)
vertex[1] = round(vertex[1] + dv, 6)
output.pop("profile_shift", None)
output = resolve_profile(output)
else:
output = deepcopy(sketch)
resolved[sketch_id] = output
return output
finally:
resolving.discard(sketch_id)
for sketch_id in {str(item) for item in sketch_ids}:
try:
resolve_one(sketch_id)
except ValueError as error:
if errors is None:
raise
errors[sketch_id] = str(error)
output = deepcopy(cdsl)
output.setdefault("geometry", {})["sketches"] = [
resolved.get(str(sketch.get("id")), deepcopy(sketch))
for sketch in sketches
]
return output
@@ -0,0 +1,944 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "backend" / "engine"))
from cdsl_engine.batch_rebuild import _failure_category, _verification_classification, batch_analyze # noqa: E402
from cdsl_engine.capabilities import CapabilityAnalyzer # noqa: E402
from cdsl_engine.runtime_types import HoleSpec, PlaneSpec, TopologyRecord, TopologyRegistry # noqa: E402
from cdsl_engine.sketch_solver import SHAPE_GENERATORS, resolve_all_sketches # noqa: E402
def _workplane() -> dict:
return {"origin_mm": [0, 0, 0], "x_dir": [1, 0, 0], "normal": [0, 0, 1]}
class EngineRuntimeFoundationTests(unittest.TestCase):
def _base_block(self) -> dict:
return {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "runtime-block",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "base", "workplane": _workplane(),
"profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10},
}]},
"features": [{
"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": [],
"params": {"distance_mm": 10}, "sketch_id": "base",
}],
}
def test_runtime_module_has_no_build123d_import(self) -> None:
runtime_source = (ROOT / "backend" / "engine" / "cdsl_engine" / "runtime.py").read_text(encoding="utf-8")
self.assertNotIn("from build123d", runtime_source)
self.assertNotIn("import build123d", runtime_source)
def test_execution_session_declares_a_kernel_neutral_adapter_protocol(self) -> None:
from cdsl_engine.runtime import ExecutionSession, GeometryAdapter
self.assertIn("adapter", ExecutionSession.__dataclass_fields__)
self.assertTrue(getattr(GeometryAdapter, "_is_protocol", False))
self.assertIn("export", GeometryAdapter.__dict__)
def test_hole_spec_normalizes_wizard_subtypes_without_occ_dependencies(self) -> None:
spec = HoleSpec.from_feature("hole_wizard", {
"diameter_mm": 2, "depth_mm": 6, "end_condition": {"type": "blind"},
"positions": [{"mm": [1, 2, 3]}],
"countersink": {"diameter_mm": 4, "angle_rad": 1.5707963267948966},
}, wizard=True)
self.assertEqual(spec.positions_mm, ((1.0, 2.0, 3.0),))
self.assertEqual(spec.countersink, (4.0, 1.5707963267948966))
with self.assertRaisesRegex(ValueError, "larger than the main hole"):
HoleSpec.from_feature("hole_wizard", {
"diameter_mm": 2, "depth_mm": 6, "positions": [{"mm": [0, 0, 0]}],
"counterbore": {"diameter_mm": 2, "depth_mm": 1},
}, wizard=True)
def test_analytic_contours_create_a_region_with_hole(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "sketch", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [0, 0], "end": [10, 0]},
{"type": "line", "start": [10, 0], "end": [10, 10]},
{"type": "line", "start": [10, 10], "end": [0, 10]},
{"type": "line", "start": [0, 10], "end": [0, 0]},
]},
{"role": "inner", "closed": True, "segments": [
{"type": "circle", "center": [5, 5], "radius_mm": 2},
]},
]},
}]},
"features": [],
}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
region = sketch["contour_regions_mm"][0]
self.assertEqual(len(region["outer"]), 4)
self.assertEqual(len(region["holes"]), 1)
self.assertEqual(len(region["holes"][0]), 4)
def test_analytic_contours_normalize_disjoint_inner_roles(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "sketch", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": [
{"role": "outer", "closed": True, "segments": [
{"type": "line", "start": [0, 0], "end": [2, 0]},
{"type": "line", "start": [2, 0], "end": [2, 2]},
{"type": "line", "start": [2, 2], "end": [0, 2]},
{"type": "line", "start": [0, 2], "end": [0, 0]},
]},
{"role": "inner", "closed": True, "segments": [
{"type": "line", "start": [4, 0], "end": [6, 0]},
{"type": "line", "start": [6, 0], "end": [6, 2]},
{"type": "line", "start": [6, 2], "end": [4, 2]},
{"type": "line", "start": [4, 2], "end": [4, 0]},
]},
]},
}]},
"features": [],
}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
self.assertEqual(len(sketch["contour_regions_mm"]), 2)
self.assertTrue(all(not region["holes"] for region in sketch["contour_regions_mm"]))
def test_deferred_reference_is_currently_executable_without_a_sketch(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{"id": "s", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1}}]},
"features": [{
"id": "plane", "atomic_id": "reference_plane", "depends_on": [],
"execution_status": "deferred", "params": {"plane": _workplane()},
}],
}
analysis = CapabilityAnalyzer(atomic_ids={"reference_plane"}, profile_types=SHAPE_GENERATORS).analyze(cdsl)
self.assertFalse(analysis.runtime_eligible)
self.assertEqual(analysis.feature_results[0].resolved_status, "executable")
self.assertEqual(analysis.document_blockers[0].code, "no_solid_feature")
def test_unused_invalid_profile_does_not_block_runtime_preflight(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "abandoned", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [{"type": "line", "start": [0, 0], "end": [1, 0]}],
}]},
})
analysis = analyze_cdsl(cdsl)
self.assertTrue(analysis.runtime_eligible)
def test_used_invalid_profile_is_a_feature_level_blocker(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "broken", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": [{
"role": "outer", "closed": True,
"segments": [{"type": "line", "start": [0, 0], "end": [1, 0]}],
}]},
})
cdsl["features"].append({
"id": "broken_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 1}, "sketch_id": "broken",
})
analysis = analyze_cdsl(cdsl)
broken = next(item for item in analysis.feature_results if item.feature_id == "broken_cut")
self.assertEqual(broken.resolved_status, "blocked")
self.assertIn("profile_resolution_failed", [item.code for item in broken.blockers])
def test_used_empty_analytic_profile_is_a_feature_level_blocker(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "construction_only", "workplane": _workplane(),
"profile": {"type": "analytic_contours", "contours": []},
})
cdsl["features"].append({
"id": "empty_cut", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 1}, "sketch_id": "construction_only",
})
analysis = analyze_cdsl(cdsl)
empty = next(item for item in analysis.feature_results if item.feature_id == "empty_cut")
self.assertIn("profile_no_closed_region", [item.code for item in empty.blockers])
def test_selector_resolver_refuses_equal_candidates(self) -> None:
registry = TopologyRegistry()
plane = PlaneSpec.from_mapping(_workplane())
registry.register(TopologyRecord("first", "plane", "f1", geometry=plane.as_dict(), value=plane))
registry.register(TopologyRecord("second", "plane", "f2", geometry=plane.as_dict(), value=plane))
resolution = registry.resolve({"kind": "plane", "geometry": plane.as_dict()})
self.assertEqual(resolution.status, "ambiguous")
self.assertEqual(resolution.diagnostic.code, "selector_ambiguous")
def test_selector_resolver_normalizes_legacy_solidworks_plane_evidence(self) -> None:
registry = TopologyRegistry()
registry.register(TopologyRecord(
"face", "face", "f1", "body:f1",
geometry={
"surface_type": "plane", "plane_normal": [1, 0, 0],
"plane_offset_mm": 12.0, "bbox_mm": [12, -2, -3, 12, 2, 3], "area_mm2": 24,
},
))
resolution = registry.resolve({
"kind": "face",
"geometry": {
"surface": {"type": "plane", "parameters": [-1, 0, 0, -0.012, 0, 0]},
"box": [0.012, -0.002, -0.003, 0.012, 0.002, 0.003], "area": 0.000024,
},
})
self.assertEqual(resolution.status, "resolved")
self.assertEqual(resolution.record.record_id, "face")
def test_owner_selector_survives_a_later_body_snapshot_when_geometry_is_unchanged(self) -> None:
registry = TopologyRegistry()
geometry = {
"bbox_mm": [0, 0, 0, 0, 0, 10],
"center_mm": [0, 0, 5],
"length_mm": 10.0,
"curve_type": "line",
"start_mm": [0, 0, 0],
"end_mm": [0, 0, 10],
}
registry.replace_body_topology("base_add", "body:base_add", [
TopologyRecord("body:base_add:edge:0", "edge", "base_add", "body:base_add", geometry, "old-edge"),
])
registry.replace_body_topology("later_cut", "body:later_cut", [
TopologyRecord("body:later_cut:edge:3", "edge", "later_cut", "body:later_cut", geometry, "current-edge"),
])
resolution = registry.resolve(
{"kind": "edge", "owner_feature_id": "base_add", "geometry": geometry},
active_body_id="body:later_cut",
)
self.assertEqual(resolution.status, "resolved")
self.assertEqual(resolution.record.value, "current-edge")
self.assertEqual(resolution.record.feature_id, "later_cut")
self.assertEqual(resolution.record.owner_feature_ids, ("base_add",))
def test_ambiguous_predecessors_do_not_invent_topology_ownership(self) -> None:
registry = TopologyRegistry()
geometry = {"center_mm": [1, 2, 3]}
registry.replace_body_topology("base_add", "body:base_add", [
TopologyRecord("body:base_add:vertex:0", "vertex", "base_add", "body:base_add", geometry),
TopologyRecord("body:base_add:vertex:1", "vertex", "base_add", "body:base_add", geometry),
])
registry.replace_body_topology("later_cut", "body:later_cut", [
TopologyRecord("body:later_cut:vertex:0", "vertex", "later_cut", "body:later_cut", geometry),
])
resolution = registry.resolve(
{"kind": "vertex", "owner_feature_id": "base_add", "geometry": geometry},
active_body_id="body:later_cut",
)
self.assertEqual(resolution.status, "not_found")
def test_changed_adjacency_prevents_owner_provenance_transfer(self) -> None:
registry = TopologyRegistry()
unchanged_geometry = {
"bbox_mm": [0, 0, 0, 1, 1, 0], "center_mm": [0.5, 0.5, 0],
"normal": [0, 0, 1], "area_mm2": 1, "surface_type": "plane",
}
registry.replace_body_topology("base_add", "body:base_add", [
TopologyRecord(
"body:base_add:face:0", "face", "base_add", "body:base_add",
{**unchanged_geometry, "adjacency_signature": ["line:1.000000:2"]},
),
])
registry.replace_body_topology("later_cut", "body:later_cut", [
TopologyRecord(
"body:later_cut:face:0", "face", "later_cut", "body:later_cut",
{**unchanged_geometry, "adjacency_signature": ["line:1.000000:2", "circle:1.000000:1"]},
),
])
resolution = registry.resolve(
{"kind": "face", "owner_feature_id": "base_add", "geometry": unchanged_geometry},
active_body_id="body:later_cut",
)
self.assertEqual(resolution.status, "not_found")
def test_batch_analysis_writes_one_report_per_input(self) -> None:
document = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "batch-part",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{"id": "s", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1}}]},
"features": [{
"id": "f", "atomic_id": "reference_plane", "depends_on": [], "execution_status": "deferred",
"params": {"plane": _workplane()},
}],
}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_path = root / "input"
input_path.mkdir()
(input_path / "batch-part.cdsl.json").write_text(json.dumps(document), encoding="utf-8")
manifest = batch_analyze(input_path, root / "out", atomic_ids=frozenset({"reference_plane"}))
report = json.loads((root / "out" / "parts" / "batch-part.report.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["part_count"], 1)
self.assertTrue(report["semantic_valid"])
self.assertFalse(report["runtime_eligible"])
self.assertEqual(report["first_blocker"]["code"], "no_solid_feature")
def test_batch_analysis_part_filter_is_exact_and_rejects_unknown_ids(self) -> None:
document = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part",
"meta": {"unit": "mm"}, "geometry": {"sketches": []}, "features": [],
}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_path = root / "input"
input_path.mkdir()
for part_id in ("first", "second"):
(input_path / f"{part_id}.cdsl.json").write_text(
json.dumps({**document, "part_id": part_id}), encoding="utf-8",
)
manifest = batch_analyze(
input_path, root / "out", atomic_ids=frozenset({"reference_plane"}), part_ids=["second"],
)
self.assertEqual(manifest["part_count"], 1)
self.assertEqual(manifest["results"], [{"part_id": "second", "report": "parts/second.report.json"}])
with self.assertRaisesRegex(ValueError, "do not exist"):
batch_analyze(input_path, root / "bad", part_ids=["missing"])
def test_batch_resume_migrates_derived_failure_category_without_rebuilding(self) -> None:
document = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "cached",
"meta": {"unit": "mm"}, "geometry": {"sketches": []}, "features": [],
}
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "input"
source.mkdir()
(source / "cached.cdsl.json").write_text(json.dumps(document), encoding="utf-8")
report_path = root / "out" / "parts" / "cached.report.json"
report_path.parent.mkdir(parents=True)
report_path.write_text(json.dumps({
"part_id": "cached", "semantic_valid": True, "runtime_eligible": False, "compiled": False,
"build_attempted": False, "built": False, "geometry_verified": False, "feature_results": [],
"first_blocker": {"code": "missing_host_face"},
}), encoding="utf-8")
manifest = batch_analyze(source, root / "out")
report = json.loads(report_path.read_text(encoding="utf-8"))
self.assertEqual(manifest["failure_category_counts"], {"input_incomplete": 1})
self.assertEqual(report["failure_category"], "input_incomplete")
def test_full_export_batch_has_a_machine_readable_report_for_every_input(self) -> None:
"""Keep the shipped 998-part corpus on the capability-report path.
STEP construction is intentionally left to the resumable build job;
this CI-sized pass proves that every current export gets a semantic
result and a first blocker rather than being silently skipped.
"""
source = ROOT / "json_to_cdsl" / "output"
input_count = len(list(source.glob("*.cdsl.json")))
self.assertGreater(input_count, 0)
with tempfile.TemporaryDirectory() as directory:
out = Path(directory) / "baseline"
manifest = batch_analyze(source, out)
reports = [json.loads(path.read_text(encoding="utf-8")) for path in (out / "parts").glob("*.report.json")]
self.assertEqual(manifest["part_count"], input_count)
self.assertEqual(manifest["completed_count"], input_count)
self.assertTrue(manifest["complete"])
self.assertEqual(manifest["semantic_valid_count"], input_count)
self.assertEqual(len(reports), input_count)
self.assertTrue(all(report["runtime_eligible"] or report.get("first_blocker") for report in reports))
def test_p3_static_pool_is_explicit_and_reports_missing_capture_separately(self) -> None:
from cdsl_engine.phase_pools import select_p3_static_pool
from cdsl_engine.runtime import analyze_cdsl
source = ROOT / "json_to_cdsl" / "output"
part_ids = select_p3_static_pool(source)
self.assertEqual(len(part_ids), 266)
ineligible: dict[str, set[str]] = {}
for part_id in part_ids:
cdsl = json.loads((source / f"{part_id}.cdsl.json").read_text(encoding="utf-8"))
result = analyze_cdsl(cdsl)
if not result.runtime_eligible:
ineligible[part_id] = {
blocker.code
for feature in result.feature_results
for blocker in feature.blockers
}
self.assertEqual(len(part_ids) - len(ineligible), 262)
self.assertEqual(
ineligible,
{
"027784": {"missing_revolve_axis"},
"104237": {"missing_extent_reference"},
"239358": {"missing_extent_reference", "dependency_unavailable"},
"241720": {"missing_extent_reference"},
},
)
def test_p3_phase_pool_can_be_passed_to_batch_rebuild(self) -> None:
from cdsl_engine.phase_pools import select_p3_static_pool
source = ROOT / "json_to_cdsl" / "output"
pool = select_p3_static_pool(source)
with tempfile.TemporaryDirectory() as directory:
manifest = batch_analyze(source, Path(directory) / "p3", part_ids=pool, max_parts=1)
self.assertEqual(manifest["part_count"], len(pool))
self.assertEqual(manifest["completed_count"], 1)
self.assertFalse(manifest["complete"])
def test_phase_pool_membership_is_regressed_for_p4_and_p6(self) -> None:
from cdsl_engine.phase_pools import select_static_phase_pool
source = ROOT / "json_to_cdsl" / "output"
self.assertEqual(len(select_static_phase_pool(source, "p4")), 309)
self.assertEqual(len(select_static_phase_pool(source, "p6")), 341)
with self.assertRaisesRegex(ValueError, "Unknown CDSL runtime phase"):
select_static_phase_pool(source, "p5")
def test_nested_pattern_source_is_replayable_after_its_first_execution(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["features"].extend([
{
"id": "first_pattern", "atomic_id": "pattern_linear", "depends_on": ["base_add"],
"params": {"source_feature_ids": ["base_add"], "direction_1": [1, 0, 0],
"spacing_1_mm": 20, "pattern_count_1": 2},
},
{
"id": "nested_pattern", "atomic_id": "pattern_linear", "depends_on": ["first_pattern"],
"params": {"source_feature_ids": ["first_pattern"], "direction_1": [0, 1, 0],
"spacing_1_mm": 20, "pattern_count_1": 2},
},
])
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "nested.step")
self.assertEqual(result["engine"], "cdsl_session_runtime")
def test_pattern_source_without_body_definition_is_blocked_in_preflight(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["features"].insert(0, {
"id": "context", "atomic_id": "reference_plane", "depends_on": [],
"params": {"plane": _workplane()},
})
cdsl["features"].append({
"id": "pattern", "atomic_id": "pattern_linear", "depends_on": ["base_add", "context"],
"params": {"source_feature_ids": ["context"], "direction_1": [1, 0, 0],
"spacing_1_mm": 20, "pattern_count_1": 2},
})
analysis = analyze_cdsl(cdsl)
pattern = next(result for result in analysis.feature_results if result.feature_id == "pattern")
self.assertEqual(pattern.resolved_status, "unsupported")
self.assertIn("unsupported_pattern_source", [blocker.code for blocker in pattern.blockers])
def test_truth_comparison_keeps_frame_mismatch_distinct_from_verified_geometry(self) -> None:
truth = {"bounding_box_mm": [-10, -200, -10, 10, 200, 10], "solid_count": 1}
common = {
"truth": truth,
"volume_relative_error": 1e-8,
"area_relative_error": 1e-8,
"solid_count_matches": True,
}
self.assertEqual(
_verification_classification(
actual_box=[-10, 990, 0, 10, 1010, 400], box_delta=1190, **common,
),
"coordinate_frame_mismatch_candidate",
)
self.assertEqual(
_verification_classification(
actual_box=[-10, -200, -10, 10, 200, 10], box_delta=0, **common,
),
"verified",
)
self.assertEqual(
_verification_classification(
actual_box=[-10, 990, 0, 10, 1010, 400], box_delta=1190,
truth=truth, volume_relative_error=0.1, area_relative_error=1e-8, solid_count_matches=True,
),
"geometry_mismatch",
)
def test_batch_failure_categories_preserve_input_selector_capability_and_occ_boundaries(self) -> None:
self.assertEqual(_failure_category({"geometry_verified": True}), "geometry_verified")
self.assertEqual(_failure_category({"runtime_eligible": True, "build_attempted": False}), "runtime_eligible_not_built")
self.assertEqual(_failure_category({"built": True, "numeric_comparison": {"classification": "coordinate_frame_mismatch_candidate"}}), "coordinate_frame_mismatch_candidate")
self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "missing_host_face"}}), "input_incomplete")
self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "extent_target_not_reached"}}), "input_incomplete")
self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "selector_ambiguous"}}), "selector_resolution")
self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "unsupported_hole_subtype"}}), "unsupported_capability")
self.assertEqual(_failure_category({"built": False, "first_blocker": {"code": "build_timeout"}}), "occ_execution_failure")
def test_mirror_pattern_replays_a_selector_free_cut(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "mirror-test",
"meta": {"unit": "mm"},
"geometry": {"sketches": [
{"id": "base", "workplane": _workplane(), "profile": {"type": "rectangle", "center": [0, 0], "width_mm": 10, "height_mm": 10}},
{"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [2, 0], "radius_mm": 1}},
]},
"features": [
{"id": "ref", "atomic_id": "reference_plane", "depends_on": [], "params": {"plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}}},
{"id": "base_add", "atomic_id": "extrude_add_blind", "depends_on": ["ref"], "params": {"distance_mm": 2}, "sketch_id": "base"},
{"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 2}, "sketch_id": "cut"},
{
"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["cut_1", "ref"],
"params": {"source_feature_ids": ["cut_1"], "mirror_plane": {"kind": "plane", "stable_id": "source", "source": "solidworks", "confidence": 1, "owner_feature_id": "ref"}},
"selectors": [{"kind": "plane", "stable_id": "source", "source": "solidworks", "confidence": 1, "owner_feature_id": "ref"}],
},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "mirror.step")
self.assertAlmostEqual(result["volume_mm3"], 200 - 4 * 3.141592653589793, places=5)
self.assertIn("mirror", [item["feature_id"] for item in result["feature_results"]])
def test_mirrored_local_circle_preserves_its_reflected_world_position(self) -> None:
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
from cdsl_engine.runtime import _mirrored_sketch
from cdsl_engine.sketch_solver import resolve_all_sketches
cdsl = {
"schema": "cad.cdsl.llm.v1",
"geometry": {"sketches": [{
"id": "circles", "workplane": _workplane(),
"profile": {"type": "circles", "items": [{"center": [2, 3], "radius_mm": 1}]},
}]},
"features": [],
}
sketch = resolve_all_sketches(cdsl)["geometry"]["sketches"][0]
mirror_plane = PlaneSpec.from_mapping({
"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0],
})
mirrored = _mirrored_sketch(sketch, mirror_plane)
face = Build123dGeometryAdapter().faces_for_sketch(mirrored)[0]
center = face.center()
self.assertAlmostEqual(center.X, -2.0)
self.assertAlmostEqual(center.Y, 3.0)
self.assertAlmostEqual(center.Z, 0.0)
def test_fillet_and_hole_wizard_use_resolved_face_edge_selectors(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge")
top_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face" and item["geometry"]["surface_type"] == "plane" and item["geometry"]["normal"][2] > 0.9
)
with_fillet = deepcopy(base)
with_fillet["features"].append({
"id": "fillet", "atomic_id": "fillet", "depends_on": ["base_add"], "params": {"radius_mm": 1},
"selectors": [{"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1, "owner_feature_id": "base_add", "geometry": edge["geometry"]}],
})
filleted = rebuild_cdsl(with_fillet, root / "fillet.step")
with_hole = deepcopy(base)
with_hole["features"].append({
"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
"params": {
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 5,
"end_condition": {"type": "blind", "solidworks_code": 0}, "positions": [{"mm": [0, 0, 10]}],
"host_face": {"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"]},
},
"selectors": [{"kind": "face", "stable_id": "top", "source": "inferred_from_step", "confidence": 1, "geometry": top_face["geometry"]}],
})
holed = rebuild_cdsl(with_hole, root / "hole.step")
self.assertLess(filleted["volume_mm3"], baseline["volume_mm3"])
self.assertAlmostEqual(holed["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
def test_hole_frame_uses_local_coordinates(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
base["features"].append({
"id": "hole", "atomic_id": "hole_blind", "depends_on": ["base_add"],
"sketch_id": "base",
"params": {
"diameter_mm": 2, "depth_mm": 5, "positions": [{"mm": [0, 0, 0]}],
"host_face": {"frame": {"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0], "y_dir": [0, 1, 0], "normal": [0, 0, 1]}},
},
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(base, Path(directory) / "local-hole.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - 5 * 3.141592653589793, places=5)
def test_pattern_with_selector_source_is_blocked_before_execution(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["features"].extend([
{"id": "fillet", "atomic_id": "fillet", "depends_on": ["base_add"], "params": {"radius_mm": 1}, "selectors": [{"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1}]},
{"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["fillet"], "params": {"source_feature_ids": ["fillet"], "direction_1": [1, 0, 0], "spacing_1_mm": 10, "pattern_count_1": 2}},
])
analysis = analyze_cdsl(cdsl)
pattern = next(item for item in analysis.feature_results if item.feature_id == "repeat")
self.assertFalse(pattern.executable)
self.assertIn("unsupported_pattern_selector_transform", [item.code for item in pattern.blockers])
def test_chamfer_and_linear_pattern_execute_without_selector_guessing(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
base["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [-2, 0], "radius_mm": 1},
})
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
edge = next(item for item in baseline["topology_records"] if item["kind"] == "edge")
chamfered = deepcopy(base)
chamfered["features"].append({
"id": "chamfer", "atomic_id": "chamfer", "depends_on": ["base_add"], "params": {"distance_mm": 1},
"selectors": [{"kind": "edge", "stable_id": "edge", "source": "solidworks", "confidence": 1, "owner_feature_id": "base_add", "geometry": edge["geometry"]}],
})
chamfer_result = rebuild_cdsl(chamfered, root / "chamfer.step")
patterned = deepcopy(base)
patterned["features"].extend([
{"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"], "params": {"distance_mm": 10}, "sketch_id": "cut"},
{"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cut_1"], "params": {"source_feature_ids": ["cut_1"], "direction_1": [1, 0, 0], "spacing_1_mm": 2, "pattern_count_1": 3}},
])
pattern_result = rebuild_cdsl(patterned, root / "pattern.step")
self.assertLess(chamfer_result["volume_mm3"], baseline["volume_mm3"])
self.assertAlmostEqual(pattern_result["volume_mm3"], 1000 - 3 * 10 * 3.141592653589793, places=5)
def test_linear_pattern_replays_hole_with_explicit_host_frame(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["features"].extend([
{
"id": "hole_1", "atomic_id": "hole_blind", "depends_on": ["base_add"], "sketch_id": "base",
"params": {
"diameter_mm": 2, "depth_mm": 5, "positions": [{"mm": [0, 0, 0]}],
"host_face": {"frame": {
"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0],
"y_dir": [0, 1, 0], "normal": [0, 0, 1],
}},
},
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["hole_1"],
"params": {
"source_feature_ids": ["hole_1"], "direction_1": [1, 0, 0],
"spacing_1_mm": 4, "pattern_count_1": 2,
},
},
])
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "patterned-holes.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - 2 * 5 * 3.141592653589793, places=5)
def test_mirror_pattern_replays_local_hole_coordinates_in_the_correct_quadrant(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["features"].insert(0, {
"id": "mirror_plane", "atomic_id": "reference_plane", "depends_on": [],
"params": {"plane": {"origin_mm": [0, 0, 0], "x_dir": [0, 1, 0], "normal": [1, 0, 0]}},
})
cdsl["features"].extend([
{
"id": "hole_1", "atomic_id": "hole_blind", "depends_on": ["base_add"], "sketch_id": "base",
"params": {
"diameter_mm": 2, "depth_mm": 5, "positions": [{"mm": [2, 3, 0]}],
"host_face": {"frame": {
"origin_mm": [0, 0, 10], "x_dir": [1, 0, 0],
"y_dir": [0, 1, 0], "normal": [0, 0, 1],
}},
},
},
{
"id": "mirror", "atomic_id": "pattern_mirror", "depends_on": ["hole_1", "mirror_plane"],
"params": {
"source_feature_ids": ["hole_1"],
"mirror_plane": {"kind": "plane", "owner_feature_id": "mirror_plane"},
},
"selectors": [{"kind": "plane", "owner_feature_id": "mirror_plane"}],
},
])
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "mirrored-holes.step")
mirrored_cylinder = next(
item for item in result["topology_records"]
if item["record_id"].startswith("body:mirror.m.hole_1:face")
and item["geometry"].get("surface_type") == "cylinder"
and item["geometry"]["bbox_mm"][0] < -2.9
)
self.assertEqual(mirrored_cylinder["geometry"]["bbox_mm"][:2], [-3.0, 2.0])
self.assertEqual(mirrored_cylinder["geometry"]["bbox_mm"][3:5], [-1.0, 4.0])
def test_pattern_replays_selected_sources_in_history_order(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
cdsl["features"].extend([
{
"id": "cut_1", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 10}, "sketch_id": "cut",
},
{
"id": "repeat", "atomic_id": "pattern_linear", "depends_on": ["cut_1"],
# SolidWorks selection exports are not guaranteed to match
# history order; the executor must replay the boss before its cut.
"params": {
"source_feature_ids": ["cut_1", "base_add"],
"direction_1": [1, 0, 0], "spacing_1_mm": 20, "pattern_count_1": 2,
},
},
])
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "ordered-pattern.step")
self.assertAlmostEqual(result["volume_mm3"], 2 * (1000 - 10 * 3.141592653589793), places=5)
def test_through_all_extent_uses_current_body(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
cdsl["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
cdsl["features"].append({
"id": "cut_all", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 0, "end_condition": {"type": "through_all", "solidworks_code": 1}}, "sketch_id": "cut",
})
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "through.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * 3.141592653589793, places=5)
def test_two_sided_extrude_uses_independent_forward_and_reverse_distances(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = self._base_block()
feature = cdsl["features"][0]
feature["atomic_id"] = "extrude_add_two_sided"
feature["params"] = {
"distance_mm": 2, "reverse_distance_mm": 3,
"end_condition": {"type": "blind"}, "reverse_end_condition": {"type": "blind"},
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "two-sided.step")
self.assertAlmostEqual(result["volume_mm3"], 500.0)
self.assertEqual(result["bbox_mm"]["min"][2], -3.0)
self.assertEqual(result["bbox_mm"]["max"][2], 2.0)
def test_two_sided_extrude_requires_reverse_distance_contract(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
feature = cdsl["features"][0]
feature["atomic_id"] = "extrude_add_two_sided"
analysis = analyze_cdsl(cdsl)
self.assertIn("missing_parameter", [item.code for item in analysis.feature_results[0].blockers])
def test_revolve_can_resolve_an_owner_qualified_reference_axis(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "axis-revolve",
"meta": {"unit": "mm"},
"geometry": {"sketches": [{
"id": "profile", "workplane": _workplane(),
"profile": {"type": "rectangle", "min_mm": [2, 1], "max_mm": [4, 2]},
}]},
"features": [
{
"id": "axis", "atomic_id": "reference_axis", "depends_on": [],
"params": {"axis": {"origin_mm": [0, 0, 0], "direction": [1, 0, 0]}},
},
{
"id": "revolve", "atomic_id": "revolve_add", "depends_on": ["axis"], "sketch_id": "profile",
"params": {
"angle_deg": 360, "axis": {"selector": {"kind": "axis", "owner_feature_id": "axis"}},
},
"selectors": [{"kind": "axis", "owner_feature_id": "axis"}],
},
],
}
with tempfile.TemporaryDirectory() as directory:
result = rebuild_cdsl(cdsl, Path(directory) / "axis-revolve.step")
self.assertAlmostEqual(result["volume_mm3"], 6 * 3.141592653589793, places=5)
def test_unowned_revolve_feature_selector_is_preflight_blocked(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = json.loads((ROOT / "json_to_cdsl" / "output" / "027784.cdsl.json").read_text(encoding="utf-8"))
analysis = analyze_cdsl(cdsl)
revolve = next(item for item in analysis.feature_results if item.feature_id == "f_007")
self.assertIn("missing_revolve_axis", [item.code for item in revolve.blockers])
def test_tangent_propagation_does_not_expand_to_unrelated_box_edges(self) -> None:
from build123d import Box
from cdsl_engine.build123d_adapter import Build123dGeometryAdapter
body = Box(10, 10, 10)
selected = body.edges()[0]
expanded = Build123dGeometryAdapter().tangent_edges(body, [selected])
self.assertEqual(len(expanded), 1)
self.assertTrue(expanded[0].is_same(selected))
def test_inconsistent_quarter_arc_flags_are_normalized_from_loop_orientation(self) -> None:
from cdsl_engine.batch_rebuild import analyze_document
fixture = ROOT / "json_to_cdsl" / "output" / "053393.cdsl.json"
with tempfile.TemporaryDirectory() as directory:
report = analyze_document(fixture, out_step=Path(directory) / "053393.step")
self.assertTrue(report["runtime_eligible"])
self.assertTrue(report["built"])
self.assertTrue(report["geometry_verified"])
def test_exported_two_sided_fixture_is_geometry_verified(self) -> None:
from cdsl_engine.batch_rebuild import analyze_document
fixture = ROOT / "json_to_cdsl" / "output" / "046112.cdsl.json"
with tempfile.TemporaryDirectory() as directory:
report = analyze_document(fixture, out_step=Path(directory) / "046112.step")
self.assertTrue(report["runtime_eligible"])
self.assertTrue(report["built"])
self.assertTrue(report["geometry_verified"])
def test_selector_driven_extrude_extents_require_unique_rebuilt_topology(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
base["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(),
"profile": {"type": "circle", "center": [0, 0], "radius_mm": 1},
})
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
top_face = next(
item for item in baseline["topology_records"]
if item["kind"] == "face" and item["geometry"]["surface_type"] == "plane"
and item["geometry"]["normal"][2] > 0.9
)
top_vertex = next(
item for item in baseline["topology_records"]
if item["kind"] == "vertex" and item["geometry"]["center_mm"][2] > 9.9
)
for name, end_condition, expected_depth in (
("surface", {"type": "up_to_surface", "reference": {"kind": "face", "owner_feature_id": "base_add", "geometry": top_face["geometry"]}}, 10.0),
("vertex", {"type": "up_to_vertex", "reference": {"kind": "vertex", "owner_feature_id": "base_add", "geometry": top_vertex["geometry"]}}, 10.0),
("offset", {"type": "offset_from_surface", "reference": {"kind": "face", "owner_feature_id": "base_add", "geometry": top_face["geometry"]}}, 8.0),
("next", {"type": "through_next"}, 10.0),
):
cdsl = deepcopy(base)
cdsl["features"].append({
"id": f"cut_{name}", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 2 if name == "offset" else 0, "end_condition": end_condition},
"sketch_id": "cut",
})
result = rebuild_cdsl(cdsl, root / f"{name}.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - expected_depth * 3.141592653589793, places=5)
def test_up_to_body_extent_uses_a_uniquely_resolved_body_record(self) -> None:
from cdsl_engine.runtime import rebuild_cdsl
base = self._base_block()
base["geometry"]["sketches"].append({
"id": "cut", "workplane": _workplane(), "profile": {"type": "circle", "radius_mm": 1},
})
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
baseline = rebuild_cdsl(base, root / "baseline.step")
body = next(item for item in baseline["topology_records"] if item["kind"] == "body")
cdsl = deepcopy(base)
cdsl["features"].append({
"id": "cut_to_body", "atomic_id": "extrude_cut_blind", "depends_on": ["base_add"],
"params": {"distance_mm": 0, "end_condition": {
"type": "up_to_body", "reference": {
"kind": "body", "owner_feature_id": "base_add", "geometry": body["geometry"],
},
}},
"sketch_id": "cut",
})
result = rebuild_cdsl(cdsl, root / "up-to-body.step")
self.assertAlmostEqual(result["volume_mm3"], 1000 - 10 * 3.141592653589793, places=5)
def test_selector_dependent_extent_without_reference_is_preflight_blocked(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["features"][0]["params"]["end_condition"] = {"type": "up_to_surface"}
analysis = analyze_cdsl(cdsl)
result = analysis.feature_results[0]
self.assertIn("missing_extent_reference", [item.code for item in result.blockers])
def test_hole_wizard_unsupported_extent_is_preflight_blocked(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["features"].append({
"id": "hole", "atomic_id": "hole_wizard", "depends_on": ["base_add"],
"params": {
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 2,
"positions": [{"mm": [0, 0, 0]}], "host_face": {"kind": "face"},
"end_condition": {"type": "up_to_surface"},
},
})
analysis = analyze_cdsl(cdsl)
result = next(item for item in analysis.feature_results if item.feature_id == "hole")
self.assertIn("unsupported_hole_extent", [item.code for item in result.blockers])
def test_legacy_hole_atomics_have_the_same_host_and_shape_preflight_contract(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = self._base_block()
cdsl["features"].append({
"id": "hole", "atomic_id": "hole_counterbore", "depends_on": ["base_add"], "sketch_id": "base",
"params": {
"diameter_mm": 2, "depth_mm": 3, "positions": [{"mm": [0, 0, 0]}],
"counterbore_diameter_mm": 2, "counterbore_depth_mm": 1,
},
})
analysis = analyze_cdsl(cdsl)
result = next(item for item in analysis.feature_results if item.feature_id == "hole")
codes = {item.code for item in result.blockers}
self.assertIn("missing_host_face", codes)
self.assertIn("invalid_hole_spec", codes)
def test_body_mutation_without_a_preceding_solid_is_preflight_blocked(self) -> None:
from cdsl_engine.runtime import analyze_cdsl
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "no-body",
"meta": {"unit": "mm"}, "geometry": {"sketches": []},
"features": [{
"id": "hole", "atomic_id": "hole_wizard", "depends_on": [],
"params": {
"hole_type": "plain", "diameter_mm": 2, "depth_mm": 3,
"positions": [{"mm": [0, 0, 0]}],
"host_face": {"frame": _workplane()},
},
}],
}
result = analyze_cdsl(cdsl).feature_results[0]
self.assertIn("missing_active_body", [item.code for item in result.blockers])
if __name__ == "__main__":
unittest.main()
+8 -2
View File
@@ -53,7 +53,7 @@ class ProfileSchemaTests(unittest.TestCase):
with self.assertRaisesRegex(ValueError, "features\\[0\\]\\.atomic_id"):
validate_cdsl(cdsl, self.engine)
def test_semantic_validator_accepts_deferred_features_but_runtime_rejects_them(self) -> None:
def test_semantic_validator_preserves_deferred_features_but_runtime_checks_current_capabilities(self) -> None:
cdsl = {
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "part_id": "deferred-fillet",
"meta": {"unit": "mm"},
@@ -68,7 +68,7 @@ class ProfileSchemaTests(unittest.TestCase):
}
result = self.engine.validate_semantic_cdsl(cdsl)
self.assertEqual(result["deferred_feature_ids"], ["f01"])
with self.assertRaisesRegex(ValueError, "deferred"):
with self.assertRaisesRegex(ValueError, "missing_selector"):
validate_cdsl(cdsl, self.engine)
def test_rejects_bare_hole_coordinate_arrays_before_rebuild(self) -> None:
@@ -105,6 +105,12 @@ class ProfileSchemaTests(unittest.TestCase):
with self.assertRaisesRegex(RuntimeError, "CDSL-only rebuild failed: unsupported atomic_id: extrude"):
self.engine.run_rebuild(cdsl, out_step)
def test_product_revision_path_uses_the_cdsl_only_entry_point(self) -> None:
source = (Path(__file__).resolve().parents[2] / "backend" / "app" / "services" / "engine_service.py").read_text(encoding="utf-8")
build_revision_source = source[source.index("def build_revision"):]
self.assertIn("engine.run_cdsl_only(cdsl_copy, step_path)", build_revision_source)
self.assertNotIn("engine.run_rebuild(cdsl_copy, step_path)", build_revision_source)
def test_all_official_samples_match_the_engine_schema(self) -> None:
samples = sorted(self.settings.library_root.glob("samples/**/model.cdsl.json"))
self.assertGreater(len(samples), 0)
+339
View File
@@ -0,0 +1,339 @@
# CDSL Output Engine 重建目标
## 1. 目的
本文定义 <code>json_to_cdsl/output</code> 批量语义 CDSL 的 engine 重建目标、架构边界和验收方式。目标不是让 runtime 对未知数据作猜测,而是:
1. 补齐当前 CDSL v1.1 已声明的原子 feature 执行能力;
2. 将可确定的语义草图和 feature history 可靠地重建为 STEP
3. 对输入数据缺失、selector 歧义或内核失败给出 feature 级诊断;
4. 将批量重建率作为可重复测量的工程指标。
本文是 [cdsl-output-engine-rebuild-plan.md](cdsl-output-engine-rebuild-plan.md) 的交付目标补充。前者记录现状与建议顺序;本文规定目标架构、阶段出口和成功判定。
数据快照日期:2026-08-21。<code>json_to_cdsl/output</code> 中的 JSON 是待处理数据,不作为本文件的指令来源。
## 2. 数据事实与范围
### 2.1 批量数据
| 项目 | 数量 |
| --- | ---: |
| CDSL 文件 | 998 |
| feature 总数 | 10,659 |
| 不含 <code>unresolved</code> feature 的零件 | 633 |
| 含至少一个 <code>unresolved</code> feature 的零件 | 365 |
| 含 selector 的零件 | 489 |
| face selector | 557 |
| edge selector | 2 |
当前 <code>engine_service.validate_cdsl</code> 会先因 <code>execution_status: "deferred"</code> 拒绝所有批量文件。这个状态是导出时的 runtime 能力快照,不能继续作为升级后 runtime 的唯一事实来源。
| Atomic ID | 数量 | 目标能力 |
| --- | ---: | --- |
| <code>reference_plane</code> | 4,324 | 上下文 feature |
| <code>extrude_cut_blind</code> | 1,554 | 扩展终止条件 |
| <code>extrude_add_blind</code> | 1,281 | 扩展终止条件 |
| <code>hole_wizard</code> | 1,235 | 规范化后执行 |
| <code>reference_axis</code> | 760 | 上下文 feature |
| <code>chamfer</code> | 550 | selector 驱动修饰 |
| <code>fillet</code> | 286 | selector 驱动修饰 |
| <code>revolve_add</code> | 187 | 统一 primary executor |
| <code>revolve_cut</code> | 170 | 统一 primary executor |
| <code>pattern_linear</code> | 167 | feature 重放阵列 |
| <code>pattern_mirror</code> | 98 | feature 重放镜像 |
| <code>extrude_add_two_sided</code> | 47 | 双向 extent |
草图 profile 只有四类:<code>analytic_contours</code> 3,785、<code>circle</code> 504、<code>circles</code> 260、<code>annulus</code> 87。<code>analytic_contours</code> 中实际出现 16,352 条线、3,632 条圆弧和 313 个圆;本批没有需要作为首期阻塞项的 B-spline。
常见终止条件是 <code>blind</code> 2,456、<code>through_all</code> 763、<code>through_all_and_blind</code> 226、<code>through_all_both</code> 72。selector 依赖的 <code>up_to_surface</code> 70、<code>through_next</code> 7、<code>up_to_vertex</code> 3、<code>offset_from_surface</code> 3 排在 topology 能力之后。
### 2.2 交付边界
本目标覆盖现有 CDSL schema 中的全部 16 个 feature atomic ID。当前已执行的 9 个 atomic 也必须纳入统一架构:
extrude_add_blind extrude_add_two_sided
extrude_cut_blind revolve_add
revolve_cut hole_blind
hole_countersink hole_counterbore
sphere_add
需新增或重构进 executor 框架的 7 个 atomic
reference_plane reference_axis
hole_wizard fillet
chamfer pattern_linear
pattern_mirror
<code>analytic_contours</code> 是必须补齐的 profile runtime。它必须产生 engine 中立的闭合 region,再由几何适配器生成面。
下列行为不属于精确重建:
- 未捕获的选择集不能通过“对所有边倒角或圆角”替代;
- selector 有多个候选时不能取第一个候选;
- B-spline 离散化不能标记为精确,除非输出近似误差;
- 不允许重新启用 <code>compiler_context</code> 或 legacy translator 作为 CDSL-only 成功回退。
## 3. 成功定义
每份批量报告必须记录下列独立状态:
semantic_valid CDSL 符合 schema、ID 和依赖顺序
runtime_eligible 所有 feature 当前可执行,且输入完整、无歧义
compiled 已形成可执行 feature plan
built 已成功输出 STEP
geometry_verified 与 source truth 的数值几何比对通过
topology_observed 面、边、顶点数量已记录,仅作诊断
<code>built</code> 不是 <code>geometry_verified</code> 的同义词。默认 strict 模式只有所有 feature 均 <code>runtime_eligible</code> 时才可标记为成功重建;调试模式可以输出部分 STEP,但必须标记为 <code>partial</code>。
数值验证至少比较 bounding box、volume、surface area 和 solid count。门槛应配置化,初始建议:bounding box 每个坐标绝对误差不超过 <code>0.01 mm</code>volume 与 area 相对误差不超过 <code>0.1%</code>solid count 必须相同。拓扑数量因 STEP/OCC 表达差异不是首期硬门槛。
## 4. 目标架构
目标 pipeline 的依赖方向如下:
Semantic CDSL
-> Semantic Validator
-> Capability Analyzer
-> Feature Planner
-> Execution Session -> Atomic Executor Registry -> Geometry Adapter -> build123d / OCC
<-> Topology and Context Registry
-> STEP + Build Report
建议在 <code>backend/engine/cdsl_engine</code> 内按以下职责拆分。文件名可调整,但依赖方向不得反转。
| 层 | 责任 | 不应承担的责任 |
| --- | --- | --- |
| <code>semantic_validation</code> | schema、ID、依赖顺序与自包含数据检查 | 判断当前内核是否支持 feature |
| <code>capabilities</code> | 根据 registry、参数和 selector 条件计算可执行性 | 生成或修改 CDSL |
| <code>sketch</code> | profile、闭环、outer/inner 分类,输出 <code>SketchRegion</code> | 直接修改实体 |
| <code>planning</code> | feature DAG、归一化、执行顺序和 plan diagnostics | 复制 build123d 对象或做布尔运算 |
| <code>runtime</code> | session、executor registry、结果与错误边界 | 解析原始 JSON 细节 |
| <code>topology</code> | context、body、face、edge 注册与 selector 解析 | 私自补全缺失选择集 |
| <code>adapters/build123d</code> | region 到 B-rep、布尔、修饰、STEP 导出 | 读取 CDSL schema 或决定依赖 |
| <code>batch_rebuild</code> | 批量调度、报告、truth 对比、回归基线 | 实现几何算法 |
### 4.1 核心接口
每个 feature 必须经由同一类接口运行:
class AtomicExecutor(Protocol):
atomic_id: str
def preflight(self, node: FeaturePlanNode,
session: ExecutionSession) -> CapabilityResult: ...
def execute(self, node: FeaturePlanNode,
session: ExecutionSession) -> FeatureResult: ...
class GeometryAdapter(Protocol):
def make_regions(self, regions: list[SketchRegion]) -> GeometryResult: ...
def extrude(self, body, regions, extent: ExtentSpec, mode: BooleanMode): ...
def revolve(self, body, regions, axis: AxisSpec,
angle_deg: float, mode: BooleanMode): ...
def hole_tool(self, spec: HoleSpec, starts, inward,
through_depth_mm: float): ...
def apply_fillet(self, body, edges, radius_mm: float): ...
def apply_chamfer(self, body, targets, spec: ChamferSpec): ...
def body_geometry(self, body) -> BodyGeometry: ...
def export(self, body, path: str): ...
<code>FeatureResult</code> 至少包含 <code>feature_id</code>、产生或修改的 body、context object、拓扑快照、可回放的执行定义和 feature 级诊断。任何 executor 都不得通过全局变量或 <code>BuildPart</code> 隐式上下文查找前序结果。
<code>HoleSpec</code> 是 runtime-neutral 的孔定义:包含孔径、深度、终止条件、位置以及可选 countersink/counterbore 尺寸,但不包含 OCC 对象或 host-face 推断。runtime 负责严格解析 host frame 和坐标,adapter 只将已解析的 <code>HoleSpec</code> 构造成切削工具。
runtime 对 B-rep 实体保持 opaque:包围盒、体积、STEP 导出及所有内核向量转换都只能经 adapter 返回;runtime 不得 import 或读取 build123d/OCC 对象属性。
### 4.2 现有代码的迁移约束
现有 <code>sketch_solver.py -> llm_compiler.py -> llm_engine.py</code> 流程可以逐步迁移,但必须保持单一路径:
- <code>sketch_solver.py</code> 输出中立 region/curve 数据,不能让 profile generator 拥有实体执行逻辑;
- <code>llm_compiler.py</code> 只构建 feature plan。当前 <code>pattern_linear</code> 的坐标偏移克隆逻辑必须迁出 compiler,避免 pattern、selector 和 host face 语义被扁平化;
- <code>llm_engine.py</code> 的大分支改为 executor registrybuild123d import 只保留在 adapter 层;
- <code>engine_service.validate_cdsl</code> 只在 atomic contract 声明 <code>requires_sketch: true</code> 时强制 <code>sketch_id</code>。reference、pattern、dress-up 与 Hole Wizard 都是合法的非草图 feature
- <code>profile_schema.json</code>、<code>cdsl_schema.json</code>、executor registry 和测试必须由同一 capability 声明校验,避免维护多个手写 supported set。
### 4.3 <code>execution_status</code> 的兼容策略
保留 CDSL 中的 <code>execution_status</code>,用于说明导出时的能力快照;不再仅因其为 <code>deferred</code> 而拒绝升级后的 runtime。实际执行前由 <code>CapabilityAnalyzer</code> 为每个 feature 产生:
declared_status CDSL 中的 execution_status
resolved_status executable | blocked | unsupported
required_capabilities 原子、profile、selector、extent 能力
blockers 精确的缺参、歧义或内核前置条件
旧输出因此可以在 engine 升级后直接重跑;converter 重跑仍应更新导出状态,但不是重建前置条件。
## 5. 原子能力完成合同
### 5.1 Reference geometry
<code>reference_plane</code> 和 <code>reference_axis</code> 不生成实体,但必须作为正式 <code>FeatureResult</code> 写入 context registry,用于后续 workplane、revolve axis、mirror plane、pattern direction 和 hole host frame。
要求:
- 使用 canonical <code>PlaneSpec</code> 与 <code>AxisSpec</code>
- 校验零长度向量,正交化并记录修正后的坐标系;
- 支持由显式参数、前序 reference、feature 或 sketch 推导;
- 无法恢复的朝向缺失返回 <code>blocked: missing_reference_orientation</code>,不得默认 XY
- context feature 不修改 body,但可作为后续 feature 的依赖节点。
### 5.2 Analytic contour runtime
首期支持 <code>line</code>、<code>arc</code>、<code>circle</code>。固定流程为:二维 segment 归一化、端点容差拼接、闭环验证、workplane 映射、outer/inner 分类、生成 <code>SketchRegion</code>。一个 region 可以有一个 outer loop 和多个 hole loop。
必须诊断端点反转、退化边、自交、开放 loop、非共面输入,以及无法分类的 <code>unknown</code> contour。circle 是独立 loop,不能用零长度线模拟;嵌套环按奇偶包含关系分类。
对于四段等半径、90 度圆角组成的闭环,若导出逐段 <code>clockwise</code> 标记互相矛盾,runtime 可以依据闭环有向面积统一其短圆角方向;这是一种可证明的 rounded-rectangle 归一化。两段半圆或任意长圆弧无法仅由该规则恢复 sweep intent,必须保留原始证据或在 truth 验证中报告不匹配,不能根据目标 STEP 猜测方向。
B-spline 是后续扩展。采用采样近似时必须声明 chord tolerance 和最大偏差,且 <code>geometry_verified</code> 需使用对应容差策略。
### 5.3 Extrude 与 revolve
现有 add/cut/revolve atomic 保持原 ID,内部统一归一化为 <code>BooleanMode</code>、<code>ExtentSpec</code> 和 <code>AxisSpec</code>。执行器先生成 region 面,再调用 adapter;不得根据“草图中有圆”改变 feature 语义。
| 层次 | 终止条件 | 规则 |
| --- | --- | --- |
| A | <code>blind</code>、<code>mid_plane</code>、<code>through_all</code>、<code>through_all_both</code>、<code>through_all_and_blind</code> | 使用当前 body 的精确包围范围与 margin 计算 extent;双向值保持独立 |
| B | <code>up_to_surface</code>、<code>up_to_vertex</code>、<code>offset_from_surface</code>、<code>through_next</code>、<code>up_to_body</code> | 先通过 selector resolver 得到唯一目标,再由 adapter 做射线或相交查询;<code>up_to_body</code> 使用 active B-rep 的 body record,不允许引用失效快照 |
<code>extrude_add_two_sided</code> 必须保留正反两侧的距离与终止条件,不能简化为单个对称距离;每一侧都要独立解析 blind、through 或 selector-dependent end condition。revolve axis 可以来自显式 <code>AxisSpec</code> 或 owner-qualified 的 <code>reference_axis</code> selector;无 owner 的 source stable ID 不能被当作 OCC 轴。axis 与 profile 的退化相交必须在 preflight 阶段诊断。
### 5.4 Hole Wizard
<code>hole_wizard</code> 是独立 atomic,不在 compiler 中改写成匿名多个 hole step。执行器先解析为中立 <code>HoleSpec</code>,再选择 blind/countersink/counterbore/tapped 子型并调用 hole adapter。plan 和报告保留原 <code>feature_id</code> 与 <code>atomic_id</code>。
要求:
- 由 host face selector 或明确 workplane 得到唯一孔位 frame
- 支持 <code>blind</code>、<code>through_all</code>、<code>through_all_both</code> 与 countersink/counterbore
- 将 SolidWorks 位置坐标转换为 host frame,不能将局部坐标当世界坐标;
- thread、非标准钻尖或不支持孔型返回 <code>unsupported_hole_subtype</code>,不能静默退化为普通圆柱孔;
- 源 STEP 的 <code>stable_id</code> 只是线索,host face 必须在重建中间体上重新解析。
### 5.5 Selector 与 topology registry
selector 是 dress-up、Hole Wizard、pattern 和 selector-dependent extent 的共同前置能力。每完成一个 feature,registry 记录:
feature_id, parent feature ids, body id, context objects,
generated/owned faces and edges, bbox, center, area, normal,
surface or curve type, adjacency signature, local feature signature
每次实体变更都产生新的 active B-rep snapshot,旧 OCC 对象不能继续用于 selector。若且唯若新旧拓扑项存在唯一、完整且几何等价的签名匹配(包括 bbox、中心、面积/长度、朝向与邻接签名),才可将其 durable owner provenance 继承到新对象;分裂、合并、修改或多候选匹配不得继承 owner。这样 <code>owner_feature_id</code> 仍可约束当前实体上的有效对象,而不会把所有存活拓扑误标为最后一个变更 feature。
解析顺序固定为:<code>owner_feature_id</code> 限定候选集,显式 kind 限定面/边/轴,再以几何签名评分,最后应用置信度和唯一性阈值。0 个候选报 <code>selector_not_found</code>,多个同分候选报 <code>selector_ambiguous</code>。两种情况都不能继续 strict rebuild。
原始 <code>stable_id</code> 不能被当作 OCC 持久名称。解析器必须输出候选、评分和最终原因,而不是隐藏地选择拓扑对象。
### 5.6 Fillet 与 chamfer
实现顺序为:edge selector、face selector 展开到边、单距离 chamfer、双距离或角度 chamfer、tangent propagation。tangent propagation 只能从已解析 edge 出发,沿当前 B-rep 中共享顶点且切向连续的 edge chain 扩展,不能退化为全局边集合。每次 OCC 修饰后都重新扫描 registry,因为此前 B-rep 对象可能失效。
不得提供“没有 selector 时对全部边应用”的回退。当前 550 个 <code>chamfer</code> 与 286 个 <code>fillet</code> feature 均没有 selector;这不是仅新增 runtime 函数就能解决的问题。它们必须由 converter/exporter 提供选择集,或由针对 source truth 的独立 selector enrichment pass 产生唯一、可审计的 selector,才可进入严格重建池。
### 5.7 Feature-level pattern
<code>pattern_linear</code> 和 <code>pattern_mirror</code> 重放 source feature 的语义执行定义,不复制完整 body,也不在 compiler 中仅平移三维坐标。每个实例有独立 transform scope,并通过相同 atomic executor 在当前 body 上执行。
- linear pattern 支持一维和二维 direction/spacing/count,并保留 direction reverse
- mirror pattern 使用 canonical plane,支持显式 plane 或 registry reference
- source feature 必须已成功执行,且其 selector/reference 经 transform 后仍可唯一解析;显式 <code>host_face.frame</code> 与局部孔位是可直接变换的坐标 contract,未显式 frame 的 host selector、dress-up selector 和 B 层终止 selector 在具备完整 transform contract 前必须阻断;
- 无 source feature、不可变换 selector 或布尔失败时,整个 pattern 阻断并给出实例级错误。
## 6. 数据补全责任
engine 负责执行确定的 CDSL,不负责虚构 source intent。下列数据问题必须由 exporter/converter 或可审计 enrichment pass 解决:
| 数据缺口 | 当前数量 | 所需动作 |
| --- | ---: | --- |
| Hole Wizard 缺少 semantic selections | 761 | 捕获 host face、placement frame 和位置关联 |
| 缺 source sketch parent | 153 | 记录草图 parent feature/reference plane |
| reference plane 朝向未捕获 | 21 | 导出 origin、normal、x direction 或稳定派生关系 |
| hole 直径或深度缺失 | 3 | 捕获原始尺寸及单位 |
| revolve 缺 selection 或 axis | 2 | 捕获 revolve axis/reference |
| fillet 尺寸无效 | 1 | 导出有效 radius/distance |
| chamfer 缺 semantic selections | 1 | 捕获 edge/face 选择集 |
部分 Hole Wizard 虽有 <code>host_face</code>,但其来自 source STEP 推断。enrichment pass 必须将其转换为可解析 selector contract,并在置信度不足时保留 <code>unresolved</code>。同一规则适用于 fillet/chamfer,不能为了提高覆盖率创造不稳定 ID。
## 7. 分阶段交付与覆盖目标
批量覆盖数字是静态“输入就绪池”,不是尚未实现内核下的通过率承诺。所有数字均要求无 <code>unresolved</code>、profile 限于本批四类、<code>analytic_contours</code> 仅含 line/arc/circle,且终止条件在当前阶段已支持。
| 阶段 | 交付物 | 阶段出口 | 静态就绪池 |
| --- | --- | --- | ---: |
| P0 | <code>batch_rebuild</code>、基线 manifest、feature 级报告 | 998 个输入均有机器可读结果;可复现首个阻断 feature | 998 |
| P1 | capability analyzer、execution session、reference plane/axis、校验修正 | 非草图 atomic 可规划;旧 <code>deferred</code> 不再是唯一阻断理由 | - |
| P2 | <code>analytic_contours</code> region resolver | line/arc/circle、洞与闭环测试通过 | - |
| P3 | unified extrude/revolve 与 A 层终止条件 | 基础实体池全部通过 runtime preflight,并以 truth 运行回归 | 266 current / 271 historical estimate |
| P4 | topology/selector registry 与 Hole Wizard | 有 position、唯一 host face 的孔可严格执行 | 309 current / 312 historical estimate |
| P5 | fillet/chamfer 和 selector enrichment 闭环 | 只接受唯一 selector;所有缺 selector 输入明确阻断 | 取决于 enrichment |
| P6 | linear/mirror feature replay | source feature、context、transform 和实例诊断完整 | 341 current / 343 historical estimate |
| P7 | B 层终止条件、剩余数据补全和回归收敛 | 633 个无 unresolved 零件按实际 feature 组合进入全量目标池 | 633 |
P3 的 271、P4 的 312 与 P6 的 343 都是初始导出统计的历史估计。按当前 998 份 CDSL 的严格闭合-region contract 复算后,P3/P4/P6 静态池分别为 266/309/341:差额来自仅 reference history 或 consumed profile 无闭合 region,不能由 runtime 补齐。P3 的 266 件中另有 4 件缺 captured extent reference 或 revolve axis,因而当前 strict runtime-eligible 为 262P4/P6 当前 strict runtime-eligible 分别为 277/290,主要阻断是 threaded Hole、缺 active body/reference 及不可变换的 selector。它们不是简单相加。
运行时以 `phase_pools.select_static_phase_pool` 固化 P3/P4/P6 输入定义:无 `unresolved`、仅该阶段允许的 atomic、至少一个 primary feature,且所有 consumed sketch 都是四种可解析 profile 的闭合 region。静态池不代表每个 feature 的 selector/reference 已完整捕获;当前缺 axis、extent reference 或 history 中无 active body 的文件必须在 strict preflight 报具体 blocker,不能被计入 runtime-eligible。
剩余 365 个带 <code>unresolved</code> 的零件不能仅靠补 engine atomic 达到严格重建。P7 的目标是把每个零件归入“可通过数据补全解锁”或“当前证据不足”,而不是报告模糊失败。
## 8. 批量基准与 CI 门槛
新增单一入口,例如 <code>backend/engine/cdsl_engine/batch_rebuild.py</code>,接收 CDSL 目录、truth 目录和输出目录。每次运行至少产生:
manifest.json
parts/<part_id>.report.json
parts/<part_id>.step
summary-by-atomic.json
summary-by-blocker.json
每份报告包含:
part_id, cdsl_path, semantic_valid, runtime_eligible, compiled, built,
geometry_verified, topology_observed, first_blocker, feature_results,
unsupported_atomic_ids, unsupported_profile_types, unresolved_input,
selector_resolution, numeric_comparison, timings
报告还必须输出稳定的 <code>failure_category</code><code>input_incomplete</code>、<code>selector_resolution</code>、<code>unsupported_capability</code>、<code>occ_execution_failure</code>、<code>geometry_mismatch</code> 或 <code>coordinate_frame_mismatch_candidate</code><code>geometry_verified</code> 是唯一成功类别。未请求 <code>--build</code> 但已通过预检的报告标为 <code>runtime_eligible_not_built</code>,它是非终态,不是失败或 verified。该分类只归纳已有证据,不能将构建完成或坐标框候选计为 verified。
<code>numeric_comparison</code> 必须保留严格的 <code>passed</code> 判定,并把体积、面积、实体数及无序包围盒跨度均吻合、但绝对坐标框不吻合的情况标为 <code>coordinate_frame_mismatch_candidate</code>。该标记仅帮助定位 workplane/export frame 数据问题,不能替代 <code>geometry_verified</code>。
CI 分三层运行:
1. 单元与 contract 测试:每个 atomic executor、profile resolver、selector 歧义、extent 计算与 capability/schema 同步;
2. 小型集成 fixturereference -> sketch -> primary feature -> dress-up/pattern 跨 feature 路径;
3. 批量回归:保存按零件和 atomic 分组的基线,禁止已验证零件退化,新增通过必须附带数值比对。
每次新增 atomic 或终止条件,必须同时更新 schema contract、capability registry、executor、诊断、单元测试和至少一个批量 fixture。不得只把名称加入 <code>SUPPORTED_ATOMIC_IDS</code>。
### 8.1 当前 P3 严格基线
2026-08-23 已按当前静态 P3 pool 运行以下 CDSL-only 基线;每个零件在独立进程中构建,单件 timeout 为 15 秒:
PYTHONPATH=backend/engine python -m cdsl_engine.batch_rebuild \
json_to_cdsl/output --out /tmp/cdsl-p3-current-baseline \
--phase p3 --build --build-timeout 15
最终结果为 266/266 已完成、262 份 strict runtime eligible、253 份 built、57 份 geometry verified。其余终态分类为 147 份 <code>geometry_mismatch</code>、49 份 <code>coordinate_frame_mismatch_candidate</code>、6 份 <code>input_incomplete</code>、5 份 <code>occ_execution_failure</code>、2 份 <code>selector_resolution</code>。这是一份验收基线而非成功率承诺:坐标框候选和已构建 STEP 均没有并入 verified。
该命令和输出目录结构是可恢复的;CI 应将经过审查的报告摘要保存在持久化工件中,而不依赖本机 <code>/tmp</code> 的 STEP 临时文件。
同日使用相同的 CDSL-only、每件 15 秒隔离构建策略完成 P4 池:309/309 已完成、277 份 strict runtime eligible、258 份 built、59 份 geometry verified。其余终态为 150 份 <code>geometry_mismatch</code>、49 份 <code>coordinate_frame_mismatch_candidate</code>、8 份 <code>input_incomplete</code>、5 份 <code>occ_execution_failure</code>、12 份 <code>selector_resolution</code>、26 份 <code>unsupported_capability</code>。P4 的 5 个 OCC 失败均已出现于 P3 基础主体能力池,未发现 Hole Wizard 新增的 OCC 失败。
P6 池也已使用同一策略完成:341/341 已完成、290 份 strict runtime eligible、268 份 built、60 份 geometry verified。其余终态为 159 份 <code>geometry_mismatch</code>、49 份 <code>coordinate_frame_mismatch_candidate</code>、9 份 <code>input_incomplete</code>、6 份 <code>occ_execution_failure</code>、14 份 <code>selector_resolution</code>、44 份 <code>unsupported_capability</code>。其中嵌套 pattern source 已按可回放 feature definition 递归执行;不能产生实体的 context source 会在 capability preflight 以 <code>unsupported_pattern_source</code> 阻断,不会再被归为 OCC 执行失败。
P7 的全量 capability 审计也已完成:998/998 份 CDSL 均有 machine-readable report,全部 semantic valid290 份 strict runtime eligible(未请求 <code>--build</code>,因此分类为 <code>runtime_eligible_not_built</code>)、612 份 <code>input_incomplete</code>、96 份 <code>unsupported_capability</code>。这证明所有当前输入均被审计和分类,但不将 preflight 通过等同于 STEP 构建或 geometry verified。
## 9. 完成判定
本目标完成需要同时满足:
1. schema 已声明的 16 个 feature atomic 都有 executor、preflight contract 和 feature 级诊断;
2. <code>analytic_contours</code> 的 line/arc/circle 可构成带洞 region,并由统一 adapter 执行;
3. 266 个当前基础静态就绪零件均已由 phase-pool 回归审计;其中 262 个在现有输入下通过 strict runtime preflight,剩余 4 个以缺 axis/reference 的 feature-level blocker 报告;当前 CDSL-only P3 基线为 253 built、57 geometry verified,且所有非 verified 结果已有严格终态分类;
4. Hole、dress-up、pattern 与 selector-dependent extent 不再依赖 compiler 内特例或全局 build123d 状态;
5. 每个无法严格重建的零件都能区分为输入缺失、selector 歧义、未支持能力或 OCC 执行失败;
6. 批量报告和 CI 基线持续追踪 998 个输入,且 CDSL-only 成功路径不调用 legacy translator。
这样,engine 的扩展单位是可独立测试、可替换内核、可追溯失败原因的原子能力,而不是为某一批零件增加临时分支。