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

feat: multi-agent chat for collaborative CAD design

Browse files

Replace single-prompt generation with a multi-agent chat experience:
- 4 AI agents (Design, Engineering, CNC, CAD Coder) in shared group chat
- Hybrid orchestration: single-call JSON for Gemini/Mock, CrewAI for paid APIs
- @mention support to address specific agents
- Fullscreen 3D viewer with collapsible slide-out chat panel
- On-demand 3D preview generation
- Refactored codebase into core/, server/, agents/ packages

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

Dockerfile CHANGED
@@ -28,7 +28,9 @@ WORKDIR /app
28
  COPY --from=builder /app/.venv /app/.venv
29
 
30
  # Copy application source
31
- COPY --from=builder /app/*.py /app/
 
 
32
  COPY --from=builder /app/web /app/web/
33
  COPY --from=builder /app/entrypoint.sh /app/
34
 
 
28
  COPY --from=builder /app/.venv /app/.venv
29
 
30
  # Copy application source
31
+ COPY --from=builder /app/core /app/core/
32
+ COPY --from=builder /app/server /app/server/
33
+ COPY --from=builder /app/agents /app/agents/
34
  COPY --from=builder /app/web /app/web/
35
  COPY --from=builder /app/entrypoint.sh /app/
36
 
agents/__init__.py ADDED
File without changes
agents/crew_orchestrator.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CrewAI multi-call orchestrator for paid API backends (Anthropic/OpenAI).
2
+
3
+ Uses CrewAI's hierarchical process where a manager agent routes to
4
+ specialist agents. Each agent gets its own focused LLM call.
5
+ Better quality but uses 2-4 API calls per turn.
6
+
7
+ Note: Falls back to SingleCallOrchestrator if CrewAI is not installed.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
16
+ from agents.prompts import route_by_keywords
17
+
18
+
19
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
20
+
21
+
22
+ class CrewOrchestrator:
23
+ """Multi-call orchestrator using CrewAI hierarchical process.
24
+
25
+ Each agent gets its own LLM call with focused context.
26
+ Falls back to SingleCallOrchestrator if CrewAI is unavailable.
27
+ """
28
+
29
+ def __init__(self, backend_name: str = "anthropic", output_dir: Path | str = DEFAULT_OUTPUT_DIR):
30
+ self.backend_name = backend_name
31
+ self.output_dir = Path(output_dir)
32
+ self.output_dir.mkdir(parents=True, exist_ok=True)
33
+ self._crew_available = self._check_crewai()
34
+
35
+ def _check_crewai(self) -> bool:
36
+ """Check if CrewAI is installed and usable."""
37
+ try:
38
+ import crewai
39
+ return True
40
+ except ImportError:
41
+ return False
42
+
43
+ def chat_turn(
44
+ self,
45
+ message: str,
46
+ history: list[dict],
47
+ mentions: list[str] | None = None,
48
+ max_history: int = 30,
49
+ ) -> dict:
50
+ """Run one chat turn using CrewAI multi-call process.
51
+
52
+ Falls back to SingleCallOrchestrator if CrewAI is not available.
53
+
54
+ Returns same format as SingleCallOrchestrator:
55
+ {"responses": [...], "preview": None | {...}}
56
+ """
57
+ if not self._crew_available:
58
+ # Fallback to single-call
59
+ from agents.orchestrator import SingleCallOrchestrator
60
+ from core.backends import AnthropicBackend, OpenAIBackend
61
+
62
+ backends = {"anthropic": AnthropicBackend, "openai": OpenAIBackend}
63
+ backend_cls = backends.get(self.backend_name, AnthropicBackend)
64
+
65
+ try:
66
+ backend = backend_cls()
67
+ except Exception:
68
+ from agents.orchestrator import MockChatBackend
69
+ mock = MockChatBackend()
70
+ return mock.chat_turn(message, history, mentions)
71
+
72
+ orchestrator = SingleCallOrchestrator(
73
+ backend=backend, output_dir=self.output_dir
74
+ )
75
+ return orchestrator.chat_turn(message, history, mentions, max_history)
76
+
77
+ # TODO: Implement CrewAI hierarchical process
78
+ # For now, delegate to single-call as well
79
+ from agents.orchestrator import SingleCallOrchestrator
80
+ from core.backends import AnthropicBackend, OpenAIBackend
81
+
82
+ backends = {"anthropic": AnthropicBackend, "openai": OpenAIBackend}
83
+ backend_cls = backends.get(self.backend_name, AnthropicBackend)
84
+
85
+ try:
86
+ backend = backend_cls()
87
+ except Exception:
88
+ from agents.orchestrator import MockChatBackend
89
+ mock = MockChatBackend()
90
+ return mock.chat_turn(message, history, mentions)
91
+
92
+ orchestrator = SingleCallOrchestrator(
93
+ backend=backend, output_dir=self.output_dir
94
+ )
95
+ return orchestrator.chat_turn(message, history, mentions, max_history)
agents/definitions.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-agent definitions for NeuralCAD collaborative design chat."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class AgentDef:
8
+ """Definition of a chat agent."""
9
+ id: str
10
+ name: str
11
+ role: str
12
+ color: str
13
+ avatar: str
14
+ goal: str
15
+ backstory: str
16
+
17
+
18
+ AGENTS: dict[str, AgentDef] = {
19
+ "design": AgentDef(
20
+ id="design",
21
+ name="Design Agent",
22
+ role="Industrial Designer",
23
+ color="#7c3aed",
24
+ avatar="DA",
25
+ goal="Understand the user's intent and propose optimal form factors, shapes, and aesthetic choices for mechanical parts.",
26
+ backstory=(
27
+ "You are an experienced industrial designer specializing in mechanical parts. "
28
+ "You think about form, function, ergonomics, and visual appeal. You ask clarifying "
29
+ "questions about the part's purpose, environment, and constraints before proposing "
30
+ "designs. You suggest shapes, proportions, and features that balance aesthetics with "
31
+ "manufacturability."
32
+ ),
33
+ ),
34
+ "engineering": AgentDef(
35
+ id="engineering",
36
+ name="Engineering Agent",
37
+ role="Mechanical Engineer",
38
+ color="#00b4d8",
39
+ avatar="EA",
40
+ goal="Ensure parts are structurally sound with correct dimensions, tolerances, materials, and fastener specifications.",
41
+ backstory=(
42
+ "You are a senior mechanical engineer with deep knowledge of materials science, "
43
+ "stress analysis, and fastener standards. You specify wall thicknesses, fillet radii, "
44
+ "clearance holes (M3=3.4mm, M4=4.5mm, M5=5.5mm, M6=6.6mm, M8=9.0mm), and material "
45
+ "recommendations. You flag structural concerns and suggest reinforcements like ribs "
46
+ "or gussets when loads are significant."
47
+ ),
48
+ ),
49
+ "cnc": AgentDef(
50
+ id="cnc",
51
+ name="CNC Agent",
52
+ role="CNC Manufacturing Advisor",
53
+ color="#00e676",
54
+ avatar="CA",
55
+ goal="Advise on manufacturability: tool access, wall thickness limits, pocket ratios, axis requirements, and cost implications.",
56
+ backstory=(
57
+ "You are a CNC machinist with 20 years of shop floor experience. You know what "
58
+ "tool geometries can reach, what aspect ratios cause chatter, and when to recommend "
59
+ "3-axis vs 3+2 vs 5-axis. You flag undercuts, thin walls (<1.5mm), deep pockets "
60
+ "(>4:1 ratio), and features that need special fixturing. You think about setup count "
61
+ "and machining time."
62
+ ),
63
+ ),
64
+ "cad": AgentDef(
65
+ id="cad",
66
+ name="CAD Coder",
67
+ role="CadQuery Code Generator",
68
+ color="#ffab40",
69
+ avatar="CC",
70
+ goal="Generate valid CadQuery Python code that produces the agreed-upon 3D model.",
71
+ backstory=(
72
+ "You are an expert CadQuery programmer. You only speak when asked to generate "
73
+ "a preview or produce code. You take the design specifications agreed upon by the "
74
+ "team and translate them into precise CadQuery Python code. Your code always assigns "
75
+ "the result to a variable called `result` as a cq.Workplane object."
76
+ ),
77
+ ),
78
+ }
79
+
80
+ # Agent metadata for frontend rendering
81
+ AGENT_COLORS = {agent.id: agent.color for agent in AGENTS.values()}
82
+ AGENT_AVATARS = {agent.id: agent.avatar for agent in AGENTS.values()}
83
+ AGENT_NAMES = {agent.id: agent.name for agent in AGENTS.values()}
agents/llm_adapter.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CrewAI BaseLLM adapter for NeuralCAD's LLMBackend interface."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+
6
+ try:
7
+ from crewai import LLM as BaseLLM
8
+ except ImportError:
9
+ # Fallback if crewai not installed — allows import without dependency
10
+ class BaseLLM:
11
+ def __init__(self, model: str, **kwargs):
12
+ self.model = model
13
+ def call(self, messages, **kwargs) -> str:
14
+ raise NotImplementedError
15
+
16
+
17
+ class NeuralCADLLMAdapter(BaseLLM):
18
+ """Adapter that wraps NeuralCAD's LLMBackend for CrewAI compatibility.
19
+
20
+ Usage:
21
+ from core.backends import GeminiBackend
22
+ backend = GeminiBackend()
23
+ adapter = NeuralCADLLMAdapter(backend, model="gemini-2.5-flash")
24
+ # Now usable as CrewAI agent's llm parameter
25
+ """
26
+
27
+ def __init__(self, backend, model: str = "custom", **kwargs):
28
+ super().__init__(model=model, **kwargs)
29
+ self.backend = backend
30
+
31
+ def call(
32
+ self,
33
+ messages: str | list[dict],
34
+ tools: Any = None,
35
+ callbacks: Any = None,
36
+ available_functions: Any = None,
37
+ **kwargs,
38
+ ) -> str:
39
+ # If messages is a string, wrap it in standard format
40
+ if isinstance(messages, str):
41
+ messages = [{"role": "user", "content": messages}]
42
+ return self.backend.generate(messages)
43
+
44
+ def supports_function_calling(self) -> bool:
45
+ return False
46
+
47
+ def supports_stop_words(self) -> bool:
48
+ return False
agents/orchestrator.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-call orchestrator for multi-agent chat (Gemini/Mock mode).
2
+
3
+ One LLM call per user turn. The orchestrator builds a system prompt containing
4
+ all agent personas, sends a single request, and parses the JSON response into
5
+ individual agent messages. For mock mode no LLM call is made at all — canned
6
+ responses are returned based on keyword matching.
7
+
8
+ Both ``MockChatBackend`` and ``SingleCallOrchestrator`` return the same shape::
9
+
10
+ {
11
+ "responses": [{"agent_id", "agent_name", "message", "color", "avatar", "code"}, ...],
12
+ "preview": None | { ... execution + validation data ... }
13
+ }
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+ from typing import Optional
21
+
22
+ from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
23
+ from agents.prompts import (
24
+ build_orchestrator_system_prompt,
25
+ build_chat_messages,
26
+ parse_mentions,
27
+ route_by_keywords,
28
+ parse_orchestrator_response,
29
+ )
30
+ from core.backends import LLMBackend, MockBackend
31
+ from core.executor import execute_cadquery, export_all
32
+ from core.validator import validate_for_cnc
33
+
34
+
35
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Helpers
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def _format_response(agent_id: str, message: str, code: str | None = None) -> dict:
43
+ """Wrap a raw agent reply into the standard response envelope."""
44
+ return {
45
+ "agent_id": agent_id,
46
+ "agent_name": AGENT_NAMES[agent_id],
47
+ "message": message,
48
+ "color": AGENT_COLORS[agent_id],
49
+ "avatar": AGENT_AVATARS[agent_id],
50
+ "code": code,
51
+ }
52
+
53
+
54
+ def _execute_cad_code(code: str, prompt: str, output_dir: Path) -> dict | None:
55
+ """Execute CadQuery *code* and return preview data (or error dict)."""
56
+ exec_result = execute_cadquery(code)
57
+
58
+ if not exec_result.success:
59
+ return {"success": False, "error": exec_result.error}
60
+
61
+ # Derive a filesystem-safe part name from the prompt
62
+ part_name = prompt[:40].strip().replace(" ", "_").lower()
63
+ part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
64
+ if not part_name:
65
+ part_name = "part"
66
+
67
+ # Export STL + STEP
68
+ base_path = output_dir / part_name
69
+ try:
70
+ export_all(exec_result.result, base_path)
71
+ except Exception as exc:
72
+ return {"success": False, "error": f"Export failed: {exc}"}
73
+
74
+ # CNC validation
75
+ validation = validate_for_cnc(exec_result.result, part_name=part_name)
76
+
77
+ return {
78
+ "success": True,
79
+ "part_name": part_name,
80
+ "stl_url": f"/api/models/{part_name}.stl",
81
+ "step_url": f"/api/models/{part_name}.step",
82
+ "execution": {
83
+ "success": True,
84
+ "volume_mm3": exec_result.volume,
85
+ "bounding_box_mm": list(exec_result.bounding_box),
86
+ "face_count": exec_result.face_count,
87
+ "edge_count": exec_result.edge_count,
88
+ },
89
+ "validation": {
90
+ "machinable": validation.machinable,
91
+ "axis_recommendation": validation.axis_recommendation,
92
+ "error_count": validation.error_count,
93
+ "warning_count": validation.warning_count,
94
+ "issues": [
95
+ {"severity": i.severity, "category": i.category, "message": i.message}
96
+ for i in validation.issues
97
+ ],
98
+ },
99
+ }
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # MockChatBackend — template-based, no LLM call
104
+ # ---------------------------------------------------------------------------
105
+
106
+ class MockChatBackend:
107
+ """Template-based chat responses for mock mode (no LLM call).
108
+
109
+ Generates canned agent responses based on keyword matching.
110
+ For the CAD Coder agent, delegates to ``MockBackend`` for code generation.
111
+ """
112
+
113
+ def __init__(self, output_dir: Path | str = DEFAULT_OUTPUT_DIR):
114
+ self.output_dir = Path(output_dir)
115
+ self.output_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ # -- public interface ----------------------------------------------------
118
+
119
+ def chat_turn(
120
+ self,
121
+ message: str,
122
+ history: list[dict],
123
+ mentions: list[str] | None = None,
124
+ max_history: int = 30,
125
+ ) -> dict:
126
+ """Return ``{"responses": [...], "preview": ...}``."""
127
+ lower = message.lower()
128
+
129
+ # Determine which agents respond
130
+ if mentions:
131
+ active = mentions
132
+ else:
133
+ active = route_by_keywords(message)
134
+
135
+ responses: list[dict] = []
136
+ preview = None
137
+
138
+ if "design" in active:
139
+ responses.append(
140
+ _format_response("design", self._design_response(lower))
141
+ )
142
+
143
+ if "engineering" in active:
144
+ responses.append(
145
+ _format_response("engineering", self._engineering_response(lower))
146
+ )
147
+
148
+ if "cnc" in active:
149
+ responses.append(
150
+ _format_response("cnc", self._cnc_response(lower))
151
+ )
152
+
153
+ if "cad" in active:
154
+ # Use MockBackend for actual code generation
155
+ from core.cadquery_prompts import build_messages
156
+
157
+ mock = MockBackend()
158
+ code = mock.generate(build_messages(message))
159
+ responses.append(
160
+ _format_response(
161
+ "cad",
162
+ "Model generated. Click the 3D viewer to inspect it.",
163
+ code=code,
164
+ )
165
+ )
166
+ preview = _execute_cad_code(code, message, self.output_dir)
167
+
168
+ return {"responses": responses, "preview": preview}
169
+
170
+ # -- canned response templates -------------------------------------------
171
+
172
+ @staticmethod
173
+ def _design_response(lower: str) -> str:
174
+ if any(w in lower for w in ("bracket", "mount")):
175
+ return (
176
+ "For a mounting bracket, I'd suggest an L-shaped profile with "
177
+ "filleted corners for rigidity. What's the intended load direction?"
178
+ )
179
+ if any(w in lower for w in ("gear", "spur")):
180
+ return (
181
+ "For a spur gear, we'll need to define the module, tooth count, "
182
+ "and bore diameter. What's the mating gear specification?"
183
+ )
184
+ if any(w in lower for w in ("enclosure", "box", "housing")):
185
+ return (
186
+ "For an enclosure, I'd recommend rounded external corners for "
187
+ "aesthetics and a pocket on the top face for the lid. What "
188
+ "components go inside?"
189
+ )
190
+ return (
191
+ "I can help design that. Could you tell me more about the part's "
192
+ "purpose and any dimensional constraints?"
193
+ )
194
+
195
+ @staticmethod
196
+ def _engineering_response(lower: str) -> str:
197
+ if any(w in lower for w in ("m3", "m4", "m5", "m6", "m8")):
198
+ return (
199
+ "Good fastener choice. I'll specify the clearance holes per ISO "
200
+ "standards. Shall I add counterbores or keep them as through-holes?"
201
+ )
202
+ if any(w in lower for w in ("load", "stress", "strength")):
203
+ return (
204
+ "For the expected loads, I'd recommend 3mm minimum wall thickness "
205
+ "in aluminum 6061-T6. Adding reinforcement ribs would increase "
206
+ "stiffness significantly."
207
+ )
208
+ return (
209
+ "I'll specify the critical dimensions and tolerances. What material "
210
+ "are you planning to machine this from?"
211
+ )
212
+
213
+ @staticmethod
214
+ def _cnc_response(lower: str) -> str:
215
+ if any(w in lower for w in ("pocket", "deep", "slot")):
216
+ return (
217
+ "Keep pocket depth-to-width ratio under 4:1 for clean machining. "
218
+ "I'd recommend a 6mm endmill for this geometry."
219
+ )
220
+ if any(w in lower for w in ("5-axis", "undercut")):
221
+ return (
222
+ "That feature would require 5-axis machining. Consider redesigning "
223
+ "to avoid undercuts for 3-axis compatibility."
224
+ )
225
+ return (
226
+ "This looks achievable with standard 3-axis milling. No undercuts or "
227
+ "access issues detected so far."
228
+ )
229
+
230
+
231
+ # ---------------------------------------------------------------------------
232
+ # SingleCallOrchestrator — one LLM call per turn
233
+ # ---------------------------------------------------------------------------
234
+
235
+ class SingleCallOrchestrator:
236
+ """Orchestrator that uses a single LLM call per chat turn.
237
+
238
+ Builds a system prompt containing all agent personas, sends one LLM call,
239
+ and parses the JSON response into individual agent messages.
240
+ Used for Gemini free tier and other rate-limited backends.
241
+ """
242
+
243
+ def __init__(self, backend: LLMBackend, output_dir: Path | str = DEFAULT_OUTPUT_DIR):
244
+ self.backend = backend
245
+ self.output_dir = Path(output_dir)
246
+ self.output_dir.mkdir(parents=True, exist_ok=True)
247
+
248
+ def chat_turn(
249
+ self,
250
+ message: str,
251
+ history: list[dict],
252
+ mentions: list[str] | None = None,
253
+ max_history: int = 30,
254
+ ) -> dict:
255
+ """Run one chat turn: user message -> agent responses.
256
+
257
+ Args:
258
+ message: The user's message text (with @mentions already stripped).
259
+ history: Previous messages [{role, agent_id, content}, ...].
260
+ mentions: Agent IDs explicitly mentioned by user. ``None`` = auto-route.
261
+ max_history: Max history messages to include in context.
262
+
263
+ Returns:
264
+ ``{"responses": [...], "preview": None | {...}}``
265
+ """
266
+ # Determine which agents are active
267
+ active_agents = mentions if mentions else None # None lets orchestrator decide
268
+
269
+ # Check if CAD context is needed
270
+ include_cad = mentions is not None and "cad" in mentions
271
+ if not include_cad:
272
+ cad_keywords = [
273
+ "generate", "build", "preview", "show me",
274
+ "create", "model", "render",
275
+ ]
276
+ include_cad = any(kw in message.lower() for kw in cad_keywords)
277
+
278
+ # Build orchestrator prompt
279
+ system_prompt = build_orchestrator_system_prompt(
280
+ active_agents=active_agents,
281
+ include_cad_context=include_cad,
282
+ )
283
+
284
+ # Build message list
285
+ messages = build_chat_messages(
286
+ user_message=message,
287
+ history=history,
288
+ system_prompt=system_prompt,
289
+ max_history=max_history,
290
+ )
291
+
292
+ # Single LLM call
293
+ try:
294
+ raw_response = self.backend.generate(messages)
295
+ agent_responses = parse_orchestrator_response(raw_response)
296
+ except Exception:
297
+ # Fallback: keyword routing with generic replies
298
+ fallback_agents = route_by_keywords(message)
299
+ agent_responses = [
300
+ {
301
+ "id": aid,
302
+ "message": "I can help with that aspect of the design.",
303
+ "code": None,
304
+ }
305
+ for aid in fallback_agents
306
+ ]
307
+
308
+ # Format responses with metadata
309
+ formatted: list[dict] = []
310
+ preview = None
311
+
312
+ for resp in agent_responses:
313
+ agent_id = resp["id"]
314
+ if agent_id not in AGENTS:
315
+ continue
316
+
317
+ formatted.append(
318
+ _format_response(agent_id, resp["message"], code=resp.get("code"))
319
+ )
320
+
321
+ # If CAD Coder responded with code, execute it
322
+ if agent_id == "cad" and resp.get("code"):
323
+ preview = _execute_cad_code(resp["code"], message, self.output_dir)
324
+
325
+ return {"responses": formatted, "preview": preview}
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Factory
330
+ # ---------------------------------------------------------------------------
331
+
332
+ def get_orchestrator(
333
+ backend_name: str = "mock",
334
+ output_dir: str | Path = DEFAULT_OUTPUT_DIR,
335
+ ) -> MockChatBackend | SingleCallOrchestrator:
336
+ """Create the appropriate orchestrator for the given backend.
337
+
338
+ Args:
339
+ backend_name: ``"mock"``, ``"gemini"``, ``"anthropic"``, or ``"openai"``.
340
+ output_dir: Directory for exported model files.
341
+ """
342
+ if backend_name == "mock":
343
+ return MockChatBackend(output_dir=output_dir)
344
+
345
+ # For all LLM backends, use SingleCallOrchestrator.
346
+ # (CrewAI multi-call variant can be added later for anthropic/openai.)
347
+ from core.backends import AnthropicBackend, OpenAIBackend, GeminiBackend
348
+
349
+ backends = {
350
+ "gemini": GeminiBackend,
351
+ "anthropic": AnthropicBackend,
352
+ "openai": OpenAIBackend,
353
+ }
354
+
355
+ backend_cls = backends.get(backend_name)
356
+ if backend_cls is None:
357
+ return MockChatBackend(output_dir=output_dir)
358
+
359
+ try:
360
+ backend = backend_cls()
361
+ except Exception:
362
+ # API key missing, model unavailable, etc. — fall back to mock.
363
+ return MockChatBackend(output_dir=output_dir)
364
+
365
+ return SingleCallOrchestrator(backend=backend, output_dir=output_dir)
agents/prompts.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Orchestrator prompts and routing logic for multi-agent chat."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Optional
8
+
9
+ from agents.definitions import AGENTS, AgentDef
10
+
11
+
12
+ def build_orchestrator_system_prompt(
13
+ active_agents: list[str] | None = None,
14
+ include_cad_context: bool = False,
15
+ ) -> str:
16
+ """Build the orchestrator system prompt for single-call mode.
17
+
18
+ Args:
19
+ active_agents: List of agent IDs to include. None = all except 'cad'.
20
+ include_cad_context: Whether to include CadQuery reference for the CAD agent.
21
+ """
22
+ if active_agents is None:
23
+ active_agents = ["design", "engineering", "cnc"]
24
+
25
+ prompt_parts = [
26
+ "You are the orchestrator for a multi-agent CAD design team. "
27
+ "You control multiple specialist agents who collaborate with a user "
28
+ "to design mechanical parts for CNC machining.\n",
29
+ "## Your Agents\n",
30
+ ]
31
+
32
+ for agent_id in active_agents:
33
+ agent = AGENTS[agent_id]
34
+ prompt_parts.append(
35
+ f"### {agent.name} (id: \"{agent.id}\")\n"
36
+ f"Role: {agent.role}\n"
37
+ f"Goal: {agent.goal}\n"
38
+ f"Personality: {agent.backstory}\n"
39
+ )
40
+
41
+ prompt_parts.append(
42
+ "## Instructions\n"
43
+ "Given the conversation history and the user's latest message, "
44
+ "decide which agents should respond and generate their messages.\n\n"
45
+ "Rules:\n"
46
+ "- Select 1-3 agents that are most relevant to the user's message.\n"
47
+ "- Each agent should respond in character with their expertise.\n"
48
+ "- Keep responses concise and actionable (2-4 sentences each).\n"
49
+ "- Do NOT include the CAD Coder agent unless the user explicitly asks "
50
+ "for a preview, says 'generate', 'build it', 'show me', 'create the model', "
51
+ "or similar.\n"
52
+ "- When the CAD Coder responds, include a 'code' field with valid CadQuery Python "
53
+ "that assigns the result to a variable called `result` as a cq.Workplane.\n"
54
+ "- Agents should build on each other's points, not repeat them.\n"
55
+ )
56
+
57
+ if include_cad_context and "cad" in active_agents:
58
+ from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
59
+ prompt_parts.append(
60
+ "\n## CadQuery Reference (for CAD Coder agent)\n"
61
+ f"{CADQUERY_SYSTEM_PROMPT}\n"
62
+ )
63
+
64
+ prompt_parts.append(
65
+ "\n## Response Format\n"
66
+ "Respond with ONLY valid JSON in this exact format:\n"
67
+ "```json\n"
68
+ '{"agents": [\n'
69
+ ' {"id": "design", "message": "Your design suggestion here..."},\n'
70
+ ' {"id": "engineering", "message": "Your engineering input here..."}\n'
71
+ "]}\n"
72
+ "```\n\n"
73
+ "When the CAD Coder agent responds, add a 'code' field:\n"
74
+ "```json\n"
75
+ '{"agents": [\n'
76
+ ' {"id": "cad", "message": "Model generated.", '
77
+ '"code": "import cadquery as cq\\nresult = cq.Workplane(\'XY\').box(10,10,10)"}\n'
78
+ "]}\n"
79
+ "```\n\n"
80
+ "Output ONLY the JSON. No other text."
81
+ )
82
+
83
+ return "\n".join(prompt_parts)
84
+
85
+
86
+ def build_chat_messages(
87
+ user_message: str,
88
+ history: list[dict],
89
+ system_prompt: str,
90
+ max_history: int = 30,
91
+ ) -> list[dict]:
92
+ """Build the message list for the orchestrator LLM call.
93
+
94
+ Args:
95
+ user_message: The user's current message.
96
+ history: Previous messages [{role, agent_id, content}, ...].
97
+ system_prompt: The orchestrator system prompt.
98
+ max_history: Maximum number of history messages to include.
99
+ """
100
+ messages = [{"role": "system", "content": system_prompt}]
101
+
102
+ # Truncate history to last N messages
103
+ recent = history[-max_history:] if len(history) > max_history else history
104
+
105
+ for msg in recent:
106
+ if msg.get("role") == "user":
107
+ messages.append({"role": "user", "content": msg["content"]})
108
+ else:
109
+ # Agent messages become assistant messages with agent label
110
+ agent_id = msg.get("agent_id", "unknown")
111
+ agent_name = AGENTS.get(agent_id, AGENTS["design"]).name
112
+ messages.append({
113
+ "role": "assistant",
114
+ "content": f"[{agent_name}]: {msg['content']}"
115
+ })
116
+
117
+ # Add current user message
118
+ messages.append({"role": "user", "content": user_message})
119
+
120
+ return messages
121
+
122
+
123
+ def parse_mentions(message: str) -> tuple[str, list[str]]:
124
+ """Extract @mentions from a message and return cleaned message + mention list.
125
+
126
+ Returns:
127
+ (cleaned_message, mentions) where mentions is list of agent IDs.
128
+ """
129
+ mentions = []
130
+ cleaned = message
131
+
132
+ for agent_id in AGENTS:
133
+ pattern = rf"@{agent_id}\b"
134
+ if re.search(pattern, message, re.IGNORECASE):
135
+ mentions.append(agent_id)
136
+ cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE).strip()
137
+
138
+ return cleaned, mentions
139
+
140
+
141
+ # ── Keyword-based fallback routing ────────────────────────────────────────
142
+
143
+ _ROUTING_KEYWORDS: dict[str, list[str]] = {
144
+ "design": [
145
+ "design", "look", "shape", "style", "form", "aesthetic", "appearance",
146
+ "layout", "concept", "idea", "propose", "suggest",
147
+ ],
148
+ "engineering": [
149
+ "dimension", "tolerance", "material", "strength", "load", "stress",
150
+ "thickness", "wall", "fillet", "radius", "clearance",
151
+ "m2", "m3", "m4", "m5", "m6", "m8", "m10", "m12",
152
+ "aluminum", "steel", "brass", "titanium", "nylon",
153
+ ],
154
+ "cnc": [
155
+ "machine", "mill", "cnc", "manufacture", "machinable", "axis",
156
+ "tool", "fixture", "setup", "pocket", "undercut", "access",
157
+ "3-axis", "5-axis", "cost",
158
+ ],
159
+ "cad": [
160
+ "generate", "build", "preview", "show me", "create", "model it",
161
+ "render", "code", "make it", "produce",
162
+ ],
163
+ }
164
+
165
+
166
+ def route_by_keywords(message: str) -> list[str]:
167
+ """Fallback agent routing based on keyword matching.
168
+
169
+ Returns list of agent IDs that should respond.
170
+ """
171
+ lower = message.lower()
172
+ scores: dict[str, int] = {agent_id: 0 for agent_id in AGENTS}
173
+
174
+ for agent_id, keywords in _ROUTING_KEYWORDS.items():
175
+ for kw in keywords:
176
+ if kw in lower:
177
+ scores[agent_id] += 1
178
+
179
+ # Select agents with score > 0, sorted by score descending
180
+ active = [aid for aid, score in sorted(scores.items(), key=lambda x: -x[1]) if score > 0]
181
+
182
+ # Default: design + engineering for general discussion
183
+ if not active:
184
+ active = ["design", "engineering"]
185
+
186
+ # Cap at 3 agents
187
+ return active[:3]
188
+
189
+
190
+ def parse_orchestrator_response(response_text: str) -> list[dict]:
191
+ """Parse the orchestrator's JSON response into agent messages.
192
+
193
+ Returns list of dicts: [{"id": str, "message": str, "code": str|None}, ...]
194
+ Falls back to treating entire response as design agent message if JSON fails.
195
+ """
196
+ text = response_text.strip()
197
+
198
+ # Try to extract JSON from markdown code fences
199
+ json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
200
+ if json_match:
201
+ text = json_match.group(1)
202
+
203
+ try:
204
+ data = json.loads(text)
205
+ agents = data.get("agents", [])
206
+
207
+ # Validate structure
208
+ result = []
209
+ for agent in agents:
210
+ if isinstance(agent, dict) and "id" in agent and "message" in agent:
211
+ result.append({
212
+ "id": agent["id"],
213
+ "message": agent["message"],
214
+ "code": agent.get("code"),
215
+ })
216
+
217
+ if result:
218
+ return result
219
+ except (json.JSONDecodeError, KeyError, TypeError):
220
+ pass
221
+
222
+ # Fallback: treat entire response as design agent message
223
+ return [{"id": "design", "message": response_text, "code": None}]
core/__init__.py ADDED
File without changes
core/backends.py ADDED
@@ -0,0 +1,740 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM backend implementations for CadQuery code generation.
3
+
4
+ Supports multiple backends:
5
+ - Anthropic Claude
6
+ - OpenAI GPT-4o
7
+ - Google Gemini (free tier available)
8
+ - Mock (dynamic generation, no API key required)
9
+ - NeuralCAD (local neural pipeline, not yet implemented)
10
+ """
11
+
12
+ import base64
13
+ import mimetypes
14
+ import os
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Optional
18
+
19
+
20
+ # ── LLM Backends ──────────────────────────────────────────────────────────
21
+
22
+
23
+ class LLMBackend:
24
+ """Base class for LLM code generation backends."""
25
+
26
+ def generate(self, messages: list[dict]) -> str:
27
+ raise NotImplementedError
28
+
29
+ def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
30
+ """Generate code from messages that include an image.
31
+ Override in backends that support vision."""
32
+ raise NotImplementedError(
33
+ f"{self.__class__.__name__} does not support image input"
34
+ )
35
+
36
+
37
+ class AnthropicBackend(LLMBackend):
38
+ """Generate CadQuery code using Anthropic Claude."""
39
+
40
+ def __init__(
41
+ self, model: str = "claude-sonnet-4-20250514", api_key: Optional[str] = None
42
+ ):
43
+ import anthropic
44
+
45
+ self.client = anthropic.Anthropic(
46
+ api_key=api_key or os.environ.get("ANTHROPIC_API_KEY")
47
+ )
48
+ self.model = model
49
+
50
+ def generate(self, messages: list[dict]) -> str:
51
+ # Anthropic uses system param separately
52
+ system_msg = ""
53
+ user_messages = []
54
+ for m in messages:
55
+ if m["role"] == "system":
56
+ system_msg = m["content"]
57
+ else:
58
+ user_messages.append(m)
59
+
60
+ response = self.client.messages.create(
61
+ model=self.model,
62
+ max_tokens=4096,
63
+ system=system_msg,
64
+ messages=user_messages,
65
+ )
66
+ return response.content[0].text
67
+
68
+ def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
69
+ image_path = Path(image_path)
70
+ media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
71
+ image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
72
+
73
+ system_msg = ""
74
+ user_messages = []
75
+ for m in messages:
76
+ if m["role"] == "system":
77
+ system_msg = m["content"]
78
+ else:
79
+ msg = dict(m)
80
+ # Inject image into the last user message
81
+ if msg["role"] == "user" and msg is not m:
82
+ user_messages.append(msg)
83
+ else:
84
+ user_messages.append(msg)
85
+
86
+ # Replace last user message content with multimodal blocks
87
+ last_user = user_messages[-1]
88
+ last_user["content"] = [
89
+ {
90
+ "type": "image",
91
+ "source": {
92
+ "type": "base64",
93
+ "media_type": media_type,
94
+ "data": image_data,
95
+ },
96
+ },
97
+ {"type": "text", "text": last_user["content"]},
98
+ ]
99
+
100
+ response = self.client.messages.create(
101
+ model=self.model,
102
+ max_tokens=4096,
103
+ system=system_msg,
104
+ messages=user_messages,
105
+ )
106
+ return response.content[0].text
107
+
108
+
109
+ class OpenAIBackend(LLMBackend):
110
+ """Generate CadQuery code using OpenAI GPT-4o."""
111
+
112
+ def __init__(self, model: str = "gpt-4o", api_key: Optional[str] = None):
113
+ import openai
114
+
115
+ self.client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
116
+ self.model = model
117
+
118
+ def generate(self, messages: list[dict]) -> str:
119
+ response = self.client.chat.completions.create(
120
+ model=self.model,
121
+ messages=messages,
122
+ max_tokens=4096,
123
+ temperature=0.2,
124
+ )
125
+ return response.choices[0].message.content
126
+
127
+ def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
128
+ image_path = Path(image_path)
129
+ media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
130
+ image_data = base64.b64encode(image_path.read_bytes()).decode("utf-8")
131
+ data_url = f"data:{media_type};base64,{image_data}"
132
+
133
+ # Copy messages, replace last user message with multimodal content
134
+ patched = [dict(m) for m in messages]
135
+ last_user = patched[-1]
136
+ last_user["content"] = [
137
+ {"type": "image_url", "image_url": {"url": data_url}},
138
+ {"type": "text", "text": last_user["content"]},
139
+ ]
140
+
141
+ response = self.client.chat.completions.create(
142
+ model=self.model,
143
+ messages=patched,
144
+ max_tokens=4096,
145
+ temperature=0.2,
146
+ )
147
+ return response.choices[0].message.content
148
+
149
+
150
+ class GeminiBackend(LLMBackend):
151
+ """Generate CadQuery code using Google Gemini (free tier available)."""
152
+
153
+ def __init__(self, model: str = "gemini-2.5-flash", api_key: Optional[str] = None):
154
+ from google import genai
155
+
156
+ self.client = genai.Client(api_key=api_key or os.environ.get("GEMINI_API_KEY"))
157
+ self.model = model
158
+
159
+ def generate(self, messages: list[dict]) -> str:
160
+ # Convert messages to Gemini format: system instruction + contents
161
+ system_msg = ""
162
+ contents = []
163
+ for m in messages:
164
+ if m["role"] == "system":
165
+ system_msg = m["content"]
166
+ elif m["role"] == "user":
167
+ contents.append({"role": "user", "parts": [{"text": m["content"]}]})
168
+ elif m["role"] == "assistant":
169
+ contents.append({"role": "model", "parts": [{"text": m["content"]}]})
170
+
171
+ from google.genai import types
172
+
173
+ response = self.client.models.generate_content(
174
+ model=self.model,
175
+ contents=contents,
176
+ config=types.GenerateContentConfig(
177
+ system_instruction=system_msg,
178
+ max_output_tokens=4096,
179
+ temperature=0.2,
180
+ ),
181
+ )
182
+ return response.text
183
+
184
+ def generate_with_image(self, messages: list[dict], image_path: str | Path) -> str:
185
+ from google.genai import types
186
+
187
+ image_path = Path(image_path)
188
+ image_data = image_path.read_bytes()
189
+ media_type = mimetypes.guess_type(str(image_path))[0] or "image/png"
190
+
191
+ system_msg = ""
192
+ contents = []
193
+ for m in messages:
194
+ if m["role"] == "system":
195
+ system_msg = m["content"]
196
+ elif m["role"] == "user":
197
+ contents.append({"role": "user", "parts": [{"text": m["content"]}]})
198
+ elif m["role"] == "assistant":
199
+ contents.append({"role": "model", "parts": [{"text": m["content"]}]})
200
+
201
+ # Add image to the last user message
202
+ if contents and contents[-1]["role"] == "user":
203
+ contents[-1]["parts"].insert(0, {
204
+ "inline_data": {"mime_type": media_type, "data": image_data}
205
+ })
206
+
207
+ response = self.client.models.generate_content(
208
+ model=self.model,
209
+ contents=contents,
210
+ config=types.GenerateContentConfig(
211
+ system_instruction=system_msg,
212
+ max_output_tokens=4096,
213
+ temperature=0.2,
214
+ ),
215
+ )
216
+ return response.text
217
+
218
+
219
+ class MockBackend(LLMBackend):
220
+ """
221
+ Mock backend that dynamically generates CadQuery code from any prompt.
222
+ Parses dimensions, shape type, and features from the text, then assembles
223
+ parametric code. No API key required.
224
+ """
225
+
226
+ # Word-to-number mapping for natural language counts
227
+ _WORD_NUMS = {
228
+ "one": 1,
229
+ "two": 2,
230
+ "three": 3,
231
+ "four": 4,
232
+ "five": 5,
233
+ "six": 6,
234
+ "seven": 7,
235
+ "eight": 8,
236
+ "nine": 9,
237
+ "ten": 10,
238
+ "twelve": 12,
239
+ "sixteen": 16,
240
+ "twenty": 20,
241
+ }
242
+
243
+ # Metric thread clearance hole diameters
244
+ _THREAD_CLEARANCE = {
245
+ "m2": 2.4,
246
+ "m3": 3.4,
247
+ "m4": 4.5,
248
+ "m5": 5.5,
249
+ "m6": 6.6,
250
+ "m8": 9.0,
251
+ "m10": 11.0,
252
+ "m12": 13.5,
253
+ }
254
+
255
+ # Shape detection patterns → base shape key
256
+ _SHAPE_PATTERNS = {
257
+ "cylinder": [
258
+ "cylinder",
259
+ "rod",
260
+ "shaft",
261
+ "axle",
262
+ "spacer",
263
+ "washer",
264
+ "bushing",
265
+ "sleeve",
266
+ "tube",
267
+ "pipe",
268
+ "dowel",
269
+ "pin",
270
+ ],
271
+ "plate": [
272
+ "plate",
273
+ "bracket",
274
+ "mount",
275
+ "flange",
276
+ "baseplate",
277
+ "panel",
278
+ "shim",
279
+ "cover",
280
+ "lid",
281
+ ],
282
+ "box": [
283
+ "box",
284
+ "block",
285
+ "enclosure",
286
+ "housing",
287
+ "case",
288
+ "cube",
289
+ "container",
290
+ "shell",
291
+ ],
292
+ "l_bracket": [
293
+ "l-bracket",
294
+ "l bracket",
295
+ "angle bracket",
296
+ "corner bracket",
297
+ "l-shaped",
298
+ ],
299
+ }
300
+
301
+ # Feature detection keywords
302
+ _FEATURE_KEYWORDS = {
303
+ "holes": ["hole", "holes", "bolt", "bolts", "screw", "screws", "bore", "bores"],
304
+ "pocket": ["pocket", "recess", "cavity", "cutout", "mortise"],
305
+ "slot": ["slot", "slots", "groove", "channel", "keyway"],
306
+ "fillet": ["fillet", "fillets", "round", "rounded"],
307
+ "chamfer": ["chamfer", "chamfers", "bevel", "beveled"],
308
+ "through_hole": ["through hole", "through-hole", "thru hole", "thru-hole"],
309
+ "counterbore": ["counterbore", "counterbored", "cbore"],
310
+ "fins": ["fin", "fins", "cooling", "heatsink", "heat sink", "radiator"],
311
+ "ribs": ["rib", "ribs", "stiffener", "stiffeners", "web"],
312
+ "boss": ["boss", "bosses", "standoff", "standoffs", "pillar"],
313
+ }
314
+
315
+ def _parse_prompt(self, text: str) -> dict:
316
+ """Extract dimensions, shape, and features from natural language."""
317
+ lower = text.lower()
318
+
319
+ # Extract all numbers with optional units
320
+ raw_nums = re.findall(r"(\d+\.?\d*)\s*(?:mm|cm|m\b)?", lower)
321
+ dimensions = [float(n) for n in raw_nums if 0.1 < float(n) < 2000]
322
+
323
+ # Detect metric thread sizes (M3, M6, etc.)
324
+ thread_match = re.search(r"\bm(\d+)\b", lower)
325
+ hole_dia = None
326
+ if thread_match:
327
+ key = f"m{thread_match.group(1)}"
328
+ hole_dia = self._THREAD_CLEARANCE.get(
329
+ key, float(thread_match.group(1)) * 1.1
330
+ )
331
+
332
+ # Detect hole diameter from "Xmm hole"
333
+ hole_dim_match = re.search(
334
+ r"(\d+\.?\d*)\s*mm\s*(?:hole|bore|holes|bores)", lower
335
+ )
336
+ if hole_dim_match and not hole_dia:
337
+ hole_dia = float(hole_dim_match.group(1))
338
+
339
+ # Detect count (numeric or word)
340
+ count = None
341
+ count_match = re.search(
342
+ r"(\d+)\s*(?:hole|bolt|screw|bore|fin|rib|slot|boss)", lower
343
+ )
344
+ if count_match:
345
+ count = int(count_match.group(1))
346
+ else:
347
+ for word, num in self._WORD_NUMS.items():
348
+ if re.search(rf"\b{word}\b.*(?:hole|bolt|screw|bore|fin|slot)", lower):
349
+ count = num
350
+ break
351
+
352
+ # Detect base shape
353
+ shape = "box"
354
+ for shape_key, keywords in self._SHAPE_PATTERNS.items():
355
+ if any(kw in lower for kw in keywords):
356
+ shape = shape_key
357
+ break
358
+
359
+ # Detect features
360
+ features = set()
361
+ for feat, keywords in self._FEATURE_KEYWORDS.items():
362
+ if any(kw in lower for kw in keywords):
363
+ features.add(feat)
364
+
365
+ # If holes mentioned but no specific feature, add generic holes
366
+ if (
367
+ any(w in lower for w in ["hole", "holes", "bolt", "screw"])
368
+ and "holes" not in features
369
+ ):
370
+ features.add("holes")
371
+
372
+ return {
373
+ "dimensions": dimensions,
374
+ "shape": shape,
375
+ "features": features,
376
+ "hole_dia": hole_dia or 5.5,
377
+ "count": count or 4,
378
+ "prompt": text,
379
+ }
380
+
381
+ def _generate_code(self, p: dict) -> str:
382
+ """Build CadQuery code from parsed parameters."""
383
+ dims = p["dimensions"]
384
+ shape = p["shape"]
385
+ features = p["features"]
386
+ prompt = p["prompt"]
387
+
388
+ lines = ["import cadquery as cq"]
389
+ if shape == "cylinder" and "fins" in features:
390
+ lines.append("import math")
391
+ lines.append(f"")
392
+ lines.append(f"# Generated from: {prompt}")
393
+
394
+ if shape == "cylinder":
395
+ radius = dims[0] / 2 if dims else 15.0
396
+ height = dims[1] if len(dims) > 1 else radius * 2
397
+ lines.append(f"# Cylinder: radius={radius}mm, height={height}mm")
398
+ lines.append(f"result = (")
399
+ lines.append(f" cq.Workplane('XY')")
400
+ lines.append(f" .cylinder({height}, {radius})")
401
+
402
+ if "holes" in features or "through_hole" in features:
403
+ lines.append(f" .faces('>Z').workplane()")
404
+ lines.append(f" .hole({p['hole_dia']})")
405
+
406
+ if "chamfer" in features or "fillet" not in features:
407
+ lines.append(f" .edges('>Z or <Z').chamfer(0.5)")
408
+
409
+ if "fillet" in features:
410
+ lines.append(f" .edges('>Z or <Z').fillet(1.0)")
411
+
412
+ lines.append(f")")
413
+
414
+ if "fins" in features:
415
+ n_fins = p["count"] if p["count"] > 4 else 8
416
+ fin_h = max(height * 0.8, 5)
417
+ fin_w = 1.5
418
+ lines.append(f"")
419
+ lines.append(f"# Add {n_fins} cooling fins")
420
+ lines.append(f"for i in range({n_fins}):")
421
+ lines.append(f" angle = i * 360 / {n_fins}")
422
+ lines.append(f" rad = math.radians(angle)")
423
+ lines.append(f" fx = {radius + 3} * math.cos(rad)")
424
+ lines.append(f" fy = {radius + 3} * math.sin(rad)")
425
+ lines.append(f" fin = (")
426
+ lines.append(f" cq.Workplane('XY')")
427
+ lines.append(
428
+ f" .transformed(offset=(fx, fy, 0), rotate=(0, 0, angle))"
429
+ )
430
+ lines.append(f" .rect({fin_w}, {radius * 0.6})")
431
+ lines.append(f" .extrude({fin_h})")
432
+ lines.append(f" )")
433
+ lines.append(f" result = result.union(fin)")
434
+
435
+ elif shape == "plate":
436
+ w = dims[0] if dims else 80.0
437
+ h = dims[1] if len(dims) > 1 else w * 0.6
438
+ t = dims[2] if len(dims) > 2 else 5.0
439
+ lines.append(f"# Plate: {w}x{h}x{t}mm")
440
+ lines.append(f"result = (")
441
+ lines.append(f" cq.Workplane('XY')")
442
+ lines.append(f" .box({w}, {h}, {t})")
443
+
444
+ if "holes" in features or "through_hole" in features:
445
+ n = p["count"]
446
+ dia = p["hole_dia"]
447
+ # Distribute holes in a grid or circle
448
+ if "flange" in p["prompt"].lower() or n >= 6:
449
+ # Bolt circle pattern
450
+ r = min(w, h) * 0.35
451
+ lines.append(f" .faces('>Z').workplane()")
452
+ lines.append(f" .polarArray({r}, 0, 360, {n})")
453
+ lines.append(f" .hole({dia})")
454
+ if "bore" in p["prompt"].lower() or "flange" in p["prompt"].lower():
455
+ lines.append(f" .faces('>Z').workplane()")
456
+ lines.append(f" .hole({dia * 3}) # Center bore")
457
+ else:
458
+ # Rectangular pattern
459
+ ox = w * 0.35
460
+ oy = h * 0.35
461
+ pts = []
462
+ if n == 1:
463
+ pts = [(0, 0)]
464
+ elif n == 2:
465
+ pts = [(-ox, 0), (ox, 0)]
466
+ elif n == 4:
467
+ pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
468
+ else:
469
+ pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
470
+ lines.append(f" .faces('>Z').workplane()")
471
+ lines.append(f" .pushPoints({pts})")
472
+ lines.append(f" .hole({dia})")
473
+
474
+ if "pocket" in features:
475
+ pw = w * 0.4
476
+ ph = h * 0.35
477
+ pd = t * 0.6
478
+ lines.append(f" .faces('>Z').workplane()")
479
+ lines.append(f" .rect({pw}, {ph})")
480
+ lines.append(f" .cutBlind(-{pd}) # Central pocket")
481
+
482
+ if "slot" in features:
483
+ sl = w * 0.35
484
+ sw = max(t * 0.8, 4)
485
+ lines.append(f" .faces('>Z').workplane()")
486
+ lines.append(f" .slot2D({sl}, {sw}).cutBlind(-{t})")
487
+
488
+ if "fillet" in features:
489
+ lines.append(f" .edges('|Z').fillet({max(t * 0.4, 1.5)})")
490
+ else:
491
+ lines.append(f" .edges('>Z').chamfer(0.5)")
492
+
493
+ lines.append(f")")
494
+
495
+ elif shape == "l_bracket":
496
+ arm = dims[0] if dims else 50.0
497
+ width = dims[1] if len(dims) > 1 else 20.0
498
+ t = dims[2] if len(dims) > 2 else 4.0
499
+ lines.append(f"# L-bracket: {arm}mm arms, {width}mm wide, {t}mm thick")
500
+ lines.append(f"result = (")
501
+ lines.append(f" cq.Workplane('XZ')")
502
+ lines.append(f" .moveTo(0, 0)")
503
+ lines.append(f" .lineTo({arm}, 0)")
504
+ lines.append(f" .lineTo({arm}, {t})")
505
+ lines.append(f" .lineTo({t}, {t})")
506
+ lines.append(f" .lineTo({t}, {arm})")
507
+ lines.append(f" .lineTo(0, {arm})")
508
+ lines.append(f" .close()")
509
+ lines.append(f" .extrude({width})")
510
+ lines.append(f" .edges('|Y').fillet({max(t * 0.5, 1.5)})")
511
+
512
+ if "holes" in features:
513
+ lines.append(
514
+ f" .faces('>Z').workplane(centerOption='CenterOfBoundBox')"
515
+ )
516
+ lines.append(f" .center({arm * 0.5}, 0)")
517
+ lines.append(f" .hole({p['hole_dia']})")
518
+ lines.append(
519
+ f" .faces('>X').workplane(centerOption='CenterOfBoundBox')"
520
+ )
521
+ lines.append(f" .center(0, {arm * 0.5})")
522
+ lines.append(f" .hole({p['hole_dia']})")
523
+
524
+ lines.append(f" .edges().chamfer(0.5)")
525
+ lines.append(f")")
526
+
527
+ else: # box / enclosure / housing
528
+ w = dims[0] if dims else 60.0
529
+ h = dims[1] if len(dims) > 1 else w * 0.65
530
+ d = dims[2] if len(dims) > 2 else 20.0
531
+ lines.append(f"# Box: {w}x{h}x{d}mm")
532
+ lines.append(f"result = (")
533
+ lines.append(f" cq.Workplane('XY')")
534
+ lines.append(f" .box({w}, {h}, {d})")
535
+
536
+ if "holes" in features or "through_hole" in features:
537
+ ox = w * 0.35
538
+ oy = h * 0.35
539
+ pts = [(-ox, -oy), (-ox, oy), (ox, -oy), (ox, oy)]
540
+ lines.append(f" .faces('>Z').workplane()")
541
+ lines.append(f" .pushPoints({pts})")
542
+ lines.append(f" .hole({p['hole_dia']})")
543
+
544
+ if "pocket" in features:
545
+ pw = w * 0.5
546
+ ph = h * 0.4
547
+ pd = d * 0.4
548
+ lines.append(f" .faces('>Z').workplane()")
549
+ lines.append(f" .rect({pw}, {ph})")
550
+ lines.append(f" .cutBlind(-{pd})")
551
+
552
+ if "slot" in features:
553
+ sl = w * 0.4
554
+ sw = 6
555
+ lines.append(f" .faces('>Z').workplane()")
556
+ lines.append(f" .slot2D({sl}, {sw}).cutBlind(-{d})")
557
+
558
+ if "boss" in features:
559
+ n = min(p["count"], 4)
560
+ bx = w * 0.3
561
+ by = h * 0.3
562
+ boss_pts = [(-bx, -by), (-bx, by), (bx, -by), (bx, by)][:n]
563
+ lines.append(f" .faces('>Z').workplane()")
564
+ lines.append(f" .pushPoints({boss_pts})")
565
+ lines.append(f" .circle(4).extrude(6) # Mounting bosses")
566
+
567
+ if "ribs" in features:
568
+ n_ribs = p["count"] if p["count"] <= 8 else 4
569
+ spacing = w / (n_ribs + 1)
570
+ lines.append(f" .faces('>Z').workplane()")
571
+ for i in range(n_ribs):
572
+ rx = -w / 2 + spacing * (i + 1)
573
+ lines.append(f" .center({rx if i == 0 else spacing}, 0)")
574
+ lines.append(f" .rect(2, {h * 0.8}).extrude({d * 0.3})")
575
+
576
+ if "fillet" in features:
577
+ lines.append(f" .edges('|Z').fillet({min(d * 0.2, 3)})")
578
+ elif "chamfer" in features:
579
+ lines.append(f" .edges('>Z').chamfer(1.0)")
580
+ else:
581
+ lines.append(f" .edges('>Z').chamfer(0.5)")
582
+
583
+ lines.append(f")")
584
+
585
+ return "\n".join(lines) + "\n"
586
+
587
+ # Curated hero responses for specific prompts
588
+ _CURATED = {
589
+ "gear": """\
590
+ import cadquery as cq
591
+ import math
592
+
593
+ # Simple spur gear approximation: 20 teeth, module 2, 10mm thick
594
+ module = 2
595
+ teeth = 20
596
+ pitch_radius = module * teeth / 2
597
+ outer_radius = pitch_radius + module
598
+ tooth_angle = 360 / teeth
599
+
600
+ result = (
601
+ cq.Workplane("XY")
602
+ .cylinder(10, outer_radius)
603
+ .faces(">Z").workplane()
604
+ .hole(12)
605
+ )
606
+
607
+ for i in range(teeth):
608
+ angle = i * tooth_angle
609
+ rad = math.radians(angle)
610
+ gap_x = pitch_radius * math.cos(rad)
611
+ gap_y = pitch_radius * math.sin(rad)
612
+ cutter = (
613
+ cq.Workplane("XY")
614
+ .transformed(offset=(gap_x, gap_y, 0), rotate=(0, 0, angle))
615
+ .rect(module * 0.8, module * 2.5)
616
+ .extrude(12)
617
+ )
618
+ result = result.cut(cutter)
619
+
620
+ result = result.edges(">Z or <Z").chamfer(0.3)
621
+ """,
622
+ }
623
+
624
+ def generate(self, messages: list[dict]) -> str:
625
+ user_msg = messages[-1]["content"]
626
+ lower = user_msg.lower()
627
+
628
+ # Check curated responses first
629
+ for key, code in self._CURATED.items():
630
+ if key in lower:
631
+ return code
632
+
633
+ # Dynamic generation for everything else
634
+ params = self._parse_prompt(user_msg)
635
+ return self._generate_code(params)
636
+
637
+
638
+ class NeuralCADBackend(LLMBackend):
639
+ """
640
+ Neural CAD pipeline backend.
641
+
642
+ Runs trained models locally:
643
+ Text/Image → CLIP encoder → contrastive latent
644
+ → Diffusion prior → latent
645
+ → Transformer decoder → CAD command sequence
646
+ → OpenCascade kernel → B-rep solid
647
+
648
+ Unlike LLM backends, this does not generate CadQuery code strings.
649
+ Instead it produces CAD command sequences decoded directly into geometry.
650
+ """
651
+
652
+ def __init__(
653
+ self,
654
+ model_dir: str | Path = "./models",
655
+ device: str = "cuda",
656
+ clip_model: str = "clip_encoder.pt",
657
+ prior_model: str = "diffusion_prior.pt",
658
+ decoder_model: str = "transformer_decoder.pt",
659
+ ):
660
+ self.model_dir = Path(model_dir)
661
+ self.device = device
662
+ self.clip_encoder = None
663
+ self.diffusion_prior = None
664
+ self.transformer_decoder = None
665
+ self._model_config = {
666
+ "clip": clip_model,
667
+ "prior": prior_model,
668
+ "decoder": decoder_model,
669
+ }
670
+
671
+ def load_models(self):
672
+ """Load all model weights from disk. Call once before inference."""
673
+ raise NotImplementedError(
674
+ f"Model loading not yet implemented. "
675
+ f"Expected model files in: {self.model_dir}"
676
+ )
677
+
678
+ def encode_text(self, text: str):
679
+ """Encode text prompt to CLIP latent vector."""
680
+ raise NotImplementedError("CLIP text encoder not yet implemented")
681
+
682
+ def encode_image(self, image_path: str | Path):
683
+ """Encode image (photo/sketch) to CLIP latent vector."""
684
+ raise NotImplementedError("CLIP image encoder not yet implemented")
685
+
686
+ def run_diffusion_prior(self, clip_embedding):
687
+ """Map CLIP embedding to CAD latent via diffusion prior."""
688
+ raise NotImplementedError("Diffusion prior not yet implemented")
689
+
690
+ def decode_to_cad_sequence(self, latent):
691
+ """Decode latent to CAD command sequence."""
692
+ raise NotImplementedError("Transformer decoder not yet implemented")
693
+
694
+ def cad_sequence_to_solid(self, cad_commands: list[dict]):
695
+ """Execute CAD command sequence through OpenCascade kernel → B-rep solid."""
696
+ raise NotImplementedError("CAD kernel execution not yet implemented")
697
+
698
+ def generate(self, messages: list[dict]) -> str:
699
+ """
700
+ LLMBackend-compatible interface.
701
+
702
+ Extracts the text prompt from messages, runs the full neural pipeline,
703
+ and returns CadQuery-equivalent code as a string for compatibility
704
+ with the existing execution/validation/export pipeline.
705
+ """
706
+ user_msg = messages[-1]["content"]
707
+
708
+ clip_emb = self.encode_text(user_msg)
709
+ latent = self.run_diffusion_prior(clip_emb)
710
+ cad_commands = self.decode_to_cad_sequence(latent)
711
+ return self._cad_commands_to_code(cad_commands)
712
+
713
+ def generate_from_image(self, image_path: str | Path, text_hint: str = "") -> str:
714
+ """
715
+ Image-conditioned generation (not available on LLM backends).
716
+
717
+ Args:
718
+ image_path: Path to photo or sketch of the desired part.
719
+ text_hint: Optional text to guide generation alongside the image.
720
+
721
+ Returns:
722
+ CadQuery code string for pipeline compatibility.
723
+ """
724
+ img_emb = self.encode_image(image_path)
725
+ if text_hint:
726
+ txt_emb = self.encode_text(text_hint)
727
+ # Fuse text + image embeddings (strategy TBD — average, concat, cross-attn)
728
+ clip_emb = (img_emb + txt_emb) / 2 # placeholder fusion
729
+ else:
730
+ clip_emb = img_emb
731
+
732
+ latent = self.run_diffusion_prior(clip_emb)
733
+ cad_commands = self.decode_to_cad_sequence(latent)
734
+ return self._cad_commands_to_code(cad_commands)
735
+
736
+ def _cad_commands_to_code(self, cad_commands: list[dict]) -> str:
737
+ """Convert internal CAD command sequence to CadQuery Python code string."""
738
+ raise NotImplementedError(
739
+ "CAD command → CadQuery code serializer not yet implemented"
740
+ )
core/cadquery_prompts.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
core/executor.py ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+ }
core/pipeline.py ADDED
@@ -0,0 +1,192 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ from core.cadquery_prompts import build_messages
17
+ from core.executor import ExecutionResult, execute_cadquery, export_all
18
+ from core.validator import validate_for_cnc, CNCValidationResult
19
+ from core.backends import (
20
+ LLMBackend, MockBackend, AnthropicBackend, OpenAIBackend,
21
+ GeminiBackend, NeuralCADBackend
22
+ )
23
+
24
+
25
+ # ── Pipeline ──────────────────────────────────────────────────────────────
26
+
27
+
28
+ @dataclass
29
+ class PipelineResult:
30
+ prompt: str
31
+ generated_code: str
32
+ execution: ExecutionResult
33
+ validation: Optional[CNCValidationResult] = None
34
+ exported_files: dict[str, Path] = None
35
+ retry_count: int = 0
36
+
37
+ def summary(self) -> str:
38
+ lines = [
39
+ "=" * 60,
40
+ "TEXT-TO-CNC PIPELINE RESULT",
41
+ "=" * 60,
42
+ f"Prompt: {self.prompt}",
43
+ f"Retries: {self.retry_count}",
44
+ "",
45
+ "── Execution ──",
46
+ self.execution.summary(),
47
+ "",
48
+ ]
49
+ if self.validation:
50
+ lines += ["── CNC Validation ──", self.validation.summary(), ""]
51
+ if self.exported_files:
52
+ lines += ["── Exported Files ──"]
53
+ for fmt, path in self.exported_files.items():
54
+ lines.append(f" {fmt.upper()}: {path}")
55
+ lines.append("=" * 60)
56
+ return "\n".join(lines)
57
+
58
+
59
+ def run_pipeline(
60
+ prompt: str,
61
+ backend: Optional[LLMBackend] = None,
62
+ output_dir: str | Path = "./output",
63
+ max_retries: int = 2,
64
+ export: bool = True,
65
+ validate: bool = True,
66
+ part_name: Optional[str] = None,
67
+ ) -> PipelineResult:
68
+ """
69
+ Run the full text-to-CNC pipeline.
70
+
71
+ Args:
72
+ prompt: Natural language description of the part.
73
+ backend: LLM backend to use. Defaults to MockBackend.
74
+ output_dir: Directory for exported files.
75
+ max_retries: Number of retry attempts if code execution fails.
76
+ export: Whether to export STEP/STL files.
77
+ validate: Whether to run CNC validation.
78
+ part_name: Name for the part (used in filenames).
79
+
80
+ Returns:
81
+ PipelineResult with all pipeline outputs.
82
+ """
83
+ if backend is None:
84
+ backend = MockBackend()
85
+
86
+ output_dir = Path(output_dir)
87
+ output_dir.mkdir(parents=True, exist_ok=True)
88
+
89
+ if part_name is None:
90
+ # Generate a filename-safe name from the prompt
91
+ part_name = prompt[:40].strip().replace(" ", "_").lower()
92
+ part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
93
+
94
+ # Stage 1: Generate code
95
+ messages = build_messages(prompt)
96
+ generated_code = backend.generate(messages)
97
+
98
+ # Stage 2: Execute with retry loop
99
+ execution = execute_cadquery(generated_code)
100
+ retry_count = 0
101
+
102
+ while not execution.success and retry_count < max_retries:
103
+ retry_count += 1
104
+ print(f" Retry {retry_count}/{max_retries}: fixing error...")
105
+
106
+ # Feed the error back to the LLM for self-correction
107
+ error_feedback = (
108
+ f"The previous code failed with this error:\n"
109
+ f"```\n{execution.error}\n```\n\n"
110
+ f"Please fix the code and return only the corrected Python code. "
111
+ f"Original request: {prompt}"
112
+ )
113
+ messages_retry = build_messages(error_feedback)
114
+ generated_code = backend.generate(messages_retry)
115
+ execution = execute_cadquery(generated_code)
116
+
117
+ # Stage 3: Validate for CNC
118
+ validation = None
119
+ if execution.success and validate:
120
+ validation = validate_for_cnc(execution.result, part_name=part_name)
121
+
122
+ # Stage 4: Export
123
+ exported_files = {}
124
+ if execution.success and export:
125
+ base_path = output_dir / part_name
126
+ try:
127
+ exported_files = export_all(execution.result, base_path)
128
+ except Exception as e:
129
+ print(f" Export warning: {e}")
130
+
131
+ return PipelineResult(
132
+ prompt=prompt,
133
+ generated_code=generated_code,
134
+ execution=execution,
135
+ validation=validation,
136
+ exported_files=exported_files or {},
137
+ retry_count=retry_count,
138
+ )
139
+
140
+
141
+ # ── CLI Entry Point ───────────────────────────────────────────────────────
142
+
143
+ if __name__ == "__main__":
144
+ import argparse
145
+
146
+ parser = argparse.ArgumentParser(description="Text-to-CNC Model Generator")
147
+ parser.add_argument("prompt", nargs="?", default=None, help="Part description")
148
+ parser.add_argument(
149
+ "--backend", choices=["mock", "anthropic", "openai", "gemini", "neural"], default="mock"
150
+ )
151
+ parser.add_argument("--output-dir", default="./output")
152
+ parser.add_argument("--retries", type=int, default=2)
153
+ parser.add_argument("--name", default=None, help="Part name for file output")
154
+ parser.add_argument("--no-validate", action="store_true")
155
+ parser.add_argument("--no-export", action="store_true")
156
+ args = parser.parse_args()
157
+
158
+ if args.prompt is None:
159
+ args.prompt = "A simple mounting bracket with two M5 bolt holes"
160
+
161
+ # Select backend
162
+ if args.backend == "neural":
163
+ backend = NeuralCADBackend()
164
+ elif args.backend == "anthropic":
165
+ backend = AnthropicBackend()
166
+ elif args.backend == "openai":
167
+ backend = OpenAIBackend()
168
+ elif args.backend == "gemini":
169
+ backend = GeminiBackend()
170
+ else:
171
+ backend = MockBackend()
172
+
173
+ print(f"Generating CNC model for: '{args.prompt}'")
174
+ print(f"Backend: {args.backend}")
175
+ print()
176
+
177
+ result = run_pipeline(
178
+ prompt=args.prompt,
179
+ backend=backend,
180
+ output_dir=args.output_dir,
181
+ max_retries=args.retries,
182
+ export=not args.no_export,
183
+ validate=not args.no_validate,
184
+ part_name=args.name,
185
+ )
186
+
187
+ print(result.summary())
188
+
189
+ if result.execution.success:
190
+ print("\nGenerated Code:")
191
+ print("-" * 40)
192
+ print(result.generated_code)
core/validator.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
docker-compose.yml CHANGED
@@ -1,7 +1,7 @@
1
  services:
2
  mcp-server:
3
  build: .
4
- command: python mcp_server.py --transport sse --port 8000
5
  ports:
6
  - "8000:8000"
7
  volumes:
@@ -9,7 +9,7 @@ services:
9
 
10
  web:
11
  build: .
12
- command: python web_server.py --host 0.0.0.0 --port 5000
13
  ports:
14
  - "5000:5000"
15
  environment:
 
1
  services:
2
  mcp-server:
3
  build: .
4
+ command: python -m server.mcp --transport sse --port 8000
5
  ports:
6
  - "8000:8000"
7
  volumes:
 
9
 
10
  web:
11
  build: .
12
+ command: python -m server.web --host 0.0.0.0 --port 5000
13
  ports:
14
  - "5000:5000"
15
  environment:
entrypoint.sh CHANGED
@@ -5,7 +5,7 @@ echo "=== NeuralCAD Container Starting ==="
5
 
6
  # Start MCP CAD server in background
7
  echo "Starting MCP CAD server on port 8000..."
8
- python mcp_server.py --transport sse --port 8000 &
9
  MCP_PID=$!
10
 
11
  # Wait for MCP server to be ready
@@ -22,4 +22,4 @@ echo "MCP server running (PID $MCP_PID)"
22
  export MCP_SERVER_URL=http://localhost:8000/sse
23
  PORT=${PORT:-7860}
24
  echo "Starting web server on port $PORT..."
25
- exec python web_server.py --host 0.0.0.0 --port "$PORT"
 
5
 
6
  # Start MCP CAD server in background
7
  echo "Starting MCP CAD server on port 8000..."
8
+ python -m server.mcp --transport sse --port 8000 &
9
  MCP_PID=$!
10
 
11
  # Wait for MCP server to be ready
 
22
  export MCP_SERVER_URL=http://localhost:8000/sse
23
  PORT=${PORT:-7860}
24
  echo "Starting web server on port $PORT..."
25
+ exec python -m server.web --host 0.0.0.0 --port "$PORT"
pyproject.toml CHANGED
@@ -11,6 +11,7 @@ dependencies = [
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",
 
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",
server/__init__.py ADDED
File without changes
server/mcp.py ADDED
@@ -0,0 +1,499 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 -m server.mcp # stdio transport (default)
15
+ python -m server.mcp --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
+ from core.cadquery_prompts import build_messages, CADQUERY_SYSTEM_PROMPT
26
+ from core.executor import ExecutionResult, execute_cadquery, export_all, sanitize_code
27
+ from core.validator import validate_for_cnc, CNCValidationResult
28
+
29
+ # ── Server Setup ──────────────────────────────────────────────────────────
30
+
31
+ mcp = FastMCP(
32
+ "text-to-cnc",
33
+ instructions=(
34
+ "Generate CNC-machinable 3D models from text descriptions. "
35
+ "Converts natural language → CadQuery code → validated STEP/STL files. "
36
+ "Version 1.0.0"
37
+ ),
38
+ )
39
+
40
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
41
+ DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
42
+
43
+
44
+ # ── Helper: LLM Backend Selection ────────────────────────────────────────
45
+
46
+ def get_backend(backend_name: str = "mock"):
47
+ """Get the appropriate LLM backend."""
48
+ from core.backends import MockBackend, AnthropicBackend, OpenAIBackend, GeminiBackend, NeuralCADBackend
49
+
50
+ if backend_name == "neural":
51
+ return NeuralCADBackend()
52
+ elif backend_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
53
+ return AnthropicBackend()
54
+ elif backend_name == "openai" and os.environ.get("OPENAI_API_KEY"):
55
+ return OpenAIBackend()
56
+ elif backend_name == "gemini" and os.environ.get("GEMINI_API_KEY"):
57
+ return GeminiBackend()
58
+ else:
59
+ return MockBackend()
60
+
61
+
62
+ # ── Tool: generate_cnc_model ─────────────────────────────────────────────
63
+
64
+ @mcp.tool()
65
+ def generate_cnc_model(
66
+ prompt: str,
67
+ part_name: str = "",
68
+ backend: str = "mock",
69
+ max_retries: int = 2,
70
+ output_format: str = "both",
71
+ ) -> str:
72
+ """
73
+ Generate a CNC-machinable 3D model from a text description.
74
+
75
+ Takes a natural language description of a mechanical part, generates
76
+ CadQuery Python code via an LLM, executes it to produce a 3D solid,
77
+ validates it for CNC manufacturability, and exports STEP/STL files.
78
+
79
+ Args:
80
+ prompt: Natural language description of the part to generate.
81
+ Example: "A mounting bracket with four M6 bolt holes, 80mm wide"
82
+ part_name: Optional name for the part (used in filenames).
83
+ If empty, auto-generated from the prompt.
84
+ backend: LLM backend to use: "mock" (no API key), "anthropic", or "openai".
85
+ max_retries: Number of retry attempts if code generation fails (0-3).
86
+ output_format: Export format: "step", "stl", or "both".
87
+
88
+ Returns:
89
+ JSON string with generation results including:
90
+ - generated_code: The CadQuery Python code
91
+ - execution: Success/failure status and geometry metadata
92
+ - validation: CNC manufacturability analysis
93
+ - exported_files: Paths to generated STEP/STL files
94
+ """
95
+ from core.pipeline import run_pipeline
96
+
97
+ if not part_name:
98
+ part_name = prompt[:40].strip().replace(" ", "_").lower()
99
+ part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
100
+
101
+ llm_backend = get_backend(backend)
102
+
103
+ result = run_pipeline(
104
+ prompt=prompt,
105
+ backend=llm_backend,
106
+ output_dir=DEFAULT_OUTPUT_DIR,
107
+ max_retries=min(max_retries, 3),
108
+ export=True,
109
+ validate=True,
110
+ part_name=part_name,
111
+ )
112
+
113
+ # Build response
114
+ response = {
115
+ "success": result.execution.success,
116
+ "prompt": prompt,
117
+ "part_name": part_name,
118
+ "retries": result.retry_count,
119
+ "generated_code": result.generated_code,
120
+ "execution": {
121
+ "success": result.execution.success,
122
+ "volume_mm3": result.execution.volume,
123
+ "bounding_box_mm": list(result.execution.bounding_box) if result.execution.bounding_box else [],
124
+ "face_count": result.execution.face_count,
125
+ "edge_count": result.execution.edge_count,
126
+ "error": result.execution.error,
127
+ },
128
+ }
129
+
130
+ if result.validation:
131
+ response["validation"] = {
132
+ "machinable": result.validation.machinable,
133
+ "axis_recommendation": result.validation.axis_recommendation,
134
+ "error_count": result.validation.error_count,
135
+ "warning_count": result.validation.warning_count,
136
+ "issues": [
137
+ {"severity": i.severity, "category": i.category, "message": i.message}
138
+ for i in result.validation.issues
139
+ ],
140
+ }
141
+
142
+ if result.exported_files:
143
+ response["exported_files"] = {
144
+ fmt: str(path) for fmt, path in result.exported_files.items()
145
+ }
146
+
147
+ return json.dumps(response, indent=2)
148
+
149
+
150
+ # ── Tool: validate_cnc_model ─────────────────────────────────────────────
151
+
152
+ @mcp.tool()
153
+ def validate_cnc_model(
154
+ cadquery_code: str,
155
+ part_name: str = "Part",
156
+ min_wall_thickness_mm: float = 1.5,
157
+ max_part_size_mm: float = 500.0,
158
+ ) -> str:
159
+ """
160
+ Validate CadQuery code for CNC manufacturability without generating new code.
161
+
162
+ Executes the provided CadQuery code, then runs manufacturability checks
163
+ including wall thickness, tool access, aspect ratios, and surface complexity.
164
+
165
+ Args:
166
+ cadquery_code: Valid CadQuery Python code that assigns result to `result`.
167
+ Example: 'import cadquery as cq\\nresult = cq.Workplane("XY").box(10,10,10)'
168
+ part_name: Name for the part in the validation report.
169
+ min_wall_thickness_mm: Minimum acceptable wall thickness in mm (default 1.5).
170
+ max_part_size_mm: Maximum part dimension in mm (default 500).
171
+
172
+ Returns:
173
+ JSON string with execution status and CNC validation results including
174
+ machinable flag, axis recommendation, and list of issues.
175
+ """
176
+ exec_result = execute_cadquery(cadquery_code)
177
+
178
+ response = {
179
+ "execution_success": exec_result.success,
180
+ "error": exec_result.error,
181
+ "volume_mm3": exec_result.volume,
182
+ "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
183
+ }
184
+
185
+ if exec_result.success:
186
+ config = {
187
+ "min_wall_thickness_mm": min_wall_thickness_mm,
188
+ "max_part_size_mm": max_part_size_mm,
189
+ }
190
+ validation = validate_for_cnc(exec_result.result, part_name=part_name, config=config)
191
+ response["validation"] = {
192
+ "machinable": validation.machinable,
193
+ "axis_recommendation": validation.axis_recommendation,
194
+ "error_count": validation.error_count,
195
+ "warning_count": validation.warning_count,
196
+ "issues": [
197
+ {"severity": i.severity, "category": i.category, "message": i.message}
198
+ for i in validation.issues
199
+ ],
200
+ "summary": validation.summary(),
201
+ }
202
+
203
+ return json.dumps(response, indent=2)
204
+
205
+
206
+ # ── Tool: execute_cadquery ───────────────────────────────────────────────
207
+
208
+ @mcp.tool()
209
+ def execute_cadquery_code(
210
+ code: str,
211
+ export_path: str = "",
212
+ ) -> str:
213
+ """
214
+ Execute CadQuery Python code and return geometry information.
215
+
216
+ Runs CadQuery code in a sandboxed environment and returns metadata
217
+ about the resulting 3D solid (volume, bounding box, face/edge counts).
218
+ Optionally exports to STEP/STL.
219
+
220
+ Args:
221
+ code: CadQuery Python code. Must assign the final solid to a variable
222
+ called `result`. Example:
223
+ 'import cadquery as cq\\nresult = cq.Workplane("XY").box(20,20,20).hole(8)'
224
+ export_path: Optional base file path for STEP/STL export (without extension).
225
+ Example: "output/my_part" → creates my_part.step and my_part.stl
226
+
227
+ Returns:
228
+ JSON string with execution results including success status,
229
+ geometry metadata, stdout output, and export file paths if requested.
230
+ """
231
+ exec_result = execute_cadquery(code)
232
+
233
+ response = {
234
+ "success": exec_result.success,
235
+ "error": exec_result.error,
236
+ "stdout": exec_result.stdout,
237
+ "volume_mm3": exec_result.volume,
238
+ "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
239
+ "face_count": exec_result.face_count,
240
+ "edge_count": exec_result.edge_count,
241
+ }
242
+
243
+ if exec_result.success and export_path:
244
+ try:
245
+ files = export_all(exec_result.result, export_path)
246
+ response["exported_files"] = {fmt: str(p) for fmt, p in files.items()}
247
+ except Exception as e:
248
+ response["export_error"] = str(e)
249
+
250
+ return json.dumps(response, indent=2)
251
+
252
+
253
+ # ── Tool: list_models ────────────────────────────────────────────────────
254
+
255
+ @mcp.tool()
256
+ def list_models(output_dir: str = "") -> str:
257
+ """
258
+ List all previously generated CNC models in the output directory.
259
+
260
+ Returns a list of generated STEP and STL files with their sizes.
261
+
262
+ Args:
263
+ output_dir: Directory to scan. Defaults to the server's output directory.
264
+
265
+ Returns:
266
+ JSON string with a list of model files and their sizes in bytes.
267
+ """
268
+ scan_dir = Path(output_dir) if output_dir else DEFAULT_OUTPUT_DIR
269
+
270
+ if not scan_dir.exists():
271
+ return json.dumps({"error": f"Directory not found: {scan_dir}"})
272
+
273
+ models = {}
274
+ for ext in ("*.step", "*.stl"):
275
+ for f in scan_dir.glob(ext):
276
+ name = f.stem
277
+ if name not in models:
278
+ models[name] = {"name": name, "files": {}}
279
+ models[name]["files"][f.suffix.lstrip(".")] = {
280
+ "path": str(f),
281
+ "size_bytes": f.stat().st_size,
282
+ }
283
+
284
+ return json.dumps({
285
+ "output_dir": str(scan_dir),
286
+ "model_count": len(models),
287
+ "models": list(models.values()),
288
+ }, indent=2)
289
+
290
+
291
+ # ── Tool: generate_from_image ───────────────────────────────────────────
292
+
293
+ @mcp.tool()
294
+ def generate_from_image(
295
+ image_path: str,
296
+ text_hint: str = "",
297
+ part_name: str = "",
298
+ backend: str = "anthropic",
299
+ max_retries: int = 2,
300
+ ) -> str:
301
+ """
302
+ Generate a CNC-machinable 3D model from a photo or sketch image.
303
+
304
+ Sends the image to a vision-capable LLM (Claude or GPT-4o) along with
305
+ the CadQuery system prompt to generate code, then executes, validates,
306
+ and exports the result.
307
+
308
+ Args:
309
+ image_path: Path to an image file (photo, sketch, or CAD screenshot).
310
+ text_hint: Optional text to guide generation alongside the image.
311
+ Example: "This is a mounting bracket — add M6 bolt holes"
312
+ part_name: Optional name for the part (used in filenames).
313
+ backend: LLM backend: "anthropic" or "openai". Must support vision.
314
+ max_retries: Number of retry attempts if code execution fails (0-3).
315
+
316
+ Returns:
317
+ JSON string with generation results including generated code,
318
+ execution status, validation, and exported file paths.
319
+ """
320
+ if not Path(image_path).exists():
321
+ return json.dumps({"success": False, "error": f"Image not found: {image_path}"})
322
+
323
+ if not part_name:
324
+ part_name = Path(image_path).stem
325
+
326
+ llm_backend = get_backend(backend)
327
+
328
+ # Build prompt with optional text hint
329
+ prompt = "Generate CadQuery code for the mechanical part shown in this image."
330
+ if text_hint:
331
+ prompt += f"\n\nAdditional context: {text_hint}"
332
+
333
+ messages = build_messages(prompt)
334
+
335
+ # Use vision-capable generate_with_image
336
+ generated_code = llm_backend.generate_with_image(messages, image_path)
337
+
338
+ # Run through standard execution/validation/export
339
+ exec_result = execute_cadquery(generated_code)
340
+ retry_count = 0
341
+
342
+ while not exec_result.success and retry_count < min(max_retries, 3):
343
+ retry_count += 1
344
+ error_feedback = (
345
+ f"The previous code failed with this error:\n"
346
+ f"```\n{exec_result.error}\n```\n\n"
347
+ f"Please fix the code and return only the corrected Python code."
348
+ )
349
+ retry_messages = build_messages(error_feedback)
350
+ generated_code = llm_backend.generate_with_image(retry_messages, image_path)
351
+ exec_result = execute_cadquery(generated_code)
352
+
353
+ response = {
354
+ "success": exec_result.success,
355
+ "image_path": image_path,
356
+ "text_hint": text_hint,
357
+ "part_name": part_name,
358
+ "backend": backend,
359
+ "retries": retry_count,
360
+ "generated_code": generated_code,
361
+ "execution": {
362
+ "success": exec_result.success,
363
+ "volume_mm3": exec_result.volume,
364
+ "bounding_box_mm": list(exec_result.bounding_box) if exec_result.bounding_box else [],
365
+ "face_count": exec_result.face_count,
366
+ "edge_count": exec_result.edge_count,
367
+ "error": exec_result.error,
368
+ },
369
+ }
370
+
371
+ if exec_result.success:
372
+ validation = validate_for_cnc(exec_result.result, part_name=part_name)
373
+ response["validation"] = {
374
+ "machinable": validation.machinable,
375
+ "axis_recommendation": validation.axis_recommendation,
376
+ "error_count": validation.error_count,
377
+ "warning_count": validation.warning_count,
378
+ "issues": [
379
+ {"severity": i.severity, "category": i.category, "message": i.message}
380
+ for i in validation.issues
381
+ ],
382
+ }
383
+
384
+ base_path = DEFAULT_OUTPUT_DIR / part_name
385
+ try:
386
+ exported = export_all(exec_result.result, base_path)
387
+ response["exported_files"] = {fmt: str(p) for fmt, p in exported.items()}
388
+ except Exception as e:
389
+ response["export_error"] = str(e)
390
+
391
+ return json.dumps(response, indent=2)
392
+
393
+
394
+ # ── Tool: chat_turn ───────────────────────��─────────────────────────────
395
+
396
+ @mcp.tool()
397
+ def chat_turn(
398
+ message: str,
399
+ history: str = "[]",
400
+ mentions: str = "[]",
401
+ backend: str = "mock",
402
+ ) -> str:
403
+ """
404
+ Multi-agent chat turn for collaborative CAD design.
405
+
406
+ Send a message to the design team agents (Design, Engineering, CNC, CAD Coder).
407
+ Agents collaborate to help you design a mechanical part step by step.
408
+
409
+ Args:
410
+ message: Your message to the design team.
411
+ Use @design, @engineering, @cnc, or @cad to address specific agents.
412
+ history: JSON string of previous messages. Format:
413
+ [{"role": "user"|"agent", "agent_id": "design", "content": "..."}]
414
+ mentions: JSON string of agent IDs to address. Format: ["design", "engineering"]
415
+ Empty list = auto-route based on message content.
416
+ backend: LLM backend: "mock", "gemini", "anthropic", "openai".
417
+
418
+ Returns:
419
+ JSON string with agent responses and optional 3D preview data.
420
+ """
421
+ import json as json_mod
422
+
423
+ from agents.orchestrator import get_orchestrator
424
+ from agents.crew_orchestrator import CrewOrchestrator
425
+ from agents.prompts import parse_mentions
426
+
427
+ history_list = json_mod.loads(history) if isinstance(history, str) else history
428
+ mentions_list = json_mod.loads(mentions) if isinstance(mentions, str) else mentions
429
+
430
+ # Parse @mentions from message if not provided
431
+ if not mentions_list:
432
+ message, mentions_list = parse_mentions(message)
433
+
434
+ mentions_or_none = mentions_list if mentions_list else None
435
+
436
+ if backend in ("anthropic", "openai"):
437
+ orchestrator = CrewOrchestrator(backend_name=backend, output_dir=DEFAULT_OUTPUT_DIR)
438
+ else:
439
+ orchestrator = get_orchestrator(backend, output_dir=DEFAULT_OUTPUT_DIR)
440
+
441
+ result = orchestrator.chat_turn(
442
+ message=message,
443
+ history=history_list,
444
+ mentions=mentions_or_none,
445
+ )
446
+
447
+ return json_mod.dumps(result, indent=2)
448
+
449
+
450
+ # ── Resource: System prompt (for transparency) ───────────────────────────
451
+
452
+ @mcp.resource("text-to-cnc://system-prompt")
453
+ def get_system_prompt() -> str:
454
+ """The CadQuery generation system prompt used by the LLM."""
455
+ return CADQUERY_SYSTEM_PROMPT
456
+
457
+
458
+ @mcp.resource("text-to-cnc://capabilities")
459
+ def get_capabilities() -> str:
460
+ """Server capabilities and configuration."""
461
+ backends = ["mock (always available)", "neural (local models — requires trained weights)"]
462
+ if os.environ.get("ANTHROPIC_API_KEY"):
463
+ backends.append("anthropic (API key detected)")
464
+ if os.environ.get("OPENAI_API_KEY"):
465
+ backends.append("openai (API key detected)")
466
+ if os.environ.get("GEMINI_API_KEY"):
467
+ backends.append("gemini (API key detected)")
468
+
469
+ return json.dumps({
470
+ "name": "text-to-cnc",
471
+ "version": "1.0.0",
472
+ "available_backends": backends,
473
+ "output_dir": str(DEFAULT_OUTPUT_DIR),
474
+ "export_formats": ["STEP", "STL"],
475
+ "cnc_validation": True,
476
+ "max_retries": 3,
477
+ }, indent=2)
478
+
479
+
480
+ # ── Entry Point ──────────────────────────────────────────────────────────
481
+
482
+ if __name__ == "__main__":
483
+ import argparse
484
+
485
+ parser = argparse.ArgumentParser(description="Text-to-CNC MCP Server")
486
+ parser.add_argument(
487
+ "--transport", choices=["stdio", "sse"], default="stdio",
488
+ help="MCP transport (default: stdio)"
489
+ )
490
+ parser.add_argument(
491
+ "--port", type=int, default=8000,
492
+ help="Port for SSE transport (default: 8000)"
493
+ )
494
+ args = parser.parse_args()
495
+
496
+ if args.transport == "sse":
497
+ mcp.run(transport="sse")
498
+ else:
499
+ mcp.run(transport="stdio")
server/routes.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat API routes for multi-agent design conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from fastapi import APIRouter
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from agents.orchestrator import get_orchestrator
11
+ from agents.crew_orchestrator import CrewOrchestrator
12
+ from agents.prompts import parse_mentions
13
+ from agents.definitions import AGENTS
14
+
15
+ router = APIRouter()
16
+
17
+ OUTPUT_DIR = Path(__file__).parent.parent / "output"
18
+
19
+
20
+ @router.post("/api/chat")
21
+ async def chat(body: dict):
22
+ """Multi-agent chat turn.
23
+
24
+ Request body:
25
+ {
26
+ "message": "I need a servo bracket 60mm wide",
27
+ "history": [{"role": "user"|"agent", "agent_id": "...", "content": "..."}, ...],
28
+ "mentions": ["design", "engineering"], // or [] for auto-routing
29
+ "backend": "mock"|"gemini"|"anthropic"|"openai"
30
+ }
31
+
32
+ Response:
33
+ {
34
+ "responses": [
35
+ {"agent_id", "agent_name", "message", "color", "avatar", "code"}, ...
36
+ ],
37
+ "preview": null | {"success", "part_name", "stl_url", "step_url", "execution", "validation"}
38
+ }
39
+ """
40
+ message = body.get("message", "").strip()
41
+ if not message:
42
+ return JSONResponse({"error": "Empty message"}, status_code=400)
43
+
44
+ history = body.get("history", [])
45
+ backend_name = body.get("backend", "mock")
46
+
47
+ # Parse @mentions from message if not provided
48
+ raw_mentions = body.get("mentions", [])
49
+ if not raw_mentions:
50
+ message, raw_mentions = parse_mentions(message)
51
+
52
+ mentions = raw_mentions if raw_mentions else None
53
+
54
+ # Select orchestrator based on backend
55
+ if backend_name in ("anthropic", "openai"):
56
+ orchestrator = CrewOrchestrator(
57
+ backend_name=backend_name, output_dir=OUTPUT_DIR
58
+ )
59
+ else:
60
+ orchestrator = get_orchestrator(backend_name, output_dir=OUTPUT_DIR)
61
+
62
+ # Run chat turn
63
+ try:
64
+ result = orchestrator.chat_turn(
65
+ message=message,
66
+ history=history,
67
+ mentions=mentions,
68
+ )
69
+ return JSONResponse(result)
70
+ except Exception as e:
71
+ return JSONResponse({"error": str(e)}, status_code=500)
72
+
73
+
74
+ @router.post("/api/report")
75
+ async def report(body: dict):
76
+ """Generate a design report from conversation history.
77
+
78
+ Request body:
79
+ {
80
+ "part_name": "servo_bracket",
81
+ "history": [...],
82
+ "backend": "gemini"
83
+ }
84
+ """
85
+ part_name = body.get("part_name", "part")
86
+ history = body.get("history", [])
87
+ backend_name = body.get("backend", "mock")
88
+
89
+ # Build report from conversation
90
+ report_sections = []
91
+ report_sections.append(f"# Design Report: {part_name}\n")
92
+
93
+ design_decisions = []
94
+ engineering_specs = []
95
+ cnc_notes = []
96
+
97
+ for msg in history:
98
+ agent_id = msg.get("agent_id", "")
99
+ content = msg.get("content", "")
100
+ if agent_id == "design":
101
+ design_decisions.append(content)
102
+ elif agent_id == "engineering":
103
+ engineering_specs.append(content)
104
+ elif agent_id == "cnc":
105
+ cnc_notes.append(content)
106
+
107
+ if design_decisions:
108
+ report_sections.append("## Design Decisions")
109
+ for d in design_decisions:
110
+ report_sections.append(f"- {d}")
111
+
112
+ if engineering_specs:
113
+ report_sections.append("\n## Engineering Specifications")
114
+ for s in engineering_specs:
115
+ report_sections.append(f"- {s}")
116
+
117
+ if cnc_notes:
118
+ report_sections.append("\n## Manufacturing Notes")
119
+ for n in cnc_notes:
120
+ report_sections.append(f"- {n}")
121
+
122
+ # Check if model files exist
123
+ stl_path = OUTPUT_DIR / f"{part_name}.stl"
124
+ step_path = OUTPUT_DIR / f"{part_name}.step"
125
+
126
+ report_sections.append("\n## Exported Files")
127
+ report_sections.append(f"- STEP: {'Available' if step_path.exists() else 'Not generated'}")
128
+ report_sections.append(f"- STL: {'Available' if stl_path.exists() else 'Not generated'}")
129
+
130
+ report_text = "\n".join(report_sections)
131
+
132
+ return JSONResponse({
133
+ "part_name": part_name,
134
+ "report": report_text,
135
+ })
136
+
137
+
138
+ @router.get("/api/agents")
139
+ async def list_agents():
140
+ """List available agents and their metadata."""
141
+ return JSONResponse({
142
+ "agents": [
143
+ {
144
+ "id": agent.id,
145
+ "name": agent.name,
146
+ "role": agent.role,
147
+ "color": agent.color,
148
+ "avatar": agent.avatar,
149
+ }
150
+ for agent in AGENTS.values()
151
+ ]
152
+ })
server/web.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 -m server.mcp --transport sse --port 8000
11
+
12
+ # Then start web server:
13
+ python -m server.web
14
+
15
+ # Or auto-launch MCP server:
16
+ python -m server.web --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 server.routes import router
35
+
36
+ from mcp import ClientSession
37
+ from mcp.client.sse import sse_client
38
+
39
+ # ── Config ───────────────────────────────────────────────────────────────
40
+
41
+ MCP_SERVER_URL = os.environ.get("MCP_SERVER_URL", "http://localhost:8000/sse")
42
+ OUTPUT_DIR = Path(__file__).parent.parent / "output"
43
+ WEB_DIR = Path(__file__).parent.parent / "web"
44
+ PORT = int(os.environ.get("WEB_PORT", "5000"))
45
+
46
+ # ── MCP Client Management ───────────────────────────────────────────────
47
+
48
+ _mcp_process = None
49
+
50
+
51
+ async def call_mcp_tool(tool_name: str, arguments: dict) -> dict:
52
+ """Connect to MCP server, call a tool, return parsed JSON result."""
53
+ async with sse_client(url=MCP_SERVER_URL) as streams:
54
+ async with ClientSession(*streams) as session:
55
+ await session.initialize()
56
+ result = await session.call_tool(name=tool_name, arguments=arguments)
57
+ if result.content:
58
+ return json.loads(result.content[0].text)
59
+ return {"error": "Empty response from MCP server"}
60
+
61
+
62
+ async def read_mcp_resource(uri: str) -> str:
63
+ """Connect to MCP server and read a resource."""
64
+ async with sse_client(url=MCP_SERVER_URL) as streams:
65
+ async with ClientSession(*streams) as session:
66
+ await session.initialize()
67
+ result = await session.read_resource(uri=uri)
68
+ if result.contents:
69
+ return result.contents[0].text
70
+ return "{}"
71
+
72
+
73
+ def start_mcp_server(port: int = 8000):
74
+ """Launch mcp.py as a subprocess with SSE transport."""
75
+ global _mcp_process
76
+ mcp_script = Path(__file__).parent / "mcp.py"
77
+ _mcp_process = subprocess.Popen(
78
+ [sys.executable, str(mcp_script), "--transport", "sse", "--port", str(port)],
79
+ stdout=subprocess.PIPE,
80
+ stderr=subprocess.PIPE,
81
+ )
82
+ # Give it a moment to start
83
+ time.sleep(2)
84
+ if _mcp_process.poll() is not None:
85
+ stderr = _mcp_process.stderr.read().decode() if _mcp_process.stderr else ""
86
+ raise RuntimeError(f"MCP server failed to start: {stderr}")
87
+ print(f" MCP server started (PID {_mcp_process.pid}) on port {port}")
88
+
89
+
90
+ # ── FastAPI App ──────────────────────────────────────────────────────────
91
+
92
+ @asynccontextmanager
93
+ async def lifespan(app: FastAPI):
94
+ OUTPUT_DIR.mkdir(exist_ok=True)
95
+ yield
96
+ global _mcp_process
97
+ if _mcp_process:
98
+ _mcp_process.terminate()
99
+ _mcp_process.wait()
100
+
101
+
102
+ app = FastAPI(title="NeuralCAD Web Demo", lifespan=lifespan)
103
+
104
+ app.add_middleware(
105
+ CORSMiddleware,
106
+ allow_origins=["*"],
107
+ allow_methods=["*"],
108
+ allow_headers=["*"],
109
+ )
110
+
111
+ app.include_router(router)
112
+
113
+
114
+ # ── Routes ───────────────────────────────────────────────────────────────
115
+
116
+ @app.get("/", response_class=HTMLResponse)
117
+ async def index():
118
+ index_file = WEB_DIR / "index.html"
119
+ return HTMLResponse(index_file.read_text())
120
+
121
+
122
+ @app.post("/api/generate")
123
+ async def generate(body: dict):
124
+ result = await call_mcp_tool("generate_cnc_model", {
125
+ "prompt": body.get("prompt", ""),
126
+ "part_name": body.get("part_name", ""),
127
+ "backend": body.get("backend", "mock"),
128
+ "max_retries": body.get("max_retries", 2),
129
+ })
130
+ return JSONResponse(result)
131
+
132
+
133
+ @app.post("/api/generate-image")
134
+ async def generate_image(
135
+ image: UploadFile = File(...),
136
+ text_hint: str = Form(""),
137
+ part_name: str = Form(""),
138
+ backend: str = Form("anthropic"),
139
+ ):
140
+ # Save uploaded image to temp file
141
+ suffix = Path(image.filename or "upload.png").suffix
142
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
143
+ tmp.write(await image.read())
144
+ tmp_path = tmp.name
145
+
146
+ try:
147
+ result = await call_mcp_tool("generate_from_image", {
148
+ "image_path": tmp_path,
149
+ "text_hint": text_hint,
150
+ "part_name": part_name,
151
+ "backend": backend,
152
+ })
153
+ return JSONResponse(result)
154
+ finally:
155
+ os.unlink(tmp_path)
156
+
157
+
158
+ @app.post("/api/validate")
159
+ async def validate(body: dict):
160
+ result = await call_mcp_tool("validate_cnc_model", {
161
+ "cadquery_code": body.get("code", ""),
162
+ "part_name": body.get("part_name", "Part"),
163
+ })
164
+ return JSONResponse(result)
165
+
166
+
167
+ @app.get("/api/models")
168
+ async def list_models():
169
+ result = await call_mcp_tool("list_models", {
170
+ "output_dir": str(OUTPUT_DIR),
171
+ })
172
+ return JSONResponse(result)
173
+
174
+
175
+ @app.get("/api/models/{name}.stl")
176
+ async def get_stl(name: str):
177
+ path = OUTPUT_DIR / f"{name}.stl"
178
+ if not path.exists():
179
+ return JSONResponse({"error": f"STL not found: {name}"}, status_code=404)
180
+ return FileResponse(path, media_type="model/stl", filename=f"{name}.stl")
181
+
182
+
183
+ @app.get("/api/models/{name}.step")
184
+ async def get_step(name: str):
185
+ path = OUTPUT_DIR / f"{name}.step"
186
+ if not path.exists():
187
+ return JSONResponse({"error": f"STEP not found: {name}"}, status_code=404)
188
+ return FileResponse(path, media_type="application/step", filename=f"{name}.step")
189
+
190
+
191
+ @app.get("/api/capabilities")
192
+ async def capabilities():
193
+ try:
194
+ text = await read_mcp_resource("text-to-cnc://capabilities")
195
+ return JSONResponse(json.loads(text))
196
+ except Exception as e:
197
+ return JSONResponse({"error": str(e)}, status_code=502)
198
+
199
+
200
+ # ── Entry Point ──────────────────────────────────────────────────────────
201
+
202
+ if __name__ == "__main__":
203
+ import argparse
204
+ import uvicorn
205
+
206
+ parser = argparse.ArgumentParser(description="NeuralCAD Web Demo Server")
207
+ parser.add_argument("--port", type=int, default=PORT, help="Web server port (default: 5000)")
208
+ parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
209
+ parser.add_argument(
210
+ "--start-mcp", action="store_true",
211
+ help="Auto-launch MCP server as subprocess before starting web server"
212
+ )
213
+ parser.add_argument("--mcp-port", type=int, default=8000, help="MCP server port (default: 8000)")
214
+ args = parser.parse_args()
215
+
216
+ if args.start_mcp:
217
+ MCP_SERVER_URL = f"http://localhost:{args.mcp_port}/sse"
218
+ print(f"Starting MCP CAD server on port {args.mcp_port}...")
219
+ start_mcp_server(args.mcp_port)
220
+
221
+ print(f"Starting NeuralCAD Web Demo on http://localhost:{args.port}")
222
+ print(f"MCP server: {MCP_SERVER_URL}")
223
+ uvicorn.run(app, host=args.host, port=args.port)
web/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>NeuralCAD — Text-to-CNC Pipeline</title>
7
 
8
  <!-- Three.js -->
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
@@ -37,6 +37,11 @@
37
  --machined-steel: #8899aa;
38
  --font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
39
  --font-body: 'DM Sans', system-ui, sans-serif;
 
 
 
 
 
40
  }
41
 
42
  html, body {
@@ -47,15 +52,23 @@
47
  font-family: var(--font-body);
48
  }
49
 
50
- /* ── LAYOUT ─────────────────────────────────────── */
 
 
 
 
 
 
51
 
52
  #app {
53
  display: flex;
54
  flex-direction: column;
55
  height: 100vh;
 
 
56
  }
57
 
58
- /* ── TOP BAR ────────────────────────────────────── */
59
 
60
  #topbar {
61
  flex: 0 0 44px;
@@ -65,7 +78,7 @@
65
  align-items: center;
66
  justify-content: space-between;
67
  padding: 0 16px;
68
- z-index: 10;
69
  position: relative;
70
  }
71
 
@@ -85,21 +98,10 @@
85
  gap: 10px;
86
  }
87
 
88
- .logo-mark {
89
- width: 22px; height: 22px;
90
- border: 2px solid var(--accent);
91
- transform: rotate(45deg);
92
- display: flex;
93
- align-items: center;
94
- justify-content: center;
95
- position: relative;
96
- }
97
-
98
- .logo-mark::after {
99
- content: '';
100
- width: 6px; height: 6px;
101
- background: var(--accent);
102
- border-radius: 1px;
103
  }
104
 
105
  .logo-text {
@@ -122,6 +124,12 @@
122
  border-left: 1px solid var(--border);
123
  }
124
 
 
 
 
 
 
 
125
  .topbar-right {
126
  display: flex;
127
  align-items: center;
@@ -131,7 +139,7 @@
131
  .backend-toggle {
132
  display: flex;
133
  align-items: center;
134
- gap: 2px;
135
  background: var(--bg-void);
136
  border: 1px solid var(--border);
137
  border-radius: 4px;
@@ -146,8 +154,11 @@
146
  cursor: pointer;
147
  color: var(--text-muted);
148
  transition: all 0.2s;
 
149
  }
150
 
 
 
151
  .backend-toggle button.active {
152
  background: var(--accent-glow);
153
  color: var(--accent);
@@ -157,8 +168,28 @@
157
  color: var(--text-secondary);
158
  }
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  .status-dot {
161
- width: 6px; height: 6px;
162
  border-radius: 50%;
163
  background: var(--success);
164
  box-shadow: 0 0 6px var(--success);
@@ -170,19 +201,20 @@
170
  50% { opacity: 0.4; }
171
  }
172
 
173
- .version-tag {
174
- font-family: var(--font-mono);
175
- font-size: 10px;
176
- color: var(--text-muted);
177
- padding: 2px 8px;
178
- border: 1px solid var(--border);
179
- border-radius: 3px;
 
180
  }
181
 
182
- /* ── 3D VIEWER ──────────────────────────────────── */
183
 
184
  #viewer-container {
185
- flex: 1 1 60%;
186
  position: relative;
187
  background: var(--bg-void);
188
  overflow: hidden;
@@ -195,42 +227,12 @@
195
  display: block;
196
  }
197
 
198
- /* Viewport grid effect */
199
- #viewer-container::before {
200
- content: '';
201
- position: absolute;
202
- inset: 0;
203
- background-image:
204
- linear-gradient(var(--border) 1px, transparent 1px),
205
- linear-gradient(90deg, var(--border) 1px, transparent 1px);
206
- background-size: 60px 60px;
207
- opacity: 0.15;
208
- pointer-events: none;
209
- z-index: 1;
210
- }
211
-
212
- /* Corner brackets */
213
- .corner-bracket {
214
- position: absolute;
215
- width: 20px; height: 20px;
216
- border-color: var(--accent-dim);
217
- border-style: solid;
218
- border-width: 0;
219
- opacity: 0.5;
220
- z-index: 2;
221
- pointer-events: none;
222
- }
223
- .corner-bracket.tl { top: 12px; left: 12px; border-top-width: 2px; border-left-width: 2px; }
224
- .corner-bracket.tr { top: 12px; right: 12px; border-top-width: 2px; border-right-width: 2px; }
225
- .corner-bracket.bl { bottom: 12px; left: 12px; border-bottom-width: 2px; border-left-width: 2px; }
226
- .corner-bracket.br { bottom: 12px; right: 12px; border-bottom-width: 2px; border-right-width: 2px; }
227
-
228
- /* Stats overlay */
229
  #geo-stats {
230
  position: absolute;
231
  top: 14px;
232
- right: 14px;
233
- z-index: 3;
234
  background: rgba(6, 8, 12, 0.85);
235
  border: 1px solid var(--border);
236
  border-radius: 4px;
@@ -247,17 +249,19 @@
247
  .stat-label { color: var(--text-muted); }
248
  .stat-value { color: var(--accent); }
249
 
250
- /* CNC badge */
251
  #cnc-badge {
252
  position: absolute;
253
  top: 14px;
254
- left: 14px;
255
- z-index: 3;
256
  display: none;
257
  gap: 6px;
 
258
  }
259
 
260
  #cnc-badge.visible { display: flex; }
 
261
 
262
  .badge {
263
  font-family: var(--font-mono);
@@ -293,12 +297,12 @@
293
  color: var(--accent);
294
  }
295
 
296
- /* Download buttons */
297
  #download-btns {
298
  position: absolute;
299
  bottom: 14px;
300
- right: 14px;
301
- z-index: 3;
302
  display: none;
303
  gap: 6px;
304
  }
@@ -330,20 +334,23 @@
330
  #viewer-hint {
331
  position: absolute;
332
  bottom: 16px;
333
- left: 16px;
334
- z-index: 3;
335
  font-family: var(--font-mono);
336
  font-size: 10px;
337
  color: var(--text-muted);
338
  letter-spacing: 0.5px;
339
  pointer-events: none;
 
340
  }
341
 
 
 
342
  /* Loading spinner */
343
  #viewer-loading {
344
  position: absolute;
345
  inset: 0;
346
- z-index: 4;
347
  display: none;
348
  align-items: center;
349
  justify-content: center;
@@ -376,12 +383,12 @@
376
  #viewer-empty {
377
  position: absolute;
378
  inset: 0;
379
- z-index: 2;
380
  display: flex;
381
  align-items: center;
382
  justify-content: center;
383
  flex-direction: column;
384
- gap: 12px;
385
  pointer-events: none;
386
  }
387
 
@@ -393,6 +400,7 @@
393
  align-items: center;
394
  justify-content: center;
395
  transform: rotate(45deg);
 
396
  }
397
 
398
  .empty-icon-inner {
@@ -405,267 +413,537 @@
405
 
406
  .empty-text {
407
  font-family: var(--font-mono);
408
- font-size: 11px;
409
  color: var(--text-muted);
410
  letter-spacing: 1px;
 
 
411
  }
412
 
413
- /* ── BOTTOM PANEL ───────────────────────────────── */
414
 
415
- #bottom-panel {
416
- flex: 0 0 auto;
417
- height: 40vh;
418
- min-height: 240px;
419
- max-height: 360px;
420
- background: var(--bg-panel);
421
- border-top: 1px solid var(--border);
 
 
422
  display: flex;
423
  flex-direction: column;
424
- position: relative;
 
 
425
  }
426
 
427
- #bottom-panel::before {
428
- content: '';
 
 
 
 
 
429
  position: absolute;
430
- top: -1px;
431
- left: 0; right: 0;
432
- height: 1px;
433
- background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
434
- opacity: 0.3;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
435
  }
436
 
437
- /* Tabs */
438
- #tabs {
439
- flex: 0 0 36px;
 
 
 
 
 
440
  display: flex;
441
- gap: 0;
 
 
442
  border-bottom: 1px solid var(--border);
443
- padding: 0 12px;
444
- background: var(--bg-panel);
445
  }
446
 
447
- .tab-btn {
448
- all: unset;
 
 
 
 
 
449
  font-family: var(--font-mono);
450
  font-size: 11px;
451
- font-weight: 400;
452
- letter-spacing: 0.5px;
453
- color: var(--text-muted);
454
- padding: 0 16px;
455
- cursor: pointer;
456
- position: relative;
457
- transition: color 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  display: flex;
 
 
 
 
 
 
 
 
 
459
  align-items: center;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  gap: 6px;
 
461
  }
462
 
463
- .tab-btn:hover { color: var(--text-secondary); }
 
 
 
 
 
 
 
 
 
 
 
 
464
 
465
- .tab-btn.active {
 
466
  color: var(--accent);
 
467
  }
468
 
469
- .tab-btn.active::after {
470
- content: '';
471
- position: absolute;
472
- bottom: -1px;
473
- left: 8px; right: 8px;
474
- height: 1px;
475
- background: var(--accent);
476
  }
477
 
478
- .tab-count {
479
- font-size: 9px;
480
- padding: 1px 5px;
481
- border-radius: 3px;
482
- background: var(--bg-void);
483
- color: var(--text-muted);
484
  }
485
 
486
- .tab-btn.active .tab-count {
487
- background: var(--accent-glow);
488
- color: var(--accent);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
489
  }
490
 
491
- /* Tab content */
492
- .tab-content {
493
  flex: 1;
494
- min-height: 0;
495
- overflow: auto;
496
- display: none;
497
- padding: 12px 16px;
498
  }
499
 
500
- .tab-content.active { display: flex; }
 
 
 
 
 
 
 
501
 
502
- /* Scrollbar */
503
- .tab-content::-webkit-scrollbar { width: 4px; }
504
- .tab-content::-webkit-scrollbar-track { background: transparent; }
505
- .tab-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
 
 
 
 
 
 
506
 
507
- /* ── GENERATE TAB ───────────────────────────────── */
 
 
 
 
508
 
509
- #tab-generate {
510
- gap: 12px;
 
 
 
 
 
 
 
 
511
  }
512
 
513
- .input-area {
514
- flex: 1;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
  display: flex;
516
  flex-direction: column;
517
  gap: 8px;
518
- min-width: 0;
519
  }
520
 
521
- #prompt-input {
 
 
 
 
 
 
522
  flex: 1;
523
- min-height: 60px;
 
524
  background: var(--bg-input);
525
  border: 1px solid var(--border);
526
- border-radius: 4px;
527
- padding: 10px 12px;
528
  color: var(--text-primary);
529
  font-family: var(--font-body);
530
  font-size: 13px;
531
- line-height: 1.5;
532
  resize: none;
533
  outline: none;
534
  transition: border-color 0.2s;
535
  }
536
 
537
- #prompt-input::placeholder { color: var(--text-muted); }
538
- #prompt-input:focus { border-color: var(--accent-dim); }
539
 
540
- .action-row {
 
 
 
 
541
  display: flex;
542
- gap: 8px;
543
  align-items: center;
 
 
 
 
544
  }
545
 
546
- .btn-generate {
547
- all: unset;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  display: flex;
549
  align-items: center;
550
- justify-content: center;
551
- gap: 8px;
552
- flex: 1;
553
- padding: 8px 20px;
554
- background: linear-gradient(135deg, var(--accent-dim), var(--accent));
555
- border-radius: 4px;
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  font-family: var(--font-mono);
 
 
557
  font-size: 12px;
558
- font-weight: 600;
559
- color: var(--bg-void);
560
- letter-spacing: 1px;
561
- cursor: pointer;
562
- transition: all 0.2s;
563
  }
564
 
565
- .btn-generate:hover { filter: brightness(1.15); }
566
-
567
- .btn-generate:disabled {
568
- opacity: 0.4;
569
- cursor: not-allowed;
570
- filter: none;
571
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
572
 
573
- .btn-image {
574
- all: unset;
575
- display: flex;
576
- align-items: center;
577
- justify-content: center;
578
- width: 36px; height: 36px;
579
- background: var(--bg-surface);
580
  border: 1px solid var(--border);
581
- border-radius: 4px;
582
- color: var(--text-muted);
583
- cursor: pointer;
584
- font-size: 16px;
585
- transition: all 0.2s;
 
586
  }
587
 
588
- .btn-image:hover {
589
- border-color: var(--accent-dim);
590
- color: var(--accent);
591
  }
592
 
593
- .examples-sidebar {
594
- flex: 0 0 220px;
595
  display: flex;
596
- flex-direction: column;
597
- gap: 4px;
598
- overflow-y: auto;
 
599
  }
600
 
601
- .examples-label {
602
  font-family: var(--font-mono);
603
- font-size: 9px;
604
- color: var(--text-muted);
605
- letter-spacing: 2px;
 
606
  text-transform: uppercase;
607
- margin-bottom: 2px;
608
  }
609
 
610
- .example-btn {
611
  all: unset;
 
612
  display: flex;
613
  align-items: center;
614
- gap: 8px;
615
- padding: 7px 10px;
616
- background: var(--bg-surface);
617
- border: 1px solid var(--border);
618
  border-radius: 4px;
619
- font-family: var(--font-mono);
620
- font-size: 10px;
621
- color: var(--text-secondary);
622
  cursor: pointer;
623
- transition: all 0.2s;
624
- white-space: nowrap;
625
- overflow: hidden;
626
- text-overflow: ellipsis;
627
- }
628
-
629
- .example-btn:hover {
630
- border-color: var(--accent-dim);
631
- color: var(--text-primary);
632
- background: var(--bg-input);
633
- }
634
-
635
- .example-arrow {
636
  color: var(--text-muted);
637
- font-size: 8px;
638
- flex-shrink: 0;
639
  }
640
 
641
- /* ── CODE TAB ───────────────────────────────────── */
642
-
643
- #tab-code {
644
- padding: 0;
645
  }
646
 
647
  #code-display {
648
- width: 100%;
649
- height: 100%;
650
  margin: 0;
651
- padding: 14px 16px;
652
  background: var(--bg-input);
653
- border: none;
654
  color: var(--machined-steel);
655
  font-family: var(--font-mono);
656
- font-size: 11.5px;
657
  line-height: 1.7;
658
  overflow: auto;
659
  white-space: pre;
660
  tab-size: 4;
661
  }
662
 
663
- .code-empty {
664
- color: var(--text-muted) !important;
665
- font-style: italic;
666
- }
667
-
668
- /* Syntax coloring via JS */
669
  .kw { color: #c792ea; }
670
  .fn { color: #82aaff; }
671
  .cm { color: #546e7a; }
@@ -673,102 +951,84 @@
673
  .nu { color: #f78c6c; }
674
  .op { color: #89ddff; }
675
 
676
- /* ── VALIDATION TAB ─────────────────────────────── */
677
 
678
- #tab-validation {
679
- flex-direction: column;
680
- gap: 10px;
681
- }
682
-
683
- .validation-empty {
684
- font-family: var(--font-mono);
685
- font-size: 11px;
686
- color: var(--text-muted);
687
- letter-spacing: 0.5px;
688
- margin: auto;
689
- }
690
-
691
- .validation-header {
692
- display: flex;
693
  align-items: center;
694
- gap: 12px;
695
- padding-bottom: 10px;
696
- border-bottom: 1px solid var(--border);
697
  }
698
 
699
- .validation-header .badge { font-size: 11px; }
700
-
701
- .validation-axis {
702
- font-family: var(--font-mono);
703
- font-size: 11px;
704
- color: var(--text-secondary);
705
- }
706
 
707
- .issue-list {
 
 
 
 
 
708
  display: flex;
709
  flex-direction: column;
710
- gap: 4px;
 
 
711
  }
712
 
713
- .issue-item {
714
  display: flex;
715
- align-items: flex-start;
716
- gap: 10px;
717
- padding: 6px 8px;
718
- border-radius: 3px;
719
- font-size: 12px;
720
- font-family: var(--font-mono);
721
- line-height: 1.4;
722
  }
723
 
724
- .issue-item.error { background: rgba(255, 82, 82, 0.05); }
725
- .issue-item.warning { background: rgba(255, 171, 64, 0.05); }
726
- .issue-item.info { background: rgba(0, 180, 216, 0.03); }
727
-
728
- .issue-severity {
729
- flex-shrink: 0;
730
- font-size: 10px;
731
  font-weight: 600;
 
 
732
  text-transform: uppercase;
733
- letter-spacing: 0.5px;
734
- width: 52px;
735
  }
736
 
737
- .issue-severity.error { color: var(--error); }
738
- .issue-severity.warning { color: var(--warning); }
739
- .issue-severity.info { color: var(--accent); }
740
-
741
- .issue-message { color: var(--text-secondary); }
742
-
743
- /* ── GALLERY TAB ────────────────────────────────── */
744
-
745
- #tab-gallery {
746
- gap: 8px;
747
  flex-wrap: wrap;
 
748
  align-content: flex-start;
749
  }
750
 
751
  .gallery-empty {
 
 
 
752
  font-family: var(--font-mono);
753
  font-size: 11px;
754
  color: var(--text-muted);
755
  letter-spacing: 0.5px;
756
- margin: auto;
757
  }
758
 
759
  .gallery-card {
760
  all: unset;
761
  flex: 0 0 auto;
762
- width: 160px;
763
  background: var(--bg-surface);
764
  border: 1px solid var(--border);
765
- border-radius: 4px;
766
- padding: 10px;
767
  cursor: pointer;
768
  transition: all 0.2s;
769
  display: flex;
770
  flex-direction: column;
771
- gap: 6px;
772
  }
773
 
774
  .gallery-card:hover {
@@ -794,7 +1054,7 @@
794
  gap: 8px;
795
  }
796
 
797
- /* ── ANIMATIONS ─────────────────────────────────── */
798
 
799
  @keyframes fade-in-up {
800
  from { opacity: 0; transform: translateY(8px); }
@@ -805,138 +1065,175 @@
805
  animation: fade-in-up 0.3s ease-out both;
806
  }
807
 
808
- /* ── RESPONSIVE ─────────────────────────────────── */
809
 
810
  @media (max-width: 768px) {
811
- .examples-sidebar { display: none; }
812
  .logo-sub { display: none; }
813
- #bottom-panel { height: 50vh; max-height: none; }
 
 
814
  }
815
  </style>
816
  </head>
817
- <body>
818
  <div id="app">
819
 
820
- <!-- ── TOP BAR ─────────────────────────────────── -->
821
  <div id="topbar">
822
  <div class="logo">
823
- <div class="logo-mark"></div>
824
  <span class="logo-text">NeuralCAD</span>
825
- <span class="logo-sub">Text-to-CNC Pipeline</span>
826
  </div>
827
  <div class="topbar-right">
828
  <div class="backend-toggle">
829
  <button id="btn-mock" class="active" onclick="setBackend('mock')">MOCK</button>
830
  <button id="btn-gemini" onclick="setBackend('gemini')">GEMINI</button>
831
- <button id="btn-api" onclick="setBackend('anthropic')">CLAUDE</button>
832
  </div>
833
- <div class="status-dot" id="status-dot" title="MCP Server Connected"></div>
834
- <div class="version-tag">v1.0.0</div>
 
 
 
835
  </div>
836
  </div>
837
 
838
- <!-- ── 3D VIEWER ───────────────────────────────── -->
839
- <div id="viewer-container">
840
- <canvas id="viewer-canvas"></canvas>
841
 
842
- <div class="corner-bracket tl"></div>
843
- <div class="corner-bracket tr"></div>
844
- <div class="corner-bracket bl"></div>
845
- <div class="corner-bracket br"></div>
846
 
847
- <div id="geo-stats">
848
- <div><span class="stat-label">VOL </span><span class="stat-value" id="stat-volume"></span></div>
849
- <div><span class="stat-label">BBOX </span><span class="stat-value" id="stat-bbox"></span></div>
850
- <div><span class="stat-label">FACES </span><span class="stat-value" id="stat-faces"></span><span class="stat-label"> EDGES </span><span class="stat-value" id="stat-edges"></span></div>
851
- </div>
852
 
853
- <div id="cnc-badge">
854
- <div class="badge badge-success" id="badge-cnc"></div>
855
- <div class="badge badge-info" id="badge-axis"></div>
856
- </div>
857
 
858
- <div id="download-btns">
859
- <a class="dl-btn" id="dl-step" download>STEP</a>
860
- <a class="dl-btn" id="dl-stl" download>STL</a>
861
- </div>
 
862
 
863
- <div id="viewer-hint">DRAG ROTATE &middot; SCROLL ZOOM &middot; RIGHT-DRAG PAN</div>
864
 
865
- <div id="viewer-loading">
866
- <div class="spinner"></div>
867
- <div class="loading-text" id="loading-msg">GENERATING MODEL...</div>
868
- </div>
869
 
870
- <div id="viewer-empty">
871
- <div class="empty-icon"><div class="empty-icon-inner"></div></div>
872
- <div class="empty-text">DESCRIBE A PART TO BEGIN</div>
 
873
  </div>
874
- </div>
875
 
876
- <!-- ── BOTTOM PANEL ────────────────────────────── -->
877
- <div id="bottom-panel">
878
- <div id="tabs">
879
- <button class="tab-btn active" data-tab="generate">GENERATE</button>
880
- <button class="tab-btn" data-tab="code">CODE</button>
881
- <button class="tab-btn" data-tab="validation">VALIDATION <span class="tab-count" id="issue-count" style="display:none">0</span></button>
882
- <button class="tab-btn" data-tab="gallery">GALLERY <span class="tab-count" id="gallery-count" style="display:none">0</span></button>
883
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
884
 
885
- <!-- Generate Tab -->
886
- <div class="tab-content active" id="tab-generate">
887
- <div class="input-area">
888
- <textarea id="prompt-input" placeholder="Describe a mechanical part...&#10;&#10;e.g. A mounting bracket with four M6 bolt holes, 80mm wide, with rounded corners"></textarea>
889
- <div class="action-row">
890
- <button class="btn-generate" id="btn-gen" onclick="doGenerate()">
891
- <span id="btn-gen-text">GENERATE MODEL</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
892
  </button>
893
- <button class="btn-image" title="Generate from image" onclick="document.getElementById('image-input').click()">
894
- <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>
895
  </button>
896
- <input type="file" id="image-input" accept="image/*" style="display:none" onchange="doImageGenerate(this)">
897
  </div>
898
- </div>
899
- <div class="examples-sidebar">
900
- <div class="examples-label">Quick Examples</div>
901
- <button class="example-btn" onclick="runExample('A mounting bracket with four M6 bolt holes, 80mm wide')">
902
- <span class="example-arrow">&#9654;</span> Mounting bracket
903
- </button>
904
- <button class="example-btn" onclick="runExample('A spur gear with 20 teeth, module 2, 10mm thick')">
905
- <span class="example-arrow">&#9654;</span> Spur gear
906
- </button>
907
- <button class="example-btn" onclick="runExample('A 100mm pipe flange with 8 M8 bolt holes and center bore')">
908
- <span class="example-arrow">&#9654;</span> Pipe flange
909
- </button>
910
- <button class="example-btn" onclick="runExample('A 30mm cylinder with 12 cooling fins, heatsink')">
911
- <span class="example-arrow">&#9654;</span> Heatsink
912
- </button>
913
- <button class="example-btn" onclick="runExample('An L-bracket 60mm arms with M5 holes, 25mm wide')">
914
- <span class="example-arrow">&#9654;</span> L-bracket
915
- </button>
916
- <button class="example-btn" onclick="runExample('An enclosure 120x80x40mm with pocket, slots, and rounded corners')">
917
- <span class="example-arrow">&#9654;</span> Electronics enclosure
918
- </button>
919
- <button class="example-btn" onclick="runExample('A 50x50x10mm plate with central slot and two M6 holes')">
920
- <span class="example-arrow">&#9654;</span> Slotted plate
921
- </button>
922
- <button class="example-btn" onclick="runExample('A box 80x60x30mm with 4 mounting bosses and chamfered edges')">
923
- <span class="example-arrow">&#9654;</span> Boss mount
924
- </button>
925
  </div>
926
  </div>
927
 
928
- <!-- Code Tab -->
929
- <div class="tab-content" id="tab-code">
930
- <pre id="code-display" class="code-empty">No generated code yet. Run a generation to see the CadQuery output.</pre>
931
- </div>
 
 
 
 
 
 
 
 
 
 
932
 
933
- <!-- Validation Tab -->
934
- <div class="tab-content" id="tab-validation">
935
- <div class="validation-empty">No validation data. Generate a model first.</div>
 
 
 
936
  </div>
 
 
 
937
 
938
- <!-- Gallery Tab -->
939
- <div class="tab-content" id="tab-gallery">
 
 
 
 
 
 
940
  <div class="gallery-empty">No models generated yet.</div>
941
  </div>
942
  </div>
@@ -946,9 +1243,21 @@
946
  // ── STATE ─────────────────────────────────────────────
947
 
948
  let currentBackend = 'mock';
 
 
949
  let currentPartName = '';
950
- let scene, camera, renderer, controls, currentMesh;
 
951
  const galleryItems = [];
 
 
 
 
 
 
 
 
 
952
 
953
  // ── THREE.JS SETUP ────────────────────────────────────
954
 
@@ -986,6 +1295,11 @@ function initViewer() {
986
  rimLight.position.set(0, -50, 100);
987
  scene.add(rimLight);
988
 
 
 
 
 
 
989
  // Controls
990
  controls = new THREE.OrbitControls(camera, renderer.domElement);
991
  controls.enableDamping = true;
@@ -1017,14 +1331,12 @@ function loadSTL(url) {
1017
  return new Promise((resolve, reject) => {
1018
  const loader = new THREE.STLLoader();
1019
  loader.load(url, (geometry) => {
1020
- // Remove existing mesh
1021
  if (currentMesh) {
1022
  scene.remove(currentMesh);
1023
  currentMesh.geometry.dispose();
1024
  currentMesh.material.dispose();
1025
  }
1026
 
1027
- // Material: machined steel look
1028
  const material = new THREE.MeshPhongMaterial({
1029
  color: 0x7799aa,
1030
  specular: 0x445566,
@@ -1036,7 +1348,6 @@ function loadSTL(url) {
1036
  mesh.castShadow = true;
1037
  mesh.receiveShadow = true;
1038
 
1039
- // Center geometry
1040
  geometry.computeBoundingBox();
1041
  const center = new THREE.Vector3();
1042
  geometry.boundingBox.getCenter(center);
@@ -1046,15 +1357,19 @@ function loadSTL(url) {
1046
  currentMesh = mesh;
1047
 
1048
  // Fit camera
1049
- const box = geometry.boundingBox;
1050
  const size = new THREE.Vector3();
1051
- box.getSize(size);
1052
  const maxDim = Math.max(size.x, size.y, size.z);
1053
  const dist = maxDim * 2.5;
1054
  camera.position.set(dist * 0.7, dist * 0.5, dist * 0.7);
1055
  controls.target.set(0, 0, 0);
1056
  controls.update();
1057
 
 
 
 
 
 
1058
  document.getElementById('viewer-empty').style.display = 'none';
1059
  resolve();
1060
  }, undefined, reject);
@@ -1067,132 +1382,279 @@ function setBackend(name) {
1067
  currentBackend = name;
1068
  document.getElementById('btn-mock').classList.toggle('active', name === 'mock');
1069
  document.getElementById('btn-gemini').classList.toggle('active', name === 'gemini');
1070
- document.getElementById('btn-api').classList.toggle('active', name === 'anthropic');
1071
  }
1072
 
1073
- // ── TABS ──────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
 
1075
- document.getElementById('tabs').addEventListener('click', (e) => {
1076
- const btn = e.target.closest('.tab-btn');
1077
- if (!btn) return;
1078
- const tabName = btn.dataset.tab;
1079
 
1080
- document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
1081
- document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
1082
 
1083
- btn.classList.add('active');
1084
- document.getElementById('tab-' + tabName).classList.add('active');
1085
- });
 
 
 
 
 
1086
 
1087
- // ── GENERATION ────────────────────────────────────────
 
 
1088
 
1089
- async function doGenerate() {
1090
- const prompt = document.getElementById('prompt-input').value.trim();
1091
- if (!prompt) return;
1092
 
1093
- setLoading(true, 'GENERATING MODEL...');
 
1094
 
1095
  try {
1096
- const resp = await fetch('/api/generate', {
1097
  method: 'POST',
1098
  headers: { 'Content-Type': 'application/json' },
1099
- body: JSON.stringify({ prompt, backend: currentBackend }),
 
 
 
 
 
1100
  });
1101
  const data = await resp.json();
1102
- handleResult(data);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1103
  } catch (err) {
1104
- setLoading(false);
1105
- alert('Error: ' + err.message);
 
 
 
 
 
 
 
1106
  }
1107
  }
1108
 
1109
- async function doImageGenerate(input) {
1110
- const file = input.files[0];
1111
- if (!file) return;
 
 
 
 
 
 
 
 
 
 
1112
 
1113
- setLoading(true, 'ANALYZING IMAGE...');
 
 
 
 
1114
 
1115
- const form = new FormData();
1116
- form.append('image', file);
1117
- form.append('backend', currentBackend === 'mock' ? 'anthropic' : currentBackend);
1118
 
1119
- try {
1120
- const resp = await fetch('/api/generate-image', { method: 'POST', body: form });
1121
- const data = await resp.json();
1122
- handleResult(data);
1123
- } catch (err) {
1124
- setLoading(false);
1125
- alert('Error: ' + err.message);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1126
  }
1127
 
1128
- input.value = '';
 
1129
  }
1130
 
1131
- function runExample(prompt) {
1132
- document.getElementById('prompt-input').value = prompt;
1133
- // Force mock for examples (reliability)
1134
- const prevBackend = currentBackend;
1135
- currentBackend = 'mock';
1136
- doGenerate().then(() => { currentBackend = prevBackend; });
 
 
1137
  }
1138
 
1139
- async function handleResult(data) {
1140
- if (!data.success) {
1141
- setLoading(false);
1142
- alert('Generation failed: ' + (data.execution?.error || data.error || 'Unknown error'));
1143
- return;
1144
- }
1145
 
1146
- currentPartName = data.part_name;
 
 
 
 
 
1147
 
1148
- // Update code tab
1149
- updateCodeTab(data.generated_code);
1150
 
1151
- // Update validation tab
1152
- updateValidationTab(data.validation, data.execution);
1153
 
1154
- // Update geometry stats
1155
- updateGeoStats(data.execution);
 
 
1156
 
1157
- // Update CNC badge
1158
- updateCNCBadge(data.validation);
 
1159
 
1160
- // Update downloads
1161
- updateDownloads(data.part_name, data.exported_files);
 
1162
 
1163
- // Load 3D model
1164
- setLoading(true, 'LOADING 3D MODEL...');
1165
- try {
1166
- await loadSTL('/api/models/' + data.part_name + '.stl');
1167
- } catch (e) {
1168
- console.warn('STL load failed:', e);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1169
  }
 
1170
 
1171
- setLoading(false);
 
 
 
1172
 
1173
- // Add to gallery
1174
- addToGallery(data);
 
 
 
1175
 
1176
- // Switch to generate tab if not already there
1177
- loadGallery();
 
 
 
 
 
 
 
 
 
 
 
1178
  }
1179
 
1180
  // ── UI UPDATES ────────────────────────────────────────
1181
 
1182
- function setLoading(on, msg) {
1183
  const el = document.getElementById('viewer-loading');
1184
- const btn = document.getElementById('btn-gen');
1185
- const btnText = document.getElementById('btn-gen-text');
1186
-
1187
  if (on) {
1188
  el.classList.add('visible');
1189
  document.getElementById('loading-msg').textContent = msg || 'GENERATING...';
1190
- btn.disabled = true;
1191
- btnText.textContent = 'GENERATING...';
1192
  } else {
1193
  el.classList.remove('visible');
1194
- btn.disabled = false;
1195
- btnText.textContent = 'GENERATE MODEL';
1196
  }
1197
  }
1198
 
@@ -1211,8 +1673,8 @@ function updateGeoStats(exec) {
1211
  bbox.map(v => v.toFixed(1)).join(' \u00D7 ') + ' mm';
1212
  }
1213
 
1214
- document.getElementById('stat-faces').textContent = exec.face_count || '';
1215
- document.getElementById('stat-edges').textContent = exec.edge_count || '';
1216
  }
1217
 
1218
  function updateCNCBadge(validation) {
@@ -1233,125 +1695,83 @@ function updateCNCBadge(validation) {
1233
  axisBadge.textContent = (validation.axis_recommendation || '').toUpperCase();
1234
  }
1235
 
1236
- function updateDownloads(partName, files) {
1237
  const el = document.getElementById('download-btns');
1238
- if (!files) { el.classList.remove('visible'); return; }
1239
  el.classList.add('visible');
1240
 
1241
  document.getElementById('dl-step').href = '/api/models/' + partName + '.step';
1242
  document.getElementById('dl-stl').href = '/api/models/' + partName + '.stl';
 
1243
  }
1244
 
1245
- function updateCodeTab(code) {
1246
- const el = document.getElementById('code-display');
1247
- if (!code) {
1248
- el.textContent = 'No generated code yet.';
1249
- el.classList.add('code-empty');
1250
- return;
 
 
 
 
1251
  }
1252
- el.classList.remove('code-empty');
1253
- el.innerHTML = highlightPython(code);
 
 
 
 
1254
  }
1255
 
1256
  function highlightPython(code) {
1257
- // Simple Python syntax highlighting
1258
  let escaped = code
1259
  .replace(/&/g, '&amp;')
1260
  .replace(/</g, '&lt;')
1261
  .replace(/>/g, '&gt;');
1262
 
1263
- // Comments
1264
  escaped = escaped.replace(/(#.*$)/gm, '<span class="cm">$1</span>');
1265
- // Strings
1266
  escaped = escaped.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"[^"\n]*"|'[^'\n]*')/g, '<span class="st">$1</span>');
1267
- // Keywords
1268
  const kw = /\b(import|from|as|def|class|return|if|else|elif|for|while|in|not|and|or|True|False|None|with|try|except|finally|raise|pass|break|continue|lambda|yield)\b/g;
1269
  escaped = escaped.replace(kw, '<span class="kw">$1</span>');
1270
- // Numbers
1271
  escaped = escaped.replace(/\b(\d+\.?\d*)\b/g, '<span class="nu">$1</span>');
1272
- // Function calls
1273
  escaped = escaped.replace(/\.([a-zA-Z_]\w*)\(/g, '.<span class="fn">$1</span>(');
1274
 
1275
  return escaped;
1276
  }
1277
 
1278
- function updateValidationTab(validation, exec) {
1279
- const el = document.getElementById('tab-validation');
1280
-
1281
- if (!validation) {
1282
- el.innerHTML = '<div class="validation-empty">No validation data. Generate a model first.</div>';
1283
- document.getElementById('issue-count').style.display = 'none';
1284
- return;
1285
- }
1286
-
1287
- let html = '<div class="validation-header">';
1288
-
1289
- if (validation.machinable) {
1290
- html += '<div class="badge badge-success">\u2713 MACHINABLE</div>';
1291
- } else {
1292
- html += '<div class="badge badge-error">\u2717 NOT MACHINABLE</div>';
1293
- }
1294
-
1295
- if (validation.axis_recommendation) {
1296
- html += '<div class="validation-axis">Recommended: ' + validation.axis_recommendation + '</div>';
1297
- }
1298
-
1299
- const errs = validation.error_count || 0;
1300
- const warns = validation.warning_count || 0;
1301
- html += '<div class="validation-axis" style="margin-left:auto">' + errs + ' errors, ' + warns + ' warnings</div>';
1302
- html += '</div>';
1303
-
1304
- if (validation.issues && validation.issues.length > 0) {
1305
- html += '<div class="issue-list">';
1306
- for (const issue of validation.issues) {
1307
- const sev = issue.severity || 'info';
1308
- html += '<div class="issue-item ' + sev + '">';
1309
- html += '<span class="issue-severity ' + sev + '">' + sev.toUpperCase() + '</span>';
1310
- html += '<span class="issue-message">' + escapeHtml(issue.message) + '</span>';
1311
- html += '</div>';
1312
- }
1313
- html += '</div>';
1314
- }
1315
-
1316
- el.innerHTML = html;
1317
-
1318
- const totalIssues = (validation.issues || []).length;
1319
- const countEl = document.getElementById('issue-count');
1320
- if (totalIssues > 0) {
1321
- countEl.textContent = totalIssues;
1322
- countEl.style.display = '';
1323
- } else {
1324
- countEl.style.display = 'none';
1325
- }
1326
- }
1327
 
1328
  function addToGallery(data) {
1329
  galleryItems.unshift({
1330
  name: data.part_name,
1331
- prompt: data.prompt,
1332
  volume: data.execution?.volume_mm3,
1333
  faces: data.execution?.face_count,
1334
  machinable: data.validation?.machinable,
1335
  });
1336
- loadGallery();
1337
  }
1338
 
1339
- function loadGallery() {
1340
- const el = document.getElementById('tab-gallery');
1341
- const countEl = document.getElementById('gallery-count');
 
 
 
 
 
 
 
 
1342
 
1343
  if (galleryItems.length === 0) {
1344
- el.innerHTML = '<div class="gallery-empty">No models generated yet.</div>';
1345
- countEl.style.display = 'none';
1346
  return;
1347
  }
1348
 
1349
- countEl.textContent = galleryItems.length;
1350
- countEl.style.display = '';
1351
-
1352
  let html = '';
1353
  for (const item of galleryItems) {
1354
- html += '<button class="gallery-card fade-in" onclick="loadGalleryItem(\'' + item.name + '\')">';
1355
  html += '<div class="gallery-card-name">' + escapeHtml(item.name) + '</div>';
1356
  html += '<div class="gallery-card-meta">';
1357
  if (item.faces) html += '<span>' + item.faces + ' faces</span>';
@@ -1362,19 +1782,22 @@ function loadGallery() {
1362
  html += '</div></button>';
1363
  }
1364
 
1365
- el.innerHTML = html;
1366
  }
1367
 
1368
  async function loadGalleryItem(name) {
1369
- setLoading(true, 'LOADING MODEL...');
 
1370
  try {
1371
  await loadSTL('/api/models/' + name + '.stl');
1372
  } catch (e) {
1373
  console.warn('Failed to load:', e);
1374
  }
1375
- setLoading(false);
1376
  }
1377
 
 
 
1378
  function escapeHtml(str) {
1379
  const div = document.createElement('div');
1380
  div.textContent = str;
@@ -1390,26 +1813,89 @@ async function checkServer() {
1390
  if (resp.ok) {
1391
  dot.style.background = 'var(--success)';
1392
  dot.style.boxShadow = '0 0 6px var(--success)';
1393
- dot.title = 'MCP Server Connected';
1394
  } else {
1395
  dot.style.background = 'var(--warning)';
1396
  dot.style.boxShadow = '0 0 6px var(--warning)';
1397
- dot.title = 'MCP Server Error';
1398
  }
1399
  } catch {
1400
  const dot = document.getElementById('status-dot');
1401
  dot.style.background = 'var(--error)';
1402
  dot.style.boxShadow = '0 0 6px var(--error)';
1403
- dot.title = 'MCP Server Offline';
1404
  }
1405
  }
1406
 
1407
- // ── KEYBOARD SHORTCUT ─────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1408
 
1409
- document.getElementById('prompt-input').addEventListener('keydown', (e) => {
1410
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1411
  e.preventDefault();
1412
- doGenerate();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1413
  }
1414
  });
1415
 
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>NeuralCAD — Multi-Agent Design</title>
7
 
8
  <!-- Three.js -->
9
  <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
 
37
  --machined-steel: #8899aa;
38
  --font-mono: 'JetBrains Mono', 'Cascadia Code', monospace;
39
  --font-body: 'DM Sans', system-ui, sans-serif;
40
+ --agent-design: #7c3aed;
41
+ --agent-engineering: #00b4d8;
42
+ --agent-cnc: #00e676;
43
+ --agent-cad: #ffab40;
44
+ --chat-width: 340px;
45
  }
46
 
47
  html, body {
 
52
  font-family: var(--font-body);
53
  }
54
 
55
+ /* ---- Scrollbar ---- */
56
+ ::-webkit-scrollbar { width: 5px; }
57
+ ::-webkit-scrollbar-track { background: transparent; }
58
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
59
+ ::-webkit-scrollbar-thumb:hover { background: var(--border-active); }
60
+
61
+ /* ---- LAYOUT ---- */
62
 
63
  #app {
64
  display: flex;
65
  flex-direction: column;
66
  height: 100vh;
67
+ width: 100vw;
68
+ overflow: hidden;
69
  }
70
 
71
+ /* ---- TOP BAR ---- */
72
 
73
  #topbar {
74
  flex: 0 0 44px;
 
78
  align-items: center;
79
  justify-content: space-between;
80
  padding: 0 16px;
81
+ z-index: 100;
82
  position: relative;
83
  }
84
 
 
98
  gap: 10px;
99
  }
100
 
101
+ .logo-diamond {
102
+ color: var(--accent);
103
+ font-size: 18px;
104
+ line-height: 1;
 
 
 
 
 
 
 
 
 
 
 
105
  }
106
 
107
  .logo-text {
 
124
  border-left: 1px solid var(--border);
125
  }
126
 
127
+ .topbar-center {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 12px;
131
+ }
132
+
133
  .topbar-right {
134
  display: flex;
135
  align-items: center;
 
139
  .backend-toggle {
140
  display: flex;
141
  align-items: center;
142
+ gap: 0;
143
  background: var(--bg-void);
144
  border: 1px solid var(--border);
145
  border-radius: 4px;
 
154
  cursor: pointer;
155
  color: var(--text-muted);
156
  transition: all 0.2s;
157
+ border-right: 1px solid var(--border);
158
  }
159
 
160
+ .backend-toggle button:last-child { border-right: none; }
161
+
162
  .backend-toggle button.active {
163
  background: var(--accent-glow);
164
  color: var(--accent);
 
168
  color: var(--text-secondary);
169
  }
170
 
171
+ .gallery-btn {
172
+ all: unset;
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 6px;
176
+ padding: 4px 12px;
177
+ font-family: var(--font-mono);
178
+ font-size: 11px;
179
+ color: var(--text-secondary);
180
+ border: 1px solid var(--border);
181
+ border-radius: 4px;
182
+ cursor: pointer;
183
+ transition: all 0.2s;
184
+ }
185
+
186
+ .gallery-btn:hover {
187
+ border-color: var(--accent-dim);
188
+ color: var(--accent);
189
+ }
190
+
191
  .status-dot {
192
+ width: 7px; height: 7px;
193
  border-radius: 50%;
194
  background: var(--success);
195
  box-shadow: 0 0 6px var(--success);
 
201
  50% { opacity: 0.4; }
202
  }
203
 
204
+ /* ---- MAIN AREA ---- */
205
+
206
+ #main {
207
+ flex: 1;
208
+ display: flex;
209
+ position: relative;
210
+ min-height: 0;
211
+ overflow: hidden;
212
  }
213
 
214
+ /* ---- 3D VIEWER ---- */
215
 
216
  #viewer-container {
217
+ flex: 1;
218
  position: relative;
219
  background: var(--bg-void);
220
  overflow: hidden;
 
227
  display: block;
228
  }
229
 
230
+ /* Geo stats overlay - top left */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
231
  #geo-stats {
232
  position: absolute;
233
  top: 14px;
234
+ left: 14px;
235
+ z-index: 10;
236
  background: rgba(6, 8, 12, 0.85);
237
  border: 1px solid var(--border);
238
  border-radius: 4px;
 
249
  .stat-label { color: var(--text-muted); }
250
  .stat-value { color: var(--accent); }
251
 
252
+ /* CNC badge - top right of viewer (NOT behind chat) */
253
  #cnc-badge {
254
  position: absolute;
255
  top: 14px;
256
+ right: 14px;
257
+ z-index: 10;
258
  display: none;
259
  gap: 6px;
260
+ transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
261
  }
262
 
263
  #cnc-badge.visible { display: flex; }
264
+ body.chat-open #cnc-badge { right: calc(var(--chat-width) + 14px); }
265
 
266
  .badge {
267
  font-family: var(--font-mono);
 
297
  color: var(--accent);
298
  }
299
 
300
+ /* Download buttons - bottom left */
301
  #download-btns {
302
  position: absolute;
303
  bottom: 14px;
304
+ left: 14px;
305
+ z-index: 10;
306
  display: none;
307
  gap: 6px;
308
  }
 
334
  #viewer-hint {
335
  position: absolute;
336
  bottom: 16px;
337
+ right: 16px;
338
+ z-index: 10;
339
  font-family: var(--font-mono);
340
  font-size: 10px;
341
  color: var(--text-muted);
342
  letter-spacing: 0.5px;
343
  pointer-events: none;
344
+ transition: right 0.35s cubic-bezier(0.4, 0, 0.2, 1);
345
  }
346
 
347
+ body.chat-open #viewer-hint { right: calc(var(--chat-width) + 16px); }
348
+
349
  /* Loading spinner */
350
  #viewer-loading {
351
  position: absolute;
352
  inset: 0;
353
+ z-index: 20;
354
  display: none;
355
  align-items: center;
356
  justify-content: center;
 
383
  #viewer-empty {
384
  position: absolute;
385
  inset: 0;
386
+ z-index: 5;
387
  display: flex;
388
  align-items: center;
389
  justify-content: center;
390
  flex-direction: column;
391
+ gap: 16px;
392
  pointer-events: none;
393
  }
394
 
 
400
  align-items: center;
401
  justify-content: center;
402
  transform: rotate(45deg);
403
+ opacity: 0.5;
404
  }
405
 
406
  .empty-icon-inner {
 
413
 
414
  .empty-text {
415
  font-family: var(--font-mono);
416
+ font-size: 12px;
417
  color: var(--text-muted);
418
  letter-spacing: 1px;
419
+ text-align: center;
420
+ line-height: 1.6;
421
  }
422
 
423
+ /* ---- CHAT PANEL ---- */
424
 
425
+ #chat-panel {
426
+ position: absolute;
427
+ top: 0;
428
+ right: 0;
429
+ width: var(--chat-width);
430
+ height: 100%;
431
+ background: rgba(10, 14, 20, 0.92);
432
+ backdrop-filter: blur(16px);
433
+ border-left: 1px solid var(--border);
434
  display: flex;
435
  flex-direction: column;
436
+ z-index: 50;
437
+ transform: translateX(0);
438
+ transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
439
  }
440
 
441
+ #chat-panel.collapsed {
442
+ transform: translateX(100%);
443
+ }
444
+
445
+ /* Collapse toggle */
446
+ #chat-toggle {
447
+ all: unset;
448
  position: absolute;
449
+ top: 50%;
450
+ left: -28px;
451
+ transform: translateY(-50%);
452
+ width: 28px;
453
+ height: 56px;
454
+ background: rgba(10, 14, 20, 0.92);
455
+ backdrop-filter: blur(16px);
456
+ border: 1px solid var(--border);
457
+ border-right: none;
458
+ border-radius: 6px 0 0 6px;
459
+ display: flex;
460
+ align-items: center;
461
+ justify-content: center;
462
+ cursor: pointer;
463
+ color: var(--text-secondary);
464
+ font-size: 14px;
465
+ transition: all 0.2s;
466
+ z-index: 51;
467
+ }
468
+
469
+ #chat-toggle:hover {
470
+ color: var(--accent);
471
+ background: rgba(10, 14, 20, 0.98);
472
+ }
473
+
474
+ /* Floating open pill */
475
+ #chat-open-pill {
476
+ position: fixed;
477
+ bottom: 20px;
478
+ left: 50%;
479
+ transform: translateX(-50%);
480
+ z-index: 60;
481
+ display: none;
482
+ align-items: center;
483
+ gap: 10px;
484
+ padding: 10px 20px;
485
+ background: rgba(10, 14, 20, 0.95);
486
+ backdrop-filter: blur(16px);
487
+ border: 1px solid var(--border);
488
+ border-radius: 24px;
489
+ cursor: pointer;
490
+ font-family: var(--font-mono);
491
+ font-size: 12px;
492
+ color: var(--text-primary);
493
+ letter-spacing: 0.5px;
494
+ transition: all 0.3s;
495
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
496
+ }
497
+
498
+ #chat-open-pill:hover {
499
+ border-color: var(--accent-dim);
500
+ box-shadow: 0 4px 32px rgba(0, 180, 216, 0.15);
501
+ }
502
+
503
+ #chat-open-pill.visible { display: flex; }
504
+
505
+ .pill-dots {
506
+ display: flex;
507
+ gap: 4px;
508
  }
509
 
510
+ .pill-dot {
511
+ width: 8px; height: 8px;
512
+ border-radius: 50%;
513
+ }
514
+
515
+ /* Chat header */
516
+ .chat-header {
517
+ flex: 0 0 48px;
518
  display: flex;
519
+ align-items: center;
520
+ justify-content: space-between;
521
+ padding: 0 16px;
522
  border-bottom: 1px solid var(--border);
 
 
523
  }
524
 
525
+ .chat-header-left {
526
+ display: flex;
527
+ align-items: center;
528
+ gap: 10px;
529
+ }
530
+
531
+ .chat-header-title {
532
  font-family: var(--font-mono);
533
  font-size: 11px;
534
+ font-weight: 600;
535
+ letter-spacing: 2px;
536
+ color: var(--text-secondary);
537
+ text-transform: uppercase;
538
+ }
539
+
540
+ .agent-dots {
541
+ display: flex;
542
+ gap: 5px;
543
+ }
544
+
545
+ .agent-dot {
546
+ width: 8px; height: 8px;
547
+ border-radius: 50%;
548
+ opacity: 0.8;
549
+ }
550
+
551
+ /* Messages area */
552
+ #chat-messages {
553
+ flex: 1;
554
+ overflow-y: auto;
555
+ padding: 16px 12px;
556
  display: flex;
557
+ flex-direction: column;
558
+ gap: 12px;
559
+ min-height: 0;
560
+ }
561
+
562
+ /* Quick examples */
563
+ .quick-examples {
564
+ display: flex;
565
+ flex-direction: column;
566
  align-items: center;
567
+ gap: 12px;
568
+ padding: 40px 16px 20px;
569
+ }
570
+
571
+ .quick-examples-label {
572
+ font-family: var(--font-mono);
573
+ font-size: 10px;
574
+ color: var(--text-muted);
575
+ letter-spacing: 2px;
576
+ text-transform: uppercase;
577
+ }
578
+
579
+ .quick-chips {
580
+ display: flex;
581
+ flex-wrap: wrap;
582
  gap: 6px;
583
+ justify-content: center;
584
  }
585
 
586
+ .quick-chip {
587
+ all: unset;
588
+ padding: 6px 12px;
589
+ font-family: var(--font-mono);
590
+ font-size: 11px;
591
+ color: var(--text-secondary);
592
+ background: var(--bg-surface);
593
+ border: 1px solid var(--border);
594
+ border-radius: 16px;
595
+ cursor: pointer;
596
+ transition: all 0.2s;
597
+ white-space: nowrap;
598
+ }
599
 
600
+ .quick-chip:hover {
601
+ border-color: var(--accent-dim);
602
  color: var(--accent);
603
+ background: var(--accent-glow);
604
  }
605
 
606
+ /* Message bubbles */
607
+ .msg {
608
+ display: flex;
609
+ gap: 8px;
610
+ max-width: 100%;
611
+ animation: msg-in 0.25s ease-out both;
 
612
  }
613
 
614
+ @keyframes msg-in {
615
+ from { opacity: 0; transform: translateY(8px); }
616
+ to { opacity: 1; transform: translateY(0); }
 
 
 
617
  }
618
 
619
+ .msg-user {
620
+ justify-content: flex-end;
621
+ }
622
+
623
+ .msg-user .msg-bubble {
624
+ background: #1a2a3a;
625
+ border: 1px solid rgba(0, 180, 216, 0.15);
626
+ border-radius: 12px 12px 4px 12px;
627
+ padding: 8px 12px;
628
+ font-size: 13px;
629
+ line-height: 1.5;
630
+ color: var(--text-primary);
631
+ max-width: 85%;
632
+ word-wrap: break-word;
633
+ }
634
+
635
+ .msg-agent {
636
+ align-items: flex-start;
637
+ }
638
+
639
+ .msg-avatar {
640
+ flex-shrink: 0;
641
+ width: 24px; height: 24px;
642
+ border-radius: 50%;
643
+ display: flex;
644
+ align-items: center;
645
+ justify-content: center;
646
+ font-size: 11px;
647
+ font-weight: 700;
648
+ color: rgba(0, 0, 0, 0.7);
649
+ margin-top: 2px;
650
  }
651
 
652
+ .msg-agent-body {
 
653
  flex: 1;
654
+ min-width: 0;
 
 
 
655
  }
656
 
657
+ .msg-agent-name {
658
+ font-family: var(--font-mono);
659
+ font-size: 10px;
660
+ font-weight: 600;
661
+ letter-spacing: 0.5px;
662
+ margin-bottom: 3px;
663
+ text-transform: uppercase;
664
+ }
665
 
666
+ .msg-agent-bubble {
667
+ background: var(--bg-surface);
668
+ border: 1px solid var(--border);
669
+ border-radius: 4px 12px 12px 12px;
670
+ padding: 8px 12px;
671
+ font-size: 13px;
672
+ line-height: 1.5;
673
+ color: var(--text-primary);
674
+ word-wrap: break-word;
675
+ }
676
 
677
+ /* CAD Coder special styling */
678
+ .msg-agent-bubble.cad-bubble {
679
+ background: rgba(255, 171, 64, 0.08);
680
+ border-color: rgba(255, 171, 64, 0.2);
681
+ }
682
 
683
+ .msg-view-code {
684
+ display: inline-block;
685
+ margin-top: 6px;
686
+ font-family: var(--font-mono);
687
+ font-size: 10px;
688
+ color: var(--warning);
689
+ cursor: pointer;
690
+ text-decoration: none;
691
+ letter-spacing: 0.5px;
692
+ transition: opacity 0.2s;
693
  }
694
 
695
+ .msg-view-code:hover { opacity: 0.7; }
696
+
697
+ /* Typing indicator */
698
+ .typing-indicator {
699
+ display: flex;
700
+ align-items: center;
701
+ gap: 8px;
702
+ padding: 8px 12px;
703
+ }
704
+
705
+ .typing-dots {
706
+ display: flex;
707
+ gap: 4px;
708
+ }
709
+
710
+ .typing-dots span {
711
+ width: 6px; height: 6px;
712
+ border-radius: 50%;
713
+ background: var(--text-muted);
714
+ animation: typing-bounce 1.2s ease-in-out infinite;
715
+ }
716
+
717
+ .typing-dots span:nth-child(2) { animation-delay: 0.15s; }
718
+ .typing-dots span:nth-child(3) { animation-delay: 0.3s; }
719
+
720
+ @keyframes typing-bounce {
721
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
722
+ 30% { transform: translateY(-4px); opacity: 1; }
723
+ }
724
+
725
+ .typing-label {
726
+ font-family: var(--font-mono);
727
+ font-size: 10px;
728
+ color: var(--text-muted);
729
+ letter-spacing: 0.5px;
730
+ }
731
+
732
+ /* Chat input area */
733
+ .chat-input-area {
734
+ flex: 0 0 auto;
735
+ padding: 12px;
736
+ border-top: 1px solid var(--border);
737
  display: flex;
738
  flex-direction: column;
739
  gap: 8px;
 
740
  }
741
 
742
+ .chat-input-row {
743
+ display: flex;
744
+ gap: 6px;
745
+ align-items: flex-end;
746
+ }
747
+
748
+ #chat-input {
749
  flex: 1;
750
+ min-height: 38px;
751
+ max-height: 120px;
752
  background: var(--bg-input);
753
  border: 1px solid var(--border);
754
+ border-radius: 8px;
755
+ padding: 8px 12px;
756
  color: var(--text-primary);
757
  font-family: var(--font-body);
758
  font-size: 13px;
759
+ line-height: 1.4;
760
  resize: none;
761
  outline: none;
762
  transition: border-color 0.2s;
763
  }
764
 
765
+ #chat-input::placeholder { color: var(--text-muted); }
766
+ #chat-input:focus { border-color: var(--accent-dim); }
767
 
768
+ .chat-btn {
769
+ all: unset;
770
+ flex-shrink: 0;
771
+ width: 34px; height: 34px;
772
+ border-radius: 8px;
773
  display: flex;
 
774
  align-items: center;
775
+ justify-content: center;
776
+ cursor: pointer;
777
+ transition: all 0.2s;
778
+ font-size: 16px;
779
  }
780
 
781
+ .chat-btn-preview {
782
+ background: rgba(255, 171, 64, 0.1);
783
+ border: 1px solid rgba(255, 171, 64, 0.25);
784
+ color: var(--warning);
785
+ }
786
+
787
+ .chat-btn-preview:hover {
788
+ background: rgba(255, 171, 64, 0.2);
789
+ border-color: var(--warning);
790
+ }
791
+
792
+ .chat-btn-send {
793
+ background: var(--accent-glow);
794
+ border: 1px solid rgba(0, 180, 216, 0.3);
795
+ color: var(--accent);
796
+ }
797
+
798
+ .chat-btn-send:hover {
799
+ background: rgba(0, 180, 216, 0.25);
800
+ border-color: var(--accent);
801
+ }
802
+
803
+ .chat-shortcut-hint {
804
+ font-family: var(--font-mono);
805
+ font-size: 9px;
806
+ color: var(--text-muted);
807
+ text-align: right;
808
+ letter-spacing: 0.3px;
809
+ }
810
+
811
+ /* @mention autocomplete */
812
+ #mention-dropdown {
813
+ display: none;
814
+ position: absolute;
815
+ bottom: 100%;
816
+ left: 12px;
817
+ right: 12px;
818
+ margin-bottom: 4px;
819
+ background: var(--bg-panel);
820
+ border: 1px solid var(--border);
821
+ border-radius: 8px;
822
+ overflow: hidden;
823
+ box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.4);
824
+ z-index: 55;
825
+ }
826
+
827
+ #mention-dropdown.visible { display: block; }
828
+
829
+ .mention-option {
830
  display: flex;
831
  align-items: center;
832
+ gap: 10px;
833
+ padding: 8px 12px;
834
+ cursor: pointer;
835
+ transition: background 0.15s;
836
+ font-size: 12px;
837
+ }
838
+
839
+ .mention-option:hover,
840
+ .mention-option.active {
841
+ background: var(--bg-surface);
842
+ }
843
+
844
+ .mention-dot {
845
+ width: 10px; height: 10px;
846
+ border-radius: 50%;
847
+ flex-shrink: 0;
848
+ }
849
+
850
+ .mention-name {
851
  font-family: var(--font-mono);
852
+ font-weight: 500;
853
+ color: var(--text-primary);
854
  font-size: 12px;
 
 
 
 
 
855
  }
856
 
857
+ .mention-role {
858
+ font-family: var(--font-mono);
859
+ font-size: 10px;
860
+ color: var(--text-muted);
861
+ margin-left: auto;
862
+ }
863
+
864
+ /* ---- CODE VIEWER MODAL ---- */
865
+
866
+ #code-modal {
867
+ display: none;
868
+ position: fixed;
869
+ inset: 0;
870
+ z-index: 200;
871
+ align-items: center;
872
+ justify-content: center;
873
+ background: rgba(6, 8, 12, 0.85);
874
+ backdrop-filter: blur(8px);
875
+ }
876
+
877
+ #code-modal.visible { display: flex; }
878
 
879
+ .code-modal-inner {
880
+ width: min(720px, 90vw);
881
+ max-height: 80vh;
882
+ background: var(--bg-panel);
 
 
 
883
  border: 1px solid var(--border);
884
+ border-radius: 8px;
885
+ display: flex;
886
+ flex-direction: column;
887
+ overflow: hidden;
888
+ box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
889
+ animation: modal-in 0.25s ease-out;
890
  }
891
 
892
+ @keyframes modal-in {
893
+ from { opacity: 0; transform: scale(0.96) translateY(12px); }
894
+ to { opacity: 1; transform: scale(1) translateY(0); }
895
  }
896
 
897
+ .code-modal-header {
 
898
  display: flex;
899
+ align-items: center;
900
+ justify-content: space-between;
901
+ padding: 12px 16px;
902
+ border-bottom: 1px solid var(--border);
903
  }
904
 
905
+ .code-modal-title {
906
  font-family: var(--font-mono);
907
+ font-size: 11px;
908
+ font-weight: 600;
909
+ color: var(--text-secondary);
910
+ letter-spacing: 1px;
911
  text-transform: uppercase;
 
912
  }
913
 
914
+ .code-modal-close {
915
  all: unset;
916
+ width: 28px; height: 28px;
917
  display: flex;
918
  align-items: center;
919
+ justify-content: center;
 
 
 
920
  border-radius: 4px;
 
 
 
921
  cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
922
  color: var(--text-muted);
923
+ font-size: 18px;
924
+ transition: all 0.15s;
925
  }
926
 
927
+ .code-modal-close:hover {
928
+ background: var(--bg-surface);
929
+ color: var(--text-primary);
 
930
  }
931
 
932
  #code-display {
933
+ flex: 1;
 
934
  margin: 0;
935
+ padding: 16px;
936
  background: var(--bg-input);
 
937
  color: var(--machined-steel);
938
  font-family: var(--font-mono);
939
+ font-size: 12px;
940
  line-height: 1.7;
941
  overflow: auto;
942
  white-space: pre;
943
  tab-size: 4;
944
  }
945
 
946
+ /* Syntax coloring */
 
 
 
 
 
947
  .kw { color: #c792ea; }
948
  .fn { color: #82aaff; }
949
  .cm { color: #546e7a; }
 
951
  .nu { color: #f78c6c; }
952
  .op { color: #89ddff; }
953
 
954
+ /* ---- GALLERY MODAL ---- */
955
 
956
+ #gallery-modal {
957
+ display: none;
958
+ position: fixed;
959
+ inset: 0;
960
+ z-index: 200;
 
 
 
 
 
 
 
 
 
 
961
  align-items: center;
962
+ justify-content: center;
963
+ background: rgba(6, 8, 12, 0.85);
964
+ backdrop-filter: blur(8px);
965
  }
966
 
967
+ #gallery-modal.visible { display: flex; }
 
 
 
 
 
 
968
 
969
+ .gallery-modal-inner {
970
+ width: min(800px, 90vw);
971
+ max-height: 80vh;
972
+ background: var(--bg-panel);
973
+ border: 1px solid var(--border);
974
+ border-radius: 8px;
975
  display: flex;
976
  flex-direction: column;
977
+ overflow: hidden;
978
+ box-shadow: 0 16px 64px rgba(0, 0, 0, 0.5);
979
+ animation: modal-in 0.25s ease-out;
980
  }
981
 
982
+ .gallery-modal-header {
983
  display: flex;
984
+ align-items: center;
985
+ justify-content: space-between;
986
+ padding: 12px 16px;
987
+ border-bottom: 1px solid var(--border);
 
 
 
988
  }
989
 
990
+ .gallery-modal-title {
991
+ font-family: var(--font-mono);
992
+ font-size: 11px;
 
 
 
 
993
  font-weight: 600;
994
+ color: var(--text-secondary);
995
+ letter-spacing: 1px;
996
  text-transform: uppercase;
 
 
997
  }
998
 
999
+ .gallery-grid {
1000
+ flex: 1;
1001
+ overflow-y: auto;
1002
+ padding: 16px;
1003
+ display: flex;
 
 
 
 
 
1004
  flex-wrap: wrap;
1005
+ gap: 12px;
1006
  align-content: flex-start;
1007
  }
1008
 
1009
  .gallery-empty {
1010
+ width: 100%;
1011
+ text-align: center;
1012
+ padding: 40px;
1013
  font-family: var(--font-mono);
1014
  font-size: 11px;
1015
  color: var(--text-muted);
1016
  letter-spacing: 0.5px;
 
1017
  }
1018
 
1019
  .gallery-card {
1020
  all: unset;
1021
  flex: 0 0 auto;
1022
+ width: 180px;
1023
  background: var(--bg-surface);
1024
  border: 1px solid var(--border);
1025
+ border-radius: 6px;
1026
+ padding: 12px;
1027
  cursor: pointer;
1028
  transition: all 0.2s;
1029
  display: flex;
1030
  flex-direction: column;
1031
+ gap: 8px;
1032
  }
1033
 
1034
  .gallery-card:hover {
 
1054
  gap: 8px;
1055
  }
1056
 
1057
+ /* ---- ANIMATIONS ---- */
1058
 
1059
  @keyframes fade-in-up {
1060
  from { opacity: 0; transform: translateY(8px); }
 
1065
  animation: fade-in-up 0.3s ease-out both;
1066
  }
1067
 
1068
+ /* ---- RESPONSIVE ---- */
1069
 
1070
  @media (max-width: 768px) {
 
1071
  .logo-sub { display: none; }
1072
+ :root { --chat-width: 100vw; }
1073
+ #chat-toggle { display: none; }
1074
+ .gallery-btn span { display: none; }
1075
  }
1076
  </style>
1077
  </head>
1078
+ <body class="chat-open">
1079
  <div id="app">
1080
 
1081
+ <!-- ---- TOP BAR ---- -->
1082
  <div id="topbar">
1083
  <div class="logo">
1084
+ <span class="logo-diamond">&#9670;</span>
1085
  <span class="logo-text">NeuralCAD</span>
1086
+ <span class="logo-sub">Multi-Agent Design</span>
1087
  </div>
1088
  <div class="topbar-right">
1089
  <div class="backend-toggle">
1090
  <button id="btn-mock" class="active" onclick="setBackend('mock')">MOCK</button>
1091
  <button id="btn-gemini" onclick="setBackend('gemini')">GEMINI</button>
1092
+ <button id="btn-claude" onclick="setBackend('anthropic')">CLAUDE</button>
1093
  </div>
1094
+ <button class="gallery-btn" onclick="openGallery()">
1095
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
1096
+ <span>GALLERY</span>
1097
+ </button>
1098
+ <div class="status-dot" id="status-dot" title="Server Connected"></div>
1099
  </div>
1100
  </div>
1101
 
1102
+ <!-- ---- MAIN AREA ---- -->
1103
+ <div id="main">
 
1104
 
1105
+ <!-- 3D Viewer -->
1106
+ <div id="viewer-container">
1107
+ <canvas id="viewer-canvas"></canvas>
 
1108
 
1109
+ <div id="geo-stats">
1110
+ <div><span class="stat-label">VOL </span><span class="stat-value" id="stat-volume">&mdash;</span></div>
1111
+ <div><span class="stat-label">BBOX </span><span class="stat-value" id="stat-bbox">&mdash;</span></div>
1112
+ <div><span class="stat-label">FACES </span><span class="stat-value" id="stat-faces">&mdash;</span><span class="stat-label"> EDGES </span><span class="stat-value" id="stat-edges">&mdash;</span></div>
1113
+ </div>
1114
 
1115
+ <div id="cnc-badge">
1116
+ <div class="badge badge-success" id="badge-cnc"></div>
1117
+ <div class="badge badge-info" id="badge-axis"></div>
1118
+ </div>
1119
 
1120
+ <div id="download-btns">
1121
+ <a class="dl-btn" id="dl-step" download>STEP</a>
1122
+ <a class="dl-btn" id="dl-stl" download>STL</a>
1123
+ <a class="dl-btn" id="dl-report" download>REPORT</a>
1124
+ </div>
1125
 
1126
+ <div id="viewer-hint">DRAG ROTATE &middot; SCROLL ZOOM &middot; RIGHT-DRAG PAN</div>
1127
 
1128
+ <div id="viewer-loading">
1129
+ <div class="spinner"></div>
1130
+ <div class="loading-text" id="loading-msg">GENERATING MODEL...</div>
1131
+ </div>
1132
 
1133
+ <div id="viewer-empty">
1134
+ <div class="empty-icon"><div class="empty-icon-inner"></div></div>
1135
+ <div class="empty-text">Start a conversation to<br>design your part</div>
1136
+ </div>
1137
  </div>
 
1138
 
1139
+ <!-- Chat Panel -->
1140
+ <div id="chat-panel">
1141
+ <button id="chat-toggle" onclick="toggleChat()" title="Toggle chat panel">&#9664;</button>
1142
+
1143
+ <div class="chat-header">
1144
+ <div class="chat-header-left">
1145
+ <span class="chat-header-title">Design Chat</span>
1146
+ <div class="agent-dots">
1147
+ <div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
1148
+ <div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
1149
+ <div class="agent-dot" style="background: var(--agent-cnc);" title="CNC Agent"></div>
1150
+ <div class="agent-dot" style="background: var(--agent-cad);" title="CAD Coder Agent"></div>
1151
+ </div>
1152
+ </div>
1153
+ </div>
1154
+
1155
+ <div id="chat-messages">
1156
+ <div class="quick-examples" id="quick-examples">
1157
+ <div class="quick-examples-label">Quick Start</div>
1158
+ <div class="quick-chips">
1159
+ <button class="quick-chip" onclick="quickSend('Design a servo bracket')">Design a servo bracket</button>
1160
+ <button class="quick-chip" onclick="quickSend('I need a spur gear')">I need a spur gear</button>
1161
+ <button class="quick-chip" onclick="quickSend('Create a heatsink')">Create a heatsink</button>
1162
+ <button class="quick-chip" onclick="quickSend('Design a pipe flange')">Design a pipe flange</button>
1163
+ </div>
1164
+ </div>
1165
+ </div>
1166
 
1167
+ <div class="chat-input-area" style="position: relative;">
1168
+ <div id="mention-dropdown">
1169
+ <div class="mention-option" data-agent="design" onclick="insertMention('design')">
1170
+ <div class="mention-dot" style="background: var(--agent-design);"></div>
1171
+ <span class="mention-name">@design</span>
1172
+ <span class="mention-role">Design Agent</span>
1173
+ </div>
1174
+ <div class="mention-option" data-agent="engineering" onclick="insertMention('engineering')">
1175
+ <div class="mention-dot" style="background: var(--agent-engineering);"></div>
1176
+ <span class="mention-name">@engineering</span>
1177
+ <span class="mention-role">Engineering Agent</span>
1178
+ </div>
1179
+ <div class="mention-option" data-agent="cnc" onclick="insertMention('cnc')">
1180
+ <div class="mention-dot" style="background: var(--agent-cnc);"></div>
1181
+ <span class="mention-name">@cnc</span>
1182
+ <span class="mention-role">CNC Agent</span>
1183
+ </div>
1184
+ <div class="mention-option" data-agent="cad" onclick="insertMention('cad')">
1185
+ <div class="mention-dot" style="background: var(--agent-cad);"></div>
1186
+ <span class="mention-name">@cad</span>
1187
+ <span class="mention-role">CAD Coder</span>
1188
+ </div>
1189
+ </div>
1190
+ <div class="chat-input-row">
1191
+ <textarea id="chat-input" rows="1" placeholder="Type your message..."></textarea>
1192
+ <button class="chat-btn chat-btn-preview" onclick="sendPreview()" title="Generate 3D preview">
1193
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
1194
  </button>
1195
+ <button class="chat-btn chat-btn-send" onclick="sendFromInput()" title="Send message">
1196
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
1197
  </button>
 
1198
  </div>
1199
+ <div class="chat-shortcut-hint">Ctrl+Enter to send</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1200
  </div>
1201
  </div>
1202
 
1203
+ </div>
1204
+ </div>
1205
+
1206
+ <!-- Floating open pill (when chat collapsed) -->
1207
+ <div id="chat-open-pill" onclick="toggleChat()">
1208
+ <span>Open Chat</span>
1209
+ <div class="pill-dots">
1210
+ <div class="pill-dot" style="background: var(--agent-design);"></div>
1211
+ <div class="pill-dot" style="background: var(--agent-engineering);"></div>
1212
+ <div class="pill-dot" style="background: var(--agent-cnc);"></div>
1213
+ <div class="pill-dot" style="background: var(--agent-cad);"></div>
1214
+ </div>
1215
+ <span>&#9654;</span>
1216
+ </div>
1217
 
1218
+ <!-- Code Viewer Modal -->
1219
+ <div id="code-modal">
1220
+ <div class="code-modal-inner">
1221
+ <div class="code-modal-header">
1222
+ <span class="code-modal-title">CadQuery Code</span>
1223
+ <button class="code-modal-close" onclick="closeCodeModal()">&times;</button>
1224
  </div>
1225
+ <pre id="code-display"></pre>
1226
+ </div>
1227
+ </div>
1228
 
1229
+ <!-- Gallery Modal -->
1230
+ <div id="gallery-modal">
1231
+ <div class="gallery-modal-inner">
1232
+ <div class="gallery-modal-header">
1233
+ <span class="gallery-modal-title">Model Gallery</span>
1234
+ <button class="code-modal-close" onclick="closeGallery()">&times;</button>
1235
+ </div>
1236
+ <div class="gallery-grid" id="gallery-grid">
1237
  <div class="gallery-empty">No models generated yet.</div>
1238
  </div>
1239
  </div>
 
1243
  // ── STATE ─────────────────────────────────────────────
1244
 
1245
  let currentBackend = 'mock';
1246
+ let chatHistory = [];
1247
+ let chatPanelOpen = true;
1248
  let currentPartName = '';
1249
+ let currentCode = '';
1250
+ let scene, camera, renderer, controls, currentMesh, gridHelper;
1251
  const galleryItems = [];
1252
+ let mentionActive = false;
1253
+ let mentionIndex = 0;
1254
+
1255
+ const AGENTS = {
1256
+ design: { name: 'Design', color: '#7c3aed', avatar: 'D' },
1257
+ engineering: { name: 'Engineering', color: '#00b4d8', avatar: 'E' },
1258
+ cnc: { name: 'CNC', color: '#00e676', avatar: 'C' },
1259
+ cad: { name: 'CAD Coder', color: '#ffab40', avatar: '{}' },
1260
+ };
1261
 
1262
  // ── THREE.JS SETUP ────────────────────────────────────
1263
 
 
1295
  rimLight.position.set(0, -50, 100);
1296
  scene.add(rimLight);
1297
 
1298
+ // Grid helper
1299
+ gridHelper = new THREE.GridHelper(400, 40, 0x1a2636, 0x111822);
1300
+ gridHelper.position.y = -0.5;
1301
+ scene.add(gridHelper);
1302
+
1303
  // Controls
1304
  controls = new THREE.OrbitControls(camera, renderer.domElement);
1305
  controls.enableDamping = true;
 
1331
  return new Promise((resolve, reject) => {
1332
  const loader = new THREE.STLLoader();
1333
  loader.load(url, (geometry) => {
 
1334
  if (currentMesh) {
1335
  scene.remove(currentMesh);
1336
  currentMesh.geometry.dispose();
1337
  currentMesh.material.dispose();
1338
  }
1339
 
 
1340
  const material = new THREE.MeshPhongMaterial({
1341
  color: 0x7799aa,
1342
  specular: 0x445566,
 
1348
  mesh.castShadow = true;
1349
  mesh.receiveShadow = true;
1350
 
 
1351
  geometry.computeBoundingBox();
1352
  const center = new THREE.Vector3();
1353
  geometry.boundingBox.getCenter(center);
 
1357
  currentMesh = mesh;
1358
 
1359
  // Fit camera
 
1360
  const size = new THREE.Vector3();
1361
+ geometry.boundingBox.getSize(size);
1362
  const maxDim = Math.max(size.x, size.y, size.z);
1363
  const dist = maxDim * 2.5;
1364
  camera.position.set(dist * 0.7, dist * 0.5, dist * 0.7);
1365
  controls.target.set(0, 0, 0);
1366
  controls.update();
1367
 
1368
+ // Update grid to match model scale
1369
+ if (gridHelper) {
1370
+ gridHelper.position.y = -size.y / 2 - 0.5;
1371
+ }
1372
+
1373
  document.getElementById('viewer-empty').style.display = 'none';
1374
  resolve();
1375
  }, undefined, reject);
 
1382
  currentBackend = name;
1383
  document.getElementById('btn-mock').classList.toggle('active', name === 'mock');
1384
  document.getElementById('btn-gemini').classList.toggle('active', name === 'gemini');
1385
+ document.getElementById('btn-claude').classList.toggle('active', name === 'anthropic');
1386
  }
1387
 
1388
+ // ── CHAT PANEL TOGGLE ─────────────────────────────────
1389
+
1390
+ function toggleChat() {
1391
+ chatPanelOpen = !chatPanelOpen;
1392
+ const panel = document.getElementById('chat-panel');
1393
+ const pill = document.getElementById('chat-open-pill');
1394
+ const toggle = document.getElementById('chat-toggle');
1395
+
1396
+ if (chatPanelOpen) {
1397
+ panel.classList.remove('collapsed');
1398
+ pill.classList.remove('visible');
1399
+ toggle.innerHTML = '&#9664;';
1400
+ document.body.classList.add('chat-open');
1401
+ } else {
1402
+ panel.classList.add('collapsed');
1403
+ pill.classList.add('visible');
1404
+ toggle.innerHTML = '&#9654;';
1405
+ document.body.classList.remove('chat-open');
1406
+ }
1407
+ }
1408
 
1409
+ // ── CHAT MESSAGING ────────────────────────────────────
 
 
 
1410
 
1411
+ async function sendMessage(text) {
1412
+ if (!text.trim()) return;
1413
 
1414
+ // Parse @mentions
1415
+ const mentions = [];
1416
+ const mentionRegex = /@(design|engineering|cnc|cad)\b/gi;
1417
+ let match;
1418
+ while ((match = mentionRegex.exec(text)) !== null) {
1419
+ mentions.push(match[1].toLowerCase());
1420
+ }
1421
+ const cleanedText = text.replace(mentionRegex, '').trim();
1422
 
1423
+ // Hide quick examples
1424
+ const examples = document.getElementById('quick-examples');
1425
+ if (examples) examples.style.display = 'none';
1426
 
1427
+ // Add user message
1428
+ addMessage({ role: 'user', content: text });
1429
+ chatHistory.push({ role: 'user', content: text });
1430
 
1431
+ // Show typing
1432
+ showTyping();
1433
 
1434
  try {
1435
+ const resp = await fetch('/api/chat', {
1436
  method: 'POST',
1437
  headers: { 'Content-Type': 'application/json' },
1438
+ body: JSON.stringify({
1439
+ message: cleanedText,
1440
+ history: chatHistory,
1441
+ mentions: mentions,
1442
+ backend: currentBackend,
1443
+ }),
1444
  });
1445
  const data = await resp.json();
1446
+
1447
+ hideTyping();
1448
+
1449
+ // Add agent responses
1450
+ for (const r of data.responses) {
1451
+ addMessage({
1452
+ role: 'agent',
1453
+ agent_id: r.agent_id,
1454
+ agent_name: r.agent_name,
1455
+ content: r.message,
1456
+ color: r.color,
1457
+ avatar: r.avatar,
1458
+ code: r.code,
1459
+ });
1460
+ chatHistory.push({ role: 'agent', agent_id: r.agent_id, content: r.message });
1461
+ }
1462
+
1463
+ // If preview available, load 3D model
1464
+ if (data.preview && data.preview.success) {
1465
+ setViewerLoading(true, 'LOADING 3D MODEL...');
1466
+ try {
1467
+ await loadSTL(data.preview.stl_url);
1468
+ } catch (e) {
1469
+ console.warn('STL load failed:', e);
1470
+ }
1471
+ setViewerLoading(false);
1472
+ updateGeoStats(data.preview.execution);
1473
+ updateCNCBadge(data.preview.validation);
1474
+ updateDownloads(data.preview.part_name);
1475
+
1476
+ if (data.preview.part_name) {
1477
+ currentPartName = data.preview.part_name;
1478
+ addToGallery(data.preview);
1479
+ }
1480
+ }
1481
  } catch (err) {
1482
+ hideTyping();
1483
+ addMessage({
1484
+ role: 'agent',
1485
+ agent_id: 'system',
1486
+ agent_name: 'System',
1487
+ content: 'Error: ' + err.message,
1488
+ color: '#ff5252',
1489
+ avatar: '!',
1490
+ });
1491
  }
1492
  }
1493
 
1494
+ function sendFromInput() {
1495
+ const input = document.getElementById('chat-input');
1496
+ const text = input.value.trim();
1497
+ if (!text) return;
1498
+ input.value = '';
1499
+ input.style.height = 'auto';
1500
+ closeMentionDropdown();
1501
+ sendMessage(text);
1502
+ }
1503
+
1504
+ function sendPreview() {
1505
+ sendMessage('@cad Generate a 3D preview based on our discussion');
1506
+ }
1507
 
1508
+ function quickSend(text) {
1509
+ const examples = document.getElementById('quick-examples');
1510
+ if (examples) examples.style.display = 'none';
1511
+ sendMessage(text);
1512
+ }
1513
 
1514
+ // ── MESSAGE RENDERING ─────────────────────────────────
 
 
1515
 
1516
+ function addMessage(msg) {
1517
+ const container = document.getElementById('chat-messages');
1518
+
1519
+ const el = document.createElement('div');
1520
+
1521
+ if (msg.role === 'user') {
1522
+ el.className = 'msg msg-user';
1523
+ el.innerHTML = '<div class="msg-bubble">' + escapeHtml(msg.content) + '</div>';
1524
+ } else {
1525
+ const agentId = msg.agent_id || 'system';
1526
+ const agentInfo = AGENTS[agentId] || { name: msg.agent_name || 'Agent', color: msg.color || '#5a7089', avatar: '?' };
1527
+ const color = msg.color || agentInfo.color;
1528
+ const avatar = msg.avatar || agentInfo.avatar;
1529
+ const name = msg.agent_name || agentInfo.name;
1530
+ const isCad = agentId === 'cad';
1531
+
1532
+ el.className = 'msg msg-agent';
1533
+
1534
+ let html = '<div class="msg-avatar" style="background: ' + color + ';">' + avatar + '</div>';
1535
+ html += '<div class="msg-agent-body">';
1536
+ html += '<div class="msg-agent-name" style="color: ' + color + ';">' + escapeHtml(name) + '</div>';
1537
+ html += '<div class="msg-agent-bubble' + (isCad ? ' cad-bubble' : '') + '">' + escapeHtml(msg.content);
1538
+
1539
+ if (msg.code) {
1540
+ currentCode = msg.code;
1541
+ html += '<br><a class="msg-view-code" onclick="openCodeModal()">&#9654; View code</a>';
1542
+ }
1543
+
1544
+ html += '</div></div>';
1545
+ el.innerHTML = html;
1546
  }
1547
 
1548
+ container.appendChild(el);
1549
+ scrollChatToBottom();
1550
  }
1551
 
1552
+ function showTyping() {
1553
+ const container = document.getElementById('chat-messages');
1554
+ const el = document.createElement('div');
1555
+ el.className = 'typing-indicator';
1556
+ el.id = 'typing-indicator';
1557
+ el.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div><span class="typing-label">Agents are thinking...</span>';
1558
+ container.appendChild(el);
1559
+ scrollChatToBottom();
1560
  }
1561
 
1562
+ function hideTyping() {
1563
+ const el = document.getElementById('typing-indicator');
1564
+ if (el) el.remove();
1565
+ }
 
 
1566
 
1567
+ function scrollChatToBottom() {
1568
+ const container = document.getElementById('chat-messages');
1569
+ requestAnimationFrame(() => {
1570
+ container.scrollTop = container.scrollHeight;
1571
+ });
1572
+ }
1573
 
1574
+ // ── @MENTION AUTOCOMPLETE ─────────────────────────────
 
1575
 
1576
+ const mentionAgents = ['design', 'engineering', 'cnc', 'cad'];
 
1577
 
1578
+ function handleInputForMention(e) {
1579
+ const input = document.getElementById('chat-input');
1580
+ const val = input.value;
1581
+ const pos = input.selectionStart;
1582
 
1583
+ // Find @ before cursor
1584
+ const before = val.substring(0, pos);
1585
+ const atMatch = before.match(/@(\w*)$/);
1586
 
1587
+ if (atMatch) {
1588
+ const query = atMatch[1].toLowerCase();
1589
+ const filtered = mentionAgents.filter(a => a.startsWith(query));
1590
 
1591
+ if (filtered.length > 0) {
1592
+ showMentionDropdown(filtered);
1593
+ mentionActive = true;
1594
+ return;
1595
+ }
1596
+ }
1597
+
1598
+ closeMentionDropdown();
1599
+ }
1600
+
1601
+ function showMentionDropdown(filtered) {
1602
+ const dropdown = document.getElementById('mention-dropdown');
1603
+ const options = dropdown.querySelectorAll('.mention-option');
1604
+ let visibleCount = 0;
1605
+
1606
+ options.forEach(opt => {
1607
+ const agent = opt.dataset.agent;
1608
+ if (filtered.includes(agent)) {
1609
+ opt.style.display = 'flex';
1610
+ visibleCount++;
1611
+ } else {
1612
+ opt.style.display = 'none';
1613
+ }
1614
+ });
1615
+
1616
+ if (visibleCount > 0) {
1617
+ dropdown.classList.add('visible');
1618
+ mentionIndex = 0;
1619
+ updateMentionHighlight();
1620
  }
1621
+ }
1622
 
1623
+ function closeMentionDropdown() {
1624
+ document.getElementById('mention-dropdown').classList.remove('visible');
1625
+ mentionActive = false;
1626
+ }
1627
 
1628
+ function updateMentionHighlight() {
1629
+ const options = Array.from(document.querySelectorAll('#mention-dropdown .mention-option'))
1630
+ .filter(o => o.style.display !== 'none');
1631
+ options.forEach((o, i) => o.classList.toggle('active', i === mentionIndex));
1632
+ }
1633
 
1634
+ function insertMention(agent) {
1635
+ const input = document.getElementById('chat-input');
1636
+ const val = input.value;
1637
+ const pos = input.selectionStart;
1638
+ const before = val.substring(0, pos);
1639
+ const after = val.substring(pos);
1640
+ const atPos = before.lastIndexOf('@');
1641
+
1642
+ input.value = before.substring(0, atPos) + '@' + agent + ' ' + after;
1643
+ input.focus();
1644
+ const newPos = atPos + agent.length + 2;
1645
+ input.setSelectionRange(newPos, newPos);
1646
+ closeMentionDropdown();
1647
  }
1648
 
1649
  // ── UI UPDATES ────────────────────────────────────────
1650
 
1651
+ function setViewerLoading(on, msg) {
1652
  const el = document.getElementById('viewer-loading');
 
 
 
1653
  if (on) {
1654
  el.classList.add('visible');
1655
  document.getElementById('loading-msg').textContent = msg || 'GENERATING...';
 
 
1656
  } else {
1657
  el.classList.remove('visible');
 
 
1658
  }
1659
  }
1660
 
 
1673
  bbox.map(v => v.toFixed(1)).join(' \u00D7 ') + ' mm';
1674
  }
1675
 
1676
+ document.getElementById('stat-faces').textContent = exec.face_count || '\u2014';
1677
+ document.getElementById('stat-edges').textContent = exec.edge_count || '\u2014';
1678
  }
1679
 
1680
  function updateCNCBadge(validation) {
 
1695
  axisBadge.textContent = (validation.axis_recommendation || '').toUpperCase();
1696
  }
1697
 
1698
+ function updateDownloads(partName) {
1699
  const el = document.getElementById('download-btns');
1700
+ if (!partName) { el.classList.remove('visible'); return; }
1701
  el.classList.add('visible');
1702
 
1703
  document.getElementById('dl-step').href = '/api/models/' + partName + '.step';
1704
  document.getElementById('dl-stl').href = '/api/models/' + partName + '.stl';
1705
+ document.getElementById('dl-report').href = '/api/models/' + partName + '_report.json';
1706
  }
1707
 
1708
+ // ── CODE MODAL ────────────────────────────────────────
1709
+
1710
+ function openCodeModal() {
1711
+ const modal = document.getElementById('code-modal');
1712
+ const display = document.getElementById('code-display');
1713
+
1714
+ if (currentCode) {
1715
+ display.innerHTML = highlightPython(currentCode);
1716
+ } else {
1717
+ display.textContent = 'No code available.';
1718
  }
1719
+
1720
+ modal.classList.add('visible');
1721
+ }
1722
+
1723
+ function closeCodeModal() {
1724
+ document.getElementById('code-modal').classList.remove('visible');
1725
  }
1726
 
1727
  function highlightPython(code) {
 
1728
  let escaped = code
1729
  .replace(/&/g, '&amp;')
1730
  .replace(/</g, '&lt;')
1731
  .replace(/>/g, '&gt;');
1732
 
 
1733
  escaped = escaped.replace(/(#.*$)/gm, '<span class="cm">$1</span>');
 
1734
  escaped = escaped.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"[^"\n]*"|'[^'\n]*')/g, '<span class="st">$1</span>');
1735
+
1736
  const kw = /\b(import|from|as|def|class|return|if|else|elif|for|while|in|not|and|or|True|False|None|with|try|except|finally|raise|pass|break|continue|lambda|yield)\b/g;
1737
  escaped = escaped.replace(kw, '<span class="kw">$1</span>');
 
1738
  escaped = escaped.replace(/\b(\d+\.?\d*)\b/g, '<span class="nu">$1</span>');
 
1739
  escaped = escaped.replace(/\.([a-zA-Z_]\w*)\(/g, '.<span class="fn">$1</span>(');
1740
 
1741
  return escaped;
1742
  }
1743
 
1744
+ // ── GALLERY ───────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1745
 
1746
  function addToGallery(data) {
1747
  galleryItems.unshift({
1748
  name: data.part_name,
 
1749
  volume: data.execution?.volume_mm3,
1750
  faces: data.execution?.face_count,
1751
  machinable: data.validation?.machinable,
1752
  });
 
1753
  }
1754
 
1755
+ function openGallery() {
1756
+ renderGallery();
1757
+ document.getElementById('gallery-modal').classList.add('visible');
1758
+ }
1759
+
1760
+ function closeGallery() {
1761
+ document.getElementById('gallery-modal').classList.remove('visible');
1762
+ }
1763
+
1764
+ function renderGallery() {
1765
+ const grid = document.getElementById('gallery-grid');
1766
 
1767
  if (galleryItems.length === 0) {
1768
+ grid.innerHTML = '<div class="gallery-empty">No models generated yet.</div>';
 
1769
  return;
1770
  }
1771
 
 
 
 
1772
  let html = '';
1773
  for (const item of galleryItems) {
1774
+ html += '<button class="gallery-card fade-in" onclick="loadGalleryItem(\'' + escapeHtml(item.name) + '\')">';
1775
  html += '<div class="gallery-card-name">' + escapeHtml(item.name) + '</div>';
1776
  html += '<div class="gallery-card-meta">';
1777
  if (item.faces) html += '<span>' + item.faces + ' faces</span>';
 
1782
  html += '</div></button>';
1783
  }
1784
 
1785
+ grid.innerHTML = html;
1786
  }
1787
 
1788
  async function loadGalleryItem(name) {
1789
+ closeGallery();
1790
+ setViewerLoading(true, 'LOADING MODEL...');
1791
  try {
1792
  await loadSTL('/api/models/' + name + '.stl');
1793
  } catch (e) {
1794
  console.warn('Failed to load:', e);
1795
  }
1796
+ setViewerLoading(false);
1797
  }
1798
 
1799
+ // ── UTILS ─────────────────────────────────────────────
1800
+
1801
  function escapeHtml(str) {
1802
  const div = document.createElement('div');
1803
  div.textContent = str;
 
1813
  if (resp.ok) {
1814
  dot.style.background = 'var(--success)';
1815
  dot.style.boxShadow = '0 0 6px var(--success)';
1816
+ dot.title = 'Server Connected';
1817
  } else {
1818
  dot.style.background = 'var(--warning)';
1819
  dot.style.boxShadow = '0 0 6px var(--warning)';
1820
+ dot.title = 'Server Error';
1821
  }
1822
  } catch {
1823
  const dot = document.getElementById('status-dot');
1824
  dot.style.background = 'var(--error)';
1825
  dot.style.boxShadow = '0 0 6px var(--error)';
1826
+ dot.title = 'Server Offline';
1827
  }
1828
  }
1829
 
1830
+ // ── KEYBOARD / INPUT EVENTS ──────────────────────────
1831
+
1832
+ const chatInput = document.getElementById('chat-input');
1833
+
1834
+ chatInput.addEventListener('input', (e) => {
1835
+ // Auto-resize
1836
+ chatInput.style.height = 'auto';
1837
+ chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
1838
+
1839
+ // Check for @mention
1840
+ handleInputForMention(e);
1841
+ });
1842
+
1843
+ chatInput.addEventListener('keydown', (e) => {
1844
+ if (mentionActive) {
1845
+ const dropdown = document.getElementById('mention-dropdown');
1846
+ const visibleOptions = Array.from(dropdown.querySelectorAll('.mention-option'))
1847
+ .filter(o => o.style.display !== 'none');
1848
+
1849
+ if (e.key === 'ArrowDown') {
1850
+ e.preventDefault();
1851
+ mentionIndex = (mentionIndex + 1) % visibleOptions.length;
1852
+ updateMentionHighlight();
1853
+ return;
1854
+ }
1855
+ if (e.key === 'ArrowUp') {
1856
+ e.preventDefault();
1857
+ mentionIndex = (mentionIndex - 1 + visibleOptions.length) % visibleOptions.length;
1858
+ updateMentionHighlight();
1859
+ return;
1860
+ }
1861
+ if (e.key === 'Enter' || e.key === 'Tab') {
1862
+ e.preventDefault();
1863
+ const agent = visibleOptions[mentionIndex]?.dataset.agent;
1864
+ if (agent) insertMention(agent);
1865
+ return;
1866
+ }
1867
+ if (e.key === 'Escape') {
1868
+ closeMentionDropdown();
1869
+ return;
1870
+ }
1871
+ }
1872
 
 
1873
  if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1874
  e.preventDefault();
1875
+ sendFromInput();
1876
+ }
1877
+
1878
+ // Regular enter sends (without shift)
1879
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
1880
+ e.preventDefault();
1881
+ sendFromInput();
1882
+ }
1883
+ });
1884
+
1885
+ // Close modals on backdrop click
1886
+ document.getElementById('code-modal').addEventListener('click', (e) => {
1887
+ if (e.target === document.getElementById('code-modal')) closeCodeModal();
1888
+ });
1889
+
1890
+ document.getElementById('gallery-modal').addEventListener('click', (e) => {
1891
+ if (e.target === document.getElementById('gallery-modal')) closeGallery();
1892
+ });
1893
+
1894
+ // Escape to close modals
1895
+ document.addEventListener('keydown', (e) => {
1896
+ if (e.key === 'Escape') {
1897
+ closeCodeModal();
1898
+ closeGallery();
1899
  }
1900
  });
1901