CallMeDaniel Claude Opus 4.6 (1M context) commited on
Commit
bc9d2b2
·
1 Parent(s): 6f93f91

chore: clean up for HF Spaces deployment

Browse files

- Remove old root-level .py files (now in core/, server/ packages)
- Remove crewai from deps (optional, not in uv.lock)
- Add .env to .gitignore
- Add env var passthrough in docker-compose.yml
- Add multi-agent chat design spec

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

.gitignore CHANGED
@@ -4,3 +4,4 @@ output/
4
  .superpowers/
5
  .venv/
6
  .worktrees/
 
 
4
  .superpowers/
5
  .venv/
6
  .worktrees/
7
+ .env
cadquery_system_prompt.py DELETED
@@ -1,151 +0,0 @@
1
- """
2
- System prompt and few-shot examples for the CadQuery code generation LLM.
3
- This module defines the domain knowledge the LLM needs to produce valid,
4
- CNC-machinable CadQuery scripts.
5
- """
6
-
7
- CADQUERY_SYSTEM_PROMPT = """\
8
- You are an expert CNC machinist and CAD engineer. Your job is to generate
9
- CadQuery Python code that creates a 3D solid model from a natural-language
10
- description of a mechanical part.
11
-
12
- ## Rules
13
- 1. Output ONLY valid Python code. No markdown fences, no explanations.
14
- 2. Always `import cadquery as cq` at the top.
15
- 3. The final result MUST be assigned to a variable called `result` and must be
16
- a `cq.Workplane` object (not a raw Shape).
17
- 4. Design for CNC machinability:
18
- - Prefer prismatic geometry (boxes, cylinders, slots, pockets, holes).
19
- - Add fillets >= 1mm on internal corners (tool radius constraint).
20
- - Avoid undercuts that a 3-axis mill cannot reach.
21
- - Avoid infinitely thin walls — minimum 1.5mm wall thickness.
22
- - Chamfer sharp external edges (0.5mm default) for deburring.
23
- 5. Use millimeters as the unit system.
24
- 6. Keep the code concise but readable. Add a brief comment header describing
25
- the part.
26
- 7. If the description is ambiguous, make reasonable engineering assumptions
27
- and note them in comments.
28
- 8. Center the part on the origin when practical.
29
-
30
- ## CadQuery Quick Reference
31
- - `cq.Workplane("XY")` — start a workplane
32
- - `.box(length, width, height)` — centered box
33
- - `.cylinder(height, radius)` — centered cylinder
34
- - `.hole(diameter)` — through hole
35
- - `.cboreHole(diameter, cboreDiameter, cboreDepth)` — counterbore hole
36
- - `.cskHole(diameter, cskDiameter, cskAngle)` — countersink hole
37
- - `.slot2D(length, width)` — 2D slot profile, then extrude
38
- - `.rect(x, y)` / `.circle(radius)` — 2D sketch primitives
39
- - `.extrude(distance)` — extrude sketch into solid
40
- - `.cut(other_solid)` — boolean subtract
41
- - `.union(other_solid)` — boolean add
42
- - `.fillet(radius)` — fillet edges
43
- - `.chamfer(distance)` — chamfer edges
44
- - `.faces(">Z")` / `.faces("<Z")` — select top/bottom faces
45
- - `.edges("|Z")` — select edges parallel to Z
46
- - `.pushPoints([(x,y), ...])` — array of features
47
- - `.polarArray(radius, startAngle, angle, count)` — circular pattern
48
- - `.workplane(offset=d)` — offset workplane
49
- - `.transformed(offset=(x,y,z), rotate=(rx,ry,rz))` — transformed workplane
50
-
51
- ## Output Format
52
- Return ONLY the Python code. Nothing else.
53
- """
54
-
55
- FEW_SHOT_EXAMPLES = [
56
- {
57
- "prompt": "A simple mounting bracket with two M5 bolt holes, 60mm wide, 40mm tall, 5mm thick",
58
- "code": """\
59
- import cadquery as cq
60
-
61
- # Mounting bracket: 60x40x5mm plate with two M5 (5.5mm clearance) holes
62
- # Holes spaced 40mm apart, centered horizontally, 15mm from top
63
-
64
- result = (
65
- cq.Workplane("XY")
66
- .box(60, 40, 5) # Main plate
67
- .faces(">Z")
68
- .workplane()
69
- .pushPoints([(-20, 5), (20, 5)]) # Two hole positions
70
- .hole(5.5) # M5 clearance holes
71
- .edges("|Z")
72
- .fillet(2) # Fillet vertical edges for machinability
73
- .edges(">Z")
74
- .chamfer(0.5) # Chamfer top edges
75
- )
76
- """
77
- },
78
- {
79
- "prompt": "A cylindrical spacer, 25mm outer diameter, 10mm inner hole, 15mm tall",
80
- "code": """\
81
- import cadquery as cq
82
-
83
- # Cylindrical spacer: OD=25mm, ID=10mm, height=15mm
84
- # Chamfered top and bottom edges for deburring
85
-
86
- result = (
87
- cq.Workplane("XY")
88
- .cylinder(15, 25 / 2) # OD=25mm cylinder, height=15mm
89
- .faces(">Z")
90
- .workplane()
91
- .hole(10) # 10mm through hole
92
- .edges()
93
- .chamfer(0.5) # Chamfer all edges
94
- )
95
- """
96
- },
97
- {
98
- "prompt": "An L-shaped bracket, 50mm on each arm, 20mm wide, 4mm thick, with a 6mm hole in each arm",
99
- "code": """\
100
- import cadquery as cq
101
-
102
- # L-shaped bracket: two arms 50mm each, 20mm wide, 4mm thick
103
- # One 6mm hole centered in each arm
104
- # Internal corner filleted for CNC tool access
105
-
106
- # Build as a 2D profile extruded to width
107
- result = (
108
- cq.Workplane("XZ")
109
- .moveTo(0, 0)
110
- .lineTo(50, 0) # Horizontal arm
111
- .lineTo(50, 4)
112
- .lineTo(4, 4)
113
- .lineTo(4, 50) # Vertical arm
114
- .lineTo(0, 50)
115
- .close()
116
- .extrude(20) # Extrude to 20mm width
117
-
118
- # Internal fillet for CNC machinability (min tool radius)
119
- .edges("|Y").fillet(3)
120
-
121
- # Hole in horizontal arm
122
- .faces(">Z")
123
- .workplane(centerOption="CenterOfBoundBox")
124
- .center(25, 0)
125
- .hole(6)
126
-
127
- # Hole in vertical arm
128
- .faces(">X")
129
- .workplane(centerOption="CenterOfBoundBox")
130
- .center(0, 25)
131
- .hole(6)
132
-
133
- # Chamfer external edges
134
- .edges().chamfer(0.5)
135
- )
136
- """
137
- },
138
- ]
139
-
140
-
141
- def build_messages(user_prompt: str) -> list[dict]:
142
- """Build the message list for the LLM API call, including system prompt
143
- and few-shot examples."""
144
- messages = [{"role": "system", "content": CADQUERY_SYSTEM_PROMPT}]
145
-
146
- for ex in FEW_SHOT_EXAMPLES:
147
- messages.append({"role": "user", "content": ex["prompt"]})
148
- messages.append({"role": "assistant", "content": ex["code"]})
149
-
150
- messages.append({"role": "user", "content": user_prompt})
151
- return messages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cnc_validator.py DELETED
@@ -1,224 +0,0 @@
1
- """
2
- CNC Manufacturability Validator.
3
- Checks a CadQuery solid for common CNC machining issues:
4
- - Thin walls
5
- - Sharp internal corners (no fillet / too small for tool)
6
- - Deep narrow pockets (aspect ratio)
7
- - Overall size feasibility
8
- - Undercut detection (basic heuristic)
9
- """
10
-
11
- from dataclasses import dataclass, field
12
- from typing import Optional
13
-
14
- import cadquery as cq
15
-
16
-
17
- @dataclass
18
- class CNCIssue:
19
- severity: str # "error", "warning", "info"
20
- category: str
21
- message: str
22
-
23
-
24
- @dataclass
25
- class CNCValidationResult:
26
- part_name: str
27
- issues: list[CNCIssue] = field(default_factory=list)
28
- machinable: bool = True
29
- axis_recommendation: str = "3-axis"
30
-
31
- @property
32
- def error_count(self) -> int:
33
- return sum(1 for i in self.issues if i.severity == "error")
34
-
35
- @property
36
- def warning_count(self) -> int:
37
- return sum(1 for i in self.issues if i.severity == "warning")
38
-
39
- def summary(self) -> str:
40
- status = "PASS" if self.machinable else "FAIL"
41
- lines = [
42
- f"CNC Validation [{status}] — {self.part_name}",
43
- f" Recommended: {self.axis_recommendation} milling",
44
- f" Errors: {self.error_count} | Warnings: {self.warning_count}",
45
- ]
46
- for issue in self.issues:
47
- icon = {"error": "✗", "warning": "⚠", "info": "ℹ"}[issue.severity]
48
- lines.append(f" {icon} [{issue.category}] {issue.message}")
49
- return "\n".join(lines)
50
-
51
-
52
- # --- Configurable thresholds ---
53
-
54
- DEFAULT_CONFIG = {
55
- "min_wall_thickness_mm": 1.5,
56
- "min_fillet_radius_mm": 1.0, # Typical smallest endmill radius
57
- "max_pocket_depth_ratio": 4.0, # depth / width ratio
58
- "max_part_size_mm": 500.0, # Typical CNC work envelope
59
- "min_part_size_mm": 1.0,
60
- "min_hole_diameter_mm": 1.0,
61
- }
62
-
63
-
64
- def validate_for_cnc(
65
- workplane: cq.Workplane,
66
- part_name: str = "Part",
67
- config: Optional[dict] = None,
68
- ) -> CNCValidationResult:
69
- """
70
- Run manufacturability checks on a CadQuery solid.
71
- Returns a CNCValidationResult with issues found.
72
- """
73
- cfg = {**DEFAULT_CONFIG, **(config or {})}
74
- result = CNCValidationResult(part_name=part_name)
75
- shape = workplane.val()
76
- bb = shape.BoundingBox()
77
-
78
- # --- 1. Bounding box / size checks ---
79
- dims = sorted([bb.xlen, bb.ylen, bb.zlen])
80
- max_dim = dims[-1]
81
- min_dim = dims[0]
82
-
83
- if max_dim > cfg["max_part_size_mm"]:
84
- result.issues.append(
85
- CNCIssue(
86
- "error",
87
- "Size",
88
- f"Part too large: {max_dim:.1f}mm exceeds {cfg['max_part_size_mm']}mm work envelope",
89
- )
90
- )
91
- result.machinable = False
92
-
93
- if min_dim < cfg["min_part_size_mm"]:
94
- result.issues.append(
95
- CNCIssue(
96
- "warning",
97
- "Size",
98
- f"Very small dimension: {min_dim:.2f}mm — may be difficult to fixture",
99
- )
100
- )
101
-
102
- # --- 2. Volume sanity check ---
103
- volume = shape.Volume()
104
- bb_volume = bb.xlen * bb.ylen * bb.zlen
105
- if bb_volume > 0:
106
- fill_ratio = volume / bb_volume
107
- if fill_ratio < 0.05:
108
- result.issues.append(
109
- CNCIssue(
110
- "warning",
111
- "Geometry",
112
- f"Very low fill ratio ({fill_ratio:.1%}) — complex geometry, high machining time",
113
- )
114
- )
115
- result.issues.append(
116
- CNCIssue(
117
- "info",
118
- "Geometry",
119
- f"Fill ratio: {fill_ratio:.1%} (volume/bounding box)",
120
- )
121
- )
122
-
123
- # --- 3. Face and edge complexity ---
124
- faces = workplane.faces().vals()
125
- edges = workplane.edges().vals()
126
-
127
- n_faces = len(faces)
128
- n_edges = len(edges)
129
-
130
- if n_faces > 100:
131
- result.issues.append(
132
- CNCIssue(
133
- "warning",
134
- "Complexity",
135
- f"{n_faces} faces detected — may require multi-setup or 5-axis",
136
- )
137
- )
138
- result.axis_recommendation = "5-axis"
139
- elif n_faces > 50:
140
- result.issues.append(
141
- CNCIssue(
142
- "info",
143
- "Complexity",
144
- f"{n_faces} faces — consider 4-axis or indexed 5-axis",
145
- )
146
- )
147
- result.axis_recommendation = "3+2 axis"
148
-
149
- # --- 4. Edge length analysis (thin feature proxy) ---
150
- edge_lengths = []
151
- for edge in edges:
152
- try:
153
- edge_lengths.append(edge.Length())
154
- except Exception:
155
- pass
156
-
157
- if edge_lengths:
158
- min_edge = min(edge_lengths)
159
- if min_edge < cfg["min_wall_thickness_mm"]:
160
- result.issues.append(
161
- CNCIssue(
162
- "warning",
163
- "Thin Feature",
164
- f"Shortest edge: {min_edge:.2f}mm — below min wall thickness "
165
- f"({cfg['min_wall_thickness_mm']}mm)",
166
- )
167
- )
168
-
169
- # --- 5. Aspect ratio check (deep pocket heuristic) ---
170
- # Only flag if the narrowest dimension is small enough to be a pocket/slot
171
- if dims[0] > 0 and dims[0] < 20:
172
- aspect = dims[2] / dims[0] # tallest / narrowest
173
- if aspect > cfg["max_pocket_depth_ratio"]:
174
- result.issues.append(
175
- CNCIssue(
176
- "warning",
177
- "Deep Feature",
178
- f"Aspect ratio {aspect:.1f}:1 — may require long-reach tooling or "
179
- f"special fixturing",
180
- )
181
- )
182
-
183
- # --- 6. Surface type analysis ---
184
- has_freeform = False
185
- planar_count = 0
186
- cylindrical_count = 0
187
-
188
- for face in faces:
189
- try:
190
- geom_type = face.geomType()
191
- if geom_type == "PLANE":
192
- planar_count += 1
193
- elif geom_type == "CYLINDER":
194
- cylindrical_count += 1
195
- elif geom_type in ("BSPLINE", "BEZIER", "OTHER"):
196
- has_freeform = True
197
- except Exception:
198
- pass
199
-
200
- if has_freeform:
201
- result.issues.append(
202
- CNCIssue(
203
- "warning",
204
- "Surface",
205
- "Freeform/spline surfaces detected — requires 3D contouring toolpaths",
206
- )
207
- )
208
- if result.axis_recommendation == "3-axis":
209
- result.axis_recommendation = "3-axis (with 3D finishing)"
210
-
211
- result.issues.append(
212
- CNCIssue(
213
- "info",
214
- "Surface",
215
- f"Faces: {planar_count} planar, {cylindrical_count} cylindrical, "
216
- f"{n_faces - planar_count - cylindrical_count} other",
217
- )
218
- )
219
-
220
- # --- 7. Set final machinable flag ---
221
- if result.error_count > 0:
222
- result.machinable = False
223
-
224
- return result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
code_executor.py DELETED
@@ -1,189 +0,0 @@
1
- """
2
- Safe CadQuery code execution engine.
3
- Executes LLM-generated CadQuery code in a sandboxed namespace,
4
- validates the result, and exports to STEP/STL.
5
- """
6
-
7
- import io
8
- import sys
9
- import traceback
10
- from dataclasses import dataclass, field
11
- from pathlib import Path
12
- from typing import Optional
13
-
14
- import cadquery as cq
15
-
16
-
17
- @dataclass
18
- class ExecutionResult:
19
- """Result of executing a CadQuery script."""
20
- success: bool
21
- result: Optional[cq.Workplane] = None
22
- code: str = ""
23
- error: Optional[str] = None
24
- stdout: str = ""
25
- volume: float = 0.0
26
- bounding_box: tuple = ()
27
- face_count: int = 0
28
- edge_count: int = 0
29
-
30
- def summary(self) -> str:
31
- if not self.success:
32
- return f"FAILED: {self.error}"
33
- bb = self.bounding_box
34
- return (
35
- f"OK | Volume: {self.volume:.1f} mm³ | "
36
- f"BBox: {bb[0]:.1f}×{bb[1]:.1f}×{bb[2]:.1f} mm | "
37
- f"Faces: {self.face_count} | Edges: {self.edge_count}"
38
- )
39
-
40
-
41
- # Allowed imports in the sandboxed namespace
42
- SAFE_NAMESPACE = {
43
- "cq": cq,
44
- "cadquery": cq,
45
- "math": __import__("math"),
46
- "__builtins__": {
47
- "range": range,
48
- "len": len,
49
- "abs": abs,
50
- "min": min,
51
- "max": max,
52
- "round": round,
53
- "int": int,
54
- "float": float,
55
- "tuple": tuple,
56
- "list": list,
57
- "True": True,
58
- "False": False,
59
- "None": None,
60
- "print": print,
61
- "enumerate": enumerate,
62
- "zip": zip,
63
- "__import__": __import__,
64
- },
65
- }
66
-
67
-
68
- def sanitize_code(code: str) -> str:
69
- """Clean up LLM output — strip markdown fences, trailing whitespace,
70
- and redundant import statements (already in namespace)."""
71
- code = code.strip()
72
-
73
- # Remove markdown code fences if present
74
- if code.startswith("```python"):
75
- code = code[len("```python"):]
76
- elif code.startswith("```"):
77
- code = code[3:]
78
- if code.endswith("```"):
79
- code = code[:-3]
80
-
81
- # Strip import lines for modules already in namespace
82
- lines = code.strip().splitlines()
83
- cleaned = []
84
- for line in lines:
85
- stripped = line.strip()
86
- # Keep the line unless it's a redundant cadquery/math import
87
- if stripped.startswith("import cadquery") or stripped.startswith("from cadquery"):
88
- continue
89
- if stripped == "import math":
90
- continue
91
- cleaned.append(line)
92
-
93
- return "\n".join(cleaned).strip()
94
-
95
-
96
- def execute_cadquery(code: str) -> ExecutionResult:
97
- """
98
- Execute a CadQuery script string and return the result.
99
- The script must assign its output to a variable called `result`.
100
- """
101
- code = sanitize_code(code)
102
-
103
- # Capture stdout
104
- old_stdout = sys.stdout
105
- sys.stdout = captured = io.StringIO()
106
-
107
- namespace = dict(SAFE_NAMESPACE)
108
-
109
- try:
110
- exec(code, namespace) # noqa: S102
111
- except Exception:
112
- sys.stdout = old_stdout
113
- return ExecutionResult(
114
- success=False,
115
- code=code,
116
- error=traceback.format_exc(),
117
- stdout=captured.getvalue(),
118
- )
119
-
120
- sys.stdout = old_stdout
121
- stdout_text = captured.getvalue()
122
-
123
- # Extract the result
124
- result_obj = namespace.get("result")
125
- if result_obj is None:
126
- return ExecutionResult(
127
- success=False,
128
- code=code,
129
- error="Script did not assign a value to `result`.",
130
- stdout=stdout_text,
131
- )
132
-
133
- if not isinstance(result_obj, cq.Workplane):
134
- return ExecutionResult(
135
- success=False,
136
- code=code,
137
- error=f"Expected cq.Workplane, got {type(result_obj).__name__}",
138
- stdout=stdout_text,
139
- )
140
-
141
- # Extract geometry metadata
142
- try:
143
- shape = result_obj.val()
144
- bb = result_obj.val().BoundingBox()
145
- bbox_dims = (bb.xlen, bb.ylen, bb.zlen)
146
- volume = shape.Volume()
147
- faces = len(result_obj.faces().vals())
148
- edges = len(result_obj.edges().vals())
149
- except Exception as e:
150
- return ExecutionResult(
151
- success=False,
152
- code=code,
153
- error=f"Geometry extraction failed: {e}",
154
- stdout=stdout_text,
155
- )
156
-
157
- return ExecutionResult(
158
- success=True,
159
- result=result_obj,
160
- code=code,
161
- stdout=stdout_text,
162
- volume=volume,
163
- bounding_box=bbox_dims,
164
- face_count=faces,
165
- edge_count=edges,
166
- )
167
-
168
-
169
- def export_step(result: cq.Workplane, path: str | Path) -> Path:
170
- """Export a CadQuery workplane to STEP format."""
171
- path = Path(path)
172
- cq.exporters.export(result, str(path), exportType="STEP")
173
- return path
174
-
175
-
176
- def export_stl(result: cq.Workplane, path: str | Path, tolerance: float = 0.01) -> Path:
177
- """Export a CadQuery workplane to STL format."""
178
- path = Path(path)
179
- cq.exporters.export(result, str(path), exportType="STL", tolerance=tolerance)
180
- return path
181
-
182
-
183
- def export_all(result: cq.Workplane, base_path: str | Path) -> dict[str, Path]:
184
- """Export to both STEP and STL."""
185
- base = Path(base_path)
186
- return {
187
- "step": export_step(result, base.with_suffix(".step")),
188
- "stl": export_stl(result, base.with_suffix(".stl")),
189
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker-compose.yml CHANGED
@@ -4,6 +4,10 @@ services:
4
  command: python -m server.mcp --transport sse --port 8000
5
  ports:
6
  - "8000:8000"
 
 
 
 
7
  volumes:
8
  - ./output:/app/output
9
 
 
4
  command: python -m server.mcp --transport sse --port 8000
5
  ports:
6
  - "8000:8000"
7
+ environment:
8
+ GEMINI_API_KEY: ${GEMINI_API_KEY:-}
9
+ ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
10
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
11
  volumes:
12
  - ./output:/app/output
13
 
docs/superpowers/specs/2026-04-08-multi-agent-chat-design.md ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuralCAD Multi-Agent Chat Design
2
+
3
+ ## Context
4
+
5
+ NeuralCAD currently uses a single-prompt flow: user describes a part, one LLM call generates CadQuery code, and the result renders in a 3D viewer. This works for simple parts but doesn't support iterative design refinement.
6
+
7
+ The goal is to replace this with a **multi-agent chat experience** where 4 specialized AI agents (Design, Engineering, CNC, CAD Coder) collaborate with the user in a shared conversation to plan and refine a mechanical part before generating the 3D model. The user drives the conversation, agents contribute their expertise, and the user can request a 3D preview on demand.
8
+
9
+ ## Agent Definitions
10
+
11
+ Four agents participate in a shared group chat. Each has a distinct role, color, and expertise:
12
+
13
+ | Agent | ID | Color | Avatar | Role |
14
+ |-------|----|-------|--------|------|
15
+ | Design Agent | `design` | `#7c3aed` (purple) | DA | Industrial/product design: shape, form, aesthetics, ergonomics. Asks about intent, proposes form factors, considers user experience. |
16
+ | Engineering Agent | `engineering` | `#00b4d8` (cyan) | EA | Structural/mechanical: dimensions, tolerances, materials, stress analysis, fastener specs (M3/M4/M6 clearance holes). |
17
+ | CNC/Manufacturing Agent | `cnc` | `#00e676` (green) | CA | Manufacturability: tool access, wall thickness, pocket aspect ratios, axis requirements, fixturing, cost implications. |
18
+ | CAD Coder Agent | `cad` | `#ffab40` (amber) | CC | Code generation: takes the agreed design and produces CadQuery Python code. Only responds when a preview is requested. |
19
+
20
+ ## Orchestration Architecture
21
+
22
+ ### Hybrid Approach: CrewAI Agents + Custom Orchestrator
23
+
24
+ Use **CrewAI** for agent definitions (roles, goals, backstories) and the `BaseLLM` adapter pattern, but implement **two orchestration modes**:
25
+
26
+ #### Single-Call Mode (Gemini Free Tier / Mock)
27
+
28
+ One LLM call per user turn. The system prompt contains all agent personas and routing rules. The LLM returns a structured JSON response:
29
+
30
+ ```json
31
+ {
32
+ "agents": [
33
+ {"id": "design", "message": "For an MG996R servo, I'd suggest an L-bracket..."},
34
+ {"id": "engineering", "message": "3mm wall thickness in aluminum 6061..."}
35
+ ]
36
+ }
37
+ ```
38
+
39
+ The orchestrator system prompt instructs the LLM to:
40
+ - Analyze the user's message and conversation context
41
+ - Select 1-3 relevant agents to respond (never all four unless appropriate)
42
+ - Generate each agent's response in character
43
+ - Only include the `cad` agent when the user explicitly requests a preview
44
+ - When `cad` responds, include a `code` field with valid CadQuery Python
45
+
46
+ If the user @mentions specific agents, the system prompt is modified to only include those agents.
47
+
48
+ **Fallback**: If JSON parsing fails, use rule-based keyword matching to select agents and re-call the LLM with a simpler prompt for just those agents.
49
+
50
+ #### Multi-Call Mode (Anthropic / OpenAI)
51
+
52
+ CrewAI's hierarchical process with a manager agent. Each agent gets its own LLM call with focused context. The manager routes based on conversation state. Better quality but uses 2-4 API calls per turn.
53
+
54
+ #### Mode Selection
55
+
56
+ | Backend | Mode | Reason |
57
+ |---------|------|--------|
58
+ | `mock` | Template-based | No LLM call. Returns canned agent responses based on keyword matching. For the CAD Coder agent, delegates to the existing MockBackend which generates CadQuery code from prompt parsing. Useful for UI development and demos without API keys. |
59
+ | `gemini` | Single-call | Free tier rate limits (15 RPM) |
60
+ | `anthropic` | Multi-call | Paid API, better quality |
61
+ | `openai` | Multi-call | Paid API, better quality |
62
+
63
+ ### @Mention System
64
+
65
+ Users can direct messages to specific agents by typing `@design`, `@engineering`, `@cnc`, or `@cad` in their message.
66
+
67
+ - Frontend parses @mentions from the message text before sending
68
+ - @mentions are sent as a `mentions` array in the API request
69
+ - When mentions are present:
70
+ - Single-call mode: system prompt only includes mentioned agents' personas
71
+ - Multi-call mode: only mentioned agents are activated in the crew
72
+ - When no mentions: orchestrator decides which agents respond
73
+ - `@cad` triggers CAD code generation (same as clicking the preview button)
74
+
75
+ ### Agent Prompt Structure
76
+
77
+ Each agent has:
78
+ - **System persona**: role description, expertise, communication style
79
+ - **Conversation context**: last N messages from the chat history
80
+ - **User message**: the current message with @mention context
81
+
82
+ The CAD Coder agent additionally receives:
83
+ - The full CadQuery system prompt (from `cadquery_system_prompt.py`)
84
+ - Few-shot examples of CadQuery code
85
+ - A summary of design decisions from the conversation so far
86
+
87
+ ## Chat API
88
+
89
+ ### Endpoint: `POST /api/chat`
90
+
91
+ **Request:**
92
+ ```json
93
+ {
94
+ "history": [
95
+ {"role": "user", "content": "I need a servo bracket"},
96
+ {"role": "design", "content": "What type of servo?"},
97
+ {"role": "user", "content": "MG996R, for a camera gimbal"}
98
+ ],
99
+ "message": "Make it 60mm wide with M4 base mounting",
100
+ "mentions": [],
101
+ "backend": "gemini"
102
+ }
103
+ ```
104
+
105
+ **Response:**
106
+ ```json
107
+ {
108
+ "responses": [
109
+ {
110
+ "agent_id": "design",
111
+ "agent_name": "Design Agent",
112
+ "message": "L-bracket with servo pocket on vertical face...",
113
+ "color": "#7c3aed",
114
+ "avatar": "DA"
115
+ },
116
+ {
117
+ "agent_id": "engineering",
118
+ "agent_name": "Engineering Agent",
119
+ "message": "3mm walls, 5mm fillet on the L-bend...",
120
+ "color": "#00b4d8",
121
+ "avatar": "EA"
122
+ }
123
+ ],
124
+ "preview": null
125
+ }
126
+ ```
127
+
128
+ **Response with preview (when CAD Coder responds):**
129
+ ```json
130
+ {
131
+ "responses": [
132
+ {
133
+ "agent_id": "cad",
134
+ "agent_name": "CAD Coder",
135
+ "message": "Model generated successfully.",
136
+ "color": "#ffab40",
137
+ "avatar": "CC",
138
+ "code": "import cadquery as cq\nresult = cq.Workplane('XY')..."
139
+ }
140
+ ],
141
+ "preview": {
142
+ "part_name": "servo_bracket",
143
+ "stl_url": "/api/models/servo_bracket.stl",
144
+ "step_url": "/api/models/servo_bracket.step",
145
+ "execution": {
146
+ "success": true,
147
+ "volume_mm3": 4230.5,
148
+ "bounding_box_mm": [60.0, 43.0, 25.0],
149
+ "face_count": 34,
150
+ "edge_count": 52
151
+ },
152
+ "validation": {
153
+ "machinable": true,
154
+ "axis_recommendation": "3-axis",
155
+ "issues": []
156
+ }
157
+ }
158
+ }
159
+ ```
160
+
161
+ ### State Management
162
+
163
+ - **Stateless backend**: frontend sends full conversation history with each request
164
+ - **Backend truncation**: history truncated to last 30 messages to stay within token limits
165
+ - **No sessions**: no server-side session storage needed
166
+ - **Gallery persistence**: saved models stored in the `output/` directory with metadata JSON files
167
+
168
+ ### Existing Endpoints (Preserved)
169
+
170
+ - `GET /api/models` — list generated models
171
+ - `GET /api/models/{name}.stl` — download STL
172
+ - `GET /api/models/{name}.step` — download STEP
173
+ - `GET /api/capabilities` — server status
174
+
175
+ ### New Endpoint: `POST /api/report`
176
+
177
+ Generates a design report document. Requires conversation history since the backend is stateless.
178
+
179
+ **Request:**
180
+ ```json
181
+ {
182
+ "part_name": "servo_bracket",
183
+ "history": [...],
184
+ "backend": "gemini"
185
+ }
186
+ ```
187
+
188
+ The LLM summarizes the conversation into a report containing:
189
+ - Design decisions extracted from conversation
190
+ - Final dimensions and specifications
191
+ - CNC validation results and axis recommendation
192
+ - Agent recommendations summary
193
+
194
+ For `mock` backend, the report is assembled from the last CAD Coder response metadata without an LLM call.
195
+
196
+ ## Frontend Design
197
+
198
+ ### Layout: Fullscreen 3D Viewer + Slide-out Chat
199
+
200
+ The 3D viewer occupies the **entire viewport** as the primary element. The chat panel slides in/out from the right side.
201
+
202
+ ```
203
+ +--[TopBar: Logo | Backend Toggle | Status]------------------+
204
+ | | CHAT |
205
+ | | PANEL |
206
+ | FULLSCREEN 3D VIEWER | (340px)|
207
+ | (Three.js WebGL) | |
208
+ | | [msgs] |
209
+ | [Geo Stats] [CNC Badge] | [msgs] |
210
+ | | [msgs] |
211
+ | |--------|
212
+ | [STEP] [STL] [Report] |[input] |
213
+ +----------------------------------------------------+--------+
214
+ ```
215
+
216
+ ### 3D Viewer
217
+
218
+ - Same Three.js setup as current (STLLoader, OrbitControls, MeshPhongMaterial)
219
+ - Fullscreen, edge-to-edge behind the chat panel
220
+ - Semi-transparent overlays for geo stats (top-left), CNC badge (top-right of viewer area), downloads (bottom-left)
221
+ - Empty state shows a subtle prompt: "Start a conversation to design your part"
222
+ - When model is loaded: auto-centers, fits camera to bounding box, slow auto-rotate when idle
223
+
224
+ ### Chat Panel
225
+
226
+ - **Width**: 340px, slides in from right
227
+ - **Background**: semi-transparent (`rgba(10,14,20,0.92)`) with `backdrop-filter: blur(16px)` so the 3D model is visible behind
228
+ - **Collapse/expand**: toggle button (chevron) in chat header, or floating pill at bottom center when collapsed
229
+ - **Agent dots**: row of 4 colored dots in the header showing active agents
230
+
231
+ #### Message Rendering
232
+
233
+ - **User messages**: right-aligned, dark blue bubble (`#1a2a3a`), rounded corners
234
+ - **Agent messages**: left-aligned with colored avatar circle (24px), agent label above message text in agent's color
235
+ - **CAD Coder messages**: distinct background (`rgba(255,171,64,0.08)`) with "View CadQuery code" link
236
+
237
+ #### Input Area
238
+
239
+ - Text input with placeholder "Type your message..."
240
+ - **@mention autocomplete**: typing `@` shows a dropdown with agent names, selecting inserts `@design` etc.
241
+ - **Preview button** (eye icon, amber `#ffab40`): triggers CAD Coder to generate 3D model from current conversation
242
+ - **Send button** (arrow icon, cyan `#00b4d8`): sends the message
243
+ - **Ctrl/Cmd+Enter**: keyboard shortcut to send
244
+
245
+ #### Quick Examples
246
+
247
+ On first load (empty chat), show example conversation starters as clickable chips:
248
+ - "Design a mounting bracket for an MG996R servo"
249
+ - "I need a spur gear with 20 teeth"
250
+ - "Create a heatsink for a 30mm cylinder"
251
+ - "Design a pipe flange with M8 bolt holes"
252
+
253
+ These insert the text into the chat input and auto-send.
254
+
255
+ ### Gallery
256
+
257
+ - Accessed via a button in the top bar (not a tab)
258
+ - Opens as a modal/dropdown overlay
259
+ - Shows previously generated models as cards with thumbnail, name, face count, CNC status
260
+ - Click to load model into the 3D viewer
261
+
262
+ ### Backend Toggle
263
+
264
+ - Same as current: MOCK / GEMINI / CLAUDE radio buttons in the top bar
265
+ - Changing backend affects which orchestration mode is used (single-call vs multi-call)
266
+
267
+ ## Refactored File Structure
268
+
269
+ ```
270
+ NeuralCAD/
271
+ ├── agents/
272
+ │ ├── __init__.py
273
+ │ ├── definitions.py # CrewAI Agent + Task definitions for all 4 agents
274
+ │ ├── orchestrator.py # Single-call JSON orchestrator (Gemini/Mock mode)
275
+ │ ├── crew_orchestrator.py # Multi-call CrewAI hierarchical process (Anthropic/OpenAI)
276
+ │ ├── llm_adapter.py # CrewAI BaseLLM wrapper around LLMBackend
277
+ │ └── prompts.py # Agent system prompts, personas, routing rules
278
+ ├── core/
279
+ │ ├── __init__.py
280
+ │ ├── backends.py # LLMBackend base + AnthropicBackend, OpenAIBackend,
281
+ │ │ # GeminiBackend, MockBackend (extracted from pipeline.py)
282
+ │ ├── executor.py # Sandboxed CadQuery execution (from code_executor.py)
283
+ │ ├── validator.py # CNC validation (from cnc_validator.py)
284
+ │ ├── cadquery_prompts.py # CadQuery system prompt + few-shot examples
285
+ │ │ # (from cadquery_system_prompt.py)
286
+ │ └── pipeline.py # run_pipeline() for CAD generation
287
+ │ │ # (simplified, called by CAD Coder agent)
288
+ ├── server/
289
+ │ ├── __init__.py
290
+ │ ├── web.py # FastAPI app, static file serving (from web_server.py)
291
+ │ ├── mcp.py # MCP server + tools (from mcp_server.py)
292
+ │ └── routes.py # /api/chat, /api/report endpoints
293
+ ├── web/
294
+ │ └── index.html # Complete rewrite: fullscreen 3D viewer + slide-out chat
295
+ ├── pyproject.toml # + crewai dependency
296
+ ├── Dockerfile # Updated for new structure
297
+ ├── docker-compose.yml # Same services, updated paths
298
+ └── entrypoint.sh # Updated entry point
299
+ ```
300
+
301
+ ### Key Refactoring Notes
302
+
303
+ - `pipeline.py` (922 lines) is split into `core/backends.py` (LLM backends), `core/pipeline.py` (run_pipeline), and `agents/` (orchestration)
304
+ - `code_executor.py` → `core/executor.py` (unchanged logic)
305
+ - `cnc_validator.py` → `core/validator.py` (unchanged logic)
306
+ - `cadquery_system_prompt.py` → `core/cadquery_prompts.py` (unchanged logic)
307
+ - `web_server.py` → `server/web.py` + `server/routes.py`
308
+ - `mcp_server.py` → `server/mcp.py` (add `chat_turn` MCP tool for Claude Desktop)
309
+ - `web/index.html` → complete rewrite
310
+
311
+ ## Data Flow: Chat Turn
312
+
313
+ ```
314
+ 1. User types message in chat UI
315
+ 2. Frontend parses @mentions from message text
316
+ 3. POST /api/chat { history, message, mentions, backend }
317
+ |
318
+ 4. Backend selects orchestration mode:
319
+ ├── gemini/mock → Single-call orchestrator
320
+ │ ├── Build system prompt with agent personas + routing rules
321
+ │ ├── Include conversation history (last 30 msgs)
322
+ │ ├── If @mentions: only include mentioned agent personas
323
+ │ ├── LLMBackend.generate(messages) → JSON string
324
+ │ ├── Parse JSON → list of agent responses
325
+ │ └── Fallback: keyword routing + simpler re-call if JSON fails
326
+
327
+ └── anthropic/openai → CrewAI hierarchical process
328
+ ├── Manager agent routes to relevant agents
329
+ ├── Each agent gets own LLM call via NeuralCADLLMAdapter
330
+ └── Collect responses from all activated agents
331
+ |
332
+ 5. If CAD Coder agent responded with code:
333
+ ├── execute_cadquery(code) → ExecutionResult
334
+ ├── export_step() + export_stl() → files in output/
335
+ ├── validate_for_cnc() → CNCValidationResult
336
+ └── Build preview object with URLs and metadata
337
+ |
338
+ 6. Return JSON response → Frontend renders agent messages
339
+ |
340
+ 7. If preview present:
341
+ ├── Load STL into Three.js viewer
342
+ ├── Show geo stats overlay
343
+ ├── Show CNC badge
344
+ └── Enable download buttons
345
+ ```
346
+
347
+ ## MCP Compatibility
348
+
349
+ Add a new `chat_turn` MCP tool alongside existing tools:
350
+
351
+ ```python
352
+ @mcp.tool()
353
+ async def chat_turn(
354
+ message: str,
355
+ history: list[dict] | None = None,
356
+ mentions: list[str] | None = None,
357
+ backend: str = "gemini"
358
+ ) -> dict:
359
+ """Multi-agent chat turn for collaborative CAD design."""
360
+ ```
361
+
362
+ Existing MCP tools (`generate_cnc_model`, `validate_cnc_model`, `execute_cadquery_code`, `list_models`) remain unchanged for backward compatibility with Claude Desktop.
363
+
364
+ ## Verification Plan
365
+
366
+ ### Unit Tests
367
+ - Test single-call orchestrator JSON parsing with valid and malformed responses
368
+ - Test @mention parsing in frontend
369
+ - Test keyword-based fallback routing
370
+ - Test LLM adapter wraps LLMBackend correctly
371
+
372
+ ### Integration Tests
373
+ - Full chat turn: user message → agent responses → verify correct agents selected
374
+ - Preview generation: chat with `@cad` → verify STL/STEP files created
375
+ - Backend switching: verify single-call mode for Gemini, multi-call for Anthropic
376
+ - Conversation history truncation at 30 messages
377
+
378
+ ### Manual Testing
379
+ - Open web UI, start a conversation about a servo bracket
380
+ - Verify agents respond with appropriate expertise
381
+ - Use @mentions to direct messages to specific agents
382
+ - Click preview button → verify 3D model loads
383
+ - Download STEP and STL files
384
+ - Collapse/expand chat panel
385
+ - Test with Gemini free tier (rate limit behavior)
386
+ - Test quick example conversation starters
387
+
388
+ ### MCP Testing
389
+ - Call `chat_turn` tool from Claude Desktop
390
+ - Verify existing MCP tools still work
mcp_server.py DELETED
@@ -1,446 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Text-to-CNC MCP Server
4
- ======================
5
- Exposes the text-to-CNC pipeline as MCP tools over stdio transport.
6
-
7
- Tools:
8
- - generate_cnc_model: Text prompt → CadQuery code → 3D solid → STEP/STL
9
- - validate_cnc_model: Run CNC manufacturability checks on CadQuery code
10
- - execute_cadquery: Run arbitrary CadQuery code and get geometry info
11
- - list_models: List previously generated models in the output dir
12
-
13
- Usage:
14
- python mcp_server.py # stdio transport (default)
15
- python mcp_server.py --transport sse # SSE transport on port 8000
16
- """
17
-
18
- import json
19
- import os
20
- import sys
21
- from pathlib import Path
22
-
23
- from mcp.server.fastmcp import FastMCP
24
-
25
- # Ensure the project modules are importable
26
- sys.path.insert(0, str(Path(__file__).parent))
27
-
28
- from cadquery_system_prompt import build_messages, CADQUERY_SYSTEM_PROMPT
29
- from code_executor import ExecutionResult, execute_cadquery, export_all, sanitize_code
30
- from cnc_validator import validate_for_cnc, CNCValidationResult
31
-
32
- # ── Server Setup ──────────────────────────────────────────────────────────
33
-
34
- mcp = FastMCP(
35
- "text-to-cnc",
36
- instructions=(
37
- "Generate CNC-machinable 3D models from text descriptions. "
38
- "Converts natural language → CadQuery code → validated STEP/STL files. "
39
- "Version 1.0.0"
40
- ),
41
- )
42
-
43
- DEFAULT_OUTPUT_DIR = Path(__file__).parent / "output"
44
- DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
45
-
46
-
47
- # ── Helper: LLM Backend Selection ────────────────────────────────────────
48
-
49
- def get_backend(backend_name: str = "mock"):
50
- """Get the appropriate LLM backend."""
51
- from pipeline import MockBackend, AnthropicBackend, OpenAIBackend, GeminiBackend, NeuralCADBackend
52
-
53
- if backend_name == "neural":
54
- return NeuralCADBackend()
55
- elif backend_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
56
- return AnthropicBackend()
57
- elif backend_name == "openai" and os.environ.get("OPENAI_API_KEY"):
58
- return OpenAIBackend()
59
- elif backend_name == "gemini" and os.environ.get("GEMINI_API_KEY"):
60
- return GeminiBackend()
61
- else:
62
- return MockBackend()
63
-
64
-
65
- # ── Tool: generate_cnc_model ─────────────────────────────────────────────
66
-
67
- @mcp.tool()
68
- def generate_cnc_model(
69
- prompt: str,
70
- part_name: str = "",
71
- backend: str = "mock",
72
- max_retries: int = 2,
73
- output_format: str = "both",
74
- ) -> str:
75
- """
76
- Generate a CNC-machinable 3D model from a text description.
77
-
78
- Takes a natural language description of a mechanical part, generates
79
- CadQuery Python code via an LLM, executes it to produce a 3D solid,
80
- validates it for CNC manufacturability, and exports STEP/STL files.
81
-
82
- Args:
83
- prompt: Natural language description of the part to generate.
84
- Example: "A mounting bracket with four M6 bolt holes, 80mm wide"
85
- part_name: Optional name for the part (used in filenames).
86
- If empty, auto-generated from the prompt.
87
- backend: LLM backend to use: "mock" (no API key), "anthropic", or "openai".
88
- max_retries: Number of retry attempts if code generation fails (0-3).
89
- output_format: Export format: "step", "stl", or "both".
90
-
91
- Returns:
92
- JSON string with generation results including:
93
- - generated_code: The CadQuery Python code
94
- - execution: Success/failure status and geometry metadata
95
- - validation: CNC manufacturability analysis
96
- - exported_files: Paths to generated STEP/STL files
97
- """
98
- from pipeline import run_pipeline
99
-
100
- if not part_name:
101
- part_name = prompt[:40].strip().replace(" ", "_").lower()
102
- part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
103
-
104
- llm_backend = get_backend(backend)
105
-
106
- result = run_pipeline(
107
- prompt=prompt,
108
- backend=llm_backend,
109
- output_dir=DEFAULT_OUTPUT_DIR,
110
- max_retries=min(max_retries, 3),
111
- export=True,
112
- validate=True,
113
- part_name=part_name,
114
- )
115
-
116
- # Build response
117
- response = {
118
- "success": result.execution.success,
119
- "prompt": prompt,
120
- "part_name": part_name,
121
- "retries": result.retry_count,
122
- "generated_code": result.generated_code,
123
- "execution": {
124
- "success": result.execution.success,
125
- "volume_mm3": result.execution.volume,
126
- "bounding_box_mm": list(result.execution.bounding_box) if result.execution.bounding_box else [],
127
- "face_count": result.execution.face_count,
128
- "edge_count": result.execution.edge_count,
129
- "error": result.execution.error,
130
- },
131
- }
132
-
133
- if result.validation:
134
- response["validation"] = {
135
- "machinable": result.validation.machinable,
136
- "axis_recommendation": result.validation.axis_recommendation,
137
- "error_count": result.validation.error_count,
138
- "warning_count": result.validation.warning_count,
139
- "issues": [
140
- {"severity": i.severity, "category": i.category, "message": i.message}
141
- for i in result.validation.issues
142
- ],
143
- }
144
-
145
- if result.exported_files:
146
- response["exported_files"] = {
147
- fmt: str(path) for fmt, path in result.exported_files.items()
148
- }
149
-
150
- return json.dumps(response, indent=2)
151
-
152
-
153
- # ── Tool: validate_cnc_model ─────────────────────────────────────────────
154
-
155
- @mcp.tool()
156
- def validate_cnc_model(
157
- cadquery_code: str,
158
- part_name: str = "Part",
159
- min_wall_thickness_mm: float = 1.5,
160
- max_part_size_mm: float = 500.0,
161
- ) -> str:
162
- """
163
- Validate CadQuery code for CNC manufacturability without generating new code.
164
-
165
- Executes the provided CadQuery code, then runs manufacturability checks
166
- including wall thickness, tool access, aspect ratios, and surface complexity.
167
-
168
- Args:
169
- cadquery_code: Valid CadQuery Python code that assigns result to `result`.
170
- Example: 'import cadquery as cq\\nresult = cq.Workplane("XY").box(10,10,10)'
171
- part_name: Name for the part in the validation report.
172
- min_wall_thickness_mm: Minimum acceptable wall thickness in mm (default 1.5).
173
- max_part_size_mm: Maximum part dimension in mm (default 500).
174
-
175
- Returns:
176
- JSON string with execution status and CNC validation results including
177
- machinable flag, axis recommendation, and list of issues.
178
- """
179
- exec_result = execute_cadquery(cadquery_code)
180
-
181
- response = {
182
- "execution_success": exec_result.success,
183
- "error": exec_result.error,
184
- "volume_mm3": exec_result.volume,
185
- "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
186
- }
187
-
188
- if exec_result.success:
189
- config = {
190
- "min_wall_thickness_mm": min_wall_thickness_mm,
191
- "max_part_size_mm": max_part_size_mm,
192
- }
193
- validation = validate_for_cnc(exec_result.result, part_name=part_name, config=config)
194
- response["validation"] = {
195
- "machinable": validation.machinable,
196
- "axis_recommendation": validation.axis_recommendation,
197
- "error_count": validation.error_count,
198
- "warning_count": validation.warning_count,
199
- "issues": [
200
- {"severity": i.severity, "category": i.category, "message": i.message}
201
- for i in validation.issues
202
- ],
203
- "summary": validation.summary(),
204
- }
205
-
206
- return json.dumps(response, indent=2)
207
-
208
-
209
- # ── Tool: execute_cadquery ───────────────────────────────────────────────
210
-
211
- @mcp.tool()
212
- def execute_cadquery_code(
213
- code: str,
214
- export_path: str = "",
215
- ) -> str:
216
- """
217
- Execute CadQuery Python code and return geometry information.
218
-
219
- Runs CadQuery code in a sandboxed environment and returns metadata
220
- about the resulting 3D solid (volume, bounding box, face/edge counts).
221
- Optionally exports to STEP/STL.
222
-
223
- Args:
224
- code: CadQuery Python code. Must assign the final solid to a variable
225
- called `result`. Example:
226
- 'import cadquery as cq\\nresult = cq.Workplane("XY").box(20,20,20).hole(8)'
227
- export_path: Optional base file path for STEP/STL export (without extension).
228
- Example: "output/my_part" → creates my_part.step and my_part.stl
229
-
230
- Returns:
231
- JSON string with execution results including success status,
232
- geometry metadata, stdout output, and export file paths if requested.
233
- """
234
- exec_result = execute_cadquery(code)
235
-
236
- response = {
237
- "success": exec_result.success,
238
- "error": exec_result.error,
239
- "stdout": exec_result.stdout,
240
- "volume_mm3": exec_result.volume,
241
- "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
242
- "face_count": exec_result.face_count,
243
- "edge_count": exec_result.edge_count,
244
- }
245
-
246
- if exec_result.success and export_path:
247
- try:
248
- files = export_all(exec_result.result, export_path)
249
- response["exported_files"] = {fmt: str(p) for fmt, p in files.items()}
250
- except Exception as e:
251
- response["export_error"] = str(e)
252
-
253
- return json.dumps(response, indent=2)
254
-
255
-
256
- # ── Tool: list_models ────────────────────────────────────────────────────
257
-
258
- @mcp.tool()
259
- def list_models(output_dir: str = "") -> str:
260
- """
261
- List all previously generated CNC models in the output directory.
262
-
263
- Returns a list of generated STEP and STL files with their sizes.
264
-
265
- Args:
266
- output_dir: Directory to scan. Defaults to the server's output directory.
267
-
268
- Returns:
269
- JSON string with a list of model files and their sizes in bytes.
270
- """
271
- scan_dir = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
272
-
273
- if not scan_dir.exists():
274
- return json.dumps({"error": f"Directory not found: {scan_dir}"})
275
-
276
- models = {}
277
- for ext in ("*.step", "*.stl"):
278
- for f in scan_dir.glob(ext):
279
- name = f.stem
280
- if name not in models:
281
- models[name] = {"name": name, "files": {}}
282
- models[name]["files"][f.suffix.lstrip(".")] = {
283
- "path": str(f),
284
- "size_bytes": f.stat().st_size,
285
- }
286
-
287
- return json.dumps({
288
- "output_dir": str(scan_dir),
289
- "model_count": len(models),
290
- "models": list(models.values()),
291
- }, indent=2)
292
-
293
-
294
- # ── Tool: generate_from_image ───────────────────────────────────────────
295
-
296
- @mcp.tool()
297
- def generate_from_image(
298
- image_path: str,
299
- text_hint: str = "",
300
- part_name: str = "",
301
- backend: str = "anthropic",
302
- max_retries: int = 2,
303
- ) -> str:
304
- """
305
- Generate a CNC-machinable 3D model from a photo or sketch image.
306
-
307
- Sends the image to a vision-capable LLM (Claude or GPT-4o) along with
308
- the CadQuery system prompt to generate code, then executes, validates,
309
- and exports the result.
310
-
311
- Args:
312
- image_path: Path to an image file (photo, sketch, or CAD screenshot).
313
- text_hint: Optional text to guide generation alongside the image.
314
- Example: "This is a mounting bracket — add M6 bolt holes"
315
- part_name: Optional name for the part (used in filenames).
316
- backend: LLM backend: "anthropic" or "openai". Must support vision.
317
- max_retries: Number of retry attempts if code execution fails (0-3).
318
-
319
- Returns:
320
- JSON string with generation results including generated code,
321
- execution status, validation, and exported file paths.
322
- """
323
- if not Path(image_path).exists():
324
- return json.dumps({"success": False, "error": f"Image not found: {image_path}"})
325
-
326
- if not part_name:
327
- part_name = Path(image_path).stem
328
-
329
- llm_backend = get_backend(backend)
330
-
331
- # Build prompt with optional text hint
332
- prompt = "Generate CadQuery code for the mechanical part shown in this image."
333
- if text_hint:
334
- prompt += f"\n\nAdditional context: {text_hint}"
335
-
336
- messages = build_messages(prompt)
337
-
338
- # Use vision-capable generate_with_image
339
- generated_code = llm_backend.generate_with_image(messages, image_path)
340
-
341
- # Run through standard execution/validation/export
342
- exec_result = execute_cadquery(generated_code)
343
- retry_count = 0
344
-
345
- while not exec_result.success and retry_count < min(max_retries, 3):
346
- retry_count += 1
347
- error_feedback = (
348
- f"The previous code failed with this error:\n"
349
- f"```\n{exec_result.error}\n```\n\n"
350
- f"Please fix the code and return only the corrected Python code."
351
- )
352
- retry_messages = build_messages(error_feedback)
353
- generated_code = llm_backend.generate_with_image(retry_messages, image_path)
354
- exec_result = execute_cadquery(generated_code)
355
-
356
- response = {
357
- "success": exec_result.success,
358
- "image_path": image_path,
359
- "text_hint": text_hint,
360
- "part_name": part_name,
361
- "backend": backend,
362
- "retries": retry_count,
363
- "generated_code": generated_code,
364
- "execution": {
365
- "success": exec_result.success,
366
- "volume_mm3": exec_result.volume,
367
- "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
368
- "face_count": exec_result.face_count,
369
- "edge_count": exec_result.edge_count,
370
- "error": exec_result.error,
371
- },
372
- }
373
-
374
- if exec_result.success:
375
- validation = validate_for_cnc(exec_result.result, part_name=part_name)
376
- response["validation"] = {
377
- "machinable": validation.machinable,
378
- "axis_recommendation": validation.axis_recommendation,
379
- "error_count": validation.error_count,
380
- "warning_count": validation.warning_count,
381
- "issues": [
382
- {"severity": i.severity, "category": i.category, "message": i.message}
383
- for i in validation.issues
384
- ],
385
- }
386
-
387
- base_path = DEFAULT_OUTPUT_DIR / part_name
388
- try:
389
- exported = export_all(exec_result.result, base_path)
390
- response["exported_files"] = {fmt: str(p) for fmt, p in exported.items()}
391
- except Exception as e:
392
- response["export_error"] = str(e)
393
-
394
- return json.dumps(response, indent=2)
395
-
396
-
397
- # ── Resource: System prompt (for transparency) ───────────────────────────
398
-
399
- @mcp.resource("text-to-cnc://system-prompt")
400
- def get_system_prompt() -> str:
401
- """The CadQuery generation system prompt used by the LLM."""
402
- return CADQUERY_SYSTEM_PROMPT
403
-
404
-
405
- @mcp.resource("text-to-cnc://capabilities")
406
- def get_capabilities() -> str:
407
- """Server capabilities and configuration."""
408
- backends = ["mock (always available)", "neural (local models — requires trained weights)"]
409
- if os.environ.get("ANTHROPIC_API_KEY"):
410
- backends.append("anthropic (API key detected)")
411
- if os.environ.get("OPENAI_API_KEY"):
412
- backends.append("openai (API key detected)")
413
- if os.environ.get("GEMINI_API_KEY"):
414
- backends.append("gemini (API key detected)")
415
-
416
- return json.dumps({
417
- "name": "text-to-cnc",
418
- "version": "1.0.0",
419
- "available_backends": backends,
420
- "output_dir": str(DEFAULT_OUTPUT_DIR),
421
- "export_formats": ["STEP", "STL"],
422
- "cnc_validation": True,
423
- "max_retries": 3,
424
- }, indent=2)
425
-
426
-
427
- # ── Entry Point ──────────────────────────────────────────────────────────
428
-
429
- if __name__ == "__main__":
430
- import argparse
431
-
432
- parser = argparse.ArgumentParser(description="Text-to-CNC MCP Server")
433
- parser.add_argument(
434
- "--transport", choices=["stdio", "sse"], default="stdio",
435
- help="MCP transport (default: stdio)"
436
- )
437
- parser.add_argument(
438
- "--port", type=int, default=8000,
439
- help="Port for SSE transport (default: 8000)"
440
- )
441
- args = parser.parse_args()
442
-
443
- if args.transport == "sse":
444
- mcp.run(transport="sse")
445
- else:
446
- mcp.run(transport="stdio")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pipeline.py DELETED
@@ -1,921 +0,0 @@
1
- """
2
- Text-to-CNC Pipeline — Main orchestrator.
3
-
4
- Pipeline stages:
5
- 1. Text prompt → LLM → CadQuery code
6
- 2. CadQuery code → Execute → 3D Solid
7
- 3. 3D Solid → CNC Validation
8
- 4. 3D Solid → STEP / STL export
9
- 5. (Optional) Auto-retry with error feedback if execution fails
10
-
11
- Supports multiple LLM backends:
12
- - Anthropic Claude (default)
13
- - OpenAI GPT-4o
14
- - Local / mock (for testing without API keys)
15
- """
16
-
17
- import base64
18
- import math
19
- import mimetypes
20
- import os
21
- import re
22
- from dataclasses import dataclass
23
- from pathlib import Path
24
- from typing import Optional
25
-
26
- from cadquery_system_prompt import build_messages
27
- from code_executor import ExecutionResult, execute_cadquery, export_all
28
- from cnc_validator import validate_for_cnc, CNCValidationResult
29
-
30
-
31
- # ── LLM Backends ──────────────────────────────────────────────────────────
32
-
33
-
34
- class LLMBackend:
35
- """Base class for LLM code generation backends."""
36
-
37
- def generate(self, messages: list[dict]) -> str:
38
- raise NotImplementedError
39
-
40
- def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
41
- """Generate code from messages that include an image.
42
- Override in backends that support vision."""
43
- raise NotImplementedError(
44
- f"{self.__class__.__name__} does not support image input"
45
- )
46
-
47
-
48
- class AnthropicBackend(LLMBackend):
49
- """Generate CadQuery code using Anthropic Claude."""
50
-
51
- def __init__(
52
- self, model: str = "claude-sonnet-4-20250514", api_key: Optional[str] = None
53
- ):
54
- import anthropic
55
-
56
- self.client = anthropic.Anthropic(
57
- api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
58
- )
59
- self.model = model
60
-
61
- def generate(self, messages: list[dict]) -> str:
62
- # Anthropic uses system param separately
63
- system_msg = ""
64
- user_messages = []
65
- for m in messages:
66
- if m["role"] == "system":
67
- system_msg = m["content"]
68
- else:
69
- user_messages.append(m)
70
-
71
- response = self.client.messages.create(
72
- model=self.model,
73
- max_tokens=4096,
74
- system=system_msg,
75
- messages=user_messages,
76
- )
77
- return response.content[0].text
78
-
79
- def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
80
- image_path = Path(image_path)
81
- media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
82
- image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
83
-
84
- system_msg = ""
85
- user_messages = []
86
- for m in messages:
87
- if m["role"] == "system":
88
- system_msg = m["content"]
89
- else:
90
- msg = dict(m)
91
- # Inject image into the last user message
92
- if msg["role"] == "user" and msg is not m:
93
- user_messages.append(msg)
94
- else:
95
- user_messages.append(msg)
96
-
97
- # Replace last user message content with multimodal blocks
98
- last_user = user_messages[-1]
99
- last_user["content"] = [
100
- {
101
- "type": "image",
102
- "source": {
103
- "type": "base64",
104
- "media_type": media_type,
105
- "data": image_data,
106
- },
107
- },
108
- {"type": "text", "text": last_user["content"]},
109
- ]
110
-
111
- response = self.client.messages.create(
112
- model=self.model,
113
- max_tokens=4096,
114
- system=system_msg,
115
- messages=user_messages,
116
- )
117
- return response.content[0].text
118
-
119
-
120
- class OpenAIBackend(LLMBackend):
121
- """Generate CadQuery code using OpenAI GPT-4o."""
122
-
123
- def __init__(self, model: str = "gpt-4o", api_key: Optional[str] = None):
124
- import openai
125
-
126
- self.client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
127
- self.model = model
128
-
129
- def generate(self, messages: list[dict]) -> str:
130
- response = self.client.chat.completions.create(
131
- model=self.model,
132
- messages=messages,
133
- max_tokens=4096,
134
- temperature=0.2,
135
- )
136
- return response.choices[0].message.content
137
-
138
- def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
139
- image_path = Path(image_path)
140
- media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
141
- image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
142
- data_url = f"data:{media_type};base64,{image_data}"
143
-
144
- # Copy messages, replace last user message with multimodal content
145
- patched = [dict(m) for m in messages]
146
- last_user = patched[-1]
147
- last_user["content"] = [
148
- {"type": "image_url", "image_url": {"url": data_url}},
149
- {"type": "text", "text": last_user["content"]},
150
- ]
151
-
152
- response = self.client.chat.completions.create(
153
- model=self.model,
154
- messages=patched,
155
- max_tokens=4096,
156
- temperature=0.2,
157
- )
158
- return response.choices[0].message.content
159
-
160
-
161
- class GeminiBackend(LLMBackend):
162
- """Generate CadQuery code using Google Gemini (free tier available)."""
163
-
164
- def __init__(self, model: str = "gemini-2.5-flash", api_key: Optional[str] = None):
165
- from google import genai
166
-
167
- self.client = genai.Client(api_key=api_key or os.environ.get("GEMINI_API_KEY"))
168
- self.model = model
169
-
170
- def generate(self, messages: list[dict]) -> str:
171
- # Convert messages to Gemini format: system instruction + contents
172
- system_msg = ""
173
- contents = []
174
- for m in messages:
175
- if m["role"] == "system":
176
- system_msg = m["content"]
177
- elif m["role"] == "user":
178
- contents.append({"role": "user", "parts": [{"text": m["content"]}]})
179
- elif m["role"] == "assistant":
180
- contents.append({"role": "model", "parts": [{"text": m["content"]}]})
181
-
182
- from google.genai import types
183
-
184
- response = self.client.models.generate_content(
185
- model=self.model,
186
- contents=contents,
187
- config=types.GenerateContentConfig(
188
- system_instruction=system_msg,
189
- max_output_tokens=4096,
190
- temperature=0.2,
191
- ),
192
- )
193
- return response.text
194
-
195
- def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
196
- from google.genai import types
197
-
198
- image_path = Path(image_path)
199
- image_data = image_path.read_bytes()
200
- media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
201
-
202
- system_msg = ""
203
- contents = []
204
- for m in messages:
205
- if m["role"] == "system":
206
- system_msg = m["content"]
207
- elif m["role"] == "user":
208
- contents.append({"role": "user", "parts": [{"text": m["content"]}]})
209
- elif m["role"] == "assistant":
210
- contents.append({"role": "model", "parts": [{"text": m["content"]}]})
211
-
212
- # Add image to the last user message
213
- if contents and contents[-1]["role"] == "user":
214
- contents[-1]["parts"].insert(0, {
215
- "inline_data": {"mime_type": media_type, "data": image_data}
216
- })
217
-
218
- response = self.client.models.generate_content(
219
- model=self.model,
220
- contents=contents,
221
- config=types.GenerateContentConfig(
222
- system_instruction=system_msg,
223
- max_output_tokens=4096,
224
- temperature=0.2,
225
- ),
226
- )
227
- return response.text
228
-
229
-
230
- class MockBackend(LLMBackend):
231
- """
232
- Mock backend that dynamically generates CadQuery code from any prompt.
233
- Parses dimensions, shape type, and features from the text, then assembles
234
- parametric code. No API key required.
235
- """
236
-
237
- # Word-to-number mapping for natural language counts
238
- _WORD_NUMS = {
239
- "one": 1,
240
- "two": 2,
241
- "three": 3,
242
- "four": 4,
243
- "five": 5,
244
- "six": 6,
245
- "seven": 7,
246
- "eight": 8,
247
- "nine": 9,
248
- "ten": 10,
249
- "twelve": 12,
250
- "sixteen": 16,
251
- "twenty": 20,
252
- }
253
-
254
- # Metric thread clearance hole diameters
255
- _THREAD_CLEARANCE = {
256
- "m2": 2.4,
257
- "m3": 3.4,
258
- "m4": 4.5,
259
- "m5": 5.5,
260
- "m6": 6.6,
261
- "m8": 9.0,
262
- "m10": 11.0,
263
- "m12": 13.5,
264
- }
265
-
266
- # Shape detection patterns → base shape key
267
- _SHAPE_PATTERNS = {
268
- "cylinder": [
269
- "cylinder",
270
- "rod",
271
- "shaft",
272
- "axle",
273
- "spacer",
274
- "washer",
275
- "bushing",
276
- "sleeve",
277
- "tube",
278
- "pipe",
279
- "dowel",
280
- "pin",
281
- ],
282
- "plate": [
283
- "plate",
284
- "bracket",
285
- "mount",
286
- "flange",
287
- "baseplate",
288
- "panel",
289
- "shim",
290
- "cover",
291
- "lid",
292
- ],
293
- "box": [
294
- "box",
295
- "block",
296
- "enclosure",
297
- "housing",
298
- "case",
299
- "cube",
300
- "container",
301
- "shell",
302
- ],
303
- "l_bracket": [
304
- "l-bracket",
305
- "l bracket",
306
- "angle bracket",
307
- "corner bracket",
308
- "l-shaped",
309
- ],
310
- }
311
-
312
- # Feature detection keywords
313
- _FEATURE_KEYWORDS = {
314
- "holes": ["hole", "holes", "bolt", "bolts", "screw", "screws", "bore", "bores"],
315
- "pocket": ["pocket", "recess", "cavity", "cutout", "mortise"],
316
- "slot": ["slot", "slots", "groove", "channel", "keyway"],
317
- "fillet": ["fillet", "fillets", "round", "rounded"],
318
- "chamfer": ["chamfer", "chamfers", "bevel", "beveled"],
319
- "through_hole": ["through hole", "through-hole", "thru hole", "thru-hole"],
320
- "counterbore": ["counterbore", "counterbored", "cbore"],
321
- "fins": ["fin", "fins", "cooling", "heatsink", "heat sink", "radiator"],
322
- "ribs": ["rib", "ribs", "stiffener", "stiffeners", "web"],
323
- "boss": ["boss", "bosses", "standoff", "standoffs", "pillar"],
324
- }
325
-
326
- def _parse_prompt(self, text: str) -> dict:
327
- """Extract dimensions, shape, and features from natural language."""
328
- lower = text.lower()
329
-
330
- # Extract all numbers with optional units
331
- raw_nums = re.findall(r"(\d+\.?\d*)\s*(?:mm|cm|m\b)?", lower)
332
- dimensions = [float(n) for n in raw_nums if 0.1 < float(n) < 2000]
333
-
334
- # Detect metric thread sizes (M3, M6, etc.)
335
- thread_match = re.search(r"\bm(\d+)\b", lower)
336
- hole_dia = None
337
- if thread_match:
338
- key = f"m{thread_match.group(1)}"
339
- hole_dia = self._THREAD_CLEARANCE.get(
340
- key, float(thread_match.group(1)) * 1.1
341
- )
342
-
343
- # Detect hole diameter from "Xmm hole"
344
- hole_dim_match = re.search(
345
- r"(\d+\.?\d*)\s*mm\s*(?:hole|bore|holes|bores)", lower
346
- )
347
- if hole_dim_match and not hole_dia:
348
- hole_dia = float(hole_dim_match.group(1))
349
-
350
- # Detect count (numeric or word)
351
- count = None
352
- count_match = re.search(
353
- r"(\d+)\s*(?:hole|bolt|screw|bore|fin|rib|slot|boss)", lower
354
- )
355
- if count_match:
356
- count = int(count_match.group(1))
357
- else:
358
- for word, num in self._WORD_NUMS.items():
359
- if re.search(rf"\b{word}\b.*(?:hole|bolt|screw|bore|fin|slot)", lower):
360
- count = num
361
- break
362
-
363
- # Detect base shape
364
- shape = "box"
365
- for shape_key, keywords in self._SHAPE_PATTERNS.items():
366
- if any(kw in lower for kw in keywords):
367
- shape = shape_key
368
- break
369
-
370
- # Detect features
371
- features = set()
372
- for feat, keywords in self._FEATURE_KEYWORDS.items():
373
- if any(kw in lower for kw in keywords):
374
- features.add(feat)
375
-
376
- # If holes mentioned but no specific feature, add generic holes
377
- if (
378
- any(w in lower for w in ["hole", "holes", "bolt", "screw"])
379
- and "holes" not in features
380
- ):
381
- features.add("holes")
382
-
383
- return {
384
- "dimensions": dimensions,
385
- "shape": shape,
386
- "features": features,
387
- "hole_dia": hole_dia or 5.5,
388
- "count": count or 4,
389
- "prompt": text,
390
- }
391
-
392
- def _generate_code(self, p: dict) -> str:
393
- """Build CadQuery code from parsed parameters."""
394
- dims = p["dimensions"]
395
- shape = p["shape"]
396
- features = p["features"]
397
- prompt = p["prompt"]
398
-
399
- lines = ["import cadquery as cq"]
400
- if shape == "cylinder" and "fins" in features:
401
- lines.append("import math")
402
- lines.append(f"")
403
- lines.append(f"# Generated from: {prompt}")
404
-
405
- if shape == "cylinder":
406
- radius = dims[0] / 2 if dims else 15.0
407
- height = dims[1] if len(dims) > 1 else radius * 2
408
- lines.append(f"# Cylinder: radius={radius}mm, height={height}mm")
409
- lines.append(f"result = (")
410
- lines.append(f" cq.Workplane('XY')")
411
- lines.append(f" .cylinder({height}, {radius})")
412
-
413
- if "holes" in features or "through_hole" in features:
414
- lines.append(f" .faces('>Z').workplane()")
415
- lines.append(f" .hole({p['hole_dia']})")
416
-
417
- if "chamfer" in features or "fillet" not in features:
418
- lines.append(f" .edges('>Z or <Z').chamfer(0.5)")
419
-
420
- if "fillet" in features:
421
- lines.append(f" .edges('>Z or <Z').fillet(1.0)")
422
-
423
- lines.append(f")")
424
-
425
- if "fins" in features:
426
- n_fins = p["count"] if p["count"] > 4 else 8
427
- fin_h = max(height * 0.8, 5)
428
- fin_w = 1.5
429
- lines.append(f"")
430
- lines.append(f"# Add {n_fins} cooling fins")
431
- lines.append(f"for i in range({n_fins}):")
432
- lines.append(f" angle = i * 360 / {n_fins}")
433
- lines.append(f" rad = math.radians(angle)")
434
- lines.append(f" fx = {radius + 3} * math.cos(rad)")
435
- lines.append(f" fy = {radius + 3} * math.sin(rad)")
436
- lines.append(f" fin = (")
437
- lines.append(f" cq.Workplane('XY')")
438
- lines.append(
439
- f" .transformed(offset=(fx, fy, 0), rotate=(0, 0, angle))"
440
- )
441
- lines.append(f" .rect({fin_w}, {radius * 0.6})")
442
- lines.append(f" .extrude({fin_h})")
443
- lines.append(f" )")
444
- lines.append(f" result = result.union(fin)")
445
-
446
- elif shape == "plate":
447
- w = dims[0] if dims else 80.0
448
- h = dims[1] if len(dims) > 1 else w * 0.6
449
- t = dims[2] if len(dims) > 2 else 5.0
450
- lines.append(f"# Plate: {w}x{h}x{t}mm")
451
- lines.append(f"result = (")
452
- lines.append(f" cq.Workplane('XY')")
453
- lines.append(f" .box({w}, {h}, {t})")
454
-
455
- if "holes" in features or "through_hole" in features:
456
- n = p["count"]
457
- dia = p["hole_dia"]
458
- # Distribute holes in a grid or circle
459
- if "flange" in p["prompt"].lower() or n >= 6:
460
- # Bolt circle pattern
461
- r = min(w, h) * 0.35
462
- lines.append(f" .faces('>Z').workplane()")
463
- lines.append(f" .polarArray({r}, 0, 360, {n})")
464
- lines.append(f" .hole({dia})")
465
- if "bore" in p["prompt"].lower() or "flange" in p["prompt"].lower():
466
- lines.append(f" .faces('>Z').workplane()")
467
- lines.append(f" .hole({dia * 3}) # Center bore")
468
- else:
469
- # Rectangular pattern
470
- ox = w * 0.35
471
- oy = h * 0.35
472
- pts = []
473
- if n == 1:
474
- pts = [(0, 0)]
475
- elif n == 2:
476
- pts = [(-ox, 0), (ox, 0)]
477
- elif n == 4:
478
- pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
479
- else:
480
- pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
481
- lines.append(f" .faces('>Z').workplane()")
482
- lines.append(f" .pushPoints({pts})")
483
- lines.append(f" .hole({dia})")
484
-
485
- if "pocket" in features:
486
- pw = w * 0.4
487
- ph = h * 0.35
488
- pd = t * 0.6
489
- lines.append(f" .faces('>Z').workplane()")
490
- lines.append(f" .rect({pw}, {ph})")
491
- lines.append(f" .cutBlind(-{pd}) # Central pocket")
492
-
493
- if "slot" in features:
494
- sl = w * 0.35
495
- sw = max(t * 0.8, 4)
496
- lines.append(f" .faces('>Z').workplane()")
497
- lines.append(f" .slot2D({sl}, {sw}).cutBlind(-{t})")
498
-
499
- if "fillet" in features:
500
- lines.append(f" .edges('|Z').fillet({max(t * 0.4, 1.5)})")
501
- else:
502
- lines.append(f" .edges('>Z').chamfer(0.5)")
503
-
504
- lines.append(f")")
505
-
506
- elif shape == "l_bracket":
507
- arm = dims[0] if dims else 50.0
508
- width = dims[1] if len(dims) > 1 else 20.0
509
- t = dims[2] if len(dims) > 2 else 4.0
510
- lines.append(f"# L-bracket: {arm}mm arms, {width}mm wide, {t}mm thick")
511
- lines.append(f"result = (")
512
- lines.append(f" cq.Workplane('XZ')")
513
- lines.append(f" .moveTo(0, 0)")
514
- lines.append(f" .lineTo({arm}, 0)")
515
- lines.append(f" .lineTo({arm}, {t})")
516
- lines.append(f" .lineTo({t}, {t})")
517
- lines.append(f" .lineTo({t}, {arm})")
518
- lines.append(f" .lineTo(0, {arm})")
519
- lines.append(f" .close()")
520
- lines.append(f" .extrude({width})")
521
- lines.append(f" .edges('|Y').fillet({max(t * 0.5, 1.5)})")
522
-
523
- if "holes" in features:
524
- lines.append(
525
- f" .faces('>Z').workplane(centerOption='CenterOfBoundBox')"
526
- )
527
- lines.append(f" .center({arm * 0.5}, 0)")
528
- lines.append(f" .hole({p['hole_dia']})")
529
- lines.append(
530
- f" .faces('>X').workplane(centerOption='CenterOfBoundBox')"
531
- )
532
- lines.append(f" .center(0, {arm * 0.5})")
533
- lines.append(f" .hole({p['hole_dia']})")
534
-
535
- lines.append(f" .edges().chamfer(0.5)")
536
- lines.append(f")")
537
-
538
- else: # box / enclosure / housing
539
- w = dims[0] if dims else 60.0
540
- h = dims[1] if len(dims) > 1 else w * 0.65
541
- d = dims[2] if len(dims) > 2 else 20.0
542
- lines.append(f"# Box: {w}x{h}x{d}mm")
543
- lines.append(f"result = (")
544
- lines.append(f" cq.Workplane('XY')")
545
- lines.append(f" .box({w}, {h}, {d})")
546
-
547
- if "holes" in features or "through_hole" in features:
548
- ox = w * 0.35
549
- oy = h * 0.35
550
- pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
551
- lines.append(f" .faces('>Z').workplane()")
552
- lines.append(f" .pushPoints({pts})")
553
- lines.append(f" .hole({p['hole_dia']})")
554
-
555
- if "pocket" in features:
556
- pw = w * 0.5
557
- ph = h * 0.4
558
- pd = d * 0.4
559
- lines.append(f" .faces('>Z').workplane()")
560
- lines.append(f" .rect({pw}, {ph})")
561
- lines.append(f" .cutBlind(-{pd})")
562
-
563
- if "slot" in features:
564
- sl = w * 0.4
565
- sw = 6
566
- lines.append(f" .faces('>Z').workplane()")
567
- lines.append(f" .slot2D({sl}, {sw}).cutBlind(-{d})")
568
-
569
- if "boss" in features:
570
- n = min(p["count"], 4)
571
- bx = w * 0.3
572
- by = h * 0.3
573
- boss_pts = [(-bx, -by), (-bx, by), (bx, -by), (bx, by)][:n]
574
- lines.append(f" .faces('>Z').workplane()")
575
- lines.append(f" .pushPoints({boss_pts})")
576
- lines.append(f" .circle(4).extrude(6) # Mounting bosses")
577
-
578
- if "ribs" in features:
579
- n_ribs = p["count"] if p["count"] <= 8 else 4
580
- spacing = w / (n_ribs + 1)
581
- lines.append(f" .faces('>Z').workplane()")
582
- for i in range(n_ribs):
583
- rx = -w / 2 + spacing * (i + 1)
584
- lines.append(f" .center({rx if i == 0 else spacing}, 0)")
585
- lines.append(f" .rect(2, {h * 0.8}).extrude({d * 0.3})")
586
-
587
- if "fillet" in features:
588
- lines.append(f" .edges('|Z').fillet({min(d * 0.2, 3)})")
589
- elif "chamfer" in features:
590
- lines.append(f" .edges('>Z').chamfer(1.0)")
591
- else:
592
- lines.append(f" .edges('>Z').chamfer(0.5)")
593
-
594
- lines.append(f")")
595
-
596
- return "\n".join(lines) + "\n"
597
-
598
- # Curated hero responses for specific prompts
599
- _CURATED = {
600
- "gear": """\
601
- import cadquery as cq
602
- import math
603
-
604
- # Simple spur gear approximation: 20 teeth, module 2, 10mm thick
605
- module = 2
606
- teeth = 20
607
- pitch_radius = module * teeth / 2
608
- outer_radius = pitch_radius + module
609
- tooth_angle = 360 / teeth
610
-
611
- result = (
612
- cq.Workplane("XY")
613
- .cylinder(10, outer_radius)
614
- .faces(">Z").workplane()
615
- .hole(12)
616
- )
617
-
618
- for i in range(teeth):
619
- angle = i * tooth_angle
620
- rad = math.radians(angle)
621
- gap_x = pitch_radius * math.cos(rad)
622
- gap_y = pitch_radius * math.sin(rad)
623
- cutter = (
624
- cq.Workplane("XY")
625
- .transformed(offset=(gap_x, gap_y, 0), rotate=(0, 0, angle))
626
- .rect(module * 0.8, module * 2.5)
627
- .extrude(12)
628
- )
629
- result = result.cut(cutter)
630
-
631
- result = result.edges(">Z or <Z").chamfer(0.3)
632
- """,
633
- }
634
-
635
- def generate(self, messages: list[dict]) -> str:
636
- user_msg = messages[-1]["content"]
637
- lower = user_msg.lower()
638
-
639
- # Check curated responses first
640
- for key, code in self._CURATED.items():
641
- if key in lower:
642
- return code
643
-
644
- # Dynamic generation for everything else
645
- params = self._parse_prompt(user_msg)
646
- return self._generate_code(params)
647
-
648
-
649
- class NeuralCADBackend(LLMBackend):
650
- """
651
- Neural CAD pipeline backend.
652
-
653
- Runs trained models locally:
654
- Text/Image → CLIP encoder → contrastive latent
655
- → Diffusion prior → latent
656
- → Transformer decoder → CAD command sequence
657
- → OpenCascade kernel → B-rep solid
658
-
659
- Unlike LLM backends, this does not generate CadQuery code strings.
660
- Instead it produces CAD command sequences decoded directly into geometry.
661
- """
662
-
663
- def __init__(
664
- self,
665
- model_dir: str | Path = "./models",
666
- device: str = "cuda",
667
- clip_model: str = "clip_encoder.pt",
668
- prior_model: str = "diffusion_prior.pt",
669
- decoder_model: str = "transformer_decoder.pt",
670
- ):
671
- self.model_dir = Path(model_dir)
672
- self.device = device
673
- self.clip_encoder = None
674
- self.diffusion_prior = None
675
- self.transformer_decoder = None
676
- self._model_config = {
677
- "clip": clip_model,
678
- "prior": prior_model,
679
- "decoder": decoder_model,
680
- }
681
-
682
- def load_models(self):
683
- """Load all model weights from disk. Call once before inference."""
684
- raise NotImplementedError(
685
- f"Model loading not yet implemented. "
686
- f"Expected model files in: {self.model_dir}"
687
- )
688
-
689
- def encode_text(self, text: str):
690
- """Encode text prompt to CLIP latent vector."""
691
- raise NotImplementedError("CLIP text encoder not yet implemented")
692
-
693
- def encode_image(self, image_path: str | Path):
694
- """Encode image (photo/sketch) to CLIP latent vector."""
695
- raise NotImplementedError("CLIP image encoder not yet implemented")
696
-
697
- def run_diffusion_prior(self, clip_embedding):
698
- """Map CLIP embedding to CAD latent via diffusion prior."""
699
- raise NotImplementedError("Diffusion prior not yet implemented")
700
-
701
- def decode_to_cad_sequence(self, latent):
702
- """Decode latent to CAD command sequence."""
703
- raise NotImplementedError("Transformer decoder not yet implemented")
704
-
705
- def cad_sequence_to_solid(self, cad_commands: list[dict]):
706
- """Execute CAD command sequence through OpenCascade kernel → B-rep solid."""
707
- raise NotImplementedError("CAD kernel execution not yet implemented")
708
-
709
- def generate(self, messages: list[dict]) -> str:
710
- """
711
- LLMBackend-compatible interface.
712
-
713
- Extracts the text prompt from messages, runs the full neural pipeline,
714
- and returns CadQuery-equivalent code as a string for compatibility
715
- with the existing execution/validation/export pipeline.
716
- """
717
- user_msg = messages[-1]["content"]
718
-
719
- clip_emb = self.encode_text(user_msg)
720
- latent = self.run_diffusion_prior(clip_emb)
721
- cad_commands = self.decode_to_cad_sequence(latent)
722
- return self._cad_commands_to_code(cad_commands)
723
-
724
- def generate_from_image(self, image_path: str | Path, text_hint: str = "") -> str:
725
- """
726
- Image-conditioned generation (not available on LLM backends).
727
-
728
- Args:
729
- image_path: Path to photo or sketch of the desired part.
730
- text_hint: Optional text to guide generation alongside the image.
731
-
732
- Returns:
733
- CadQuery code string for pipeline compatibility.
734
- """
735
- img_emb = self.encode_image(image_path)
736
- if text_hint:
737
- txt_emb = self.encode_text(text_hint)
738
- # Fuse text + image embeddings (strategy TBD — average, concat, cross-attn)
739
- clip_emb = (img_emb + txt_emb) / 2 # placeholder fusion
740
- else:
741
- clip_emb = img_emb
742
-
743
- latent = self.run_diffusion_prior(clip_emb)
744
- cad_commands = self.decode_to_cad_sequence(latent)
745
- return self._cad_commands_to_code(cad_commands)
746
-
747
- def _cad_commands_to_code(self, cad_commands: list[dict]) -> str:
748
- """Convert internal CAD command sequence to CadQuery Python code string."""
749
- raise NotImplementedError(
750
- "CAD command → CadQuery code serializer not yet implemented"
751
- )
752
-
753
-
754
- # ── Pipeline ──────────────────────────────────────────────────────────────
755
-
756
-
757
- @dataclass
758
- class PipelineResult:
759
- prompt: str
760
- generated_code: str
761
- execution: ExecutionResult
762
- validation: Optional[CNCValidationResult] = None
763
- exported_files: dict[str, Path] = None
764
- retry_count: int = 0
765
-
766
- def summary(self) -> str:
767
- lines = [
768
- "=" * 60,
769
- "TEXT-TO-CNC PIPELINE RESULT",
770
- "=" * 60,
771
- f"Prompt: {self.prompt}",
772
- f"Retries: {self.retry_count}",
773
- "",
774
- "── Execution ──",
775
- self.execution.summary(),
776
- "",
777
- ]
778
- if self.validation:
779
- lines += ["── CNC Validation ──", self.validation.summary(), ""]
780
- if self.exported_files:
781
- lines += ["── Exported Files ──"]
782
- for fmt, path in self.exported_files.items():
783
- lines.append(f" {fmt.upper()}: {path}")
784
- lines.append("=" * 60)
785
- return "\n".join(lines)
786
-
787
-
788
- def run_pipeline(
789
- prompt: str,
790
- backend: Optional[LLMBackend] = None,
791
- output_dir: str | Path = "./output",
792
- max_retries: int = 2,
793
- export: bool = True,
794
- validate: bool = True,
795
- part_name: Optional[str] = None,
796
- ) -> PipelineResult:
797
- """
798
- Run the full text-to-CNC pipeline.
799
-
800
- Args:
801
- prompt: Natural language description of the part.
802
- backend: LLM backend to use. Defaults to MockBackend.
803
- output_dir: Directory for exported files.
804
- max_retries: Number of retry attempts if code execution fails.
805
- export: Whether to export STEP/STL files.
806
- validate: Whether to run CNC validation.
807
- part_name: Name for the part (used in filenames).
808
-
809
- Returns:
810
- PipelineResult with all pipeline outputs.
811
- """
812
- if backend is None:
813
- backend = MockBackend()
814
-
815
- output_dir = Path(output_dir)
816
- output_dir.mkdir(parents=True, exist_ok=True)
817
-
818
- if part_name is None:
819
- # Generate a filename-safe name from the prompt
820
- part_name = prompt[:40].strip().replace(" ", "_").lower()
821
- part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
822
-
823
- # Stage 1: Generate code
824
- messages = build_messages(prompt)
825
- generated_code = backend.generate(messages)
826
-
827
- # Stage 2: Execute with retry loop
828
- execution = execute_cadquery(generated_code)
829
- retry_count = 0
830
-
831
- while not execution.success and retry_count < max_retries:
832
- retry_count += 1
833
- print(f" Retry {retry_count}/{max_retries}: fixing error...")
834
-
835
- # Feed the error back to the LLM for self-correction
836
- error_feedback = (
837
- f"The previous code failed with this error:\n"
838
- f"```\n{execution.error}\n```\n\n"
839
- f"Please fix the code and return only the corrected Python code. "
840
- f"Original request: {prompt}"
841
- )
842
- messages_retry = build_messages(error_feedback)
843
- generated_code = backend.generate(messages_retry)
844
- execution = execute_cadquery(generated_code)
845
-
846
- # Stage 3: Validate for CNC
847
- validation = None
848
- if execution.success and validate:
849
- validation = validate_for_cnc(execution.result, part_name=part_name)
850
-
851
- # Stage 4: Export
852
- exported_files = {}
853
- if execution.success and export:
854
- base_path = output_dir / part_name
855
- try:
856
- exported_files = export_all(execution.result, base_path)
857
- except Exception as e:
858
- print(f" Export warning: {e}")
859
-
860
- return PipelineResult(
861
- prompt=prompt,
862
- generated_code=generated_code,
863
- execution=execution,
864
- validation=validation,
865
- exported_files=exported_files or {},
866
- retry_count=retry_count,
867
- )
868
-
869
-
870
- # ── CLI Entry Point ───────────────────────────────────────────────────────
871
-
872
- if __name__ == "__main__":
873
- import argparse
874
-
875
- parser = argparse.ArgumentParser(description="Text-to-CNC Model Generator")
876
- parser.add_argument("prompt", nargs="?", default=None, help="Part description")
877
- parser.add_argument(
878
- "--backend", choices=["mock", "anthropic", "openai", "gemini", "neural"], default="mock"
879
- )
880
- parser.add_argument("--output-dir", default="./output")
881
- parser.add_argument("--retries", type=int, default=2)
882
- parser.add_argument("--name", default=None, help="Part name for file output")
883
- parser.add_argument("--no-validate", action="store_true")
884
- parser.add_argument("--no-export", action="store_true")
885
- args = parser.parse_args()
886
-
887
- if args.prompt is None:
888
- args.prompt = "A simple mounting bracket with two M5 bolt holes"
889
-
890
- # Select backend
891
- if args.backend == "neural":
892
- backend = NeuralCADBackend()
893
- elif args.backend == "anthropic":
894
- backend = AnthropicBackend()
895
- elif args.backend == "openai":
896
- backend = OpenAIBackend()
897
- elif args.backend == "gemini":
898
- backend = GeminiBackend()
899
- else:
900
- backend = MockBackend()
901
-
902
- print(f"Generating CNC model for: '{args.prompt}'")
903
- print(f"Backend: {args.backend}")
904
- print()
905
-
906
- result = run_pipeline(
907
- prompt=args.prompt,
908
- backend=backend,
909
- output_dir=args.output_dir,
910
- max_retries=args.retries,
911
- export=not args.no_export,
912
- validate=not args.no_validate,
913
- part_name=args.name,
914
- )
915
-
916
- print(result.summary())
917
-
918
- if result.execution.success:
919
- print("\nGenerated Code:")
920
- print("-" * 40)
921
- print(result.generated_code)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -11,7 +11,6 @@ dependencies = [
11
  "anthropic>=0.25.0",
12
  "openai>=1.30.0",
13
  "google-genai>=1.0.0",
14
- "crewai>=0.80.0",
15
  "mcp>=1.0.0",
16
  "fastapi>=0.110.0",
17
  "uvicorn>=0.29.0",
 
11
  "anthropic>=0.25.0",
12
  "openai>=1.30.0",
13
  "google-genai>=1.0.0",
 
14
  "mcp>=1.0.0",
15
  "fastapi>=0.110.0",
16
  "uvicorn>=0.29.0",
web_server.py DELETED
@@ -1,219 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- NeuralCAD Web Demo Server
4
- =========================
5
- FastAPI server that proxies REST requests to the MCP CAD server (SSE transport)
6
- and serves the web frontend.
7
-
8
- Usage:
9
- # Start MCP server first:
10
- python mcp_server.py --transport sse --port 8000
11
-
12
- # Then start web server:
13
- python web_server.py
14
-
15
- # Or auto-launch MCP server:
16
- python web_server.py --start-mcp
17
-
18
- # Open http://localhost:5000
19
- """
20
-
21
- import json
22
- import os
23
- import subprocess
24
- import sys
25
- import tempfile
26
- import time
27
- from contextlib import asynccontextmanager
28
- from pathlib import Path
29
-
30
- from fastapi import FastAPI, File, Form, UploadFile
31
- from fastapi.middleware.cors import CORSMiddleware
32
- from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
33
-
34
- from mcp import ClientSession
35
- from mcp.client.sse import sse_client
36
-
37
- # ── Config ───────────────────────────────────────────────────────────────
38
-
39
- MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")
40
- OUTPUT_DIR = Path(__file__).parent / "output"
41
- WEB_DIR = Path(__file__).parent / "web"
42
- PORT = int(os.environ.get("WEB_PORT", "5000"))
43
-
44
- # ── MCP Client Management ───────────────────────────────────────────────
45
-
46
- _mcp_process = None
47
-
48
-
49
- async def call_mcp_tool(tool_name: str, arguments: dict) -> dict:
50
- """Connect to MCP server, call a tool, return parsed JSON result."""
51
- async with sse_client(url=MCP_SERVER_URL) as streams:
52
- async with ClientSession(*streams) as session:
53
- await session.initialize()
54
- result = await session.call_tool(name=tool_name, arguments=arguments)
55
- if result.content:
56
- return json.loads(result.content[0].text)
57
- return {"error": "Empty response from MCP server"}
58
-
59
-
60
- async def read_mcp_resource(uri: str) -> str:
61
- """Connect to MCP server and read a resource."""
62
- async with sse_client(url=MCP_SERVER_URL) as streams:
63
- async with ClientSession(*streams) as session:
64
- await session.initialize()
65
- result = await session.read_resource(uri=uri)
66
- if result.contents:
67
- return result.contents[0].text
68
- return "{}"
69
-
70
-
71
- def start_mcp_server(port: int = 8000):
72
- """Launch mcp_server.py as a subprocess with SSE transport."""
73
- global _mcp_process
74
- mcp_script = Path(__file__).parent / "mcp_server.py"
75
- _mcp_process = subprocess.Popen(
76
- [sys.executable, str(mcp_script), "--transport", "sse", "--port", str(port)],
77
- stdout=subprocess.PIPE,
78
- stderr=subprocess.PIPE,
79
- )
80
- # Give it a moment to start
81
- time.sleep(2)
82
- if _mcp_process.poll() is not None:
83
- stderr = _mcp_process.stderr.read().decode() if _mcp_process.stderr else ""
84
- raise RuntimeError(f"MCP server failed to start: {stderr}")
85
- print(f" MCP server started (PID {_mcp_process.pid}) on port {port}")
86
-
87
-
88
- # ── FastAPI App ──────────────────────────────────────────────────────────
89
-
90
- @asynccontextmanager
91
- async def lifespan(app: FastAPI):
92
- OUTPUT_DIR.mkdir(exist_ok=True)
93
- yield
94
- global _mcp_process
95
- if _mcp_process:
96
- _mcp_process.terminate()
97
- _mcp_process.wait()
98
-
99
-
100
- app = FastAPI(title="NeuralCAD Web Demo", lifespan=lifespan)
101
-
102
- app.add_middleware(
103
- CORSMiddleware,
104
- allow_origins=["*"],
105
- allow_methods=["*"],
106
- allow_headers=["*"],
107
- )
108
-
109
-
110
- # ── Routes ───────────────────────────────────────────────────────────────
111
-
112
- @app.get("/", response_class=HTMLResponse)
113
- async def index():
114
- index_file = WEB_DIR / "index.html"
115
- return HTMLResponse(index_file.read_text())
116
-
117
-
118
- @app.post("/api/generate")
119
- async def generate(body: dict):
120
- result = await call_mcp_tool("generate_cnc_model", {
121
- "prompt": body.get("prompt", ""),
122
- "part_name": body.get("part_name", ""),
123
- "backend": body.get("backend", "mock"),
124
- "max_retries": body.get("max_retries", 2),
125
- })
126
- return JSONResponse(result)
127
-
128
-
129
- @app.post("/api/generate-image")
130
- async def generate_image(
131
- image: UploadFile = File(...),
132
- text_hint: str = Form(""),
133
- part_name: str = Form(""),
134
- backend: str = Form("anthropic"),
135
- ):
136
- # Save uploaded image to temp file
137
- suffix = Path(image.filename or "upload.png").suffix
138
- with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
139
- tmp.write(await image.read())
140
- tmp_path = tmp.name
141
-
142
- try:
143
- result = await call_mcp_tool("generate_from_image", {
144
- "image_path": tmp_path,
145
- "text_hint": text_hint,
146
- "part_name": part_name,
147
- "backend": backend,
148
- })
149
- return JSONResponse(result)
150
- finally:
151
- os.unlink(tmp_path)
152
-
153
-
154
- @app.post("/api/validate")
155
- async def validate(body: dict):
156
- result = await call_mcp_tool("validate_cnc_model", {
157
- "cadquery_code": body.get("code", ""),
158
- "part_name": body.get("part_name", "Part"),
159
- })
160
- return JSONResponse(result)
161
-
162
-
163
- @app.get("/api/models")
164
- async def list_models():
165
- result = await call_mcp_tool("list_models", {
166
- "output_dir": str(OUTPUT_DIR),
167
- })
168
- return JSONResponse(result)
169
-
170
-
171
- @app.get("/api/models/{name}.stl")
172
- async def get_stl(name: str):
173
- path = OUTPUT_DIR / f"{name}.stl"
174
- if not path.exists():
175
- return JSONResponse({"error": f"STL not found: {name}"}, status_code=404)
176
- return FileResponse(path, media_type="model/stl", filename=f"{name}.stl")
177
-
178
-
179
- @app.get("/api/models/{name}.step")
180
- async def get_step(name: str):
181
- path = OUTPUT_DIR / f"{name}.step"
182
- if not path.exists():
183
- return JSONResponse({"error": f"STEP not found: {name}"}, status_code=404)
184
- return FileResponse(path, media_type="application/step", filename=f"{name}.step")
185
-
186
-
187
- @app.get("/api/capabilities")
188
- async def capabilities():
189
- try:
190
- text = await read_mcp_resource("text-to-cnc://capabilities")
191
- return JSONResponse(json.loads(text))
192
- except Exception as e:
193
- return JSONResponse({"error": str(e)}, status_code=502)
194
-
195
-
196
- # ── Entry Point ──────────────────────────────────────────────────────────
197
-
198
- if __name__ == "__main__":
199
- import argparse
200
- import uvicorn
201
-
202
- parser = argparse.ArgumentParser(description="NeuralCAD Web Demo Server")
203
- parser.add_argument("--port", type=int, default=PORT, help="Web server port (default: 5000)")
204
- parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
205
- parser.add_argument(
206
- "--start-mcp", action="store_true",
207
- help="Auto-launch MCP server as subprocess before starting web server"
208
- )
209
- parser.add_argument("--mcp-port", type=int, default=8000, help="MCP server port (default: 8000)")
210
- args = parser.parse_args()
211
-
212
- if args.start_mcp:
213
- MCP_SERVER_URL = f"http://localhost:{args.mcp_port}/sse"
214
- print(f"Starting MCP CAD server on port {args.mcp_port}...")
215
- start_mcp_server(args.mcp_port)
216
-
217
- print(f"Starting NeuralCAD Web Demo on http://localhost:{args.port}")
218
- print(f"MCP server: {MCP_SERVER_URL}")
219
- uvicorn.run(app, host=args.host, port=args.port)