Files
cdsl-cad/backend/app/services/engine_service.py
T
2026-08-19 19:34:30 +08:00

335 lines
15 KiB
Python

from __future__ import annotations
import copy
import json
import math
import re
import sys
from pathlib import Path
from typing import Any
from vendor.cdsl_preview_runtime import step_to_glb
from app.services.storage import WorkspaceStore, now_iso, write_json
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
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")
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) or not sketches:
raise ValueError("CDSL requires features and parameterized sketches")
sketch_ids = {str(sketch.get("id")) for sketch in sketches}
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)
if str(feature.get("sketch_id") or "") not in sketch_ids:
raise ValueError(f"Feature {fid} refers to a missing sketch")
if not str(feature.get("atomic_id") or ""):
raise ValueError(f"Feature {fid} has no atomic_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}")
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_sidecars(engine_result: dict[str, Any], preview: dict[str, Any] | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
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),
})
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},
}
for name, point, normal, x_dir, y_dir in definitions
]
return ({"schema_version": "1.0", "references": references}, {"schema_version": "1.0", "edges": []})
def _set_parameter_value(document: dict[str, Any], path: list[str], value: float) -> None:
target: Any = document
for index, key in enumerate(path):
final = index == len(path) - 1
if isinstance(target, list):
item_index = int(key)
if item_index < 0 or item_index >= len(target):
raise ValueError("Parameter path is no longer valid")
if final:
target[item_index] = value
else:
target = target[item_index]
elif isinstance(target, dict):
if key not in target:
raise ValueError("Parameter path is no longer valid")
if final:
target[key] = value
else:
target = target[key]
else:
raise ValueError("Parameter path is no longer valid")
def apply_parameter_updates(cdsl: dict[str, Any], values: dict[str, float]) -> tuple[dict[str, Any], dict[str, Any]]:
contract = parameter_contract(cdsl)
entries = {str(item.get("id")): item for item in contract["parameters"]}
updated = copy.deepcopy(cdsl)
for parameter_id, raw_value in values.items():
entry = entries.get(parameter_id)
value = float(raw_value)
if entry is None or not entry.get("editable", False):
raise ValueError(f"Unknown editable parameter: {parameter_id}")
if not math.isfinite(value):
raise ValueError("Parameter values must be finite")
minimum, maximum = entry.get("minimum"), entry.get("maximum")
if isinstance(minimum, (int, float)) and value < float(minimum):
raise ValueError(f"{parameter_id} is below its minimum")
if isinstance(maximum, (int, float)) and value > float(maximum):
raise ValueError(f"{parameter_id} is above its maximum")
path = entry.get("path")
if not isinstance(path, list) or not all(isinstance(item, str) for item in path):
raise ValueError(f"{parameter_id} has an invalid path")
_set_parameter_value(updated, path, value)
declared = updated.get("meta", {}).get("editable_parameters")
if isinstance(declared, list):
for declared_entry in declared:
if isinstance(declared_entry, dict) and str(declared_entry.get("id")) == parameter_id:
declared_entry["value"] = value
return updated, parameter_contract(updated)
def build_revision(
*,
settings: Settings,
store: WorkspaceStore,
task_id: str | None,
request: str,
cdsl: dict[str, Any],
reference_ids: list[str],
summary: str,
parent_revision_id: str | None = None,
operation: dict[str, Any] | None = None,
attachments: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
engine = load_engine(settings)
task = store.ensure_task(task_id, request)
revision_id, revision_dir = store.next_revision(task["task_id"])
cdsl_copy = copy.deepcopy(cdsl)
cdsl_copy["part_id"] = task["task_id"]
meta = cdsl_copy.setdefault("meta", {})
if not isinstance(meta, dict):
raise ValueError("CDSL meta must be an object when present")
if not isinstance(meta.get("editable_parameters"), list) or not meta["editable_parameters"]:
meta["editable_parameters"] = _derived_parameters(cdsl_copy)
validate_cdsl(cdsl_copy, engine)
cdsl_path = revision_dir / "model.cdsl.json"
step_path = revision_dir / "model.step"
glb_path = revision_dir / "model.glb"
report_path = revision_dir / "rebuild-report.json"
request_path = revision_dir / "request.json"
references_path = revision_dir / "references.json"
parameters_path = revision_dir / "parameters.json"
selector_path = revision_dir / "model.selector.json"
edges_path = revision_dir / "model.edges.json"
write_json(request_path, {"request": request, "created_at": now_iso()})
write_json(references_path, {"reference_ids": reference_ids})
write_json(cdsl_path, cdsl_copy)
contract = parameter_contract(cdsl_copy)
write_json(parameters_path, contract)
try:
engine_result = engine.run_rebuild(cdsl_copy, step_path)
if engine_result.get("engine") != "cdsl_only" or not step_path.is_file() or step_path.stat().st_size == 0:
raise RuntimeError("Engine did not produce a CDSL-only STEP artifact")
preview = step_to_glb(step_path, glb_path)
selector, edges = topology_sidecars(engine_result, preview)
write_json(selector_path, selector)
write_json(edges_path, edges)
report = {"engine_result": engine_result, "preview": preview, "validated_at": now_iso()}
write_json(report_path, report)
revision = {
"revision_id": revision_id,
"status": "success",
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"step_path": step_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"glb_path": glb_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"selector_path": selector_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"edges_path": edges_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"reference_ids": reference_ids,
"summary": summary,
"engine": engine_result["engine"],
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
except Exception as error:
write_json(report_path, {"error": str(error), "validated_at": now_iso()})
revision = {
"revision_id": revision_id,
"status": "failed",
"created_at": now_iso(),
"request_path": request_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"cdsl_path": cdsl_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"report_path": report_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"parameters_path": parameters_path.relative_to(store.task_dir(task["task_id"])).as_posix(),
"reference_ids": reference_ids,
"summary": summary,
"error": str(error),
"parent_revision_id": parent_revision_id or "",
"operation": operation or {},
"attachments": attachments or [],
}
store.update_task(task["task_id"], revision)
raise
store.update_task(task["task_id"], revision)
return {"task_id": task["task_id"], **revision}