Files
cdsl-cad/backend/engine/cdsl_engine/generation_compiler.py
T
2026-08-25 11:01:59 +08:00

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,
}