优化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
+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