重构 改为标准化输出
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
"""CDSL runtime adapter driven exclusively by profile operation contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.operation_contract import (
|
||||
SEMANTIC_PREFLIGHT_NAMES,
|
||||
OperationContractError,
|
||||
canonical_hash,
|
||||
validate_fragment,
|
||||
validate_operation_contract,
|
||||
)
|
||||
from app.cad_agent.domain.verifier_registry import default_registry
|
||||
from app.services.engine_service import load_engine, topology_snapshot, validate_cdsl
|
||||
from app.services.review_renderer import ReviewRenderError, render_checkpoint
|
||||
from app.settings import Settings
|
||||
from vendor.cdsl_preview_runtime import step_to_glb
|
||||
|
||||
|
||||
class RuntimeAdapterError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ProfileCadRuntime:
|
||||
"""Adapter that owns engine imports; application code sees only its port."""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
self.engine = load_engine(settings)
|
||||
self._semantic_preflight_handlers = {
|
||||
"sketch_workplane": self._preflight_sketch_workplane,
|
||||
"profile_non_self_intersecting": self._preflight_profile_non_self_intersecting,
|
||||
"host_face_exists": self._preflight_host_face_exists,
|
||||
"hole_positions_on_host_plane": self._preflight_hole_positions_on_host_plane,
|
||||
"cut_exit_distance": self._preflight_cut_exit_distance,
|
||||
"requires_active_solid": self._preflight_requires_active_solid,
|
||||
"revolve_axis_on_sketch": self._preflight_revolve_axis_on_sketch,
|
||||
"reference_plane_nonzero_normal": self._preflight_reference_plane_nonzero_normal,
|
||||
"reference_axis_nonzero_direction": self._preflight_reference_axis_nonzero_direction,
|
||||
"selected_edges_exist": self._preflight_selected_edges_exist,
|
||||
"source_features_exist": self._preflight_source_features_exist,
|
||||
"mirror_plane_exists": self._preflight_mirror_plane_exists,
|
||||
}
|
||||
schema_path = Path(str(self.engine.__file__)).with_name("profile_schema.json")
|
||||
try:
|
||||
self._profile = json.loads(schema_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile schema is unavailable") from error
|
||||
self._contracts = self._profile.get("operation_contracts")
|
||||
if not isinstance(self._contracts, dict):
|
||||
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: profile has no v3 operation contracts")
|
||||
declared = {str(item) for item in self._contracts}
|
||||
registered = {str(item) for item in getattr(self.engine, "SUPPORTED_ATOMIC_IDS", ())}
|
||||
if declared != registered:
|
||||
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime and profile operation registries disagree")
|
||||
verifier_kinds = set(default_registry().claim_kinds)
|
||||
for contract in self._contracts.values():
|
||||
try:
|
||||
validate_operation_contract(contract)
|
||||
except OperationContractError as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error
|
||||
unknown_preflights = set(contract["semantic_preflight"]) - set(self._semantic_preflight_handlers)
|
||||
unknown_verifiers = set(contract["candidate_verifiers"]) - verifier_kinds
|
||||
if unknown_preflights or unknown_verifiers:
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_CONTRACT_INVALID: operation contract references an unavailable "
|
||||
f"{'preflight' if unknown_preflights else 'candidate verifier'}"
|
||||
)
|
||||
if set(self._semantic_preflight_handlers) != SEMANTIC_PREFLIGHT_NAMES:
|
||||
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: runtime preflight registry is incomplete")
|
||||
|
||||
def supported_atomic_ids(self) -> tuple[str, ...]:
|
||||
return tuple(sorted(self._contracts))
|
||||
|
||||
def operation_contract(self, atomic_id: str) -> dict[str, Any]:
|
||||
contract = self._contracts.get(atomic_id)
|
||||
if not isinstance(contract, dict):
|
||||
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: unknown operation {atomic_id}")
|
||||
result = deepcopy(contract)
|
||||
result["contract_hash"] = canonical_hash(contract)
|
||||
result["registry_revision"] = str(self._profile.get("schema_version") or "")
|
||||
return result
|
||||
|
||||
def selector_tokens(self, topology: dict[str, Any] | None) -> dict[str, dict[str, Any]]:
|
||||
if not isinstance(topology, dict):
|
||||
return {}
|
||||
snapshot_id = str(topology.get("snapshot_id") or "")
|
||||
if not snapshot_id:
|
||||
return {}
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for record in topology.get("records") or ():
|
||||
if not isinstance(record, dict) or not record.get("executable"):
|
||||
continue
|
||||
record_id = str(record.get("record_id") or "")
|
||||
kind = str(record.get("kind") or "")
|
||||
if not record_id or kind not in {"face", "edge", "plane", "axis", "body", "vertex"}:
|
||||
continue
|
||||
token = "sel_" + sha256(f"{snapshot_id}|{record_id}".encode("utf-8")).hexdigest()[:16]
|
||||
geometry = deepcopy(record.get("geometry") or {})
|
||||
owners = record.get("owner_feature_ids") or [record.get("feature_id") or ""]
|
||||
result[token] = {
|
||||
"token": token,
|
||||
"kind": kind,
|
||||
"snapshot_id": snapshot_id,
|
||||
"selector": {"kind": kind, "stable_id": record_id, "owner_feature_id": str(owners[0] or ""), "geometry": geometry, "source": "runtime_snapshot", "snapshot_id": snapshot_id, "confidence": 1.0},
|
||||
"geometry": geometry,
|
||||
}
|
||||
return result
|
||||
|
||||
def reference_tokens(self, cdsl: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""Return opaque, head-scoped feature references for pattern contracts."""
|
||||
result: dict[str, str] = {}
|
||||
document_hash = canonical_hash(cdsl) if isinstance(cdsl, dict) else "root"
|
||||
for feature in (cdsl or {}).get("features") or ():
|
||||
if not isinstance(feature, dict) or not isinstance(feature.get("id"), str) or not feature["id"]:
|
||||
continue
|
||||
feature_id = feature["id"]
|
||||
result["ref_" + sha256(f"{document_hash}|{feature_id}".encode("utf-8")).hexdigest()[:16]] = feature_id
|
||||
return result
|
||||
|
||||
def materialize_fragment(self, base_cdsl: dict[str, Any] | None, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], reference_tokens: dict[str, str], *, require_through: bool = False) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
# ActionCommandHandler validates the exposed schema first, but this
|
||||
# adapter is also used during crash recovery. Keep the runtime boundary
|
||||
# self-contained so a corrupted/replayed staged payload cannot produce
|
||||
# a candidate merely by bypassing that handler-level validation.
|
||||
try:
|
||||
validate_operation_contract(contract)
|
||||
except OperationContractError as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_CONTRACT_INVALID: {error}") from error
|
||||
current = self.operation_contract(str(contract.get("atomic_id") or ""))
|
||||
if (
|
||||
contract.get("contract_hash") != current["contract_hash"]
|
||||
or contract.get("registry_revision") != current["registry_revision"]
|
||||
):
|
||||
raise RuntimeAdapterError("RUNTIME_CONTRACT_INVALID: operation contract is not the current verified registry entry")
|
||||
selector_shape = str((contract.get("fragment_shape") or {}).get("selector_tokens") or "forbidden")
|
||||
selector_kind = str((contract.get("selector_policy") or {}).get("token_kind") or "")
|
||||
allowed_selectors = [
|
||||
token
|
||||
for token, value in selector_tokens.items()
|
||||
if selector_shape == "required"
|
||||
and isinstance(value, dict)
|
||||
and value.get("kind") == selector_kind
|
||||
]
|
||||
errors = validate_fragment(
|
||||
contract,
|
||||
fragment,
|
||||
selector_tokens=allowed_selectors,
|
||||
reference_tokens=list(reference_tokens),
|
||||
)
|
||||
if errors:
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: fragment no longer matches the active operation schema: "
|
||||
+ errors[0]["message"]
|
||||
)
|
||||
materialized = deepcopy(fragment)
|
||||
reference = contract["reference_policy"]
|
||||
if reference["mode"] == "snapshot_bound":
|
||||
slot = str(reference["slot"]).removeprefix("params.")
|
||||
supplied = materialized.get("feature", {}).get("params", {}).get(slot, [])
|
||||
if not isinstance(supplied, list) or not all(token in reference_tokens for token in supplied):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference token is absent or stale")
|
||||
materialized["feature"]["params"][slot] = [reference_tokens[token] for token in supplied]
|
||||
self._semantic_preflight(materialized, contract, selector_tokens, base_cdsl, require_through=require_through)
|
||||
document = deepcopy(base_cdsl) if isinstance(base_cdsl, dict) else {
|
||||
"schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "agent_preflight", "geometry": {"sketches": []}, "features": [],
|
||||
}
|
||||
geometry = document.setdefault("geometry", {})
|
||||
sketches = geometry.setdefault("sketches", []) if isinstance(geometry, dict) else None
|
||||
features = document.setdefault("features", [])
|
||||
if not isinstance(sketches, list) or not isinstance(features, list):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: base CDSL collections are invalid")
|
||||
index = len(features) + 1
|
||||
feature = materialized["feature"]
|
||||
output = {"id": f"feature_{index:03d}", "atomic_id": contract["atomic_id"], "params": deepcopy(feature["params"]), "depends_on": [str(features[-1].get("id"))] if features else []}
|
||||
if contract["fragment_shape"]["sketch"] == "required":
|
||||
sketch_id = f"sketch_{len(sketches) + 1:03d}"
|
||||
sketch = materialized["sketch"]
|
||||
sketches.append({"id": sketch_id, "workplane": deepcopy(sketch["workplane"]), "profile": deepcopy(sketch["profile"])})
|
||||
output["sketch_id"] = sketch_id
|
||||
selected = [selector_tokens[token]["selector"] for token in feature.get("selector_tokens", [])]
|
||||
slot = contract["selector_policy"]["slot"]
|
||||
if slot == "params.host_face":
|
||||
# A topology selector is authoritative while accepting the
|
||||
# action, but a later pattern replays this feature after its own
|
||||
# cut may have split the selected B-rep face. Lower the accepted
|
||||
# planar selector to a concrete, world-aligned host frame and
|
||||
# transform the author-supplied world coordinates into that
|
||||
# frame. The token remains in the fragment audit for provenance;
|
||||
# the materialized CDSL is stable under replay.
|
||||
output["params"]["host_face"], output["params"]["positions"] = self._materialized_hole_host_frame(
|
||||
selector_tokens[str(feature["selector_tokens"][0])],
|
||||
output["params"].get("positions"),
|
||||
)
|
||||
elif slot == "params.mirror_plane":
|
||||
output["params"]["mirror_plane"] = selected[0]
|
||||
elif slot == "feature.selectors":
|
||||
output["selectors"] = selected
|
||||
features.append(output)
|
||||
try:
|
||||
self._validate_finite_tree(document)
|
||||
self._validate_materialized_runtime_types(output)
|
||||
validate_cdsl(document, self.engine)
|
||||
except Exception as error:
|
||||
message = str(error)
|
||||
# A JSON-schema failure after server materialization is a registry
|
||||
# drift: the author could not have supplied the missing server
|
||||
# field. Engine capability analysis, however, can reject a
|
||||
# schema-valid author sketch (for example an unresolvable analytic
|
||||
# contour). That is a recoverable action precondition failure,
|
||||
# not a deployment defect.
|
||||
if "CDSL engine runtime preflight failed:" in message:
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: materialized fragment is not executable by the engine: "
|
||||
+ message
|
||||
) from error
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_CONTRACT_INVALID: materialized fragment violates the engine CDSL schema: "
|
||||
+ message
|
||||
) from error
|
||||
return document, {"schema_version": "cad.v3.fragment-audit.v1", "atomic_id": contract["atomic_id"], "fragment_hash": canonical_hash(fragment), "contract_hash": contract["contract_hash"], "assigned_feature_ids": [output["id"]], "assigned_sketch_ids": [output["sketch_id"]] if output.get("sketch_id") else [], "selector_snapshot_id": next(iter(selector_tokens.values()), {}).get("snapshot_id", ""), "selector_tokens": list(feature.get("selector_tokens", [])), "reference_snapshot_id": canonical_hash(reference_tokens), "reference_tokens": list(fragment.get("feature", {}).get("params", {}).get(str(reference.get("slot") or "").removeprefix("params."), [])) if reference["mode"] == "snapshot_bound" else []}
|
||||
|
||||
def rebuild(self, cdsl: dict[str, Any], output_dir: str, task_id: str, revision_id: str) -> dict[str, Any]:
|
||||
root = Path(output_dir)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
cdsl_copy = deepcopy(cdsl)
|
||||
cdsl_copy["part_id"] = task_id
|
||||
step_path = root / "model.step"
|
||||
glb_path = root / "model.glb"
|
||||
self._write_json(root / "model.cdsl.json", cdsl_copy)
|
||||
try:
|
||||
self._validate_finite_tree(cdsl_copy)
|
||||
validate_cdsl(cdsl_copy, self.engine)
|
||||
except RuntimeAdapterError:
|
||||
raise
|
||||
except Exception as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: persisted CDSL failed rebuild preflight: {error}") from error
|
||||
try:
|
||||
engine_result = self.engine.run_cdsl_only(cdsl_copy, step_path)
|
||||
if str(engine_result.get("engine") or "") != "cdsl_only":
|
||||
raise RuntimeAdapterError("engine did not execute CDSL-only rebuild")
|
||||
preview = step_to_glb(step_path, glb_path)
|
||||
health = self._health(engine_result, step_path, glb_path)
|
||||
topology = topology_snapshot(engine_result, task_id=task_id, revision_id=revision_id, preview=preview)
|
||||
self._write_json(root / "model.topology.json", topology)
|
||||
manifest = render_checkpoint(self.settings, step_path=step_path, output_dir=root / "renders")
|
||||
report = {"engine_result": engine_result, "preview": preview, "health": health, "render_manifest": manifest}
|
||||
self._write_json(root / "rebuild-report.json", report)
|
||||
return {"health": health, "topology": topology, "report": report, "render_manifest": manifest, "paths": {"cdsl": "model.cdsl.json", "step": "model.step", "glb": "model.glb", "topology": "model.topology.json", "report": "rebuild-report.json", "render_manifest": "renders/render-manifest.json"}}
|
||||
except OSError:
|
||||
# Artifact writes are a recoverable infrastructure outage. Let the
|
||||
# application handler park the same candidate stage for replay.
|
||||
raise
|
||||
except ReviewRenderError as error:
|
||||
raise RuntimeAdapterError(f"RENDER_SERVICE_UNAVAILABLE: {error}") from error
|
||||
except Exception as error:
|
||||
raise RuntimeAdapterError(f"RUNTIME_EXECUTION_FAILURE: {error}") from error
|
||||
|
||||
def _semantic_preflight(self, fragment: dict[str, Any], contract: dict[str, Any], selector_tokens: dict[str, dict[str, Any]], base_cdsl: dict[str, Any] | None, *, require_through: bool) -> None:
|
||||
if fragment.get("feature", {}).get("atomic_id") != contract.get("atomic_id"):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: atomic_id does not match active contract")
|
||||
policy = contract["selector_policy"]
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if contract["fragment_shape"]["selector_tokens"] == "required":
|
||||
if not all(token in selector_tokens and selector_tokens[token]["kind"] == policy["token_kind"] for token in supplied):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selector token is absent, stale, or has the wrong kind")
|
||||
for name in contract["semantic_preflight"]:
|
||||
self._semantic_preflight_handlers[name](fragment, selector_tokens, base_cdsl, require_through)
|
||||
|
||||
def _preflight_sketch_workplane(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
sketch = fragment.get("sketch")
|
||||
workplane = sketch.get("workplane") if isinstance(sketch, dict) else None
|
||||
if not isinstance(workplane, dict):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane is unavailable")
|
||||
normal, x_dir = workplane.get("normal"), workplane.get("x_dir")
|
||||
if not isinstance(normal, list) or not isinstance(x_dir, list) or self._norm(normal) <= 1e-9 or self._norm(x_dir) <= 1e-9 or self._norm(self._cross(normal, x_dir)) <= 1e-9:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch workplane vectors are degenerate")
|
||||
|
||||
def _preflight_profile_non_self_intersecting(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
sketch = fragment.get("sketch")
|
||||
profile = sketch.get("profile") if isinstance(sketch, dict) else None
|
||||
if not isinstance(profile, dict):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: sketch profile is unavailable")
|
||||
if profile.get("type") == "polygon":
|
||||
vertices = profile.get("vertices")
|
||||
if not isinstance(vertices, list) or len(vertices) < 3 or self._polygon_self_intersects(vertices):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: polygon profile self-intersects")
|
||||
elif profile.get("type") == "analytic_contours":
|
||||
self._preflight_analytic_contours(profile)
|
||||
|
||||
def _preflight_host_face_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "face":
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face is absent or stale")
|
||||
|
||||
def _materialized_hole_host_frame(
|
||||
self,
|
||||
selected: dict[str, Any],
|
||||
positions: Any,
|
||||
) -> tuple[dict[str, Any], list[dict[str, list[float]]]]:
|
||||
"""Lower a verified planar host selector into replay-stable CDSL data.
|
||||
|
||||
Hole position inputs are world coordinates at the author boundary.
|
||||
The engine's frame form uses local coordinates, so both values must
|
||||
be converted together. Keeping only the selector makes a later
|
||||
pattern replay depend on a face that the source cut has already
|
||||
subdivided, which is neither stable nor geometrically meaningful.
|
||||
"""
|
||||
geometry = selected.get("geometry") if isinstance(selected, dict) else None
|
||||
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||
normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||
if not self._valid_vector3(center) or not self._valid_vector3(normal, require_nonzero=True):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face has no usable plane frame")
|
||||
if not isinstance(positions, list) or not positions:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are unavailable for host-frame materialization")
|
||||
origin = [float(value) for value in center]
|
||||
unit_normal = [float(value) / self._norm(normal) for value in normal]
|
||||
seed = [1.0, 0.0, 0.0] if abs(unit_normal[0]) < 0.9 else [0.0, 1.0, 0.0]
|
||||
x_raw = [seed[index] - self._dot(seed, unit_normal) * unit_normal[index] for index in range(3)]
|
||||
x_length = self._norm(x_raw)
|
||||
if x_length <= 1e-9:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected host face cannot define a stable x direction")
|
||||
x_dir = [value / x_length for value in x_raw]
|
||||
y_dir = self._cross(unit_normal, x_dir)
|
||||
local_positions: list[dict[str, list[float]]] = []
|
||||
for position in positions:
|
||||
point = position.get("mm") if isinstance(position, dict) else None
|
||||
if not self._valid_vector3(point):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point")
|
||||
offset = [float(point[index]) - origin[index] for index in range(3)]
|
||||
local_positions.append({"mm": [
|
||||
self._dot(offset, x_dir),
|
||||
self._dot(offset, y_dir),
|
||||
self._dot(offset, unit_normal),
|
||||
]})
|
||||
return (
|
||||
{"frame": {"origin_mm": origin, "x_dir": x_dir, "y_dir": y_dir, "normal": unit_normal}},
|
||||
local_positions,
|
||||
)
|
||||
|
||||
def _preflight_hole_positions_on_host_plane(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
positions = fragment.get("feature", {}).get("params", {}).get("positions")
|
||||
if not isinstance(positions, list) or not positions or not all(isinstance(item, dict) and isinstance(item.get("mm"), list) and len(item["mm"]) == 3 for item in positions):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole positions are invalid for the host plane")
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
host = selectors.get(supplied[0]) if len(supplied) == 1 else None
|
||||
geometry = host.get("geometry") if isinstance(host, dict) else None
|
||||
normal = geometry.get("normal") if isinstance(geometry, dict) else None
|
||||
center = geometry.get("center_mm") if isinstance(geometry, dict) else None
|
||||
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
|
||||
if not isinstance(geometry, dict) or geometry.get("surface_type") != "plane" or not self._valid_vector3(normal, require_nonzero=True) or not self._valid_vector3(center):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole host must be an observable planar face")
|
||||
unit_normal = [float(component) / self._norm(normal) for component in normal]
|
||||
tolerance = 1e-5
|
||||
if not self._valid_bbox(bbox):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: host face has no usable topology bounds")
|
||||
for position in positions:
|
||||
point = position["mm"]
|
||||
if not self._valid_vector3(point):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not a finite 3D point")
|
||||
offset = [float(point[index]) - float(center[index]) for index in range(3)]
|
||||
if abs(self._dot(offset, unit_normal)) > tolerance:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is not on the selected host plane")
|
||||
if any(float(point[index]) < float(bbox[index]) - tolerance or float(point[index]) > float(bbox[index + 3]) + tolerance for index in range(3)):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host-face bounds")
|
||||
boundary_loops = geometry.get("boundary_loops_mm")
|
||||
if isinstance(boundary_loops, list) and boundary_loops and not self._point_in_planar_face(point, boundary_loops, unit_normal, tolerance):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: hole position is outside the selected host face's effective boundary")
|
||||
|
||||
def _preflight_analytic_contours(self, profile: dict[str, Any]) -> None:
|
||||
contours = profile.get("contours")
|
||||
if not isinstance(contours, list) or not contours:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: analytic profile requires at least one contour")
|
||||
tolerance = 1e-7
|
||||
for contour_index, contour in enumerate(contours):
|
||||
segments = contour.get("segments") if isinstance(contour, dict) else None
|
||||
if not isinstance(segments, list) or not segments:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is empty")
|
||||
if any(segment.get("type") == "circle" for segment in segments if isinstance(segment, dict)):
|
||||
if len(segments) != 1 or segments[0].get("type") != "circle":
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} cannot mix a full circle with other segments")
|
||||
continue
|
||||
endpoints: list[tuple[tuple[float, float], tuple[float, float]]] = []
|
||||
for segment_index, segment in enumerate(segments):
|
||||
if not isinstance(segment, dict) or segment.get("type") not in {"line", "arc"}:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an unsupported segment")
|
||||
start = self._point2(segment.get("start"))
|
||||
end = self._point2(segment.get("end"))
|
||||
if start is None or end is None:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} has non-finite endpoints")
|
||||
if self._distance2(start, end) <= tolerance:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} segment {segment_index} is degenerate")
|
||||
if segment.get("type") == "arc":
|
||||
center = self._point2(segment.get("center"))
|
||||
radius = segment.get("radius_mm")
|
||||
if center is None or not isinstance(radius, (int, float)) or isinstance(radius, bool) or not math.isfinite(float(radius)) or float(radius) <= 0:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} has an invalid circle")
|
||||
arc_tolerance = max(tolerance, float(radius) * 1e-7)
|
||||
if abs(self._distance2(start, center) - float(radius)) > arc_tolerance or abs(self._distance2(end, center) - float(radius)) > arc_tolerance:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} arc {segment_index} endpoints are not on the declared circle")
|
||||
endpoints.append((start, end))
|
||||
for segment_index in range(1, len(endpoints)):
|
||||
if self._distance2(endpoints[segment_index - 1][1], endpoints[segment_index][0]) > tolerance:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is discontinuous before segment {segment_index}")
|
||||
if contour.get("closed") is True and self._distance2(endpoints[-1][1], endpoints[0][0]) > tolerance:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} is not closed")
|
||||
line_segments = [segment for segment, raw in zip(endpoints, segments) if raw.get("type") == "line"]
|
||||
for first, (a, b) in enumerate(line_segments):
|
||||
for second, (c, d) in enumerate(line_segments):
|
||||
if second <= first + 1 or (first == 0 and second == len(line_segments) - 1 and contour.get("closed") is True):
|
||||
continue
|
||||
if self._segments_intersect(a, b, c, d):
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: analytic contour {contour_index} has an obvious self-intersection")
|
||||
|
||||
@classmethod
|
||||
def _validate_materialized_runtime_types(cls, feature: dict[str, Any]) -> None:
|
||||
atomic_id = str(feature.get("atomic_id") or "")
|
||||
if not atomic_id.startswith("hole_"):
|
||||
return
|
||||
params = feature.get("params") if isinstance(feature.get("params"), dict) else {}
|
||||
host = params.get("host_face") if isinstance(params.get("host_face"), dict) else None
|
||||
frame = host.get("frame") if isinstance(host, dict) and isinstance(host.get("frame"), dict) else None
|
||||
if not isinstance(frame, dict):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame is unavailable")
|
||||
for field in ("origin_mm", "x_dir", "y_dir", "normal"):
|
||||
if not cls._valid_vector3(frame.get(field), require_nonzero=field != "origin_mm"):
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole host_face.frame.{field} is invalid")
|
||||
x_dir, y_dir, normal = frame["x_dir"], frame["y_dir"], frame["normal"]
|
||||
if abs(cls._dot(x_dir, y_dir)) > 1e-6 or abs(cls._dot(x_dir, normal)) > 1e-6 or abs(cls._dot(y_dir, normal)) > 1e-6:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole host frame is not orthogonal")
|
||||
positions = params.get("positions")
|
||||
if not isinstance(positions, list) or not positions:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: materialized hole positions are unavailable")
|
||||
for index, position in enumerate(positions):
|
||||
point = position.get("mm") if isinstance(position, dict) else None
|
||||
if not cls._valid_vector3(point) or abs(float(point[2])) > 1e-5:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: materialized hole position {index} is invalid in the host frame")
|
||||
|
||||
@staticmethod
|
||||
def _validate_finite_tree(value: Any, path: str = "$") -> None:
|
||||
if value is None:
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must not be null")
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
raise RuntimeAdapterError(f"RUNTIME_PRECONDITION_FAILED: {path} must be finite")
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
ProfileCadRuntime._validate_finite_tree(child, f"{path}.{key}")
|
||||
elif isinstance(value, list):
|
||||
for index, child in enumerate(value):
|
||||
ProfileCadRuntime._validate_finite_tree(child, f"{path}[{index}]")
|
||||
|
||||
@staticmethod
|
||||
def _point2(value: Any) -> tuple[float, float] | None:
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
if not all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value):
|
||||
return None
|
||||
return float(value[0]), float(value[1])
|
||||
|
||||
@staticmethod
|
||||
def _distance2(left: tuple[float, float], right: tuple[float, float]) -> float:
|
||||
return math.hypot(left[0] - right[0], left[1] - right[1])
|
||||
|
||||
@classmethod
|
||||
def _point_in_planar_face(cls, point: list[float], loops: list[Any], normal: list[float], tolerance: float) -> bool:
|
||||
drop_axis = max(range(3), key=lambda index: abs(float(normal[index])))
|
||||
|
||||
def project(value: Any) -> tuple[float, float] | None:
|
||||
if not cls._valid_vector3(value):
|
||||
return None
|
||||
coordinates = [float(value[index]) for index in range(3) if index != drop_axis]
|
||||
return coordinates[0], coordinates[1]
|
||||
|
||||
projected_point = project(point)
|
||||
if projected_point is None:
|
||||
return False
|
||||
polygons: list[list[tuple[float, float]]] = []
|
||||
for loop in loops:
|
||||
polygon = [project(vertex) for vertex in loop] if isinstance(loop, list) else []
|
||||
if len(polygon) >= 3 and all(vertex is not None for vertex in polygon):
|
||||
polygons.append([vertex for vertex in polygon if vertex is not None])
|
||||
if not polygons:
|
||||
return False
|
||||
|
||||
def area(polygon: list[tuple[float, float]]) -> float:
|
||||
return abs(sum(
|
||||
polygon[index][0] * polygon[(index + 1) % len(polygon)][1]
|
||||
- polygon[(index + 1) % len(polygon)][0] * polygon[index][1]
|
||||
for index in range(len(polygon))
|
||||
)) / 2
|
||||
|
||||
def contains(polygon: list[tuple[float, float]]) -> bool:
|
||||
x, y = projected_point
|
||||
inside = False
|
||||
for index, first in enumerate(polygon):
|
||||
second = polygon[(index + 1) % len(polygon)]
|
||||
if cls._distance_to_segment_2d(projected_point, first, second) <= tolerance:
|
||||
return True
|
||||
if (first[1] > y) != (second[1] > y):
|
||||
crossing_x = (second[0] - first[0]) * (y - first[1]) / (second[1] - first[1]) + first[0]
|
||||
if x < crossing_x:
|
||||
inside = not inside
|
||||
return inside
|
||||
|
||||
outer = max(polygons, key=area)
|
||||
return contains(outer) and not any(contains(inner) for inner in polygons if inner is not outer)
|
||||
|
||||
@staticmethod
|
||||
def _distance_to_segment_2d(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float:
|
||||
dx, dy = end[0] - start[0], end[1] - start[1]
|
||||
length_squared = dx * dx + dy * dy
|
||||
if length_squared <= 1e-18:
|
||||
return math.hypot(point[0] - start[0], point[1] - start[1])
|
||||
fraction = max(0.0, min(1.0, ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / length_squared))
|
||||
return math.hypot(point[0] - (start[0] + fraction * dx), point[1] - (start[1] + fraction * dy))
|
||||
|
||||
def _preflight_cut_exit_distance(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, require_through: bool) -> None:
|
||||
if not require_through:
|
||||
return
|
||||
params = fragment.get("feature", {}).get("params", {})
|
||||
depth = params.get("depth_mm", params.get("distance_mm"))
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if not isinstance(depth, (int, float)):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cut depth is unavailable")
|
||||
if supplied:
|
||||
host = selectors[supplied[0]]["geometry"]
|
||||
bbox = host.get("bbox_mm") if isinstance(host, dict) else None
|
||||
normal = host.get("normal") or host.get("plane_normal") if isinstance(host, dict) else None
|
||||
thickness = self._span_from_bbox(bbox, normal)
|
||||
else:
|
||||
bbox = self._active_body_bbox(selectors)
|
||||
normal = (fragment.get("sketch") or {}).get("workplane", {}).get("normal")
|
||||
thickness = self._span_from_bbox(bbox, normal)
|
||||
if thickness is None:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: cannot prove through-cut exit distance from the active topology")
|
||||
if float(depth) <= thickness + 1e-6:
|
||||
raise RuntimeAdapterError(
|
||||
"RUNTIME_PRECONDITION_FAILED: "
|
||||
f"cut depth {float(depth):g} mm must exceed measured host-body thickness {thickness:g} mm"
|
||||
)
|
||||
|
||||
def _preflight_requires_active_solid(self, _fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
# A committed active CDSL document exists only after a candidate with
|
||||
# a solid has been accepted. Reference and subtraction operations
|
||||
# therefore cannot be the first feature of a part.
|
||||
if not isinstance(base, dict) or not base.get("features"):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: operation requires an active solid checkpoint")
|
||||
|
||||
def _preflight_revolve_axis_on_sketch(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
axis = fragment.get("feature", {}).get("params", {}).get("axis")
|
||||
direction = axis.get("direction") if isinstance(axis, dict) else None
|
||||
origin = axis.get("origin_mm") if isinstance(axis, dict) else None
|
||||
workplane = fragment.get("sketch", {}).get("workplane") if isinstance(fragment.get("sketch"), dict) else None
|
||||
plane_origin = workplane.get("origin_mm") if isinstance(workplane, dict) else None
|
||||
plane_normal = workplane.get("normal") if isinstance(workplane, dict) else None
|
||||
if not self._valid_vector3(direction, require_nonzero=True) or not self._valid_vector3(origin) or not self._valid_vector3(plane_origin) or not self._valid_vector3(plane_normal, require_nonzero=True):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis or sketch plane is invalid")
|
||||
unit_direction = [float(component) / self._norm(direction) for component in direction]
|
||||
unit_normal = [float(component) / self._norm(plane_normal) for component in plane_normal]
|
||||
if abs(self._dot(unit_direction, unit_normal)) > 1e-6:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not parallel to the sketch plane")
|
||||
axis_offset = [float(origin[index]) - float(plane_origin[index]) for index in range(3)]
|
||||
if abs(self._dot(axis_offset, unit_normal)) > 1e-5:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: revolve axis is not on the sketch plane")
|
||||
|
||||
def _preflight_reference_plane_nonzero_normal(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
plane = fragment.get("feature", {}).get("params", {}).get("plane")
|
||||
normal = plane.get("normal") if isinstance(plane, dict) else None
|
||||
if not isinstance(normal, list) or self._norm(normal) <= 1e-9:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference plane normal is degenerate")
|
||||
|
||||
def _preflight_reference_axis_nonzero_direction(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
axis = fragment.get("feature", {}).get("params", {}).get("axis")
|
||||
direction = axis.get("direction") if isinstance(axis, dict) else None
|
||||
if not isinstance(direction, list) or self._norm(direction) <= 1e-9:
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: reference axis direction is degenerate")
|
||||
|
||||
def _preflight_selected_edges_exist(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if not supplied or not all(isinstance(selectors.get(token), dict) and selectors[token].get("kind") == "edge" for token in supplied):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: selected edge is absent or stale")
|
||||
|
||||
def _preflight_source_features_exist(self, fragment: dict[str, Any], _selectors: dict[str, dict[str, Any]], base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
params = fragment.get("feature", {}).get("params", {})
|
||||
known = {str(feature.get("id") or "") for feature in ((base or {}).get("features") or ()) if isinstance(feature, dict)}
|
||||
if not set(params.get("source_feature_ids") or ()).issubset(known):
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: source feature is not part of current head")
|
||||
|
||||
def _preflight_mirror_plane_exists(self, fragment: dict[str, Any], selectors: dict[str, dict[str, Any]], _base: dict[str, Any] | None, _require_through: bool) -> None:
|
||||
supplied = fragment.get("feature", {}).get("selector_tokens", [])
|
||||
if len(supplied) != 1 or not isinstance(selectors.get(supplied[0]), dict) or selectors[supplied[0]].get("kind") != "plane":
|
||||
raise RuntimeAdapterError("RUNTIME_PRECONDITION_FAILED: mirror plane is absent or stale")
|
||||
|
||||
@staticmethod
|
||||
def _polygon_self_intersects(vertices: list[Any]) -> bool:
|
||||
points = [(float(point[0]), float(point[1])) for point in vertices if isinstance(point, list) and len(point) == 2]
|
||||
if len(points) != len(vertices):
|
||||
return True
|
||||
segments = list(zip(points, [*points[1:], points[0]]))
|
||||
for first, (a, b) in enumerate(segments):
|
||||
for second, (c, d) in enumerate(segments):
|
||||
if second <= first + 1 or (first == 0 and second == len(segments) - 1):
|
||||
continue
|
||||
if ProfileCadRuntime._segments_intersect(a, b, c, d):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _segments_intersect(a: tuple[float, float], b: tuple[float, float], c: tuple[float, float], d: tuple[float, float]) -> bool:
|
||||
def orientation(p: tuple[float, float], q: tuple[float, float], r: tuple[float, float]) -> float:
|
||||
return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0])
|
||||
|
||||
left = orientation(a, b, c)
|
||||
right = orientation(a, b, d)
|
||||
low = orientation(c, d, a)
|
||||
high = orientation(c, d, b)
|
||||
return (left > 0) != (right > 0) and (low > 0) != (high > 0)
|
||||
|
||||
@staticmethod
|
||||
def _active_body_bbox(selector_tokens: dict[str, dict[str, Any]]) -> list[float] | None:
|
||||
for token in selector_tokens.values():
|
||||
if token.get("kind") != "body":
|
||||
continue
|
||||
geometry = token.get("geometry")
|
||||
bbox = geometry.get("bbox_mm") if isinstance(geometry, dict) else None
|
||||
if isinstance(bbox, list) and len(bbox) == 6 and all(isinstance(value, (int, float)) for value in bbox):
|
||||
return [float(value) for value in bbox]
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _span_from_bbox(bbox: Any, normal: Any) -> float | None:
|
||||
if not isinstance(bbox, list) or len(bbox) != 6 or not isinstance(normal, list) or len(normal) != 3:
|
||||
return None
|
||||
if not all(isinstance(value, (int, float)) for value in [*bbox, *normal]):
|
||||
return None
|
||||
length = math.sqrt(sum(float(value) ** 2 for value in normal))
|
||||
if length <= 1e-9:
|
||||
return None
|
||||
return sum(
|
||||
abs(float(normal[index]) / length) * abs(float(bbox[index + 3]) - float(bbox[index]))
|
||||
for index in range(3)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _norm(vector: list[float]) -> float:
|
||||
return math.sqrt(sum(float(item) ** 2 for item in vector))
|
||||
|
||||
@staticmethod
|
||||
def _dot(left: list[float], right: list[float]) -> float:
|
||||
return sum(float(left[index]) * float(right[index]) for index in range(3))
|
||||
|
||||
@staticmethod
|
||||
def _valid_vector3(value: Any, *, require_nonzero: bool = False) -> bool:
|
||||
valid = (
|
||||
isinstance(value, list)
|
||||
and len(value) == 3
|
||||
and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value)
|
||||
)
|
||||
return valid and (not require_nonzero or ProfileCadRuntime._norm(value) > 1e-9)
|
||||
|
||||
@staticmethod
|
||||
def _valid_bbox(value: Any) -> bool:
|
||||
return (
|
||||
isinstance(value, list)
|
||||
and len(value) == 6
|
||||
and all(isinstance(item, (int, float)) and not isinstance(item, bool) and math.isfinite(float(item)) for item in value)
|
||||
and all(float(value[index]) <= float(value[index + 3]) for index in range(3))
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cross(a: list[float], b: list[float]) -> list[float]:
|
||||
return [float(a[1]) * float(b[2]) - float(a[2]) * float(b[1]), float(a[2]) * float(b[0]) - float(a[0]) * float(b[2]), float(a[0]) * float(b[1]) - float(a[1]) * float(b[0])]
|
||||
|
||||
@staticmethod
|
||||
def _write_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _health(engine_result: dict[str, Any], step_path: Path, glb_path: Path) -> dict[str, Any]:
|
||||
bbox = engine_result.get("bbox_mm") if isinstance(engine_result.get("bbox_mm"), dict) else {}
|
||||
minimum, maximum = bbox.get("min"), bbox.get("max")
|
||||
if not isinstance(minimum, list) or not isinstance(maximum, list) or len(minimum) != 3 or len(maximum) != 3 or not step_path.is_file() or not glb_path.is_file():
|
||||
raise RuntimeAdapterError("rebuild did not produce complete deterministic artifacts")
|
||||
return {"bbox_mm": {"min": [float(item) for item in minimum], "max": [float(item) for item in maximum], "dimensions": [float(maximum[index]) - float(minimum[index]) for index in range(3)]}, "volume_mm3": float(engine_result["volume_mm3"]), "solid_count": int(engine_result["solid_count"]), "feature_count": len(engine_result.get("feature_results") or [])}
|
||||
Reference in New Issue
Block a user