213 lines
11 KiB
Python
213 lines
11 KiB
Python
"""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,
|
|
}
|