Spaces:
Sleeping
Sleeping
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 +1 -0
- cadquery_system_prompt.py +0 -151
- cnc_validator.py +0 -224
- code_executor.py +0 -189
- docker-compose.yml +4 -0
- docs/superpowers/specs/2026-04-08-multi-agent-chat-design.md +390 -0
- mcp_server.py +0 -446
- pipeline.py +0 -921
- pyproject.toml +0 -1
- web_server.py +0 -219
.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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|