Files
cdsl-cad/backend/app/services/engine_service.py
T
2026-08-31 14:16:08 +08:00

468 lines
21 KiB
Python

from __future__ import annotations
import copy
from functools import lru_cache
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
from jsonschema import Draft202012Validator
from jsonschema.exceptions import SchemaError
from vendor.cdsl_preview_runtime import step_to_glb
from app.settings import Settings
def load_engine(settings: Settings) -> Any:
parent = str(settings.engine_root.parent)
if parent not in sys.path:
sys.path.insert(0, parent)
import cdsl_engine
return cdsl_engine
def _walk(value: Any) -> list[tuple[str, Any]]:
result: list[tuple[str, Any]] = []
if isinstance(value, dict):
for key, child in value.items():
result.append((str(key), child))
result.extend(_walk(child))
elif isinstance(value, list):
for child in value:
result.extend(_walk(child))
return result
@lru_cache(maxsize=16)
def _read_schema_document(path_value: str, modified_ns: int) -> dict[str, Any]:
"""Load an immutable runtime schema once per on-disk version."""
del modified_ns # The mtime is intentionally part of the cache key.
schema_path = Path(path_value)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
if not isinstance(schema, dict) or not isinstance(schema.get("feature_atomic_ids"), dict):
raise RuntimeError("The local engine schema has no feature_atomic_ids contract")
return schema
def _engine_schema(engine: Any) -> dict[str, Any]:
schema_path = Path(str(engine.__file__)).with_name("profile_schema.json")
try:
modified_ns = schema_path.stat().st_mtime_ns
except OSError as error:
raise RuntimeError("The local engine schema document is unavailable or invalid") from error
return _read_schema_document(str(schema_path), modified_ns)
def feature_atomic_contract(engine: Any, atomic_id: str) -> dict[str, Any]:
"""Return the runtime-supported parameter contract for one atomic feature."""
schema = _engine_schema(engine)
normalized_id = str(atomic_id or "").strip()
contracts = schema["feature_atomic_ids"]
contract = contracts.get(normalized_id)
registered = {str(item) for item in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
declared = {str(item) for item in schema.get("runtime_supported_atomic_ids") or ()}
if normalized_id not in registered or normalized_id not in declared or not isinstance(contract, dict):
raise ValueError(f"Unsupported runtime atomic_id: {normalized_id}")
required = contract.get("required_params") or []
optional = contract.get("optional_params") or []
if not all(isinstance(item, str) and item for item in [*required, *optional]):
raise RuntimeError(f"Runtime feature contract is invalid for {normalized_id}")
return {
"atomic_id": normalized_id,
"summary": str(contract.get("summary") or ""),
"required_params": list(dict.fromkeys(required)),
"optional_params": [item for item in dict.fromkeys(optional) if item not in required],
"position_format": str(contract.get("position_format") or ""),
"requires_sketch": contract.get("requires_sketch") is True,
"selector_slot": copy.deepcopy(contract.get("selector_slot")) if isinstance(contract.get("selector_slot"), dict) else None,
}
@lru_cache(maxsize=16)
def _cdsl_validator(path_value: str, modified_ns: int) -> Draft202012Validator:
"""Compile the JSON Schema once per runtime schema revision."""
del modified_ns
schema_path = Path(path_value)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except (OSError, json.JSONDecodeError, SchemaError) as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
return Draft202012Validator(schema)
def _cdsl_schema_path(engine: Any) -> Path:
document = _engine_schema(engine)
schema_name = str(document.get("cdsl_json_schema_file") or "")
if not schema_name or Path(schema_name).name != schema_name:
raise RuntimeError("The local engine schema has an invalid CDSL JSON Schema path")
return Path(str(engine.__file__)).with_name(schema_name)
def load_cdsl_json_schema(engine: Any) -> dict[str, Any]:
schema_path = _cdsl_schema_path(engine)
try:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
Draft202012Validator.check_schema(schema)
except (OSError, json.JSONDecodeError, SchemaError) as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
return schema
def _validate_cdsl_json_schema(cdsl: dict[str, Any], engine: Any) -> None:
schema_path = _cdsl_schema_path(engine)
try:
validator = _cdsl_validator(str(schema_path), schema_path.stat().st_mtime_ns)
except OSError as error:
raise RuntimeError("The local CDSL JSON Schema is unavailable or invalid") from error
errors = sorted(validator.iter_errors(cdsl), key=lambda error: (list(error.absolute_path), error.message))
if not errors:
return
error = errors[0]
location = "$" + "".join(
f"[{item}]" if isinstance(item, int) else f".{item}"
for item in error.absolute_path
)
raise ValueError(f"CDSL schema violation at {location}: {error.message}")
def validate_cdsl(cdsl: dict[str, Any], engine: Any) -> None:
if not isinstance(cdsl, dict):
raise ValueError("CDSL must be a JSON object")
if cdsl.get("schema") != "cad.cdsl.llm.v1":
raise ValueError("Unsupported CDSL schema")
_validate_cdsl_json_schema(cdsl, engine)
part_id = str(cdsl.get("part_id") or "")
if not re.fullmatch(r"[a-zA-Z0-9_-]{3,80}", part_id):
raise ValueError("part_id must use letters, numbers, underscores, or hyphens")
forbidden = {"compiler_context", "unknown_shape", "complex_arc_shape", "contour_edges_mm", "contour_regions_mm", "entities"}
for key, value in _walk(cdsl):
if key in forbidden or (isinstance(value, str) and value in {"unknown_shape", "complex_arc_shape"}):
raise ValueError(f"Training-unsafe CDSL field: {key}")
features = cdsl.get("features")
sketches = cdsl.get("geometry", {}).get("sketches")
if not isinstance(features, list) or not features or not isinstance(sketches, list):
raise ValueError("CDSL requires a feature list and a geometry.sketches array")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
semantic_contract = _engine_schema(engine)
atomic_contracts = semantic_contract["feature_atomic_ids"]
declared_atomic_ids = {
str(atomic_id)
for atomic_id in semantic_contract.get("runtime_supported_atomic_ids", atomic_contracts)
}
registered_atomic_ids = {str(atomic_id) for atomic_id in getattr(engine, "SUPPORTED_ATOMIC_IDS", ())}
supported_atomic_ids = sorted(declared_atomic_ids & registered_atomic_ids)
feature_ids: set[str] = set()
for feature in features:
fid = str(feature.get("id") or "")
if not fid or fid in feature_ids:
raise ValueError("Feature ids must be unique")
feature_ids.add(fid)
atomic_id = str(feature.get("atomic_id") or "")
if not atomic_id:
raise ValueError(f"Feature {fid} has no atomic_id")
contract = atomic_contracts.get(atomic_id)
if atomic_id not in supported_atomic_ids or not isinstance(contract, dict):
if feature.get("execution_status") == "deferred":
raise ValueError(f"Feature {fid} is deferred and cannot be rebuilt by the current engine")
raise ValueError(
f"Unsupported CDSL atomic_id: {atomic_id}. Supported: {', '.join(supported_atomic_ids)}"
)
params = feature.get("params")
if not isinstance(params, dict):
raise ValueError(f"Feature {fid} params must be an object")
for parameter_name in contract.get("required_params") or []:
if params.get(parameter_name) is None:
raise ValueError(f"Feature {fid} ({atomic_id}) is missing required parameter: {parameter_name}")
if atomic_id.startswith("extrude_") and float(params.get("distance_mm") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires distance_mm > 0 for runtime rebuild")
if atomic_id.startswith("revolve_") and float(params.get("angle_deg") or 0) <= 0:
raise ValueError(f"Feature {fid} ({atomic_id}) requires angle_deg > 0 for runtime rebuild")
if contract.get("requires_sketch") and str(feature.get("sketch_id") or "") not in sketch_ids:
raise ValueError(f"Feature {fid} ({atomic_id}) requires a valid sketch_id")
for dependency in feature.get("depends_on") or []:
if dependency not in feature_ids:
raise ValueError(f"Feature {fid} has a forward or missing dependency")
for sketch in sketches:
profile = sketch.get("profile")
if sketch.get("profile_from"):
continue
if not isinstance(profile, dict):
raise ValueError(f"Sketch {sketch.get('id')} has no self-contained profile")
profile_type = str(profile.get("type") or "")
if profile_type == "polygon":
if not profile.get("vertices"):
raise ValueError("Polygon profiles require vertices")
elif profile_type not in engine.SHAPE_GENERATORS:
raise ValueError(f"Unsupported CDSL profile: {profile_type}")
try:
analysis = engine.analyze_cdsl(copy.deepcopy(cdsl))
except Exception as error:
raise ValueError(f"CDSL engine runtime preflight failed: {error}") from error
if not analysis.runtime_eligible:
first = next((result for result in analysis.feature_results if not result.executable), None)
if first is None:
raise ValueError(f"CDSL engine runtime preflight failed: {analysis.document_blockers[0].code}")
blockers = ", ".join(blocker.code for blocker in first.blockers)
raise ValueError(f"CDSL engine runtime preflight failed: feature {first.feature_id}: {blockers}")
def _parameter_id(path: list[str]) -> str:
return "param_" + "_".join(re.sub(r"[^a-zA-Z0-9]+", "_", item).strip("_") for item in path)
def _parameter_label(path: list[str]) -> str:
return " / ".join(path[-2:]).replace("_mm", " (mm)").replace("_", " ")
def _derived_parameters(cdsl: dict[str, Any]) -> list[dict[str, Any]]:
parameters: list[dict[str, Any]] = []
def add(path: list[str], value: Any, group: str) -> None:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)):
return
number = float(value)
magnitude = max(abs(number), 1.0)
parameters.append({
"id": _parameter_id(path),
"name": ".".join(path),
"display_name": _parameter_label(path),
"path": path,
"value": number,
"default_value": number,
"minimum": 0.01 if number >= 0 else -magnitude * 10,
"maximum": magnitude * 10,
"step": 0.1 if abs(number) < 100 else 1.0,
"precision": 2,
"unit": "mm" if path[-1].endswith("_mm") else "",
"group": group,
"editable": True,
})
for feature_index, feature in enumerate(cdsl.get("features") or []):
for key, value in (feature.get("params") or {}).items():
add(["features", str(feature_index), "params", str(key)], value, "Features")
for sketch_index, sketch in enumerate(cdsl.get("geometry", {}).get("sketches") or []):
profile = sketch.get("profile") or {}
def walk_profile(value: Any, path: list[str]) -> None:
if isinstance(value, dict):
for key, child in value.items():
walk_profile(child, [*path, str(key)])
elif isinstance(value, list):
# Coordinates are topology anchors, not user-facing dimensions.
return
else:
add(path, value, "Sketches")
walk_profile(profile, ["geometry", "sketches", str(sketch_index), "profile"])
return parameters
def parameter_contract(cdsl: dict[str, Any]) -> dict[str, Any]:
declared = cdsl.get("meta", {}).get("editable_parameters")
if isinstance(declared, list) and declared:
values = [item for item in declared if isinstance(item, dict) and isinstance(item.get("path"), list)]
if values:
return {"schema_version": "1.0", "parameters": values, "source": "declared"}
return {"schema_version": "1.0", "parameters": _derived_parameters(cdsl), "source": "derived"}
def topology_snapshot(
engine_result: dict[str, Any],
*,
task_id: str = "",
revision_id: str = "",
preview: dict[str, Any] | None = None,
) -> dict[str, Any]:
raw_records = [
raw for raw in engine_result.get("topology_records") or ()
if isinstance(raw, dict) and raw.get("record_id") and raw.get("kind")
]
active_body_id = next(
(
str(result.get("body_id"))
for result in reversed(engine_result.get("feature_results") or ())
if isinstance(result, dict) and result.get("body_id")
),
"",
)
if not active_body_id:
active_body_id = next(
(
str(raw.get("body_id"))
for raw in reversed(raw_records)
if raw.get("kind") == "body" and raw.get("body_id")
),
"",
)
# The runtime retains historical B-rep records for provenance, but only
# the final body can resolve face, edge, vertex, and body selectors.
active_records = [
raw for raw in raw_records
if not active_body_id
or raw.get("kind") in {"plane", "axis"}
or str(raw.get("body_id") or "") == active_body_id
]
records: list[dict[str, Any]] = []
for raw in active_records:
kind = str(raw.get("kind"))
records.append({
"record_id": str(raw["record_id"]),
"kind": kind,
"feature_id": str(raw.get("feature_id") or ""),
"body_id": str(raw.get("body_id") or "") or None,
"owner_feature_ids": [str(item) for item in raw.get("owner_feature_ids") or () if str(item)],
"geometry": copy.deepcopy(raw.get("geometry") or {}),
"executable": kind in {"body", "face", "edge", "vertex", "plane", "axis"},
"synthetic": False,
})
# Preview/B-rep fallback faces are useful for visual explanation only.
# Keep them in the unified audit snapshot, but never expose them as
# executable selector candidates.
if not any(item.get("kind") == "face" for item in records):
for index, raw in enumerate((preview or {}).get("topology_faces") or ()):
if not isinstance(raw, dict):
continue
record_id = str(raw.get("id") or f"synthetic:face:{index}")
center = raw.get("center")
normal = raw.get("normal")
raw_bbox = raw.get("bbox")
if isinstance(raw_bbox, dict) and isinstance(raw_bbox.get("min"), list) and isinstance(raw_bbox.get("max"), list):
raw_bbox = [*raw_bbox["min"], *raw_bbox["max"]]
geometry = {
"surface_type": str(raw.get("surface_type") or "unknown"),
"center_mm": copy.deepcopy(center) if isinstance(center, list) else None,
"normal": copy.deepcopy(normal) if isinstance(normal, list) else None,
"bbox_mm": copy.deepcopy(raw_bbox or {}),
}
records.append({
"record_id": record_id,
"kind": "face",
"feature_id": "",
"body_id": None,
"owner_feature_ids": [],
"geometry": geometry,
"executable": False,
"synthetic": True,
})
return {
"schema_version": "cad.topology.v1",
"task_id": task_id,
"revision_id": revision_id,
"snapshot_id": f"{task_id}/{revision_id}" if task_id and revision_id else "",
"body_id": active_body_id,
"records": records,
}
def topology_sidecars(
engine_result: dict[str, Any],
preview: dict[str, Any] | None = None,
*,
snapshot: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, Any]]:
runtime_records = (snapshot or topology_snapshot(engine_result)).get("records") or []
runtime_faces = [item for item in runtime_records if item.get("kind") == "face" and item.get("executable", True)]
runtime_edges = [item for item in runtime_records if item.get("kind") == "edge" and item.get("executable", True)]
if runtime_faces or runtime_edges:
references = []
for record in runtime_faces:
geometry = record.get("geometry") or {}
references.append({
"id": str(record.get("record_id")),
"selectorType": "face",
"label": str(geometry.get("surface_type") or "face"),
"center": geometry.get("center_mm"),
"normal": geometry.get("normal"),
"frame": {
"origin_mm": geometry.get("center_mm"),
"normal": geometry.get("normal"),
"x_dir": [1, 0, 0],
"y_dir": [0, 1, 0],
},
"bbox": geometry.get("bbox_mm") or {},
"surface_type": str(geometry.get("surface_type") or "unknown"),
"owner_feature_ids": record.get("owner_feature_ids") or [],
"source": "runtime_snapshot",
"snapshot_id": (snapshot or {}).get("snapshot_id") or "",
"executable": True,
})
edge_records = [
{
**record,
"selectorType": "edge",
"source": "runtime_snapshot",
"snapshot_id": (snapshot or {}).get("snapshot_id") or "",
}
for record in runtime_edges
]
return (
{"schema_version": "cad.topology.v1", "references": references, "edges": edge_records},
{"schema_version": "cad.topology.v1", "edges": edge_records},
)
topology_faces = (preview or {}).get("topology_faces")
if isinstance(topology_faces, list) and topology_faces:
references = []
for face in topology_faces:
if not isinstance(face, dict):
continue
frame = face.get("frame")
center = face.get("center")
normal = face.get("normal")
if not isinstance(frame, dict) or not isinstance(center, list) or not isinstance(normal, list):
continue
references.append({
"id": str(face.get("id") or f"face_{len(references):03d}"),
"selectorType": "face",
"label": str(face.get("surface_type") or "face"),
"center": center,
"normal": normal,
"frame": frame,
"bbox": face.get("bbox") or {},
"surface_type": str(face.get("surface_type") or "unknown"),
"triangle_start": int(face.get("triangle_start") or 0),
"triangle_count": int(face.get("triangle_count") or 0),
"source": "preview",
"synthetic": True,
"executable": False,
})
if references:
return ({"schema_version": "1.1", "references": references}, {"schema_version": "1.0", "edges": []})
bbox = engine_result.get("bbox_mm") or {}
minimum = [float(value) for value in bbox.get("min") or [0, 0, 0]]
maximum = [float(value) for value in bbox.get("max") or [0, 0, 0]]
if len(minimum) != 3 or len(maximum) != 3:
raise ValueError("Engine result is missing a valid bounding box")
center = [(minimum[index] + maximum[index]) / 2 for index in range(3)]
definitions = [
("top", [center[0], center[1], maximum[2]], [0, 0, 1], [1, 0, 0], [0, 1, 0]),
("bottom", [center[0], center[1], minimum[2]], [0, 0, -1], [1, 0, 0], [0, -1, 0]),
("right", [maximum[0], center[1], center[2]], [1, 0, 0], [0, 1, 0], [0, 0, 1]),
("left", [minimum[0], center[1], center[2]], [-1, 0, 0], [0, 1, 0], [0, 0, -1]),
("front", [center[0], maximum[1], center[2]], [0, 1, 0], [1, 0, 0], [0, 0, -1]),
("back", [center[0], minimum[1], center[2]], [0, -1, 0], [1, 0, 0], [0, 0, 1]),
]
references = [
{
"id": f"face_{name}", "selectorType": "face", "label": name,
"center": point, "normal": normal,
"frame": {"origin_mm": point, "normal": normal, "x_dir": x_dir, "y_dir": y_dir},
"bbox": {"min": minimum, "max": maximum},
"source": "bbox_fallback",
"synthetic": True,
"executable": False,
}
for name, point, normal, x_dir, y_dir in definitions
]
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})