Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ CAD_MODELS_DIR=data/models
OPENSCAD_BIN=openscad
OPENSCAD_TIMEOUT_S=60

# --- CNC CAM (optional, build123d engine only) ------------------------------

# After a successful build123d build, a FreeCAD headless CAM job can also
# produce GRBL G-code (model.gcode) using the vendored Genmitsu toolbits in
# mnft_cad/priv/cam. Needs FreeCAD 1.1+ installed; the default points at the
# macOS bundle. Set MNFT_FREECAD_CMD to the FreeCAD binary elsewhere
# (Linux: /usr/bin/freecad; the flatpak/AppImage path also works).
MNFT_FREECAD_CMD=/Applications/FreeCAD.app/Contents/MacOS/FreeCAD
# Wall-clock budget for one CAM job (it launches FreeCAD, loads toolbit
# geometry and computes toolpaths).
MNFT_CAM_TIMEOUT_S=180

# --- references (PDF datasheet fetching) ------------------------------------

# A chat message containing a PDF URL triggers a server-side fetch stage
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,23 @@ cd cad_worker && uv pip install -e ".[smlibpy]"
smlibpy is alpha here: less production time than build123d, treat it as a
second option, not a replacement.

## CNC G-code (CAM)

On the `build123d` engine, every successful build can also produce a GRBL
`.gcode` for a desktop CNC — the chat reply carries a machining plan
(material, ops: face/drill/pocket/profile), and the "G-code (GRBL)" download
appears next to STEP/STL. Requires FreeCAD 1.1+ (`MNFT_FREECAD_CMD`, default
the macOS bundle; `MNFT_CAM_TIMEOUT_S` caps one CAM job). OpenSCAD models are
mesh-only and get no CAM.

Tool truth lives in `mnft_cad/priv/cam/toolbits.json` (feeds/speeds per
material, work envelope) plus the vendored Genmitsu `.fctb` geometry in
`mnft_cad/priv/cam/toolbits/` — tune feeds by editing the JSON, no code
changes. The G-code assumes origin = stock min corner, Z0 = stock top, and
warns in its header about holes narrower than the tool, oversized parts, and
aluminum lubrication. Feeds are conservative starting points: verify on
scrap before cutting good material.

## Examples

Three real turns from the reference web app: the ask, then what the code
Expand Down
92 changes: 92 additions & 0 deletions cad_worker/cad_worker/cam.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""CAM bridge: run one FreeCAD headless CAM job for a STEP + machining plan.

Runs in the worker env (NO FreeCAD import here -- FreeCAD is a separate
process). `run_cam` writes a spec JSON, launches the FreeCAD binary on
`cam_freecad.py`, and reads back the JSON result the script leaves next to
the output. Never raises; every failure lands in {"error": ...}.
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
from pathlib import Path

DEFAULT_FREECAD = "/Applications/FreeCAD.app/Contents/MacOS/FreeCAD"
DEFAULT_CAM_TIMEOUT_S = 180


def resolve_freecad(cam_cfg: dict | None) -> str | None:
"""The FreeCAD binary to run, or None. Precedence: payload config,
MNFT_FREECAD_CMD, the macOS bundle default."""
cmd = (cam_cfg or {}).get("freecad_cmd") or os.environ.get("MNFT_FREECAD_CMD") or DEFAULT_FREECAD
if not cmd:
return None
found = shutil.which(cmd) or (cmd if Path(cmd).is_file() else None)
return os.path.abspath(found) if found else None


def run_cam(step_path: str, plan: dict, cam_cfg: dict | None, out_dir: str | Path) -> dict:
"""Generate `model.gcode` in `out_dir` from `step_path` + `plan`.

Returns {"gcode": "model.gcode", "warnings": [...]} on success or
{"error": "<machine-readable-ish message>"} on failure.
"""
try:
cam_cfg = cam_cfg or {}
freecad = resolve_freecad(cam_cfg)
if not freecad:
return {"error": "freecad-not-found: set MNFT_FREECAD_CMD to the FreeCAD binary"}

toolbits_dir = cam_cfg.get("toolbits_dir") or os.environ.get("MNFT_TOOLBITS_DIR")
if not toolbits_dir or not Path(toolbits_dir).is_dir():
return {"error": "cam-toolbits-missing: toolbits dir not found"}

script = Path(__file__).with_name("cam_freecad.py")
if not script.is_file():
return {"error": "cam-script-missing: cam_freecad.py not next to cam.py"}

timeout_s = float(cam_cfg.get("timeout_s") or os.environ.get("MNFT_CAM_TIMEOUT_S", DEFAULT_CAM_TIMEOUT_S))
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
spec_path = out_dir / "_cam_spec.json"
result_path = out_dir / "_cam_result.json"

spec = {
"step": str(step_path),
"plan": plan,
"toolbits_dir": str(toolbits_dir),
"output": str(out_dir / "model.gcode"),
"result": str(result_path),
}
spec_path.write_text(json.dumps(spec), encoding="utf-8")

try:
proc = subprocess.run(
[freecad, "--console", str(script)],
env={**os.environ, "MNFT_CAM_SPEC": str(spec_path)},
stdin=subprocess.DEVNULL, # --console waits on stdin otherwise
capture_output=True,
text=True,
timeout=timeout_s,
)
except subprocess.TimeoutExpired:
return {"error": f"freecad-cam-timeout: exceeded {timeout_s:.0f}s"}

if proc.returncode != 0:
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-5:]
return {"error": f"freecad exited {proc.returncode}: " + " | ".join(tail)}

if not result_path.is_file():
return {"error": "freecad produced no cam result"}
result = json.loads(result_path.read_text(encoding="utf-8"))
if not result.get("ok"):
return {"error": f"cam failed: {result.get('error', 'unknown')}"}

gcode = out_dir / "model.gcode"
if not gcode.is_file() or gcode.stat().st_size == 0:
return {"error": "cam produced an empty gcode file"}
return {"gcode": "model.gcode", "warnings": list(result.get("warnings", []))}
except Exception as exc: # noqa: BLE001 -- report, never raise
return {"error": f"cam failure: {type(exc).__name__}: {exc}"}
Loading
Loading