"""CAM engine — generates CNC toolpaths and G-code from CadQuery shapes. Wraps ocp-freecad-cam to convert cq.Workplane objects into G-code strings. Falls back gracefully when ocp-freecad-cam or FreeCAD is not installed. """ from __future__ import annotations from pydantic import BaseModel, Field class ToolConfig(BaseModel): """CNC tool configuration for G-code generation.""" diameter: float = 6.0 h_feed: float = 800 v_feed: float = 200 speed: float = 18000 class CAMResult(BaseModel): """Result of G-code generation from a CadQuery shape.""" success: bool gcode: str | None = None operations: list[str] = Field(default_factory=list) tool_config: ToolConfig = Field(default_factory=ToolConfig) post_processor: str = "grbl" error: str | None = None class CAMPlan(BaseModel): """Structured machining plan output from CAM agent.""" operations: list[str] = Field(description="Ordered list: adaptive, pocket, profile, face, drill, surface, waterline") tool_diameter: float = Field(default=6.0, description="Endmill diameter in mm") tool_h_feed: float = Field(default=800, description="Horizontal feed rate mm/min") tool_v_feed: float = Field(default=200, description="Vertical feed rate mm/min") tool_speed: float = Field(default=18000, description="Spindle speed RPM") post_processor: str = Field(default="grbl", description="G-code format") def to_tool_config(self) -> ToolConfig: """Convert to ToolConfig for generate_gcode().""" return ToolConfig( diameter=self.tool_diameter, h_feed=self.tool_h_feed, v_feed=self.tool_v_feed, speed=self.tool_speed, ) from config.settings import settings def _get_default_tool_config() -> ToolConfig: """Load default roughing tool config from config.yaml cam section.""" roughing = settings.cam.tools.get("roughing") if roughing: return ToolConfig(**roughing.model_dump()) return ToolConfig() def _get_default_post_processor() -> str: """Load default post-processor from config.yaml cam section.""" return settings.cam.default_post_processor def _get_stock_offset() -> float: """Load stock offset from config.yaml cam section.""" return settings.cam.stock_offset_mm def generate_gcode( shape, operations: list[str], tool_config: ToolConfig | None = None, post_processor: str | None = None, stock_offset_mm: float | None = None, ) -> CAMResult: """Generate G-code from a CadQuery Workplane shape. Args: shape: A cq.Workplane object from the executor. operations: List of operation names: "adaptive", "pocket", "profile", "face", "drill", "surface", "waterline". tool_config: ToolConfig with diameter, h_feed, v_feed, speed. Uses config defaults if None. post_processor: Post-processor name (e.g. "grbl", "linuxcnc"). Uses config default if None. stock_offset_mm: Stock offset from bounding box in mm. Uses config default if None. Returns: CAMResult with success status and G-code string. """ if tool_config is None: tool_config = _get_default_tool_config() if post_processor is None: post_processor = _get_default_post_processor() if stock_offset_mm is None: stock_offset_mm = _get_stock_offset() if not operations: return CAMResult( success=False, gcode=None, operations=[], tool_config=tool_config, post_processor=post_processor, error="No operations specified", ) try: from ocp_freecad_cam import Job, Endmill, Drill, Ballnose from ocp_freecad_cam.fc_impl import Stock except ImportError: return CAMResult( success=False, gcode=None, operations=operations, tool_config=tool_config, post_processor=post_processor, error="ocp-freecad-cam is not installed. Install it with: pip install ocp-freecad-cam (requires FreeCAD >= 1.0.1)", ) try: tool = Endmill( diameter=tool_config.diameter, h_feed=tool_config.h_feed, v_feed=tool_config.v_feed, speed=tool_config.speed, ) so = stock_offset_mm stock = Stock(xn=so, xp=so, yn=so, yp=so, zn=0, zp=so) top = shape.faces(">Z").workplane() job = Job(top, shape, post_processor, stock=stock) for op in operations: job = _apply_operation(job, shape, tool, op) gcode = job.to_gcode() return CAMResult( success=True, gcode=gcode, operations=operations, tool_config=tool_config, post_processor=post_processor, ) except Exception as exc: return CAMResult( success=False, gcode=None, operations=operations, tool_config=tool_config, post_processor=post_processor, error=f"G-code generation failed: {exc}", ) def _apply_operation(job, shape, tool, operation: str): """Apply a single CAM operation to the job. Returns the updated job.""" op = operation.lower().strip() if op == "adaptive": try: faces = shape.faces(">Z[1]") return job.adaptive(faces, tool, step_over=40, stock_to_leave="0.2 mm") except Exception: return job.adaptive(shape.faces(">Z"), tool, step_over=40) elif op == "pocket": try: faces = shape.faces(">Z[1]") return job.pocket(faces, tool, pattern="offset") except Exception: return job.pocket(shape.faces(">Z"), tool, pattern="zigzag") elif op == "profile": return job.profile(shape.faces("Z"), tool) elif op == "drill": try: from ocp_freecad_cam import Drill as DrillTool drill = DrillTool( diameter=tool._diameter if hasattr(tool, '_diameter') else 5, h_feed=100, v_feed=50, speed=8000, ) holes = shape.faces("