neuralcad / core /cam.py
CallMeDaniel's picture
refactor: add ToolConfig pydantic model, replace tool_config dicts
cdccbe3
"""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, side="out")
elif op == "face":
return job.face(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("<Z")
return job.drill(holes, drill, peck_depth=2.0)
except Exception:
return job
elif op == "surface":
return job.surface(None, tool, cut_pattern="zigzag")
elif op == "waterline":
return job.waterline(None, tool)
else:
return job