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}