"""Deterministic GenerationSpec -> CDSL compilers. The LLM supplies semantic parameters and feature intent. This module owns the CDSL details so tool calls do not need to contain sketch IDs, selectors, or runtime-specific parameter wrappers. """ from __future__ import annotations import copy from dataclasses import dataclass from typing import Any, Protocol from .generation_spec import GenerationSpecError, validate_generation_spec def _workplane(z: float = 0.0, normal: list[float] | None = None) -> dict[str, list[float]]: return { "origin_mm": [0.0, 0.0, float(z)], "x_dir": [1.0, 0.0, 0.0], "y_dir": [0.0, 1.0, 0.0], "normal": list(normal or [0.0, 0.0, 1.0]), } def _value(spec: dict[str, Any], name: str, default: float | int | str) -> Any: parameter = (spec.get("parameters") or {}).get(name) if isinstance(parameter, dict) and "value" in parameter: return parameter["value"] return default def _number(spec: dict[str, Any], name: str, default: float) -> float: value = _value(spec, name, default) if isinstance(value, bool) or not isinstance(value, (int, float)): raise GenerationSpecError(f"Parameter {name} must be numeric", f"$.parameters.{name}.value") number = float(value) if not number == number or number in {float("inf"), float("-inf")}: raise GenerationSpecError(f"Parameter {name} must be finite", f"$.parameters.{name}.value") return number def _integer(spec: dict[str, Any], name: str, default: int) -> int: value = _number(spec, name, default) if int(value) != value: raise GenerationSpecError(f"Parameter {name} must be an integer", f"$.parameters.{name}.value") return int(value) def _positive(number: float, name: str) -> float: if number <= 0: raise GenerationSpecError(f"Parameter {name} must be greater than zero", f"$.parameters.{name}.value") return number def _acceptance(spec: dict[str, Any], generated: list[dict[str, Any]]) -> list[dict[str, Any]]: existing = copy.deepcopy(spec.get("acceptance") or []) known = {str(item.get("id")) for item in existing} for item in generated: if item["id"] not in known: existing.append(item) known.add(item["id"]) return existing @dataclass(frozen=True) class CompiledGeneration: cdsl: dict[str, Any] acceptance: list[dict[str, Any]] provenance: dict[str, Any] assumptions: list[str] approximations: list[dict[str, Any]] class Compiler(Protocol): family: str def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: ... class MountingPlateCompiler: family = "mounting_plate" def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: width = _positive(_number(spec, "width", 100.0), "width") height = _positive(_number(spec, "height", 60.0), "height") thickness = _positive(_number(spec, "thickness", 6.0), "thickness") hole_diameter = _positive(_number(spec, "hole_diameter", 4.5), "hole_diameter") hole_offset = _positive(_number(spec, "hole_edge_offset", 10.0), "hole_edge_offset") hole_count = _integer(spec, "hole_count", 4) counterbore_diameter = _positive(_number(spec, "counterbore_diameter", hole_diameter * 2), "counterbore_diameter") counterbore_depth = _positive(_number(spec, "counterbore_depth", min(thickness / 2, 3.0)), "counterbore_depth") slot_length = _positive(_number(spec, "slot_length", 20.0), "slot_length") slot_width = _positive(_number(spec, "slot_width", 12.0), "slot_width") if hole_count != 4: raise GenerationSpecError("mounting_plate compiler currently requires four corner holes", "$.parameters.hole_count.value") if counterbore_diameter <= hole_diameter: raise GenerationSpecError("counterbore_diameter must exceed hole_diameter", "$.parameters.counterbore_diameter.value") if counterbore_depth > thickness: raise GenerationSpecError("counterbore_depth must not exceed thickness", "$.parameters.counterbore_depth.value") if hole_offset * 2 >= min(width, height): raise GenerationSpecError("hole_edge_offset leaves no usable plate area", "$.parameters.hole_edge_offset.value") if slot_length < slot_width: raise GenerationSpecError("slot_length must be at least slot_width", "$.parameters.slot_length.value") centers = [ [-width / 2 + hole_offset, -height / 2 + hole_offset], [width / 2 - hole_offset, -height / 2 + hole_offset], [width / 2 - hole_offset, height / 2 - hole_offset], [-width / 2 + hole_offset, height / 2 - hole_offset], ] frame = _workplane(thickness) cdsl = { "schema": "cad.cdsl.llm.v1", "schema_version": "1.1.0", "kind": "part", "part_id": "generation-spec-preflight", "meta": {"unit": "mm", "name": str(spec["part"]["name"])}, "geometry": {"sketches": [ {"id": "base_plate", "workplane": _workplane(), "profile": { "type": "rectangle", "center": [0.0, 0.0], "width_mm": width, "height_mm": height, }}, {"id": "mounting_holes", "workplane": frame, "profile": { "type": "circles", "items": [{"center": center, "radius_mm": hole_diameter / 2} for center in centers], }}, {"id": "center_slot", "workplane": frame, "profile": { "type": "obround", "center": [0.0, 0.0], "length_mm": slot_length, "width_mm": slot_width, }}, ]}, "features": [ {"id": "base_plate", "atomic_id": "extrude_add_blind", "depends_on": [], "sketch_id": "base_plate", "params": {"distance_mm": thickness}}, {"id": "mounting_holes", "atomic_id": "extrude_cut_blind", "depends_on": ["base_plate"], "sketch_id": "mounting_holes", "params": {"distance_mm": thickness, "reverse": True}}, {"id": "mounting_counterbores", "atomic_id": "hole_counterbore", "depends_on": ["mounting_holes"], "sketch_id": "mounting_holes", "params": { "diameter_mm": hole_diameter, "depth_mm": thickness, "counterbore_diameter_mm": counterbore_diameter, "counterbore_depth_mm": counterbore_depth, "positions": [{"mm": [x, y, 0.0]} for x, y in centers], "host_face": {"frame": frame}, }}, {"id": "center_slot", "atomic_id": "extrude_cut_blind", "depends_on": ["mounting_counterbores"], "sketch_id": "center_slot", "params": {"distance_mm": thickness, "reverse": True}}, ], } assumptions = [str(item) for item in spec.get("assumptions") or []] if "hole_diameter" not in (spec.get("parameters") or {}): assumptions.append("M4 clearance hole defaults to 4.5 mm") if "counterbore_depth" not in (spec.get("parameters") or {}): assumptions.append("Counterbore depth defaults to the lesser of 3 mm and half the plate thickness") acceptance = _acceptance(spec, [ {"id": "overall_bbox", "type": "bbox", "expected": [width, height, thickness], "tolerance": 0.1, "severity": "blocking"}, {"id": "mounting_hole_count", "type": "feature_count", "feature": "mounting_holes", "expected": 4, "tolerance": 0, "severity": "blocking"}, {"id": "mounting_hole_diameter", "type": "hole_diameter", "feature": "mounting_holes", "expected": hole_diameter, "tolerance": 0.05, "severity": "blocking"}, {"id": "center_slot_size", "type": "overall_length", "feature": "center_slot", "expected": slot_length, "tolerance": 0.1, "severity": "blocking"}, ]) provenance = { "compiler": self.family, "compiler_version": "1.0", "references": references, "feature_sources": {item["id"]: item["id"] for item in spec.get("features") or []}, "parameter_paths": {name: f"parameters.{name}.value" for name in (spec.get("parameters") or {})}, } return CompiledGeneration(cdsl, acceptance, provenance, list(dict.fromkeys(assumptions)), list(spec.get("approximations") or [])) class FlangeSleeveCompiler: family = "flange_sleeve" def compile(self, spec: dict[str, Any], references: list[dict[str, Any]]) -> CompiledGeneration: from app.services.flange_sleeve_template import build_flange_sleeve_cdsl, TEMPLATE_ID aliases = { "width": "flange_width_mm", "height": "flange_height_mm", "thickness": "flange_thickness_mm", "tube_outer_diameter": "tube_outer_diameter_mm", "bore_diameter": "bore_diameter_mm", "hole_diameter": "mount_hole_diameter_mm", "counterbore_diameter": "mount_counterbore_diameter_mm", "counterbore_depth": "mount_counterbore_depth_mm", "hole_offset_x": "mount_hole_u_mm", "hole_offset_y": "mount_hole_v_mm", } plan: dict[str, Any] = {"template": TEMPLATE_ID, "name": spec["part"]["name"]} for source, target in aliases.items(): if source in (spec.get("parameters") or {}): plan[target] = _value(spec, source, 0) cdsl, values = build_flange_sleeve_cdsl(plan) acceptance = _acceptance(spec, [ {"id": "flange_bbox", "type": "bbox", "expected": [float(values["flange_width_mm"]), float(values["flange_height_mm"]), float(values["flange_thickness_mm"])], "tolerance": 0.1, "severity": "blocking"}, ]) provenance = {"compiler": self.family, "compiler_version": "1.0", "template_id": TEMPLATE_ID, "references": references} assumptions = list(dict.fromkeys([*map(str, spec.get("assumptions") or []), "Flange sleeve dimensions use the verified parameterized template defaults for omitted fields."])) return CompiledGeneration(cdsl, acceptance, provenance, assumptions, list(spec.get("approximations") or [])) COMPILERS: dict[str, Compiler] = { "mounting_plate": MountingPlateCompiler(), "flange_sleeve": FlangeSleeveCompiler(), } def compiler_for(family: str) -> Compiler: try: return COMPILERS[family] except KeyError as error: raise GenerationSpecError(f"No GenerationSpec compiler registered for part family: {family}", "$.part.family") from error def compile_generation_spec(spec: dict[str, Any], references: list[dict[str, Any]] | None = None) -> dict[str, Any]: normalized = validate_generation_spec(spec) compiler = compiler_for(str(normalized["part"]["family"])) compiled = compiler.compile(normalized, references or []) return { "spec": normalized, "cdsl": compiled.cdsl, "acceptance": compiled.acceptance, "provenance": compiled.provenance, "assumptions": compiled.assumptions, "approximations": compiled.approximations, }