CallMeDaniel Claude Opus 4.6 (1M context) commited on
Commit
edfb196
·
1 Parent(s): 265686c

docs: add CNC slicer/preview implementation plan

Browse files

8-task TDD plan covering CAM engine, CAM agent config, orchestrator
integration, G-code API endpoint, frontend G-code parser, 3D toolpath
renderer with Part/Toolpath/Overlay view toggle, and G-code download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

docs/superpowers/plans/2026-04-11-cnc-slicer-preview.md ADDED
@@ -0,0 +1,1110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CNC Slicer, Preview & CAM Agent Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add CNC toolpath generation (G-code), 3D toolpath preview in the Three.js viewport, and a CAM agent to orchestrate the process.
6
+
7
+ **Architecture:** Extends the existing pipeline after CadQuery execution. A new `core/cam.py` wraps `ocp-freecad-cam` to generate G-code from CadQuery shapes. A new CAM agent selects operations/tools. The frontend parses G-code and renders 3D toolpath lines alongside the existing STL view.
8
+
9
+ **Tech Stack:** ocp-freecad-cam (Python, wraps FreeCAD Path workbench), vanilla JavaScript G-code parser, Three.js LineSegments for toolpath rendering.
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ | File | Action | Responsibility |
16
+ |------|--------|---------------|
17
+ | `core/cam.py` | Create | CAM engine: `generate_gcode()`, `CAMResult` dataclass, `CAMResultSerializer` |
18
+ | `config.yaml` | Modify | Add `cam` agent definition, `cam:` config section, `cam` routing keywords |
19
+ | `agents/definitions.py` | No change | Auto-loads new `cam` agent from config.yaml |
20
+ | `agents/routing.py` | No change | Auto-loads new `cam` keywords from config.yaml |
21
+ | `agents/prompts.py` | Modify | Add CAM agent to orchestrator system prompt, add CAM trigger keywords |
22
+ | `agents/orchestrator.py` | Modify | Call `generate_gcode()` after CAD execution when CAM agent is active |
23
+ | `server/web.py` | Modify | Add `GET /api/models/{name}.gcode` endpoint |
24
+ | `web/index.html` | Modify | Add G-code parser, toolpath renderer, view toggle buttons, G-code download |
25
+ | `tests/test_cam.py` | Create | Tests for CAMResult, generate_gcode, serializer |
26
+ | `tests/test_cam_routing.py` | Create | Tests for CAM keyword routing |
27
+
28
+ ---
29
+
30
+ ### Task 1: CAM Engine — CAMResult and Serializer
31
+
32
+ **Files:**
33
+ - Create: `core/cam.py`
34
+ - Create: `tests/test_cam.py`
35
+
36
+ - [ ] **Step 1: Write the failing test for CAMResult dataclass**
37
+
38
+ ```python
39
+ # tests/test_cam.py
40
+ """Tests for core/cam.py — CAM engine."""
41
+
42
+ from core.cam import CAMResult, CAMResultSerializer
43
+
44
+
45
+ class TestCAMResult:
46
+ def test_success_result(self):
47
+ r = CAMResult(
48
+ success=True,
49
+ gcode="G21 G90\nG00 X0 Y0 Z10\nM30",
50
+ operations=["pocket", "profile"],
51
+ tool_config={"diameter": 6, "h_feed": 800},
52
+ post_processor="grbl",
53
+ error=None,
54
+ )
55
+ assert r.success is True
56
+ assert "G21" in r.gcode
57
+ assert r.operations == ["pocket", "profile"]
58
+ assert r.error is None
59
+
60
+ def test_failure_result(self):
61
+ r = CAMResult(
62
+ success=False,
63
+ gcode=None,
64
+ operations=[],
65
+ tool_config={},
66
+ post_processor="grbl",
67
+ error="ocp-freecad-cam not available",
68
+ )
69
+ assert r.success is False
70
+ assert r.gcode is None
71
+ assert r.error == "ocp-freecad-cam not available"
72
+
73
+
74
+ class TestCAMResultSerializer:
75
+ def test_to_dict_success(self):
76
+ r = CAMResult(
77
+ success=True,
78
+ gcode="G21 G90\nM30",
79
+ operations=["pocket"],
80
+ tool_config={"diameter": 6},
81
+ post_processor="grbl",
82
+ error=None,
83
+ )
84
+ d = CAMResultSerializer.to_dict(r)
85
+ assert d["success"] is True
86
+ assert d["gcode"] == "G21 G90\nM30"
87
+ assert d["operations"] == ["pocket"]
88
+ assert d["tool_config"] == {"diameter": 6}
89
+ assert d["post_processor"] == "grbl"
90
+ assert d["error"] is None
91
+
92
+ def test_to_dict_failure(self):
93
+ r = CAMResult(
94
+ success=False, gcode=None, operations=[], tool_config={},
95
+ post_processor="grbl", error="failed",
96
+ )
97
+ d = CAMResultSerializer.to_dict(r)
98
+ assert d["success"] is False
99
+ assert d["gcode"] is None
100
+ assert d["error"] == "failed"
101
+ ```
102
+
103
+ - [ ] **Step 2: Run test to verify it fails**
104
+
105
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_cam.py -v`
106
+ Expected: FAIL with `ModuleNotFoundError: No module named 'core.cam'`
107
+
108
+ - [ ] **Step 3: Write the CAMResult dataclass and serializer**
109
+
110
+ ```python
111
+ # core/cam.py
112
+ """CAM engine — generates CNC toolpaths and G-code from CadQuery shapes.
113
+
114
+ Wraps ocp-freecad-cam to convert cq.Workplane objects into G-code strings.
115
+ Falls back gracefully when ocp-freecad-cam or FreeCAD is not installed.
116
+ """
117
+
118
+ from __future__ import annotations
119
+
120
+ from dataclasses import dataclass, field
121
+
122
+
123
+ @dataclass
124
+ class CAMResult:
125
+ """Result of G-code generation from a CadQuery shape."""
126
+ success: bool
127
+ gcode: str | None
128
+ operations: list[str]
129
+ tool_config: dict
130
+ post_processor: str
131
+ error: str | None = None
132
+
133
+
134
+ class CAMResultSerializer:
135
+ """Serialize CAMResult to JSON-ready dict."""
136
+
137
+ @staticmethod
138
+ def to_dict(result: CAMResult) -> dict:
139
+ return {
140
+ "success": result.success,
141
+ "gcode": result.gcode,
142
+ "operations": result.operations,
143
+ "tool_config": result.tool_config,
144
+ "post_processor": result.post_processor,
145
+ "error": result.error,
146
+ }
147
+ ```
148
+
149
+ - [ ] **Step 4: Run test to verify it passes**
150
+
151
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_cam.py -v`
152
+ Expected: All 4 tests PASS
153
+
154
+ - [ ] **Step 5: Commit**
155
+
156
+ ```bash
157
+ git add core/cam.py tests/test_cam.py
158
+ git commit -m "feat(cam): add CAMResult dataclass and serializer"
159
+ ```
160
+
161
+ ---
162
+
163
+ ### Task 2: CAM Engine — generate_gcode function
164
+
165
+ **Files:**
166
+ - Modify: `core/cam.py`
167
+ - Modify: `tests/test_cam.py`
168
+
169
+ - [ ] **Step 1: Write the failing test for generate_gcode**
170
+
171
+ Append to `tests/test_cam.py`:
172
+
173
+ ```python
174
+ from unittest.mock import patch, MagicMock
175
+ from core.cam import generate_gcode
176
+
177
+
178
+ class TestGenerateGcode:
179
+ def test_returns_failure_when_ocp_not_available(self):
180
+ """When ocp-freecad-cam is not installed, return a failure CAMResult."""
181
+ # The import of ocp_freecad_cam will fail in test environment
182
+ mock_shape = MagicMock()
183
+ result = generate_gcode(
184
+ shape=mock_shape,
185
+ operations=["pocket"],
186
+ tool_config={"diameter": 6, "h_feed": 800, "v_feed": 200, "speed": 18000},
187
+ post_processor="grbl",
188
+ )
189
+ assert result.success is False
190
+ assert "not available" in result.error.lower() or "not installed" in result.error.lower()
191
+
192
+ def test_returns_failure_on_empty_operations(self):
193
+ mock_shape = MagicMock()
194
+ result = generate_gcode(
195
+ shape=mock_shape,
196
+ operations=[],
197
+ )
198
+ assert result.success is False
199
+ assert "no operations" in result.error.lower()
200
+
201
+ def test_uses_default_tool_config_when_none(self):
202
+ mock_shape = MagicMock()
203
+ result = generate_gcode(
204
+ shape=mock_shape,
205
+ operations=["pocket"],
206
+ tool_config=None,
207
+ )
208
+ # Should still return a result (failure due to no ocp, but tool_config populated)
209
+ assert result.tool_config is not None
210
+ assert "diameter" in result.tool_config
211
+
212
+ def test_uses_default_post_processor(self):
213
+ mock_shape = MagicMock()
214
+ result = generate_gcode(shape=mock_shape, operations=["pocket"])
215
+ assert result.post_processor == "grbl"
216
+ ```
217
+
218
+ - [ ] **Step 2: Run test to verify it fails**
219
+
220
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_cam.py::TestGenerateGcode -v`
221
+ Expected: FAIL with `ImportError: cannot import name 'generate_gcode'`
222
+
223
+ - [ ] **Step 3: Write the generate_gcode function**
224
+
225
+ Add to `core/cam.py` after the existing code:
226
+
227
+ ```python
228
+ from config.settings import settings
229
+
230
+
231
+ def _get_default_tool_config() -> dict:
232
+ """Load default roughing tool config from config.yaml cam section."""
233
+ cam_cfg = getattr(settings, "cam", None)
234
+ if cam_cfg and isinstance(cam_cfg, dict):
235
+ tools = cam_cfg.get("tools", {})
236
+ roughing = tools.get("roughing", {})
237
+ if roughing:
238
+ return dict(roughing)
239
+ return {"diameter": 6, "h_feed": 800, "v_feed": 200, "speed": 18000}
240
+
241
+
242
+ def _get_default_post_processor() -> str:
243
+ """Load default post-processor from config.yaml cam section."""
244
+ cam_cfg = getattr(settings, "cam", None)
245
+ if cam_cfg and isinstance(cam_cfg, dict):
246
+ return cam_cfg.get("default_post_processor", "grbl")
247
+ return "grbl"
248
+
249
+
250
+ def _get_stock_offset() -> float:
251
+ """Load stock offset from config.yaml cam section."""
252
+ cam_cfg = getattr(settings, "cam", None)
253
+ if cam_cfg and isinstance(cam_cfg, dict):
254
+ return cam_cfg.get("stock_offset_mm", 2.0)
255
+ return 2.0
256
+
257
+
258
+ def generate_gcode(
259
+ shape,
260
+ operations: list[str],
261
+ tool_config: dict | None = None,
262
+ post_processor: str | None = None,
263
+ stock_offset_mm: float | None = None,
264
+ ) -> CAMResult:
265
+ """Generate G-code from a CadQuery Workplane shape.
266
+
267
+ Args:
268
+ shape: A cq.Workplane object from the executor.
269
+ operations: List of operation names: "adaptive", "pocket", "profile",
270
+ "face", "drill", "surface", "waterline".
271
+ tool_config: Dict with diameter, h_feed, v_feed, speed. Uses config defaults if None.
272
+ post_processor: Post-processor name (e.g. "grbl", "linuxcnc"). Uses config default if None.
273
+ stock_offset_mm: Stock offset from bounding box in mm. Uses config default if None.
274
+
275
+ Returns:
276
+ CAMResult with success status and G-code string.
277
+ """
278
+ if tool_config is None:
279
+ tool_config = _get_default_tool_config()
280
+ if post_processor is None:
281
+ post_processor = _get_default_post_processor()
282
+ if stock_offset_mm is None:
283
+ stock_offset_mm = _get_stock_offset()
284
+
285
+ if not operations:
286
+ return CAMResult(
287
+ success=False, gcode=None, operations=[], tool_config=tool_config,
288
+ post_processor=post_processor, error="No operations specified",
289
+ )
290
+
291
+ try:
292
+ from ocp_freecad_cam import Job, Endmill, Drill, Ballnose
293
+ from ocp_freecad_cam.fc_impl import Stock
294
+ except ImportError:
295
+ return CAMResult(
296
+ success=False, gcode=None, operations=operations,
297
+ tool_config=tool_config, post_processor=post_processor,
298
+ error="ocp-freecad-cam is not installed. Install it with: pip install ocp-freecad-cam (requires FreeCAD >= 1.0.1)",
299
+ )
300
+
301
+ try:
302
+ # Build tool from config
303
+ tool = Endmill(
304
+ diameter=tool_config.get("diameter", 6),
305
+ h_feed=tool_config.get("h_feed", 800),
306
+ v_feed=tool_config.get("v_feed", 200),
307
+ speed=tool_config.get("speed", 18000),
308
+ )
309
+
310
+ # Set up stock with offset from bounding box
311
+ so = stock_offset_mm
312
+ stock = Stock(xn=so, xp=so, yn=so, yp=so, zn=0, zp=so)
313
+
314
+ # Get top face as work plane
315
+ top = shape.faces(">Z").workplane()
316
+
317
+ # Create job
318
+ job = Job(top, shape, post_processor, stock=stock)
319
+
320
+ # Apply operations in order
321
+ for op in operations:
322
+ job = _apply_operation(job, shape, tool, op)
323
+
324
+ gcode = job.to_gcode()
325
+
326
+ return CAMResult(
327
+ success=True, gcode=gcode, operations=operations,
328
+ tool_config=tool_config, post_processor=post_processor,
329
+ )
330
+
331
+ except Exception as exc:
332
+ return CAMResult(
333
+ success=False, gcode=None, operations=operations,
334
+ tool_config=tool_config, post_processor=post_processor,
335
+ error=f"G-code generation failed: {exc}",
336
+ )
337
+
338
+
339
+ def _apply_operation(job, shape, tool, operation: str):
340
+ """Apply a single CAM operation to the job. Returns the updated job."""
341
+ op = operation.lower().strip()
342
+
343
+ if op == "adaptive":
344
+ # Adaptive clearing on recessed faces or full model
345
+ try:
346
+ faces = shape.faces(">Z[1]")
347
+ return job.adaptive(faces, tool, step_over=40, stock_to_leave="0.2 mm")
348
+ except Exception:
349
+ return job.adaptive(shape.faces(">Z"), tool, step_over=40)
350
+
351
+ elif op == "pocket":
352
+ try:
353
+ faces = shape.faces(">Z[1]")
354
+ return job.pocket(faces, tool, pattern="offset")
355
+ except Exception:
356
+ return job.pocket(shape.faces(">Z"), tool, pattern="zigzag")
357
+
358
+ elif op == "profile":
359
+ return job.profile(shape.faces("<Z"), tool, side="out")
360
+
361
+ elif op == "face":
362
+ return job.face(shape.faces(">Z"), tool)
363
+
364
+ elif op == "drill":
365
+ try:
366
+ from ocp_freecad_cam import Drill as DrillTool
367
+ drill = DrillTool(
368
+ diameter=tool._diameter if hasattr(tool, '_diameter') else 5,
369
+ h_feed=100, v_feed=50, speed=8000,
370
+ )
371
+ # Find circular holes on bottom face
372
+ holes = shape.faces("<Z")
373
+ return job.drill(holes, drill, peck_depth=2.0)
374
+ except Exception:
375
+ return job # Skip drill if no holes found
376
+
377
+ elif op == "surface":
378
+ return job.surface(None, tool, cut_pattern="zigzag")
379
+
380
+ elif op == "waterline":
381
+ return job.waterline(None, tool)
382
+
383
+ else:
384
+ return job # Unknown operation, skip
385
+ ```
386
+
387
+ - [ ] **Step 4: Run test to verify it passes**
388
+
389
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_cam.py -v`
390
+ Expected: All 8 tests PASS
391
+
392
+ - [ ] **Step 5: Run all existing tests to verify no regressions**
393
+
394
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest --tb=short -q`
395
+ Expected: All existing tests still pass
396
+
397
+ - [ ] **Step 6: Commit**
398
+
399
+ ```bash
400
+ git add core/cam.py tests/test_cam.py
401
+ git commit -m "feat(cam): add generate_gcode function with ocp-freecad-cam wrapper"
402
+ ```
403
+
404
+ ---
405
+
406
+ ### Task 3: Config — CAM agent and cam section
407
+
408
+ **Files:**
409
+ - Modify: `config.yaml`
410
+ - Create: `tests/test_cam_routing.py`
411
+
412
+ - [ ] **Step 1: Write the failing test for CAM routing keywords**
413
+
414
+ ```python
415
+ # tests/test_cam_routing.py
416
+ """Tests for CAM agent routing keywords loaded from config."""
417
+
418
+ from agents.routing import RoutingEngine
419
+
420
+
421
+ class TestCAMRouting:
422
+ def setup_method(self):
423
+ self.engine = RoutingEngine()
424
+
425
+ def test_cam_keywords_loaded(self):
426
+ assert "cam" in self.engine.keywords
427
+
428
+ def test_route_cam_keywords_toolpath(self):
429
+ agents = self.engine.route("Generate a toolpath for this part")
430
+ assert "cam" in agents
431
+
432
+ def test_route_cam_keywords_gcode(self):
433
+ agents = self.engine.route("Create gcode for CNC milling")
434
+ assert "cam" in agents
435
+
436
+ def test_route_cam_keywords_slicer(self):
437
+ agents = self.engine.route("Run the slicer on this model")
438
+ assert "cam" in agents
439
+
440
+ def test_cam_agent_exists_in_definitions(self):
441
+ from agents.definitions import AGENTS
442
+ assert "cam" in AGENTS
443
+
444
+ def test_cam_agent_has_color(self):
445
+ from agents.definitions import AGENT_COLORS
446
+ assert "cam" in AGENT_COLORS
447
+
448
+ def test_cam_agent_has_name(self):
449
+ from agents.definitions import AGENT_NAMES
450
+ assert AGENT_NAMES["cam"] == "CAM Agent"
451
+
452
+ def test_parse_mentions_cam(self):
453
+ cleaned, mentions = self.engine.parse_mentions("@cam generate toolpath")
454
+ assert "cam" in mentions
455
+ assert "@cam" not in cleaned
456
+ ```
457
+
458
+ - [ ] **Step 2: Run test to verify it fails**
459
+
460
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_cam_routing.py -v`
461
+ Expected: FAIL with `KeyError: 'cam'` or `AssertionError`
462
+
463
+ - [ ] **Step 3: Add CAM agent and cam config section to config.yaml**
464
+
465
+ Add after the existing `cad` agent block in `config.yaml` (around line 115):
466
+
467
+ ```yaml
468
+ cam:
469
+ name: CAM Agent
470
+ role: CAM Programmer
471
+ color: "#ff6b35"
472
+ avatar: CM
473
+ goal: >
474
+ Analyze generated 3D geometry and create optimal CNC machining
475
+ strategies including operation sequencing, tool selection, and
476
+ cutting parameters.
477
+ backstory: >
478
+ You are an expert CAM programmer who converts 3D CAD models into
479
+ efficient CNC machining programs. Given a part geometry and
480
+ material, you determine the optimal sequence of operations
481
+ (roughing then finishing), select appropriate tools, and specify
482
+ feeds and speeds. Output your machining plan as a structured JSON
483
+ with an "operations" array (values: adaptive, pocket, profile,
484
+ face, drill, surface, waterline), "tool" object with diameter,
485
+ h_feed, v_feed, speed fields, and "post_processor" string.
486
+ Always plan roughing before finishing. Consider tool access,
487
+ pocket depth ratios, and surface finish requirements.
488
+ ```
489
+
490
+ Add after the `export:` section (around line 42):
491
+
492
+ ```yaml
493
+ cam:
494
+ default_post_processor: grbl
495
+ stock_offset_mm: 2.0
496
+ default_step_over_percent: 40
497
+ tools:
498
+ roughing:
499
+ diameter: 6
500
+ h_feed: 800
501
+ v_feed: 200
502
+ speed: 18000
503
+ finishing:
504
+ diameter: 3
505
+ h_feed: 400
506
+ v_feed: 100
507
+ speed: 24000
508
+ drilling:
509
+ diameter: 5
510
+ h_feed: 100
511
+ v_feed: 50
512
+ speed: 8000
513
+ post_processors:
514
+ - grbl
515
+ - linuxcnc
516
+ - fanuc
517
+ - mach3_mach4
518
+ ```
519
+
520
+ Add `cam` to routing keywords section (after the `cnc:` keywords block):
521
+
522
+ ```yaml
523
+ cam:
524
+ - toolpath
525
+ - gcode
526
+ - g-code
527
+ - cam
528
+ - slicer
529
+ - slice
530
+ - machine program
531
+ - feeds and speeds
532
+ - post-processor
533
+ - roughing
534
+ - finishing
535
+ - operations
536
+ ```
537
+
538
+ - [ ] **Step 4: Reload and run test to verify it passes**
539
+
540
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_cam_routing.py -v`
541
+ Expected: All 8 tests PASS
542
+
543
+ - [ ] **Step 5: Run all existing tests to verify no regressions**
544
+
545
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest --tb=short -q`
546
+ Expected: All tests pass (definitions.py and routing.py auto-load from config)
547
+
548
+ - [ ] **Step 6: Commit**
549
+
550
+ ```bash
551
+ git add config.yaml tests/test_cam_routing.py
552
+ git commit -m "feat(cam): add CAM agent definition and cam config section"
553
+ ```
554
+
555
+ ---
556
+
557
+ ### Task 4: Orchestrator — CAM integration after CAD execution
558
+
559
+ **Files:**
560
+ - Modify: `agents/prompts.py`
561
+ - Modify: `agents/orchestrator.py`
562
+
563
+ - [ ] **Step 1: Add CAM trigger keywords to prompts.py**
564
+
565
+ In `agents/prompts.py`, add after the `CAD_TRIGGER_KEYWORDS` list (line 17):
566
+
567
+ ```python
568
+ CAM_TRIGGER_KEYWORDS: list[str] = [
569
+ "toolpath", "gcode", "g-code", "cam", "slicer", "slice",
570
+ "machine program", "operations", "roughing", "finishing",
571
+ ]
572
+ ```
573
+
574
+ In `build_orchestrator_system_prompt()`, update the agent list default (line 31) to include `cam` when relevant. Add after the existing CAD Coder instructions (around line 86):
575
+
576
+ ```python
577
+ f"- Include the CAM Agent when the user mentions toolpath, G-code, "
578
+ f"CAM, slicer, or machining operations. The CAM Agent must output "
579
+ f"a JSON plan with 'operations' (array of: adaptive, pocket, profile, "
580
+ f"face, drill, surface, waterline), 'tool' (object with diameter, "
581
+ f"h_feed, v_feed, speed), and 'post_processor' (string like 'grbl').\n"
582
+ ```
583
+
584
+ - [ ] **Step 2: Add CAM step to _execute_cad_code in orchestrator.py**
585
+
586
+ In `agents/orchestrator.py`, add import at the top (after line 10):
587
+
588
+ ```python
589
+ from core.cam import generate_gcode, CAMResultSerializer
590
+ ```
591
+
592
+ In the `_execute_cad_code` function, after the validation step (after line 130), add CAM generation. Modify the function to accept a `cam_plan` parameter and return CAM data in the preview dict.
593
+
594
+ Replace the return block in `_execute_cad_code` (lines 122-130) with:
595
+
596
+ ```python
597
+ # CNC validation
598
+ validation = validate_for_cnc(exec_result.result, part_name=part_name)
599
+
600
+ preview_data = {
601
+ "success": True,
602
+ "part_name": part_name,
603
+ "stl_url": f"/api/models/{part_name}.stl",
604
+ "step_url": f"/api/models/{part_name}.step",
605
+ "execution": ExecutionResultSerializer.to_dict(exec_result),
606
+ "validation": ValidationResultSerializer.to_dict(validation),
607
+ }
608
+
609
+ # CAM: generate G-code if cam_plan is provided
610
+ if cam_plan and isinstance(cam_plan, dict):
611
+ cam_operations = cam_plan.get("operations", [])
612
+ cam_tool = cam_plan.get("tool", None)
613
+ cam_post = cam_plan.get("post_processor", None)
614
+ if cam_operations:
615
+ cam_result = generate_gcode(
616
+ shape=exec_result.result,
617
+ operations=cam_operations,
618
+ tool_config=cam_tool,
619
+ post_processor=cam_post,
620
+ )
621
+ preview_data["cam"] = CAMResultSerializer.to_dict(cam_result)
622
+ if cam_result.success and cam_result.gcode:
623
+ # Save G-code file
624
+ gcode_path = output_dir / f"{part_name}.gcode"
625
+ gcode_path.write_text(cam_result.gcode)
626
+ preview_data["gcode_url"] = f"/api/models/{part_name}.gcode"
627
+
628
+ return preview_data
629
+ ```
630
+
631
+ Update the `_execute_cad_code` signature to accept `cam_plan`:
632
+
633
+ ```python
634
+ def _execute_cad_code(
635
+ code: str,
636
+ prompt: str,
637
+ output_dir: Path,
638
+ backend: LLMBackend | None = None,
639
+ max_retries: int = 2,
640
+ cam_plan: dict | None = None,
641
+ ) -> dict | None:
642
+ ```
643
+
644
+ - [ ] **Step 3: Parse CAM agent response in SingleCallOrchestrator**
645
+
646
+ In `SingleCallOrchestrator.chat_turn()`, after the loop that formats responses (around line 352), add CAM plan extraction:
647
+
648
+ ```python
649
+ # If CAD Coder responded with code, execute it (with retry)
650
+ if agent_id == "cad" and resp.get("code"):
651
+ # Check if CAM agent also responded with a plan
652
+ cam_plan = None
653
+ for cam_resp in agent_responses:
654
+ if cam_resp["id"] == "cam" and cam_resp.get("code"):
655
+ try:
656
+ import json
657
+ cam_plan = json.loads(cam_resp["code"])
658
+ except (json.JSONDecodeError, TypeError):
659
+ pass
660
+ preview = _execute_cad_code(
661
+ resp["code"], message, self.output_dir, backend=self.backend,
662
+ cam_plan=cam_plan,
663
+ )
664
+ ```
665
+
666
+ Do the same in `MockChatBackend.chat_turn()` (around line 198):
667
+
668
+ ```python
669
+ preview = _execute_cad_code(code, message, self.output_dir)
670
+ ```
671
+
672
+ (Mock mode does not generate CAM plans, so `cam_plan` stays `None`.)
673
+
674
+ - [ ] **Step 4: Run all tests**
675
+
676
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest --tb=short -q`
677
+ Expected: All tests pass
678
+
679
+ - [ ] **Step 5: Commit**
680
+
681
+ ```bash
682
+ git add agents/prompts.py agents/orchestrator.py
683
+ git commit -m "feat(cam): integrate CAM generation into orchestrator pipeline"
684
+ ```
685
+
686
+ ---
687
+
688
+ ### Task 5: API — G-code download endpoint
689
+
690
+ **Files:**
691
+ - Modify: `server/web.py`
692
+
693
+ - [ ] **Step 1: Add G-code endpoint to server/web.py**
694
+
695
+ After the `get_step` endpoint (line 197), add:
696
+
697
+ ```python
698
+ @app.get("/api/models/{name}.gcode")
699
+ async def get_gcode(name: str):
700
+ path = OUTPUT_DIR / f"{name}.gcode"
701
+ if not path.exists():
702
+ return JSONResponse({"error": f"G-code not found: {name}"}, status_code=404)
703
+ return FileResponse(path, media_type="text/plain", filename=f"{name}.gcode")
704
+ ```
705
+
706
+ - [ ] **Step 2: Run existing API tests**
707
+
708
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_api_routes.py -v`
709
+ Expected: All existing API tests pass
710
+
711
+ - [ ] **Step 3: Commit**
712
+
713
+ ```bash
714
+ git add server/web.py
715
+ git commit -m "feat(api): add GET /api/models/{name}.gcode endpoint"
716
+ ```
717
+
718
+ ---
719
+
720
+ ### Task 6: Frontend — G-code parser
721
+
722
+ **Files:**
723
+ - Modify: `web/index.html`
724
+
725
+ - [ ] **Step 1: Add G-code parser function**
726
+
727
+ In `web/index.html`, add after the `animate()` function (after line 1626), before the `loadSTL` function:
728
+
729
+ ```javascript
730
+ // ── G-CODE PARSER & TOOLPATH RENDERER ─────────────────
731
+
732
+ function parseGCode(gcodeString) {
733
+ const segments = [];
734
+ let pos = { x: 0, y: 0, z: 0 };
735
+ let mode = 'G0';
736
+ let feed = 0;
737
+ let tool = 0;
738
+ let absolute = true;
739
+
740
+ for (const rawLine of gcodeString.split('\n')) {
741
+ const line = rawLine.split(';')[0].split('(')[0].trim();
742
+ if (!line) continue;
743
+
744
+ const gMatch = line.match(/G(0?[0-3]|9[01])\b/);
745
+ const xMatch = line.match(/X([-\d.]+)/);
746
+ const yMatch = line.match(/Y([-\d.]+)/);
747
+ const zMatch = line.match(/Z([-\d.]+)/);
748
+ const fMatch = line.match(/F([\d.]+)/);
749
+ const tMatch = line.match(/T(\d+)/);
750
+ const iMatch = line.match(/I([-\d.]+)/);
751
+ const jMatch = line.match(/J([-\d.]+)/);
752
+
753
+ if (gMatch) {
754
+ const code = parseInt(gMatch[1]);
755
+ if (code <= 3) mode = 'G' + code;
756
+ else if (code === 90) absolute = true;
757
+ else if (code === 91) absolute = false;
758
+ }
759
+ if (fMatch) feed = parseFloat(fMatch[1]);
760
+ if (tMatch) tool = parseInt(tMatch[1]);
761
+
762
+ const hasMove = xMatch || yMatch || zMatch;
763
+ if (!hasMove) continue;
764
+
765
+ const newPos = absolute
766
+ ? {
767
+ x: xMatch ? parseFloat(xMatch[1]) : pos.x,
768
+ y: yMatch ? parseFloat(yMatch[1]) : pos.y,
769
+ z: zMatch ? parseFloat(zMatch[1]) : pos.z,
770
+ }
771
+ : {
772
+ x: pos.x + (xMatch ? parseFloat(xMatch[1]) : 0),
773
+ y: pos.y + (yMatch ? parseFloat(yMatch[1]) : 0),
774
+ z: pos.z + (zMatch ? parseFloat(zMatch[1]) : 0),
775
+ };
776
+
777
+ if (mode === 'G0' || mode === 'G1') {
778
+ segments.push({
779
+ type: mode, start: { ...pos }, end: { ...newPos }, feed, tool,
780
+ });
781
+ } else if (mode === 'G2' || mode === 'G3') {
782
+ const cx = pos.x + (iMatch ? parseFloat(iMatch[1]) : 0);
783
+ const cy = pos.y + (jMatch ? parseFloat(jMatch[1]) : 0);
784
+ segments.push({
785
+ type: mode, start: { ...pos }, end: { ...newPos },
786
+ center: { x: cx, y: cy }, feed, tool,
787
+ });
788
+ }
789
+
790
+ pos = newPos;
791
+ }
792
+ return segments;
793
+ }
794
+
795
+ function tessellateArc(seg) {
796
+ const points = [];
797
+ const cx = seg.center.x;
798
+ const cy = seg.center.y;
799
+ const startAngle = Math.atan2(seg.start.y - cy, seg.start.x - cx);
800
+ const endAngle = Math.atan2(seg.end.y - cy, seg.end.x - cx);
801
+ const radius = Math.sqrt(
802
+ (seg.start.x - cx) ** 2 + (seg.start.y - cy) ** 2
803
+ );
804
+
805
+ let sweep = endAngle - startAngle;
806
+ if (seg.type === 'G2') {
807
+ if (sweep > 0) sweep -= 2 * Math.PI;
808
+ } else {
809
+ if (sweep < 0) sweep += 2 * Math.PI;
810
+ }
811
+
812
+ const steps = Math.max(Math.ceil(Math.abs(sweep) / (Math.PI / 90)), 4);
813
+ const zStep = (seg.end.z - seg.start.z) / steps;
814
+
815
+ for (let i = 0; i <= steps; i++) {
816
+ const t = i / steps;
817
+ const angle = startAngle + sweep * t;
818
+ points.push({
819
+ x: cx + radius * Math.cos(angle),
820
+ y: cy + radius * Math.sin(angle),
821
+ z: seg.start.z + zStep * i,
822
+ });
823
+ }
824
+ return points;
825
+ }
826
+ ```
827
+
828
+ - [ ] **Step 2: Verify no syntax errors**
829
+
830
+ Run dev server: `cd /home/daniel/NeuralCAD && python -m server.web --port 5000 &`
831
+ Open http://localhost:5000 in browser. Check browser console for JavaScript errors.
832
+ Expected: No errors. Page loads normally.
833
+
834
+ - [ ] **Step 3: Commit**
835
+
836
+ ```bash
837
+ git add web/index.html
838
+ git commit -m "feat(frontend): add G-code parser with G0/G1/G2/G3 and arc tessellation"
839
+ ```
840
+
841
+ ---
842
+
843
+ ### Task 7: Frontend — Toolpath 3D renderer and view toggle
844
+
845
+ **Files:**
846
+ - Modify: `web/index.html`
847
+
848
+ - [ ] **Step 1: Add toolpath renderer and view toggle state**
849
+
850
+ Add after the `tessellateArc` function:
851
+
852
+ ```javascript
853
+ let gcodeGroup = null;
854
+ let currentViewMode = 'part'; // 'part' | 'toolpath' | 'overlay'
855
+
856
+ function renderToolpath(segments) {
857
+ // Remove old toolpath
858
+ if (gcodeGroup) {
859
+ scene.remove(gcodeGroup);
860
+ gcodeGroup.traverse(child => {
861
+ if (child.geometry) child.geometry.dispose();
862
+ if (child.material) child.material.dispose();
863
+ });
864
+ }
865
+ gcodeGroup = new THREE.Group();
866
+
867
+ const rapids = [];
868
+ const cuts = [];
869
+
870
+ for (const seg of segments) {
871
+ if (seg.type === 'G2' || seg.type === 'G3') {
872
+ const pts = tessellateArc(seg);
873
+ for (let i = 0; i < pts.length - 1; i++) {
874
+ cuts.push(pts[i].x, pts[i].z, pts[i].y,
875
+ pts[i+1].x, pts[i+1].z, pts[i+1].y);
876
+ }
877
+ } else if (seg.type === 'G0') {
878
+ rapids.push(seg.start.x, seg.start.z, seg.start.y,
879
+ seg.end.x, seg.end.z, seg.end.y);
880
+ } else {
881
+ cuts.push(seg.start.x, seg.start.z, seg.start.y,
882
+ seg.end.x, seg.end.z, seg.end.y);
883
+ }
884
+ }
885
+
886
+ if (rapids.length) {
887
+ const geo = new THREE.BufferGeometry();
888
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(rapids, 3));
889
+ gcodeGroup.add(new THREE.LineSegments(geo,
890
+ new THREE.LineBasicMaterial({ color: 0xff4444, opacity: 0.3, transparent: true })));
891
+ }
892
+
893
+ if (cuts.length) {
894
+ const geo = new THREE.BufferGeometry();
895
+ geo.setAttribute('position', new THREE.Float32BufferAttribute(cuts, 3));
896
+ gcodeGroup.add(new THREE.LineSegments(geo,
897
+ new THREE.LineBasicMaterial({ color: 0x00b4d8 })));
898
+ }
899
+
900
+ // Center the toolpath
901
+ const box = new THREE.Box3().setFromObject(gcodeGroup);
902
+ const center = new THREE.Vector3();
903
+ box.getCenter(center);
904
+ gcodeGroup.position.sub(center);
905
+
906
+ scene.add(gcodeGroup);
907
+ applyViewMode();
908
+ }
909
+
910
+ function setViewMode(mode) {
911
+ currentViewMode = mode;
912
+ applyViewMode();
913
+ document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
914
+ const btn = document.getElementById('view-' + mode);
915
+ if (btn) btn.classList.add('active');
916
+ }
917
+
918
+ function applyViewMode() {
919
+ if (!currentMesh && !gcodeGroup) return;
920
+ if (currentViewMode === 'part') {
921
+ if (currentMesh) currentMesh.visible = true;
922
+ if (gcodeGroup) gcodeGroup.visible = false;
923
+ if (currentMesh && currentMesh.material) {
924
+ currentMesh.material.transparent = false;
925
+ currentMesh.material.opacity = 1;
926
+ }
927
+ } else if (currentViewMode === 'toolpath') {
928
+ if (currentMesh) currentMesh.visible = false;
929
+ if (gcodeGroup) gcodeGroup.visible = true;
930
+ } else if (currentViewMode === 'overlay') {
931
+ if (currentMesh) {
932
+ currentMesh.visible = true;
933
+ currentMesh.material.transparent = true;
934
+ currentMesh.material.opacity = 0.3;
935
+ }
936
+ if (gcodeGroup) gcodeGroup.visible = true;
937
+ }
938
+ }
939
+ ```
940
+
941
+ - [ ] **Step 2: Add view toggle buttons to viewport HTML**
942
+
943
+ Find the viewport container section in the HTML (search for `viewer-empty` or `viewport`). Add a toolbar div inside the viewport area. Look for the 3D viewport section and add right after the `<div id="viewport">` opening tag:
944
+
945
+ ```html
946
+ <div id="view-toolbar" style="position:absolute;top:8px;right:8px;z-index:10;display:none;gap:4px;">
947
+ <button class="view-btn active" id="view-part" onclick="setViewMode('part')"
948
+ style="padding:4px 10px;font-size:11px;font-family:var(--font-mono);background:var(--bg-surface);
949
+ color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;cursor:pointer;">Part</button>
950
+ <button class="view-btn" id="view-toolpath" onclick="setViewMode('toolpath')"
951
+ style="padding:4px 10px;font-size:11px;font-family:var(--font-mono);background:var(--bg-surface);
952
+ color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;cursor:pointer;">Toolpath</button>
953
+ <button class="view-btn" id="view-overlay" onclick="setViewMode('overlay')"
954
+ style="padding:4px 10px;font-size:11px;font-family:var(--font-mono);background:var(--bg-surface);
955
+ color:var(--text-secondary);border:1px solid var(--border);border-radius:4px;cursor:pointer;">Overlay</button>
956
+ </div>
957
+ ```
958
+
959
+ Add CSS for active state (in the `<style>` section):
960
+
961
+ ```css
962
+ .view-btn.active { background: var(--accent-dim) !important; color: var(--text-primary) !important; border-color: var(--accent) !important; }
963
+ ```
964
+
965
+ - [ ] **Step 3: Wire G-code rendering into the chat response handler**
966
+
967
+ In the `sendMessage` function, after `data.preview.success` handling (around line 1771), where `loadSTL` is called, add:
968
+
969
+ ```javascript
970
+ // If G-code/CAM data available, render toolpath
971
+ if (data.preview.cam && data.preview.cam.success && data.preview.cam.gcode) {
972
+ const segments = parseGCode(data.preview.cam.gcode);
973
+ if (segments.length > 0) {
974
+ renderToolpath(segments);
975
+ document.getElementById('view-toolbar').style.display = 'flex';
976
+ }
977
+ }
978
+ ```
979
+
980
+ - [ ] **Step 4: Add G-code download button**
981
+
982
+ In the `updateDownloads` function (around line 2056), add after the existing download links:
983
+
984
+ ```javascript
985
+ const dlGcode = document.getElementById('dl-gcode');
986
+ if (dlGcode) {
987
+ const gcodePath = '/api/models/' + partName + '.gcode';
988
+ fetch(gcodePath, { method: 'HEAD' }).then(r => {
989
+ dlGcode.style.display = r.ok ? 'inline-flex' : 'none';
990
+ dlGcode.href = gcodePath;
991
+ }).catch(() => { dlGcode.style.display = 'none'; });
992
+ }
993
+ ```
994
+
995
+ Find the download buttons HTML section and add a G-code download button:
996
+
997
+ ```html
998
+ <a id="dl-gcode" class="dl-btn" download style="display:none">
999
+ <span class="dl-icon">&#8615;</span> G-CODE
1000
+ </a>
1001
+ ```
1002
+
1003
+ Also add a G-code download link in the `renderGallery` function, after the STL link (around line 2144):
1004
+
1005
+ ```javascript
1006
+ html += '<a class="gallery-dl" href="/api/models/' + name + '.gcode" download>GCODE</a>';
1007
+ ```
1008
+
1009
+ - [ ] **Step 5: Add agent color for CAM**
1010
+
1011
+ In the CSS `:root` section, add:
1012
+
1013
+ ```css
1014
+ --agent-cam: #ff6b35;
1015
+ ```
1016
+
1017
+ In the `sendMessage` @mention regex (line 1714), update to include `cam`:
1018
+
1019
+ ```javascript
1020
+ const mentionRegex = /@(design|engineering|cnc|cad|cam)\b/gi;
1021
+ ```
1022
+
1023
+ - [ ] **Step 6: Verify in browser**
1024
+
1025
+ Open http://localhost:5000, check:
1026
+ 1. No JavaScript errors in console
1027
+ 2. View toggle buttons appear (hidden until toolpath is loaded)
1028
+ 3. G-code download button appears conditionally
1029
+
1030
+ - [ ] **Step 7: Commit**
1031
+
1032
+ ```bash
1033
+ git add web/index.html
1034
+ git commit -m "feat(frontend): add 3D toolpath renderer, view toggle, and G-code download"
1035
+ ```
1036
+
1037
+ ---
1038
+
1039
+ ### Task 8: Integration test and final verification
1040
+
1041
+ **Files:**
1042
+ - All modified files
1043
+
1044
+ - [ ] **Step 1: Run full test suite**
1045
+
1046
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest --tb=short -q`
1047
+ Expected: All tests pass including new test_cam.py and test_cam_routing.py
1048
+
1049
+ - [ ] **Step 2: Verify config loads correctly**
1050
+
1051
+ Run:
1052
+ ```bash
1053
+ cd /home/daniel/NeuralCAD && python -c "
1054
+ from config.settings import settings
1055
+ from agents.definitions import AGENTS
1056
+ from agents.routing import RoutingEngine
1057
+
1058
+ print('CAM agent:', AGENTS['cam'].name, AGENTS['cam'].color)
1059
+ print('CAM config:', settings.cam if hasattr(settings, 'cam') else 'N/A')
1060
+ engine = RoutingEngine()
1061
+ print('CAM routing:', engine.route('generate a toolpath'))
1062
+ print('All agents:', list(AGENTS.keys()))
1063
+ "
1064
+ ```
1065
+ Expected: Shows CAM Agent with color #ff6b35, cam config with tools, routing includes "cam"
1066
+
1067
+ - [ ] **Step 3: Verify G-code endpoint**
1068
+
1069
+ Run:
1070
+ ```bash
1071
+ # Create a test gcode file
1072
+ echo "G21 G90\nG00 X0 Y0 Z10\nG01 X50 Y50 Z-2 F800\nM30" > /home/daniel/NeuralCAD/output/test_part.gcode
1073
+ # Start server and test endpoint
1074
+ cd /home/daniel/NeuralCAD && timeout 5 python -c "
1075
+ from server.web import app
1076
+ from fastapi.testclient import TestClient
1077
+ client = TestClient(app)
1078
+ resp = client.get('/api/models/test_part.gcode')
1079
+ print('Status:', resp.status_code)
1080
+ print('Content-Type:', resp.headers.get('content-type'))
1081
+ " 2>/dev/null || true
1082
+ rm -f /home/daniel/NeuralCAD/output/test_part.gcode
1083
+ ```
1084
+ Expected: Status 200, Content-Type text/plain
1085
+
1086
+ - [ ] **Step 4: Manual browser test**
1087
+
1088
+ Start dev server and test the full flow:
1089
+ 1. Open http://localhost:5000
1090
+ 2. Type a design prompt (e.g., "Design a 60mm wide mounting bracket with M4 holes")
1091
+ 3. Wait for agents to respond and CAD model to generate
1092
+ 4. Type "@cam generate toolpath for this part" (or "generate gcode")
1093
+ 5. Verify:
1094
+ - CAM Agent responds with operation plan
1095
+ - Toolpath renders as colored lines in 3D viewport
1096
+ - View toggle buttons appear (Part / Toolpath / Overlay)
1097
+ - G-code download button appears
1098
+ - Switching views works correctly
1099
+
1100
+ - [ ] **Step 5: Final commit with all changes**
1101
+
1102
+ ```bash
1103
+ git add -A
1104
+ git status # verify only expected files
1105
+ git commit -m "feat: integrate CNC slicer, 3D toolpath preview, and CAM agent
1106
+
1107
+ Adds ocp-freecad-cam wrapper (core/cam.py), CAM agent with tool/operation
1108
+ selection, 3D G-code toolpath rendering in Three.js viewport with
1109
+ Part/Toolpath/Overlay view toggle, and G-code download endpoint."
1110
+ ```