Daniel Tu commited on
Commit
8a01e00
·
unverified ·
2 Parent(s): 6e3b051b9f4a46

Merge pull request #1 from danghoangnhan/feat/tests-readme-ai-quality

Browse files
.dockerignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ __pycache__
3
+ *.pyc
4
+ output/
5
+ .superpowers/
6
+ .venv/
7
+ docs/
8
+ .env
.gitignore CHANGED
@@ -1,3 +1,7 @@
1
  __pycache__/
2
  *.pyc
3
  output/
 
 
 
 
 
1
  __pycache__/
2
  *.pyc
3
  output/
4
+ .superpowers/
5
+ .venv/
6
+ .worktrees/
7
+ .env
Dockerfile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Stage 1: Builder ─────────────────────────────────────────────────────
2
+ FROM python:3.11-slim AS builder
3
+
4
+ # Install uv
5
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
6
+
7
+ WORKDIR /app
8
+
9
+ # Install dependencies (cached layer — only rebuilds when deps change)
10
+ COPY pyproject.toml uv.lock ./
11
+ RUN uv sync --frozen --no-dev --no-install-project
12
+
13
+ # Copy source code
14
+ COPY . .
15
+
16
+ # ── Stage 2: Runtime ─────────────────────────────────────────────────────
17
+ FROM python:3.11-slim
18
+
19
+ # Install runtime system dependencies required by OpenCascade (CadQuery)
20
+ RUN --mount=type=cache,target=/var/cache/apt \
21
+ --mount=type=cache,target=/var/lib/apt/lists \
22
+ apt-get update && apt-get install -y --no-install-recommends \
23
+ libgl1 libglib2.0-0 libx11-6 libxrender1
24
+
25
+ WORKDIR /app
26
+
27
+ # Copy virtual environment from builder
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
+
37
+ # Put venv on PATH
38
+ ENV PATH="/app/.venv/bin:$PATH"
39
+ ENV PYTHONUNBUFFERED=1
40
+
41
+ # Create output directory
42
+ RUN mkdir -p /app/output
43
+
44
+ EXPOSE 7860
45
+
46
+ ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]
README.md CHANGED
@@ -1,131 +1,194 @@
1
- # Text-to-CNC: Generative Model Pipeline
 
 
 
 
 
 
 
2
 
3
- A proof-of-concept pipeline that converts natural language descriptions of mechanical parts into CNC-machinable 3D models (STEP/STL), using an LLM to generate CadQuery code.
4
 
5
- ## Architecture
 
 
6
 
7
  ```
8
- Text Prompt ──→ LLM (Claude/GPT-4/Mock) ──→ CadQuery Python Code
9
-
10
- Execute in Sandbox
11
-
12
- 3D Solid (B-rep)
13
-
14
- CNC Validator Exporter
15
- (machinability (STEP + STL)
16
- checks)
 
 
 
 
 
 
 
 
 
 
17
  ```
18
 
19
- ## Pipeline Stages
20
 
21
- 1. **Prompt Code**: A domain-tuned system prompt with CNC-specific instructions and few-shot examples guides the LLM to generate valid CadQuery scripts
22
- 2. **Code → Solid**: Sandboxed execution with automatic import handling and error capture
23
- 3. **Solid → Validation**: Checks for wall thickness, tool access, aspect ratios, surface complexity, and recommends axis configuration (3/3+2/5-axis)
24
- 4. **Solid → Export**: STEP (parametric, CAM-ready) and STL (mesh) output
25
- 5. **Auto-retry**: If code execution fails, the error is fed back to the LLM for self-correction
 
26
 
27
  ## Quick Start
28
 
29
  ```bash
 
30
  pip install -r requirements.txt
31
 
32
- # Mock backend (no API key needed)
33
- python pipeline.py "A mounting bracket with four M6 holes"
34
 
35
- # With Claude
 
 
 
 
 
 
 
 
 
 
36
  export ANTHROPIC_API_KEY=sk-ant-...
37
- python pipeline.py "A flanged bearing housing" --backend anthropic
38
 
39
- # With GPT-4o
40
  export OPENAI_API_KEY=sk-...
41
- python pipeline.py "A motor mount plate" --backend openai
42
  ```
43
 
44
- ## MCP Server (Model Context Protocol)
45
 
46
- The pipeline is also exposed as an MCP server, so Claude Desktop, Claude Code, or any MCP-compatible agent can call it as a tool.
 
 
47
 
48
- ### MCP Tools
 
 
49
 
50
- | Tool | Description |
51
- |------|-------------|
52
- | `generate_cnc_model` | Text prompt → CadQuery code → 3D solid → STEP/STL with CNC validation |
53
- | `validate_cnc_model` | Run manufacturability checks on existing CadQuery code |
54
- | `execute_cadquery_code` | Execute arbitrary CadQuery code and get geometry info |
55
- | `list_models` | List previously generated models in the output directory |
56
 
57
- ### Connect to Claude Desktop
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
 
59
- Add to your `claude_desktop_config.json`:
 
 
 
 
 
 
 
 
 
 
 
60
 
61
  ```json
62
  {
63
- "mcpServers": {
64
- "text-to-cnc": {
65
- "command": "python3",
66
- "args": ["/path/to/text-to-cnc/mcp_server.py"]
67
- }
68
- }
69
  }
70
  ```
71
 
72
- ### Connect to Claude Code
73
 
74
- ```bash
75
- claude mcp add text-to-cnc python3 /path/to/text-to-cnc/mcp_server.py
76
- ```
77
 
78
- ### Run standalone
79
 
80
- ```bash
81
- # stdio (for Claude Desktop / Claude Code)
82
- python mcp_server.py
 
 
 
 
 
83
 
84
- # SSE (for remote / web integrations)
85
- python mcp_server.py --transport sse --port 8000
86
- ```
87
 
88
- ## Files
 
 
89
 
90
- | File | Purpose |
91
- |------|---------|
92
- | `mcp_server.py` | MCP server exposing pipeline as tools |
93
- | `pipeline.py` | Main orchestrator + CLI entry point |
94
- | `cadquery_system_prompt.py` | LLM system prompt + few-shot examples |
95
- | `code_executor.py` | Sandboxed CadQuery execution + STEP/STL export |
96
- | `cnc_validator.py` | CNC manufacturability checker |
97
- | `claude_desktop_config.json` | Example Claude Desktop config |
98
- | `requirements.txt` | Python dependencies |
99
 
100
- ## LLM Backends
101
 
102
- - **MockBackend**: Pre-written responses for testing (no API key needed)
103
- - **AnthropicBackend**: Claude Sonnet (recommended for code generation quality)
104
- - **OpenAIBackend**: GPT-4o
 
 
 
 
105
 
106
- ## CNC Validation Checks
107
 
108
- The validator inspects the generated solid for:
 
 
109
 
110
- - **Size feasibility** fits within a typical CNC work envelope
111
- - **Thin features** edges below minimum wall thickness
112
- - **Deep pockets** — aspect ratios requiring long-reach tooling
113
- - **Surface complexity** — freeform surfaces needing 3D contouring
114
- - **Face/edge count** — complexity proxy for axis recommendation
115
- - **Fill ratio** — material removal estimate
116
 
117
- ## Extending the Pipeline
 
118
 
119
- **To add a new LLM backend**: Subclass `LLMBackend` and implement `generate(messages) -> str`.
 
 
120
 
121
- **To add CNC validation rules**: Add check functions in `cnc_validator.py` inside `validate_for_cnc()`.
122
 
123
- **To fine-tune for production**: Replace the few-shot prompting with a fine-tuned model trained on the DeepCAD, ABC, or SldprtNet datasets (see research notes).
 
 
 
124
 
125
- ## Key Research Papers
126
 
127
  - **Text-to-CadQuery** (2025) — LLM generates CadQuery code directly
128
- - **GenCAD** (2024) — Transformer + diffusion for image→CAD sequences
129
  - **NURBGen** (2025) — NURBS-based B-rep from text via LLM
130
- - **STEP-LLM** (2026) — Direct STEP file generation from natural language
131
- - **SldprtNet** (2026) — Large-scale multimodal industrial parts dataset
 
1
+ ---
2
+ title: NeuralCAD
3
+ emoji: ⚙️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
 
10
+ # NeuralCAD Multi-Agent CAD Design
11
 
12
+ A multi-agent AI system that converts natural language descriptions of mechanical parts into CNC-machinable 3D models (STEP/STL). Four specialized AI agents collaborate with you in a shared chat to design, engineer, validate, and generate CadQuery code.
13
+
14
+ ## How It Works
15
 
16
  ```
17
+ User ──→ Chat Interface ──→ Agent Orchestrator
18
+
19
+ ┌───────────────┼───────────────┐
20
+ │ │
21
+ Design Agent Engineering CNC Agent
22
+ (form/shape) Agent (manufacturability)
23
+ │ (specs/dims) │
24
+ └───────────────┼───────────────┘
25
+
26
+ CAD Coder Agent
27
+ (CadQuery code)
28
+
29
+ Execute in Sandbox
30
+
31
+ 3D Solid (B-rep)
32
+ ╱ ╲
33
+ CNC Validator Exporter
34
+ (machinability (STEP + STL)
35
+ checks)
36
  ```
37
 
38
+ ## Agents
39
 
40
+ | Agent | Role | Expertise |
41
+ |-------|------|-----------|
42
+ | **Design Agent** | Industrial Designer | Form, aesthetics, ergonomics, shape proposals |
43
+ | **Engineering Agent** | Mechanical Engineer | Dimensions, tolerances, materials, fastener specs |
44
+ | **CNC Agent** | Manufacturing Advisor | Tool access, wall thickness, axis requirements, cost |
45
+ | **CAD Coder** | CadQuery Programmer | Generates valid CadQuery Python code on demand |
46
 
47
  ## Quick Start
48
 
49
  ```bash
50
+ # Install dependencies
51
  pip install -r requirements.txt
52
 
53
+ # Run the web app (mock backend, no API key needed)
54
+ python -m server.web --port 5000
55
 
56
+ # Open http://localhost:5000 in your browser
57
+ ```
58
+
59
+ ### With LLM Backends
60
+
61
+ ```bash
62
+ # Gemini (free tier)
63
+ export GOOGLE_API_KEY=...
64
+ # Select GEMINI in the web UI backend toggle
65
+
66
+ # Claude (recommended for quality)
67
  export ANTHROPIC_API_KEY=sk-ant-...
68
+ # Select CLAUDE in the web UI backend toggle
69
 
70
+ # GPT-4o
71
  export OPENAI_API_KEY=sk-...
 
72
  ```
73
 
74
+ ### CLI Pipeline (Direct)
75
 
76
+ ```bash
77
+ # Mock backend
78
+ python -m core.pipeline "A mounting bracket with four M6 holes"
79
 
80
+ # With Claude
81
+ python -m core.pipeline "A flanged bearing housing" --backend anthropic
82
+ ```
83
 
84
+ ## Architecture
 
 
 
 
 
85
 
86
+ ```
87
+ NeuralCAD/
88
+ ├── agents/ # Multi-agent orchestration
89
+ │ ├── definitions.py # Agent roles, colors, personas
90
+ │ ├── orchestrator.py # Single-call + Mock orchestrators
91
+ │ ├── crew_orchestrator.py # CrewAI multi-call orchestrator
92
+ │ ├── prompts.py # System prompts, routing, JSON parsing
93
+ │ ├── design_state.py # Design decision accumulator
94
+ │ └── llm_adapter.py # CrewAI LLM adapter
95
+ ├── core/ # CAD generation pipeline
96
+ │ ├── backends.py # LLM backends (Mock, Anthropic, OpenAI, Gemini)
97
+ │ ├── pipeline.py # Text-to-CNC orchestrator + CLI
98
+ │ ├── executor.py # Sandboxed CadQuery execution + export
99
+ │ ├── validator.py # CNC manufacturability checker
100
+ │ └── cadquery_prompts.py # CadQuery system prompt + few-shot examples
101
+ ├── server/ # Web + MCP servers
102
+ │ ├── web.py # FastAPI app, static serving
103
+ │ ├── routes.py # Chat API endpoints
104
+ │ └── mcp.py # MCP server (Claude Desktop / Claude Code)
105
+ ├── web/
106
+ │ └── index.html # Frontend: Three.js viewer + chat panel
107
+ └── tests/ # Test suite
108
+ ```
109
 
110
+ ### Orchestration Modes
111
+
112
+ | Backend | Mode | API Calls/Turn | Use Case |
113
+ |---------|------|----------------|----------|
114
+ | Mock | Template-based | 0 | UI development, demos |
115
+ | Gemini | Single-call | 1 | Free tier, rate-limited |
116
+ | Anthropic | CrewAI multi-call | 2-4 | Best quality |
117
+ | OpenAI | CrewAI multi-call | 2-4 | Best quality |
118
+
119
+ ### Chat API
120
+
121
+ **POST /api/chat** — Multi-agent chat turn
122
 
123
  ```json
124
  {
125
+ "message": "Make it 60mm wide with M4 base mounting",
126
+ "history": [{"role": "user", "content": "I need a servo bracket"}],
127
+ "mentions": [],
128
+ "backend": "mock"
 
 
129
  }
130
  ```
131
 
132
+ **POST /api/report** Generate design report from conversation
133
 
134
+ **GET /api/agents** — List available agents and metadata
 
 
135
 
136
+ ## Features
137
 
138
+ - **Multi-agent chat** — 4 specialist agents collaborate on part design
139
+ - **@mention system** Direct messages to specific agents (`@design`, `@engineering`, `@cnc`, `@cad`)
140
+ - **3D preview** — Real-time STL rendering with Three.js (orbit, zoom, pan)
141
+ - **Design state tracking** — Accumulates decisions across turns (localStorage persistence)
142
+ - **CNC validation** — Checks wall thickness, pocket ratios, tool access, axis requirements
143
+ - **Model gallery** — Browse and reload previously generated models
144
+ - **STEP + STL export** — Download CAM-ready files
145
+ - **MCP server** — Use from Claude Desktop or Claude Code
146
 
147
+ ## MCP Server
 
 
148
 
149
+ ```bash
150
+ # Connect to Claude Code
151
+ claude mcp add text-to-cnc python3 -m server.mcp
152
 
153
+ # Run standalone (SSE for remote integrations)
154
+ python -m server.mcp --transport sse --port 8000
155
+ ```
 
 
 
 
 
 
156
 
157
+ ### MCP Tools
158
 
159
+ | Tool | Description |
160
+ |------|-------------|
161
+ | `generate_cnc_model` | Text to CadQuery code to 3D solid to STEP/STL |
162
+ | `validate_cnc_model` | Run manufacturability checks on CadQuery code |
163
+ | `execute_cadquery_code` | Execute arbitrary CadQuery code |
164
+ | `chat_turn` | Multi-agent chat turn |
165
+ | `list_models` | List generated models |
166
 
167
+ ## Testing
168
 
169
+ ```bash
170
+ # All tests
171
+ python -m pytest
172
 
173
+ # Pure logic tests only (no CadQuery needed)
174
+ python -m pytest -m "not requires_cadquery"
 
 
 
 
175
 
176
+ # Integration tests
177
+ python -m pytest -m requires_cadquery
178
 
179
+ # Verbose
180
+ python -m pytest -v
181
+ ```
182
 
183
+ ## Docker
184
 
185
+ ```bash
186
+ docker compose up --build
187
+ # Open http://localhost:7860
188
+ ```
189
 
190
+ ## Key Research
191
 
192
  - **Text-to-CadQuery** (2025) — LLM generates CadQuery code directly
193
+ - **GenCAD** (2024) — Transformer + diffusion for image to CAD
194
  - **NURBGen** (2025) — NURBS-based B-rep from text via LLM
 
 
agents/__init__.py ADDED
File without changes
agents/crew_orchestrator.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """CrewAI multi-call orchestrator for paid API backends (Anthropic/OpenAI).
2
+
3
+ Uses CrewAI's sequential process where each specialist agent gets its own
4
+ focused LLM call. A routing step selects which agents respond, then each
5
+ agent reasons independently with its own context.
6
+
7
+ Better quality than single-call (agents can truly disagree / specialize)
8
+ but uses 2-4 API calls per turn. Used for paid backends only.
9
+
10
+ Falls back to SingleCallOrchestrator if CrewAI is not installed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ import re
17
+ from pathlib import Path
18
+
19
+ from agents.definitions import AGENTS
20
+ from agents.design_state import DesignState, extract_decisions
21
+ from agents.prompts import CAD_TRIGGER_KEYWORDS, route_by_keywords
22
+ from agents.orchestrator import _format_response, _execute_cad_code
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
27
+
28
+
29
+ def _build_agent_context(
30
+ message: str,
31
+ history: list[dict],
32
+ design_state: DesignState,
33
+ max_history: int = 20,
34
+ ) -> str:
35
+ """Build a shared context string that each CrewAI agent receives."""
36
+ parts = []
37
+
38
+ # Design state
39
+ spec = design_state.render()
40
+ if spec:
41
+ parts.append(f"## Current Design Spec\n{spec}")
42
+
43
+ # Recent conversation (compact)
44
+ recent = history[-max_history:] if len(history) > max_history else history
45
+ if recent:
46
+ lines = []
47
+ for msg in recent:
48
+ if msg.get("role") == "user":
49
+ lines.append(f"USER: {msg.get('content', '')}")
50
+ else:
51
+ aid = msg.get("agent_id", "unknown")
52
+ name = AGENTS.get(aid, AGENTS["design"]).name
53
+ lines.append(f"{name.upper()}: {msg.get('content', '')}")
54
+ parts.append("## Recent conversation\n" + "\n".join(lines))
55
+
56
+ parts.append(f"## User's latest message\n{message}")
57
+ return "\n\n".join(parts)
58
+
59
+
60
+ class CrewOrchestrator:
61
+ """Multi-call orchestrator using CrewAI.
62
+
63
+ Each selected agent gets its own LLM call with focused context and
64
+ persona, producing genuinely independent reasoning.
65
+
66
+ Falls back to SingleCallOrchestrator if CrewAI is not installed.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ backend_name: str = "anthropic",
72
+ output_dir: Path | str = DEFAULT_OUTPUT_DIR,
73
+ ):
74
+ self.backend_name = backend_name
75
+ self.output_dir = Path(output_dir)
76
+ self.output_dir.mkdir(parents=True, exist_ok=True)
77
+ self._crew_available = self._check_crewai()
78
+
79
+ @staticmethod
80
+ def _check_crewai() -> bool:
81
+ try:
82
+ import importlib.util
83
+ return importlib.util.find_spec("crewai") is not None
84
+ except (ImportError, ModuleNotFoundError):
85
+ return False
86
+
87
+ # ── Public interface ───────────────────────────────────────────────────
88
+
89
+ def chat_turn(
90
+ self,
91
+ message: str,
92
+ history: list[dict],
93
+ mentions: list[str] | None = None,
94
+ max_history: int = 30,
95
+ design_state: dict | None = None,
96
+ ) -> dict:
97
+ """Run one chat turn. Returns the standard response envelope."""
98
+ if not self._crew_available:
99
+ return self._fallback(message, history, mentions, max_history, design_state)
100
+
101
+ try:
102
+ return self._run_crew(message, history, mentions, max_history, design_state)
103
+ except Exception as exc:
104
+ logger.warning("CrewAI run failed (%s), falling back to single-call", exc)
105
+ return self._fallback(message, history, mentions, max_history, design_state)
106
+
107
+ # ── CrewAI implementation ──────────────────────────────────────────────
108
+
109
+ def _run_crew(
110
+ self,
111
+ message: str,
112
+ history: list[dict],
113
+ mentions: list[str] | None,
114
+ max_history: int,
115
+ design_state_dict: dict | None,
116
+ ) -> dict:
117
+ from crewai import Agent, Task, Crew, Process
118
+
119
+ state = DesignState(**(design_state_dict or {}))
120
+ context = _build_agent_context(message, history, state, max_history)
121
+
122
+ # Select which agents should respond
123
+ if mentions:
124
+ active_ids = mentions
125
+ else:
126
+ active_ids = route_by_keywords(message)
127
+
128
+ # Check CAD trigger
129
+ include_cad = "cad" in active_ids
130
+ if not include_cad:
131
+ include_cad = any(kw in message.lower() for kw in CAD_TRIGGER_KEYWORDS)
132
+ if include_cad and "cad" not in active_ids:
133
+ active_ids.append("cad")
134
+
135
+ # Build the LLM adapter
136
+ llm = self._build_llm()
137
+
138
+ # Create CrewAI agents + tasks for selected agents
139
+ crew_agents = []
140
+ crew_tasks = []
141
+
142
+ for agent_id in active_ids:
143
+ if agent_id not in AGENTS:
144
+ continue
145
+ agent_def = AGENTS[agent_id]
146
+
147
+ # Special instructions for CAD Coder
148
+ extra = ""
149
+ if agent_id == "cad":
150
+ from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
151
+ extra = (
152
+ "\n\nWhen generating code, output ONLY valid CadQuery Python. "
153
+ "The code must assign the result to a variable called `result` "
154
+ "as a cq.Workplane object. Import cadquery as cq.\n\n"
155
+ f"CadQuery reference:\n{CADQUERY_SYSTEM_PROMPT}"
156
+ )
157
+
158
+ crew_agent = Agent(
159
+ role=agent_def.role,
160
+ goal=agent_def.goal,
161
+ backstory=agent_def.backstory + extra,
162
+ llm=llm,
163
+ verbose=False,
164
+ allow_delegation=False,
165
+ )
166
+
167
+ task_description = (
168
+ f"{context}\n\n"
169
+ f"As the {agent_def.role}, respond to the user's latest message. "
170
+ f"Keep your response concise (2-4 sentences). "
171
+ f"Do NOT repeat anything from the conversation history. "
172
+ f"Add NEW information from your expertise."
173
+ )
174
+ if agent_id == "cad":
175
+ task_description += (
176
+ "\n\nGenerate CadQuery Python code based on the design spec "
177
+ "and conversation. Output ONLY the Python code, nothing else."
178
+ )
179
+
180
+ task = Task(
181
+ description=task_description,
182
+ expected_output=(
183
+ "A concise response from your expert perspective (2-4 sentences)."
184
+ if agent_id != "cad"
185
+ else "Valid CadQuery Python code that assigns result to a cq.Workplane."
186
+ ),
187
+ agent=crew_agent,
188
+ )
189
+
190
+ crew_agents.append(crew_agent)
191
+ crew_tasks.append(task)
192
+
193
+ if not crew_agents:
194
+ return {"responses": [], "preview": None, "design_state": state.model_dump()}
195
+
196
+ # Run the crew — sequential process so each agent runs independently
197
+ crew = Crew(
198
+ agents=crew_agents,
199
+ tasks=crew_tasks,
200
+ process=Process.sequential,
201
+ verbose=False,
202
+ )
203
+
204
+ crew_result = crew.kickoff()
205
+
206
+ # Parse results into standard response format
207
+ responses = []
208
+ preview = None
209
+
210
+ # crew_result.tasks_output gives per-task results
211
+ task_outputs = crew_result.tasks_output if hasattr(crew_result, 'tasks_output') else []
212
+
213
+ for i, agent_id in enumerate(active_ids):
214
+ if agent_id not in AGENTS:
215
+ continue
216
+
217
+ if i < len(task_outputs):
218
+ raw_output = str(task_outputs[i])
219
+ else:
220
+ raw_output = str(crew_result) if i == 0 else ""
221
+
222
+ if not raw_output.strip():
223
+ continue
224
+
225
+ if agent_id == "cad":
226
+ # Extract code from the output
227
+ code = self._extract_code(raw_output)
228
+ responses.append(_format_response(agent_id, "Model generated.", code=code))
229
+
230
+ if code:
231
+ backend = self._build_backend()
232
+ preview = _execute_cad_code(
233
+ code, message, self.output_dir, backend=backend,
234
+ )
235
+ else:
236
+ responses.append(_format_response(agent_id, raw_output.strip()))
237
+
238
+ # Update design state
239
+ agent_msgs = [{"message": r.get("message", "")} for r in responses]
240
+ updated_state = extract_decisions(agent_msgs, state, message)
241
+
242
+ return {
243
+ "responses": responses,
244
+ "preview": preview,
245
+ "design_state": updated_state.model_dump(),
246
+ }
247
+
248
+ def _extract_code(self, text: str) -> str | None:
249
+ """Extract Python code from LLM output, handling code fences."""
250
+ # Try to extract from code fences
251
+ match = re.search(r"```(?:python)?\s*\n(.*?)```", text, re.DOTALL)
252
+ if match:
253
+ return match.group(1).strip()
254
+
255
+ # If the whole output looks like code (has 'import' or 'cq.' or 'result =')
256
+ if any(marker in text for marker in ["import cadquery", "cq.", "result ="]):
257
+ return text.strip()
258
+
259
+ return None
260
+
261
+ def _build_llm(self):
262
+ """Build the CrewAI-compatible LLM from our backend."""
263
+ from agents.llm_adapter import NeuralCADLLMAdapter
264
+
265
+ backend = self._build_backend()
266
+ model_names = {
267
+ "anthropic": "claude-sonnet-4-20250514",
268
+ "openai": "gpt-4o",
269
+ "gemini": "gemini-2.5-flash",
270
+ }
271
+ return NeuralCADLLMAdapter(
272
+ backend=backend,
273
+ model=model_names.get(self.backend_name, "custom"),
274
+ )
275
+
276
+ def _build_backend(self):
277
+ """Build the underlying LLM backend."""
278
+ from core.backends import AnthropicBackend, OpenAIBackend, GeminiBackend
279
+
280
+ backends = {
281
+ "anthropic": AnthropicBackend,
282
+ "openai": OpenAIBackend,
283
+ "gemini": GeminiBackend,
284
+ }
285
+ backend_cls = backends.get(self.backend_name, AnthropicBackend)
286
+ return backend_cls()
287
+
288
+ # ── Fallback ───────────────────────────────────────────────────────────
289
+
290
+ def _fallback(
291
+ self,
292
+ message: str,
293
+ history: list[dict],
294
+ mentions: list[str] | None,
295
+ max_history: int,
296
+ design_state: dict | None,
297
+ ) -> dict:
298
+ """Fall back to SingleCallOrchestrator."""
299
+ from agents.orchestrator import SingleCallOrchestrator, MockChatBackend
300
+
301
+ try:
302
+ backend = self._build_backend()
303
+ except Exception:
304
+ logger.warning("Backend %r unavailable, falling back to mock", self.backend_name)
305
+ mock = MockChatBackend()
306
+ return mock.chat_turn(message, history, mentions, design_state=design_state)
307
+
308
+ orchestrator = SingleCallOrchestrator(backend=backend, output_dir=self.output_dir)
309
+ return orchestrator.chat_turn(
310
+ message, history, mentions, max_history, design_state=design_state,
311
+ )
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/design_state.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Design state accumulator — extracts and persists key decisions from agent messages."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class DesignState(BaseModel):
10
+ """Structured state tracking design decisions across chat turns."""
11
+ part_name: str = ""
12
+ description: str = ""
13
+ material: str = ""
14
+ dimensions: dict[str, float] = Field(default_factory=dict)
15
+ features: list[str] = Field(default_factory=list)
16
+ constraints: list[str] = Field(default_factory=list)
17
+ decisions: list[str] = Field(default_factory=list)
18
+ axis_recommendation: str = ""
19
+
20
+ def render(self) -> str:
21
+ """Render non-empty fields as a concise spec block for LLM context."""
22
+ lines = []
23
+ if self.part_name:
24
+ lines.append(f"Part: {self.part_name}")
25
+ if self.description:
26
+ lines.append(f"Description: {self.description}")
27
+ if self.material:
28
+ lines.append(f"Material: {self.material}")
29
+ if self.dimensions:
30
+ dims = ", ".join(f"{k}={v}mm" for k, v in self.dimensions.items())
31
+ lines.append(f"Dimensions: {dims}")
32
+ if self.features:
33
+ lines.append(f"Features: {'; '.join(self.features)}")
34
+ if self.constraints:
35
+ lines.append(f"Constraints: {'; '.join(self.constraints)}")
36
+ if self.axis_recommendation:
37
+ lines.append(f"Axis: {self.axis_recommendation}")
38
+ if self.decisions:
39
+ lines.append("Decisions:")
40
+ for d in self.decisions[-5:]: # Last 5 decisions to keep it concise
41
+ lines.append(f" - {d}")
42
+ return "\n".join(lines) if lines else ""
43
+
44
+
45
+ # ── Material patterns ──────────────────────────────────────────────────────
46
+
47
+ _MATERIALS = [
48
+ "aluminum", "aluminium", "steel", "stainless steel", "brass", "copper",
49
+ "titanium", "nylon", "delrin", "acetal", "abs", "polycarbonate", "peek",
50
+ ]
51
+ _MATERIAL_GRADES = {
52
+ "6061": "aluminum 6061", "7075": "aluminum 7075",
53
+ "304": "stainless steel 304", "316": "stainless steel 316",
54
+ "t6": "aluminum 6061-T6",
55
+ }
56
+
57
+ # ── Dimension context words ────────────────────────────────────────────────
58
+
59
+ _DIM_CONTEXTS = {
60
+ "wide": "width", "width": "width",
61
+ "tall": "height", "height": "height", "high": "height",
62
+ "thick": "thickness", "thickness": "thickness",
63
+ "deep": "depth", "depth": "depth",
64
+ "long": "length", "length": "length",
65
+ "diameter": "diameter", "dia": "diameter",
66
+ "radius": "radius",
67
+ "arm": "arm_length",
68
+ }
69
+
70
+
71
+ def extract_decisions(
72
+ agent_responses: list[dict],
73
+ current_state: DesignState,
74
+ user_message: str = "",
75
+ ) -> DesignState:
76
+ """Extract design decisions from agent responses and update state.
77
+
78
+ Uses regex/keyword matching — no extra LLM call.
79
+ """
80
+ state = current_state.model_copy(deep=True)
81
+
82
+ # Combine all text for scanning
83
+ all_text = user_message + " " + " ".join(r.get("message", "") for r in agent_responses)
84
+ lower = all_text.lower()
85
+
86
+ # Extract material
87
+ for grade, full_name in _MATERIAL_GRADES.items():
88
+ if grade in lower:
89
+ state.material = full_name
90
+ break
91
+ else:
92
+ for mat in _MATERIALS:
93
+ if mat in lower:
94
+ state.material = mat
95
+ break
96
+
97
+ # Extract dimensions: "60mm wide", "width of 60mm", "60 mm thick"
98
+ dim_pattern = re.compile(
99
+ r'(\d+\.?\d*)\s*mm\s+(' + '|'.join(_DIM_CONTEXTS.keys()) + r')',
100
+ re.IGNORECASE,
101
+ )
102
+ for match in dim_pattern.finditer(all_text):
103
+ value = float(match.group(1))
104
+ word = match.group(2).lower()
105
+ dim_name = _DIM_CONTEXTS.get(word, word)
106
+ state.dimensions[dim_name] = value
107
+
108
+ # Also match "width: 60mm" or "width of 60mm" patterns
109
+ dim_pattern2 = re.compile(
110
+ r'(' + '|'.join(_DIM_CONTEXTS.keys()) + r')\s*(?:of|:|\s)\s*(\d+\.?\d*)\s*mm',
111
+ re.IGNORECASE,
112
+ )
113
+ for match in dim_pattern2.finditer(all_text):
114
+ word = match.group(1).lower()
115
+ value = float(match.group(2))
116
+ dim_name = _DIM_CONTEXTS.get(word, word)
117
+ state.dimensions[dim_name] = value
118
+
119
+ # Extract fastener features: "4x M6 holes", "M4 clearance holes"
120
+ fastener_pattern = re.compile(r'(\d+)\s*[x\u00d7]\s*(M\d+)\s+\w*\s*hole', re.IGNORECASE)
121
+ for match in fastener_pattern.finditer(all_text):
122
+ feature = f"{match.group(1)}x {match.group(2).upper()} holes"
123
+ if feature not in state.features:
124
+ state.features.append(feature)
125
+
126
+ # Single fastener mention: "M6 holes", "M3 clearance holes"
127
+ single_fastener = re.compile(r'(M\d+)\s+(?:clearance\s+)?(?:hole|bolt|screw)', re.IGNORECASE)
128
+ for match in single_fastener.finditer(all_text):
129
+ feature = f"{match.group(1).upper()} holes"
130
+ if feature not in state.features and not any(feature.split()[0] in f for f in state.features):
131
+ state.features.append(feature)
132
+
133
+ # Extract axis recommendation
134
+ axis_pattern = re.compile(r'(3-axis|3\+2[\s-]*axis|5-axis)', re.IGNORECASE)
135
+ axis_match = axis_pattern.search(all_text)
136
+ if axis_match:
137
+ state.axis_recommendation = axis_match.group(1).lower()
138
+
139
+ # Extract constraint keywords
140
+ constraint_patterns = [
141
+ (r'min(?:imum)?\s+wall\s+(?:thickness\s+)?(\d+\.?\d*)\s*mm', "min wall {}mm"),
142
+ (r'max(?:imum)?\s+(?:part\s+)?size\s+(\d+\.?\d*)\s*mm', "max size {}mm"),
143
+ ]
144
+ for pattern, template in constraint_patterns:
145
+ match = re.search(pattern, all_text, re.IGNORECASE)
146
+ if match:
147
+ constraint = template.format(match.group(1))
148
+ if constraint not in state.constraints:
149
+ state.constraints.append(constraint)
150
+
151
+ # Extract decisions: sentences with agreement language from agent messages only
152
+ for resp in agent_responses:
153
+ msg = resp.get("message", "")
154
+ sentences = re.split(r'[.!?]+', msg)
155
+ for sentence in sentences:
156
+ s = sentence.strip()
157
+ if len(s) > 15 and any(kw in s.lower() for kw in [
158
+ "recommend", "suggest", "should use", "let's go with",
159
+ "i'd use", "best to", "we'll need", "i'll specify",
160
+ ]):
161
+ if s not in state.decisions and len(state.decisions) < 20:
162
+ state.decisions.append(s)
163
+
164
+ # Extract part name from user message if not set
165
+ if not state.part_name and user_message:
166
+ name_patterns = [
167
+ r'(?:need|want|design|make|create)\s+(?:a|an)\s+(.{5,40}?)\s*(?:with|for|that|,|$)',
168
+ ]
169
+ for pattern in name_patterns:
170
+ match = re.search(pattern, user_message, re.IGNORECASE)
171
+ if match:
172
+ state.part_name = match.group(1).strip()
173
+ break
174
+
175
+ return state
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,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from pathlib import Path
19
+
20
+ from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
21
+ from agents.prompts import (
22
+ build_orchestrator_system_prompt,
23
+ build_chat_messages,
24
+ route_by_keywords,
25
+ parse_orchestrator_response,
26
+ CAD_TRIGGER_KEYWORDS,
27
+ )
28
+ from agents.design_state import DesignState, extract_decisions
29
+ from core.backends import LLMBackend, MockBackend
30
+ from core.executor import execute_cadquery, export_all
31
+ from core.validator import validate_for_cnc
32
+
33
+
34
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
35
+
36
+ # Role-appropriate fallback messages when the LLM call fails.
37
+ _FALLBACK_MESSAGES: dict[str, str] = {
38
+ "design": "I'd love to help shape this design. Could you describe the part's purpose and any size constraints?",
39
+ "engineering": "I can help with the structural details. What material and load conditions are we working with?",
40
+ "cnc": "I'll check manufacturability once we have more design details. Any machining preferences (3-axis, 5-axis)?",
41
+ "cad": "I'm ready to generate the model once the design is agreed upon. Say 'preview' when you're ready.",
42
+ }
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Helpers
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def _format_response(agent_id: str, message: str, code: str | None = None) -> dict:
50
+ """Wrap a raw agent reply into the standard response envelope."""
51
+ return {
52
+ "agent_id": agent_id,
53
+ "agent_name": AGENT_NAMES[agent_id],
54
+ "message": message,
55
+ "color": AGENT_COLORS[agent_id],
56
+ "avatar": AGENT_AVATARS[agent_id],
57
+ "code": code,
58
+ }
59
+
60
+
61
+ def _execute_cad_code(
62
+ code: str,
63
+ prompt: str,
64
+ output_dir: Path,
65
+ backend: LLMBackend | None = None,
66
+ max_retries: int = 2,
67
+ ) -> dict | None:
68
+ """Execute CadQuery *code* and return preview data (or error dict).
69
+
70
+ If execution fails and a *backend* is provided, feed the error back to
71
+ the LLM for self-correction (up to *max_retries* attempts).
72
+ """
73
+ exec_result = execute_cadquery(code)
74
+ retries = 0
75
+
76
+ while not exec_result.success and backend is not None and retries < max_retries:
77
+ retries += 1
78
+ from core.cadquery_prompts import build_messages
79
+ error_feedback = (
80
+ f"The CadQuery code failed with this error:\n"
81
+ f"```\n{exec_result.error}\n```\n\n"
82
+ f"Original code:\n```python\n{code}\n```\n\n"
83
+ f"Fix the code and return ONLY the corrected Python. Original request: {prompt}"
84
+ )
85
+ try:
86
+ code = backend.generate(build_messages(error_feedback))
87
+ exec_result = execute_cadquery(code)
88
+ except Exception:
89
+ break
90
+
91
+ if not exec_result.success:
92
+ return {"success": False, "error": exec_result.error}
93
+
94
+ # Derive a filesystem-safe part name from the prompt
95
+ part_name = prompt[:40].strip().replace(" ", "_").lower()
96
+ part_name = "".join(c for c in part_name if c.isalnum() or c == "_")
97
+ if not part_name:
98
+ part_name = "part"
99
+
100
+ # Export STL + STEP
101
+ base_path = output_dir / part_name
102
+ try:
103
+ export_all(exec_result.result, base_path)
104
+ except Exception as exc:
105
+ return {"success": False, "error": f"Export failed: {exc}"}
106
+
107
+ # CNC validation
108
+ validation = validate_for_cnc(exec_result.result, part_name=part_name)
109
+
110
+ return {
111
+ "success": True,
112
+ "part_name": part_name,
113
+ "stl_url": f"/api/models/{part_name}.stl",
114
+ "step_url": f"/api/models/{part_name}.step",
115
+ "execution": {
116
+ "success": True,
117
+ "volume_mm3": exec_result.volume,
118
+ "bounding_box_mm": list(exec_result.bounding_box),
119
+ "face_count": exec_result.face_count,
120
+ "edge_count": exec_result.edge_count,
121
+ },
122
+ "validation": {
123
+ "machinable": validation.machinable,
124
+ "axis_recommendation": validation.axis_recommendation,
125
+ "error_count": validation.error_count,
126
+ "warning_count": validation.warning_count,
127
+ "issues": [
128
+ {"severity": i.severity, "category": i.category, "message": i.message}
129
+ for i in validation.issues
130
+ ],
131
+ },
132
+ }
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # MockChatBackend — template-based, no LLM call
137
+ # ---------------------------------------------------------------------------
138
+
139
+ class MockChatBackend:
140
+ """Template-based chat responses for mock mode (no LLM call).
141
+
142
+ Generates canned agent responses based on keyword matching.
143
+ For the CAD Coder agent, delegates to ``MockBackend`` for code generation.
144
+ """
145
+
146
+ def __init__(self, output_dir: Path | str = DEFAULT_OUTPUT_DIR):
147
+ self.output_dir = Path(output_dir)
148
+ self.output_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ # -- public interface ----------------------------------------------------
151
+
152
+ def chat_turn(
153
+ self,
154
+ message: str,
155
+ history: list[dict],
156
+ mentions: list[str] | None = None,
157
+ max_history: int = 30,
158
+ design_state: dict | None = None,
159
+ ) -> dict:
160
+ """Return ``{"responses": [...], "preview": ..., "design_state": ...}``."""
161
+ state = DesignState(**(design_state or {}))
162
+ lower = message.lower()
163
+
164
+ # Determine which agents respond
165
+ if mentions:
166
+ active = mentions
167
+ else:
168
+ active = route_by_keywords(message)
169
+
170
+ responses: list[dict] = []
171
+ preview = None
172
+
173
+ if "design" in active:
174
+ responses.append(
175
+ _format_response("design", self._design_response(lower))
176
+ )
177
+
178
+ if "engineering" in active:
179
+ responses.append(
180
+ _format_response("engineering", self._engineering_response(lower))
181
+ )
182
+
183
+ if "cnc" in active:
184
+ responses.append(
185
+ _format_response("cnc", self._cnc_response(lower))
186
+ )
187
+
188
+ if "cad" in active:
189
+ # Use MockBackend for actual code generation
190
+ from core.cadquery_prompts import build_messages
191
+
192
+ mock = MockBackend()
193
+ code = mock.generate(build_messages(message))
194
+ responses.append(
195
+ _format_response(
196
+ "cad",
197
+ "Model generated. Click the 3D viewer to inspect it.",
198
+ code=code,
199
+ )
200
+ )
201
+ preview = _execute_cad_code(code, message, self.output_dir)
202
+
203
+ # Update design state from responses
204
+ updated_state = extract_decisions(responses, state, message)
205
+
206
+ return {"responses": responses, "preview": preview, "design_state": updated_state.model_dump()}
207
+
208
+ # -- canned response templates -------------------------------------------
209
+
210
+ @staticmethod
211
+ def _design_response(lower: str) -> str:
212
+ if any(w in lower for w in ("bracket", "mount")):
213
+ return (
214
+ "For a mounting bracket, I'd suggest an L-shaped profile with "
215
+ "filleted corners for rigidity. What's the intended load direction?"
216
+ )
217
+ if any(w in lower for w in ("gear", "spur")):
218
+ return (
219
+ "For a spur gear, we'll need to define the module, tooth count, "
220
+ "and bore diameter. What's the mating gear specification?"
221
+ )
222
+ if any(w in lower for w in ("enclosure", "box", "housing")):
223
+ return (
224
+ "For an enclosure, I'd recommend rounded external corners for "
225
+ "aesthetics and a pocket on the top face for the lid. What "
226
+ "components go inside?"
227
+ )
228
+ return (
229
+ "I can help design that. Could you tell me more about the part's "
230
+ "purpose and any dimensional constraints?"
231
+ )
232
+
233
+ @staticmethod
234
+ def _engineering_response(lower: str) -> str:
235
+ if any(w in lower for w in ("m3", "m4", "m5", "m6", "m8")):
236
+ return (
237
+ "Good fastener choice. I'll specify the clearance holes per ISO "
238
+ "standards. Shall I add counterbores or keep them as through-holes?"
239
+ )
240
+ if any(w in lower for w in ("load", "stress", "strength")):
241
+ return (
242
+ "For the expected loads, I'd recommend 3mm minimum wall thickness "
243
+ "in aluminum 6061-T6. Adding reinforcement ribs would increase "
244
+ "stiffness significantly."
245
+ )
246
+ return (
247
+ "I'll specify the critical dimensions and tolerances. What material "
248
+ "are you planning to machine this from?"
249
+ )
250
+
251
+ @staticmethod
252
+ def _cnc_response(lower: str) -> str:
253
+ if any(w in lower for w in ("pocket", "deep", "slot")):
254
+ return (
255
+ "Keep pocket depth-to-width ratio under 4:1 for clean machining. "
256
+ "I'd recommend a 6mm endmill for this geometry."
257
+ )
258
+ if any(w in lower for w in ("5-axis", "undercut")):
259
+ return (
260
+ "That feature would require 5-axis machining. Consider redesigning "
261
+ "to avoid undercuts for 3-axis compatibility."
262
+ )
263
+ return (
264
+ "This looks achievable with standard 3-axis milling. No undercuts or "
265
+ "access issues detected so far."
266
+ )
267
+
268
+
269
+ # ---------------------------------------------------------------------------
270
+ # SingleCallOrchestrator — one LLM call per turn
271
+ # ---------------------------------------------------------------------------
272
+
273
+ class SingleCallOrchestrator:
274
+ """Orchestrator that uses a single LLM call per chat turn.
275
+
276
+ Builds a system prompt containing all agent personas, sends one LLM call,
277
+ and parses the JSON response into individual agent messages.
278
+ Used for Gemini free tier and other rate-limited backends.
279
+ """
280
+
281
+ def __init__(self, backend: LLMBackend, output_dir: Path | str = DEFAULT_OUTPUT_DIR):
282
+ self.backend = backend
283
+ self.output_dir = Path(output_dir)
284
+ self.output_dir.mkdir(parents=True, exist_ok=True)
285
+
286
+ def chat_turn(
287
+ self,
288
+ message: str,
289
+ history: list[dict],
290
+ mentions: list[str] | None = None,
291
+ max_history: int = 30,
292
+ design_state: dict | None = None,
293
+ ) -> dict:
294
+ """Run one chat turn: user message -> agent responses.
295
+
296
+ Args:
297
+ message: The user's message text (with @mentions already stripped).
298
+ history: Previous messages [{role, agent_id, content}, ...].
299
+ mentions: Agent IDs explicitly mentioned by user. ``None`` = auto-route.
300
+ max_history: Max history messages to include in context.
301
+ design_state: Persisted design state dict from previous turns.
302
+
303
+ Returns:
304
+ ``{"responses": [...], "preview": None | {...}, "design_state": {...}}``
305
+ """
306
+ state = DesignState(**(design_state or {}))
307
+
308
+ # Determine which agents are active
309
+ active_agents = mentions if mentions else None # None lets orchestrator decide
310
+
311
+ # Check if CAD context is needed
312
+ include_cad = mentions is not None and "cad" in mentions
313
+ if not include_cad:
314
+ include_cad = any(kw in message.lower() for kw in CAD_TRIGGER_KEYWORDS)
315
+
316
+ # Build orchestrator prompt
317
+ system_prompt = build_orchestrator_system_prompt(
318
+ active_agents=active_agents,
319
+ include_cad_context=include_cad,
320
+ )
321
+
322
+ # Build message list
323
+ messages = build_chat_messages(
324
+ user_message=message,
325
+ history=history,
326
+ system_prompt=system_prompt,
327
+ max_history=max_history,
328
+ design_state_text=state.render(),
329
+ )
330
+
331
+ # Single LLM call
332
+ try:
333
+ raw_response = self.backend.generate(messages)
334
+ agent_responses = parse_orchestrator_response(raw_response)
335
+ except Exception as exc:
336
+ import logging
337
+ logging.warning("Orchestrator LLM call failed: %s", exc)
338
+ # Fallback: keyword routing with role-appropriate replies
339
+ fallback_agents = route_by_keywords(message)
340
+ agent_responses = [
341
+ {"id": aid, "message": _FALLBACK_MESSAGES.get(aid, "Let me look into that."), "code": None}
342
+ for aid in fallback_agents
343
+ ]
344
+
345
+ # Format responses with metadata
346
+ formatted: list[dict] = []
347
+ preview = None
348
+
349
+ for resp in agent_responses:
350
+ agent_id = resp["id"]
351
+ if agent_id not in AGENTS:
352
+ continue
353
+
354
+ formatted.append(
355
+ _format_response(agent_id, resp["message"], code=resp.get("code"))
356
+ )
357
+
358
+ # If CAD Coder responded with code, execute it (with retry)
359
+ if agent_id == "cad" and resp.get("code"):
360
+ preview = _execute_cad_code(
361
+ resp["code"], message, self.output_dir, backend=self.backend,
362
+ )
363
+
364
+ # Update design state from responses
365
+ updated_state = extract_decisions(formatted, state, message)
366
+
367
+ return {"responses": formatted, "preview": preview, "design_state": updated_state.model_dump()}
368
+
369
+
370
+ # ---------------------------------------------------------------------------
371
+ # Factory
372
+ # ---------------------------------------------------------------------------
373
+
374
+ def get_orchestrator(
375
+ backend_name: str = "mock",
376
+ output_dir: str | Path = DEFAULT_OUTPUT_DIR,
377
+ ) -> MockChatBackend | SingleCallOrchestrator:
378
+ """Create the appropriate orchestrator for the given backend.
379
+
380
+ Args:
381
+ backend_name: ``"mock"``, ``"gemini"``, ``"anthropic"``, or ``"openai"``.
382
+ output_dir: Directory for exported model files.
383
+ """
384
+ if backend_name == "mock":
385
+ return MockChatBackend(output_dir=output_dir)
386
+
387
+ # For all LLM backends, use SingleCallOrchestrator.
388
+ # (CrewAI multi-call variant can be added later for anthropic/openai.)
389
+ from core.backends import AnthropicBackend, OpenAIBackend, GeminiBackend
390
+
391
+ backends = {
392
+ "gemini": GeminiBackend,
393
+ "anthropic": AnthropicBackend,
394
+ "openai": OpenAIBackend,
395
+ }
396
+
397
+ backend_cls = backends.get(backend_name)
398
+ if backend_cls is None:
399
+ return MockChatBackend(output_dir=output_dir)
400
+
401
+ try:
402
+ backend = backend_cls()
403
+ except Exception as exc:
404
+ import logging
405
+ logging.warning(
406
+ "Backend %r unavailable (%s), falling back to mock", backend_name, exc
407
+ )
408
+ return MockChatBackend(output_dir=output_dir)
409
+
410
+ return SingleCallOrchestrator(backend=backend, output_dir=output_dir)
agents/prompts.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # Single source of truth for CAD Coder activation keywords.
13
+ # Used by: system prompt, keyword routing, and orchestrator CAD-context check.
14
+ CAD_TRIGGER_KEYWORDS: list[str] = [
15
+ "generate", "build", "build it", "preview", "show me", "create",
16
+ "create the model", "model it", "render", "code", "make it", "produce",
17
+ ]
18
+
19
+
20
+ def build_orchestrator_system_prompt(
21
+ active_agents: list[str] | None = None,
22
+ include_cad_context: bool = False,
23
+ ) -> str:
24
+ """Build the orchestrator system prompt for single-call mode.
25
+
26
+ Args:
27
+ active_agents: List of agent IDs to include. None = all except 'cad'.
28
+ include_cad_context: Whether to include CadQuery reference for the CAD agent.
29
+ """
30
+ if active_agents is None:
31
+ active_agents = ["design", "engineering", "cnc"]
32
+
33
+ prompt_parts = [
34
+ "You are the orchestrator for a multi-agent CAD design team. "
35
+ "You control multiple specialist agents who collaborate with a user "
36
+ "to design mechanical parts for CNC machining.\n",
37
+ "## Your Agents\n",
38
+ ]
39
+
40
+ for agent_id in active_agents:
41
+ agent = AGENTS[agent_id]
42
+ prompt_parts.append(
43
+ f"### {agent.name} (id: \"{agent.id}\")\n"
44
+ f"Role: {agent.role}\n"
45
+ f"Goal: {agent.goal}\n"
46
+ f"Personality: {agent.backstory}\n"
47
+ )
48
+
49
+ prompt_parts.append(
50
+ "## Instructions\n"
51
+ "Given the conversation history and the user's latest message, "
52
+ "decide which agents should respond and generate their messages.\n\n"
53
+ "Rules:\n"
54
+ "- Select 1-3 agents that are most relevant to the user's LATEST message.\n"
55
+ "- Each agent should respond in character with their expertise.\n"
56
+ "- Keep responses concise and actionable (2-4 sentences each).\n"
57
+ "- Use the conversation history for context (know what was decided), "
58
+ "but ONLY respond to the user's latest message. Do NOT repeat or "
59
+ "paraphrase anything already said — always advance the discussion.\n"
60
+ "- Each agent should add DIFFERENT information. If one agent covers "
61
+ "dimensions, another should cover materials or tooling, not restate dimensions.\n"
62
+ f"- Do NOT include the CAD Coder agent unless the user explicitly uses one of "
63
+ f"these trigger words: {', '.join(repr(k) for k in CAD_TRIGGER_KEYWORDS)}.\n"
64
+ "- When the CAD Coder responds, include a 'code' field with valid CadQuery Python "
65
+ "that assigns the result to a variable called `result` as a cq.Workplane.\n"
66
+ )
67
+
68
+ if include_cad_context and "cad" in active_agents:
69
+ from core.cadquery_prompts import CADQUERY_SYSTEM_PROMPT
70
+ prompt_parts.append(
71
+ "\n## CadQuery Reference (for CAD Coder agent)\n"
72
+ f"{CADQUERY_SYSTEM_PROMPT}\n"
73
+ )
74
+
75
+ prompt_parts.append(
76
+ "\n## Response Format\n"
77
+ "Respond with ONLY valid JSON in this exact format:\n"
78
+ "```json\n"
79
+ '{"agents": [\n'
80
+ ' {"id": "design", "message": "Your design suggestion here..."},\n'
81
+ ' {"id": "engineering", "message": "Your engineering input here..."}\n'
82
+ "]}\n"
83
+ "```\n\n"
84
+ "When the CAD Coder agent responds, add a 'code' field:\n"
85
+ "```json\n"
86
+ '{"agents": [\n'
87
+ ' {"id": "cad", "message": "Model generated.", '
88
+ '"code": "import cadquery as cq\\nresult = cq.Workplane(\'XY\').box(10,10,10)"}\n'
89
+ "]}\n"
90
+ "```\n\n"
91
+ "Output ONLY the JSON. No other text."
92
+ )
93
+
94
+ return "\n".join(prompt_parts)
95
+
96
+
97
+ def build_chat_messages(
98
+ user_message: str,
99
+ history: list[dict],
100
+ system_prompt: str,
101
+ max_history: int = 30,
102
+ design_state_text: str = "",
103
+ ) -> list[dict]:
104
+ """Build the message list for the orchestrator LLM call.
105
+
106
+ Args:
107
+ user_message: The user's current message.
108
+ history: Previous messages [{role, agent_id, content}, ...].
109
+ system_prompt: The orchestrator system prompt.
110
+ max_history: Maximum number of history messages to include.
111
+ design_state_text: Rendered design state spec to inject as context.
112
+ """
113
+ messages = [{"role": "system", "content": system_prompt}]
114
+
115
+ # Truncate history to last N messages
116
+ recent = history[-max_history:] if len(history) > max_history else history
117
+
118
+ # Bundle history into a single context block to avoid Gemini
119
+ # treating prior agent messages as its own output and repeating them.
120
+ content_parts = []
121
+ if design_state_text:
122
+ content_parts.append(f"## Current Design Spec (agreed so far)\n{design_state_text}\n")
123
+ if recent:
124
+ history_lines = []
125
+ for msg in recent:
126
+ if msg.get("role") == "user":
127
+ history_lines.append(f"USER: {msg['content']}")
128
+ else:
129
+ agent_id = msg.get("agent_id", "unknown")
130
+ agent_name = AGENTS.get(agent_id, AGENTS["design"]).name
131
+ history_lines.append(f"{agent_name.upper()}: {msg['content']}")
132
+
133
+ history_block = "\n".join(history_lines)
134
+ content_parts.append(f"## Conversation so far:\n{history_block}\n")
135
+ content_parts.append(f"## User's new message:\n{user_message}\n\nRespond to the user's NEW message above. Do NOT repeat prior responses.")
136
+
137
+ messages.append({"role": "user", "content": "\n".join(content_parts)})
138
+
139
+ return messages
140
+
141
+
142
+ def parse_mentions(message: str) -> tuple[str, list[str]]:
143
+ """Extract @mentions from a message and return cleaned message + mention list.
144
+
145
+ Returns:
146
+ (cleaned_message, mentions) where mentions is list of agent IDs.
147
+ """
148
+ mentions = []
149
+ cleaned = message
150
+
151
+ for agent_id in AGENTS:
152
+ pattern = rf"@{agent_id}\b"
153
+ if re.search(pattern, message, re.IGNORECASE):
154
+ mentions.append(agent_id)
155
+ cleaned = re.sub(pattern, "", cleaned, flags=re.IGNORECASE).strip()
156
+
157
+ return cleaned, mentions
158
+
159
+
160
+ # ── Keyword-based fallback routing ────────────────────────────────────────
161
+
162
+ _ROUTING_KEYWORDS: dict[str, list[str]] = {
163
+ "design": [
164
+ "design", "look", "shape", "style", "form", "aesthetic", "appearance",
165
+ "layout", "concept", "idea", "propose", "suggest", "bracket", "mount",
166
+ "enclosure", "housing", "ergonomic", "profile", "contour",
167
+ ],
168
+ "engineering": [
169
+ "dimension", "tolerance", "material", "strength", "load", "stress",
170
+ "thickness", "wall", "fillet", "radius", "clearance",
171
+ "m2", "m3", "m4", "m5", "m6", "m8", "m10", "m12",
172
+ "aluminum", "steel", "brass", "titanium", "nylon",
173
+ "gear", "bearing", "flange", "heatsink", "fin", "rib",
174
+ "bolt", "screw", "thread", "torque", "deflection",
175
+ "hole", "bore", "shaft", "keyway", "spline",
176
+ ],
177
+ "cnc": [
178
+ "machine", "mill", "cnc", "manufacture", "machinable", "axis",
179
+ "tool", "fixture", "setup", "pocket", "undercut", "access",
180
+ "3-axis", "5-axis", "cost", "surface finish", "roughness",
181
+ "endmill", "drill", "tap", "chamfer tool", "deburr",
182
+ "setup count", "cycle time", "tolerance class",
183
+ ],
184
+ "cad": CAD_TRIGGER_KEYWORDS,
185
+ }
186
+
187
+
188
+ def route_by_keywords(message: str) -> list[str]:
189
+ """Fallback agent routing based on keyword matching.
190
+
191
+ Returns list of agent IDs that should respond.
192
+ """
193
+ lower = message.lower()
194
+ scores: dict[str, int] = {agent_id: 0 for agent_id in AGENTS}
195
+
196
+ for agent_id, keywords in _ROUTING_KEYWORDS.items():
197
+ for kw in keywords:
198
+ if kw in lower:
199
+ scores[agent_id] += 1
200
+
201
+ # Select agents with score > 0, sorted by score descending
202
+ active = [aid for aid, score in sorted(scores.items(), key=lambda x: -x[1]) if score > 0]
203
+
204
+ # Default: design + engineering for general discussion
205
+ if not active:
206
+ active = ["design", "engineering"]
207
+
208
+ # Cap at 3 agents
209
+ return active[:3]
210
+
211
+
212
+ def parse_orchestrator_response(response_text: str) -> list[dict]:
213
+ """Parse the orchestrator's JSON response into agent messages.
214
+
215
+ Returns list of dicts: [{"id": str, "message": str, "code": str|None}, ...]
216
+ Falls back to treating entire response as design agent message if JSON fails.
217
+ """
218
+ text = response_text.strip()
219
+
220
+ # Try to extract JSON from markdown code fences
221
+ json_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
222
+ if json_match:
223
+ text = json_match.group(1)
224
+
225
+ try:
226
+ data = json.loads(text)
227
+ agents = data.get("agents", [])
228
+
229
+ # Validate structure
230
+ result = []
231
+ for agent in agents:
232
+ if isinstance(agent, dict) and "id" in agent and "message" in agent:
233
+ result.append({
234
+ "id": agent["id"],
235
+ "message": agent["message"],
236
+ "code": agent.get("code"),
237
+ })
238
+
239
+ if result:
240
+ return result
241
+ except (json.JSONDecodeError, KeyError, TypeError):
242
+ pass
243
+
244
+ # Fallback: treat entire response as design agent message
245
+ 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
+ )
cadquery_system_prompt.py → core/cadquery_prompts.py RENAMED
File without changes
code_executor.py → core/executor.py RENAMED
@@ -7,7 +7,7 @@ validates the result, and exports to STEP/STL.
7
  import io
8
  import sys
9
  import traceback
10
- from dataclasses import dataclass, field
11
  from pathlib import Path
12
  from typing import Optional
13
 
@@ -60,7 +60,6 @@ SAFE_NAMESPACE = {
60
  "print": print,
61
  "enumerate": enumerate,
62
  "zip": zip,
63
- "__import__": __import__,
64
  },
65
  }
66
 
 
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
 
 
60
  "print": print,
61
  "enumerate": enumerate,
62
  "zip": zip,
 
63
  },
64
  }
65
 
pipeline.py → core/pipeline.py RENAMED
@@ -7,176 +7,24 @@ Pipeline stages:
7
  3. 3D Solid → CNC Validation
8
  4. 3D Solid → STEP / STL export
9
  5. (Optional) Auto-retry with error feedback if execution fails
10
-
11
- Supports multiple LLM backends:
12
- - Anthropic Claude (default)
13
- - OpenAI GPT-4o
14
- - Local / mock (for testing without API keys)
15
  """
16
 
17
- import json
18
- import os
19
  from dataclasses import dataclass
20
  from pathlib import Path
21
  from typing import Optional
22
 
23
- from cadquery_system_prompt import build_messages, CADQUERY_SYSTEM_PROMPT, FEW_SHOT_EXAMPLES
24
- from code_executor import ExecutionResult, execute_cadquery, export_all
25
- from cnc_validator import validate_for_cnc, CNCValidationResult
26
-
27
-
28
- # ── LLM Backends ──────────────────────────────────────────────────────────
29
-
30
- class LLMBackend:
31
- """Base class for LLM code generation backends."""
32
-
33
- def generate(self, messages: list[dict]) -> str:
34
- raise NotImplementedError
35
-
36
-
37
- class AnthropicBackend(LLMBackend):
38
- """Generate CadQuery code using Anthropic Claude."""
39
-
40
- def __init__(self, model: str = "claude-sonnet-4-20250514", api_key: Optional[str] = None):
41
- import anthropic
42
- self.client = anthropic.Anthropic(api_key=api_key or os.environ.get("ANTHROPIC_API_KEY"))
43
- self.model = model
44
-
45
- def generate(self, messages: list[dict]) -> str:
46
- # Anthropic uses system param separately
47
- system_msg = ""
48
- user_messages = []
49
- for m in messages:
50
- if m["role"] == "system":
51
- system_msg = m["content"]
52
- else:
53
- user_messages.append(m)
54
-
55
- response = self.client.messages.create(
56
- model=self.model,
57
- max_tokens=4096,
58
- system=system_msg,
59
- messages=user_messages,
60
- )
61
- return response.content[0].text
62
-
63
-
64
- class OpenAIBackend(LLMBackend):
65
- """Generate CadQuery code using OpenAI GPT-4o."""
66
-
67
- def __init__(self, model: str = "gpt-4o", api_key: Optional[str] = None):
68
- import openai
69
- self.client = openai.OpenAI(api_key=api_key or os.environ.get("OPENAI_API_KEY"))
70
- self.model = model
71
-
72
- def generate(self, messages: list[dict]) -> str:
73
- response = self.client.chat.completions.create(
74
- model=self.model,
75
- messages=messages,
76
- max_tokens=4096,
77
- temperature=0.2,
78
- )
79
- return response.choices[0].message.content
80
-
81
-
82
- class MockBackend(LLMBackend):
83
- """
84
- Mock backend for testing without API keys.
85
- Returns pre-written CadQuery code for common prompts,
86
- or a parametric box as fallback.
87
- """
88
-
89
- MOCK_RESPONSES = {
90
- "bracket": """\
91
- import cadquery as cq
92
-
93
- # Mounting bracket: 80x50x5mm plate with four M6 holes and central slot
94
- result = (
95
- cq.Workplane("XY")
96
- .box(80, 50, 5)
97
- .faces(">Z").workplane()
98
- .pushPoints([(-30, -15), (-30, 15), (30, -15), (30, 15)])
99
- .hole(6.5) # M6 clearance
100
- .faces(">Z").workplane()
101
- .slot2D(30, 8).cutBlind(-5) # Central slot through the plate
102
- .edges("|Z").fillet(3)
103
- )
104
- """,
105
- "gear": """\
106
- import cadquery as cq
107
- import math
108
-
109
- # Simple spur gear approximation: 20 teeth, module 2, 10mm thick
110
- module = 2
111
- teeth = 20
112
- pitch_radius = module * teeth / 2
113
- outer_radius = pitch_radius + module
114
- root_radius = pitch_radius - 1.25 * module
115
- tooth_angle = 360 / teeth
116
-
117
- # Start with outer cylinder, cut tooth gaps
118
- result = (
119
- cq.Workplane("XY")
120
- .cylinder(10, outer_radius)
121
- .faces(">Z").workplane()
122
- .hole(12) # Bore hole
123
  )
124
 
125
- # Cut tooth gaps as rectangular slots (simplified)
126
- for i in range(teeth):
127
- angle = i * tooth_angle
128
- rad = math.radians(angle)
129
- gap_x = pitch_radius * math.cos(rad)
130
- gap_y = pitch_radius * math.sin(rad)
131
-
132
- cutter = (
133
- cq.Workplane("XY")
134
- .transformed(offset=(gap_x, gap_y, 0), rotate=(0, 0, angle))
135
- .rect(module * 0.8, module * 2.5)
136
- .extrude(12)
137
- )
138
- result = result.cut(cutter)
139
-
140
- # Chamfer top/bottom edges
141
- result = result.edges(">Z or <Z").chamfer(0.3)
142
- """,
143
- "default": """\
144
- import cadquery as cq
145
-
146
- # Parametric box with holes and fillets — default demo part
147
- # 60x40x20mm block with 4 corner holes and a central pocket
148
-
149
- base = (
150
- cq.Workplane("XY")
151
- .box(60, 40, 20)
152
- # Four M5 corner holes
153
- .faces(">Z").workplane()
154
- .pushPoints([(22, 12), (22, -12), (-22, 12), (-22, -12)])
155
- .hole(5.5)
156
- # Central rectangular pocket, 8mm deep
157
- .faces(">Z").workplane()
158
- .rect(25, 15)
159
- .cutBlind(-8)
160
- )
161
-
162
- # Chamfer top external edges
163
- result = base.edges(">Z").chamfer(0.5)
164
- """,
165
- }
166
-
167
- def generate(self, messages: list[dict]) -> str:
168
- # Extract user prompt (last message)
169
- user_msg = messages[-1]["content"].lower()
170
-
171
- for key, code in self.MOCK_RESPONSES.items():
172
- if key in user_msg:
173
- return code
174
-
175
- return self.MOCK_RESPONSES["default"]
176
-
177
 
178
  # ── Pipeline ──────────────────────────────────────────────────────────────
179
 
 
180
  @dataclass
181
  class PipelineResult:
182
  prompt: str
@@ -189,7 +37,7 @@ class PipelineResult:
189
  def summary(self) -> str:
190
  lines = [
191
  "=" * 60,
192
- f"TEXT-TO-CNC PIPELINE RESULT",
193
  "=" * 60,
194
  f"Prompt: {self.prompt}",
195
  f"Retries: {self.retry_count}",
@@ -297,7 +145,9 @@ if __name__ == "__main__":
297
 
298
  parser = argparse.ArgumentParser(description="Text-to-CNC Model Generator")
299
  parser.add_argument("prompt", nargs="?", default=None, help="Part description")
300
- parser.add_argument("--backend", choices=["mock", "anthropic", "openai"], default="mock")
 
 
301
  parser.add_argument("--output-dir", default="./output")
302
  parser.add_argument("--retries", type=int, default=2)
303
  parser.add_argument("--name", default=None, help="Part name for file output")
@@ -309,10 +159,14 @@ if __name__ == "__main__":
309
  args.prompt = "A simple mounting bracket with two M5 bolt holes"
310
 
311
  # Select backend
312
- if args.backend == "anthropic":
 
 
313
  backend = AnthropicBackend()
314
  elif args.backend == "openai":
315
  backend = OpenAIBackend()
 
 
316
  else:
317
  backend = MockBackend()
318
 
 
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
 
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}",
 
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")
 
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
 
cnc_validator.py → core/validator.py RENAMED
@@ -12,7 +12,6 @@ from dataclasses import dataclass, field
12
  from typing import Optional
13
 
14
  import cadquery as cq
15
- import math
16
 
17
 
18
  @dataclass
@@ -54,9 +53,9 @@ class CNCValidationResult:
54
 
55
  DEFAULT_CONFIG = {
56
  "min_wall_thickness_mm": 1.5,
57
- "min_fillet_radius_mm": 1.0, # Typical smallest endmill radius
58
  "max_pocket_depth_ratio": 4.0, # depth / width ratio
59
- "max_part_size_mm": 500.0, # Typical CNC work envelope
60
  "min_part_size_mm": 1.0,
61
  "min_hole_diameter_mm": 1.0,
62
  }
@@ -82,17 +81,23 @@ def validate_for_cnc(
82
  min_dim = dims[0]
83
 
84
  if max_dim > cfg["max_part_size_mm"]:
85
- result.issues.append(CNCIssue(
86
- "error", "Size",
87
- f"Part too large: {max_dim:.1f}mm exceeds {cfg['max_part_size_mm']}mm work envelope"
88
- ))
 
 
 
89
  result.machinable = False
90
 
91
  if min_dim < cfg["min_part_size_mm"]:
92
- result.issues.append(CNCIssue(
93
- "warning", "Size",
94
- f"Very small dimension: {min_dim:.2f}mm — may be difficult to fixture"
95
- ))
 
 
 
96
 
97
  # --- 2. Volume sanity check ---
98
  volume = shape.Volume()
@@ -100,14 +105,20 @@ def validate_for_cnc(
100
  if bb_volume > 0:
101
  fill_ratio = volume / bb_volume
102
  if fill_ratio < 0.05:
103
- result.issues.append(CNCIssue(
104
- "warning", "Geometry",
105
- f"Very low fill ratio ({fill_ratio:.1%}) — complex geometry, high machining time"
106
- ))
107
- result.issues.append(CNCIssue(
108
- "info", "Geometry",
109
- f"Fill ratio: {fill_ratio:.1%} (volume/bounding box)"
110
- ))
 
 
 
 
 
 
111
 
112
  # --- 3. Face and edge complexity ---
113
  faces = workplane.faces().vals()
@@ -117,16 +128,22 @@ def validate_for_cnc(
117
  n_edges = len(edges)
118
 
119
  if n_faces > 100:
120
- result.issues.append(CNCIssue(
121
- "warning", "Complexity",
122
- f"{n_faces} faces detected — may require multi-setup or 5-axis"
123
- ))
 
 
 
124
  result.axis_recommendation = "5-axis"
125
  elif n_faces > 50:
126
- result.issues.append(CNCIssue(
127
- "info", "Complexity",
128
- f"{n_faces} faces — consider 4-axis or indexed 5-axis"
129
- ))
 
 
 
130
  result.axis_recommendation = "3+2 axis"
131
 
132
  # --- 4. Edge length analysis (thin feature proxy) ---
@@ -140,22 +157,28 @@ def validate_for_cnc(
140
  if edge_lengths:
141
  min_edge = min(edge_lengths)
142
  if min_edge < cfg["min_wall_thickness_mm"]:
143
- result.issues.append(CNCIssue(
144
- "warning", "Thin Feature",
145
- f"Shortest edge: {min_edge:.2f}mm — below min wall thickness "
146
- f"({cfg['min_wall_thickness_mm']}mm)"
147
- ))
 
 
 
148
 
149
  # --- 5. Aspect ratio check (deep pocket heuristic) ---
150
  # Only flag if the narrowest dimension is small enough to be a pocket/slot
151
  if dims[0] > 0 and dims[0] < 20:
152
  aspect = dims[2] / dims[0] # tallest / narrowest
153
  if aspect > cfg["max_pocket_depth_ratio"]:
154
- result.issues.append(CNCIssue(
155
- "warning", "Deep Feature",
156
- f"Aspect ratio {aspect:.1f}:1 — may require long-reach tooling or "
157
- f"special fixturing"
158
- ))
 
 
 
159
 
160
  # --- 6. Surface type analysis ---
161
  has_freeform = False
@@ -175,18 +198,24 @@ def validate_for_cnc(
175
  pass
176
 
177
  if has_freeform:
178
- result.issues.append(CNCIssue(
179
- "warning", "Surface",
180
- "Freeform/spline surfaces detected — requires 3D contouring toolpaths"
181
- ))
 
 
 
182
  if result.axis_recommendation == "3-axis":
183
  result.axis_recommendation = "3-axis (with 3D finishing)"
184
 
185
- result.issues.append(CNCIssue(
186
- "info", "Surface",
187
- f"Faces: {planar_count} planar, {cylindrical_count} cylindrical, "
188
- f"{n_faces - planar_count - cylindrical_count} other"
189
- ))
 
 
 
190
 
191
  # --- 7. Set final machinable flag ---
192
  if result.error_count > 0:
 
12
  from typing import Optional
13
 
14
  import cadquery as cq
 
15
 
16
 
17
  @dataclass
 
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
  }
 
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()
 
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()
 
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) ---
 
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
 
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:
docker-compose.yml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ mcp-server:
3
+ build: .
4
+ command: python -m server.mcp --transport sse --port 8000
5
+ ports:
6
+ - "8000:8000"
7
+ environment:
8
+ GEMINI_API_KEY: ${GEMINI_API_KEY:-}
9
+ ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
10
+ OPENAI_API_KEY: ${OPENAI_API_KEY:-}
11
+ volumes:
12
+ - ./output:/app/output
13
+
14
+ web:
15
+ build: .
16
+ command: python -m server.web --host 0.0.0.0 --port 5000
17
+ ports:
18
+ - "5000:5000"
19
+ environment:
20
+ MCP_SERVER_URL: http://mcp-server:8000/sse
21
+ depends_on:
22
+ - mcp-server
23
+ volumes:
24
+ - ./output:/app/output
docs/superpowers/plans/2026-04-08-uv-docker-deploy.md ADDED
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # uv, Docker, and HF Spaces Deployment — Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Set up uv package management, Docker containerization, and deploy to Hugging Face Spaces for a free investor demo.
6
+
7
+ **Architecture:** Replace requirements.txt with pyproject.toml managed by uv. Multi-stage Dockerfile builds a slim image with CadQuery. Container runs both MCP CAD server and web server. HF Spaces hosts it free at a public URL.
8
+
9
+ **Tech Stack:** uv 0.8+, Docker multi-stage, docker-compose, Hugging Face Spaces (Docker SDK)
10
+
11
+ ---
12
+
13
+ ### Task 1: Create pyproject.toml
14
+
15
+ **Files:**
16
+ - Create: `pyproject.toml`
17
+ - Modify: `requirements.txt`
18
+
19
+ - [ ] **Step 1: Create pyproject.toml**
20
+
21
+ ```toml
22
+ [project]
23
+ name = "neuralcad"
24
+ version = "1.0.0"
25
+ description = "Text-to-CNC pipeline: natural language to machinable 3D models"
26
+ requires-python = ">=3.10"
27
+ dependencies = [
28
+ "cadquery>=2.7.0",
29
+ "cadquery-ocp>=7.8.0",
30
+ "numpy>=1.24.0",
31
+ "trimesh>=4.0.0",
32
+ "anthropic>=0.25.0",
33
+ "openai>=1.30.0",
34
+ "mcp>=1.0.0",
35
+ "fastapi>=0.110.0",
36
+ "uvicorn>=0.29.0",
37
+ "python-multipart>=0.0.9",
38
+ ]
39
+
40
+ [dependency-groups]
41
+ dev = ["ruff", "pytest"]
42
+ ```
43
+
44
+ - [ ] **Step 2: Add backward-compat comment to requirements.txt**
45
+
46
+ Replace the contents of `requirements.txt` with:
47
+
48
+ ```
49
+ # Dependency source of truth is pyproject.toml — use `uv sync` to install.
50
+ # This file is kept for environments that don't use uv.
51
+ cadquery>=2.7.0
52
+ cadquery-ocp>=7.8.0
53
+ numpy>=1.24.0
54
+ trimesh>=4.0.0
55
+ anthropic>=0.25.0
56
+ openai>=1.30.0
57
+ mcp>=1.0.0
58
+ fastapi>=0.110.0
59
+ uvicorn>=0.29.0
60
+ python-multipart>=0.0.9
61
+ ```
62
+
63
+ - [ ] **Step 3: Generate lockfile**
64
+
65
+ Run: `uv lock`
66
+
67
+ Expected: `uv.lock` file is created in project root.
68
+
69
+ - [ ] **Step 4: Update .gitignore**
70
+
71
+ Add these lines to `.gitignore`:
72
+
73
+ ```
74
+ .venv/
75
+ ```
76
+
77
+ - [ ] **Step 5: Verify uv sync works**
78
+
79
+ Run: `uv sync`
80
+
81
+ Expected: Virtual environment created in `.venv/`, all dependencies installed. Output shows resolved packages.
82
+
83
+ - [ ] **Step 6: Commit**
84
+
85
+ ```bash
86
+ git add pyproject.toml uv.lock requirements.txt .gitignore
87
+ git commit -m "build: migrate to uv with pyproject.toml and lockfile"
88
+ ```
89
+
90
+ ---
91
+
92
+ ### Task 2: Create Dockerfile
93
+
94
+ **Files:**
95
+ - Create: `Dockerfile`
96
+
97
+ - [ ] **Step 1: Create Dockerfile**
98
+
99
+ ```dockerfile
100
+ # ── Stage 1: Builder ─────────────────────────────────────────────────────
101
+ FROM python:3.11-slim AS builder
102
+
103
+ # Install uv
104
+ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
105
+
106
+ WORKDIR /app
107
+
108
+ # Install dependencies (cached layer — only rebuilds when deps change)
109
+ COPY pyproject.toml uv.lock ./
110
+ RUN uv sync --frozen --no-dev --no-install-project
111
+
112
+ # Copy source code
113
+ COPY . .
114
+
115
+ # ── Stage 2: Runtime ─────────────────────────────────────────────────────
116
+ FROM python:3.11-slim
117
+
118
+ WORKDIR /app
119
+
120
+ # Copy virtual environment from builder
121
+ COPY --from=builder /app/.venv /app/.venv
122
+
123
+ # Copy application source
124
+ COPY --from=builder /app/*.py /app/
125
+ COPY --from=builder /app/web /app/web/
126
+ COPY --from=builder /app/entrypoint.sh /app/
127
+
128
+ # Put venv on PATH
129
+ ENV PATH="/app/.venv/bin:$PATH"
130
+ ENV PYTHONUNBUFFERED=1
131
+
132
+ # Create output directory
133
+ RUN mkdir -p /app/output
134
+
135
+ EXPOSE 7860
136
+
137
+ ENTRYPOINT ["/bin/bash", "/app/entrypoint.sh"]
138
+ ```
139
+
140
+ - [ ] **Step 2: Verify syntax**
141
+
142
+ Run: `docker build --check -f Dockerfile . 2>&1 || echo "syntax check done"`
143
+
144
+ Expected: No syntax errors. (May fail if Docker not available — that's OK, the file is valid.)
145
+
146
+ - [ ] **Step 3: Commit**
147
+
148
+ ```bash
149
+ git add Dockerfile
150
+ git commit -m "build: add multi-stage Dockerfile"
151
+ ```
152
+
153
+ ---
154
+
155
+ ### Task 3: Create entrypoint.sh
156
+
157
+ **Files:**
158
+ - Create: `entrypoint.sh`
159
+
160
+ - [ ] **Step 1: Create entrypoint.sh**
161
+
162
+ ```bash
163
+ #!/bin/bash
164
+ set -e
165
+
166
+ echo "=== NeuralCAD Container Starting ==="
167
+
168
+ # Start MCP CAD server in background
169
+ echo "Starting MCP CAD server on port 8000..."
170
+ python mcp_server.py --transport sse --port 8000 &
171
+ MCP_PID=$!
172
+
173
+ # Wait for MCP server to be ready
174
+ sleep 3
175
+
176
+ if ! kill -0 $MCP_PID 2>/dev/null; then
177
+ echo "ERROR: MCP server failed to start"
178
+ exit 1
179
+ fi
180
+
181
+ echo "MCP server running (PID $MCP_PID)"
182
+
183
+ # Start web server in foreground
184
+ export MCP_SERVER_URL=http://localhost:8000/sse
185
+ PORT=${PORT:-7860}
186
+ echo "Starting web server on port $PORT..."
187
+ exec python web_server.py --host 0.0.0.0 --port "$PORT"
188
+ ```
189
+
190
+ - [ ] **Step 2: Make executable**
191
+
192
+ Run: `chmod +x entrypoint.sh`
193
+
194
+ - [ ] **Step 3: Commit**
195
+
196
+ ```bash
197
+ git add entrypoint.sh
198
+ git commit -m "build: add container entrypoint script"
199
+ ```
200
+
201
+ ---
202
+
203
+ ### Task 4: Create .dockerignore
204
+
205
+ **Files:**
206
+ - Create: `.dockerignore`
207
+
208
+ - [ ] **Step 1: Create .dockerignore**
209
+
210
+ ```
211
+ .git
212
+ __pycache__
213
+ *.pyc
214
+ output/
215
+ .superpowers/
216
+ .venv/
217
+ docs/
218
+ .env
219
+ ```
220
+
221
+ - [ ] **Step 2: Commit**
222
+
223
+ ```bash
224
+ git add .dockerignore
225
+ git commit -m "build: add .dockerignore"
226
+ ```
227
+
228
+ ---
229
+
230
+ ### Task 5: Create docker-compose.yml
231
+
232
+ **Files:**
233
+ - Create: `docker-compose.yml`
234
+
235
+ - [ ] **Step 1: Create docker-compose.yml**
236
+
237
+ ```yaml
238
+ services:
239
+ mcp-server:
240
+ build: .
241
+ command: python mcp_server.py --transport sse --port 8000
242
+ ports:
243
+ - "8000:8000"
244
+ volumes:
245
+ - ./output:/app/output
246
+
247
+ web:
248
+ build: .
249
+ command: python web_server.py --host 0.0.0.0 --port 5000
250
+ ports:
251
+ - "5000:5000"
252
+ environment:
253
+ MCP_SERVER_URL: http://mcp-server:8000/sse
254
+ depends_on:
255
+ - mcp-server
256
+ volumes:
257
+ - ./output:/app/output
258
+ ```
259
+
260
+ - [ ] **Step 2: Commit**
261
+
262
+ ```bash
263
+ git add docker-compose.yml
264
+ git commit -m "build: add docker-compose for local dev"
265
+ ```
266
+
267
+ ---
268
+
269
+ ### Task 6: Add HF Spaces metadata to README.md
270
+
271
+ **Files:**
272
+ - Modify: `README.md`
273
+
274
+ - [ ] **Step 1: Prepend HF Spaces YAML header to README.md**
275
+
276
+ Add this YAML front matter at the very top of `README.md` (before the `# Text-to-CNC` heading):
277
+
278
+ ```yaml
279
+ ---
280
+ title: NeuralCAD
281
+ emoji: ⚙️
282
+ colorFrom: blue
283
+ colorTo: cyan
284
+ sdk: docker
285
+ app_port: 7860
286
+ ---
287
+ ```
288
+
289
+ The rest of the README content stays unchanged below the `---` closing marker.
290
+
291
+ - [ ] **Step 2: Commit**
292
+
293
+ ```bash
294
+ git add README.md
295
+ git commit -m "build: add HF Spaces metadata to README"
296
+ ```
297
+
298
+ ---
299
+
300
+ ### Task 7: Docker build and local verification
301
+
302
+ **Files:** None (verification only)
303
+
304
+ - [ ] **Step 1: Build Docker image**
305
+
306
+ Run: `docker build -t neuralcad .`
307
+
308
+ Expected: Multi-stage build completes. Final image is created. Look for `Successfully tagged neuralcad:latest`.
309
+
310
+ - [ ] **Step 2: Run container**
311
+
312
+ Run: `docker run --rm -p 7860:7860 --name neuralcad-test neuralcad`
313
+
314
+ Expected output:
315
+ ```
316
+ === NeuralCAD Container Starting ===
317
+ Starting MCP CAD server on port 8000...
318
+ MCP server running (PID ...)
319
+ Starting web server on port 7860...
320
+ ```
321
+
322
+ - [ ] **Step 3: Test in browser**
323
+
324
+ Open: `http://localhost:7860`
325
+
326
+ Verify:
327
+ 1. Page loads with NeuralCAD UI
328
+ 2. Status dot is green (MCP server connected)
329
+ 3. Click "Mounting bracket" quick example
330
+ 4. 3D model renders in viewer
331
+ 5. Code tab shows CadQuery code
332
+ 6. Validation tab shows CNC report
333
+
334
+ - [ ] **Step 4: Stop container**
335
+
336
+ Run: `docker stop neuralcad-test` (or Ctrl+C in the terminal running it)
337
+
338
+ ---
339
+
340
+ ### Task 8: Deploy to Hugging Face Spaces
341
+
342
+ **Files:** None (deployment only)
343
+
344
+ - [ ] **Step 1: Create HF Space**
345
+
346
+ Run:
347
+ ```bash
348
+ huggingface-cli login
349
+ huggingface-cli repo create neuralcad --type space --space-sdk docker
350
+ ```
351
+
352
+ If `huggingface-cli` is not installed: `uv tool install huggingface-hub[cli]`
353
+
354
+ - [ ] **Step 2: Add HF remote and push**
355
+
356
+ ```bash
357
+ git remote add hf https://huggingface.co/spaces/CallMeDaniel/neuralcad
358
+ git push hf main
359
+ ```
360
+
361
+ - [ ] **Step 3: Set API key secrets (optional)**
362
+
363
+ In the HF Space settings (https://huggingface.co/spaces/CallMeDaniel/neuralcad/settings), add secrets:
364
+ - `ANTHROPIC_API_KEY` — for live Claude generation
365
+ - `OPENAI_API_KEY` — for live GPT-4o generation
366
+
367
+ These are optional. Mock backend works without them.
368
+
369
+ - [ ] **Step 4: Verify deployment**
370
+
371
+ Open: `https://callmedaniel-neuralcad.hf.space`
372
+
373
+ Wait for container to build and start (~2-5 min first time). Verify:
374
+ 1. Page loads
375
+ 2. Quick examples work (mock backend)
376
+ 3. If API keys set: toggle to API mode, type custom prompt, verify live generation
docs/superpowers/plans/2026-04-11-tests-readme-ai-quality.md ADDED
@@ -0,0 +1,1538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuralCAD: Tests, README, and AI Quality Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Add comprehensive test coverage to NeuralCAD, update the README to reflect the current multi-agent architecture, and improve AI quality (prompt routing and report endpoint).
6
+
7
+ **Architecture:** Tests are split into two tiers: (1) pure-logic tests that run without CadQuery (prompts, routing, design state, mock orchestrator, API routes) and (2) integration tests that require CadQuery (executor, validator, pipeline, CAD code generation). The README is rewritten to document the multi-agent chat system. AI quality improvements target prompt routing accuracy and the report endpoint.
8
+
9
+ **Tech Stack:** pytest, FastAPI TestClient, httpx (for async), CadQuery (integration tests only)
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ tests/
17
+ ├── conftest.py # Shared fixtures (tmp output dir, mock backend, sample history)
18
+ ├── test_prompts.py # Prompt building, @mention parsing, keyword routing, JSON parsing
19
+ ├── test_design_state.py # DesignState model + extract_decisions()
20
+ ├── test_mock_orchestrator.py # MockChatBackend.chat_turn() — response shape, routing, canned messages
21
+ ├── test_single_call_orchestrator.py # SingleCallOrchestrator with a fake LLM backend
22
+ ├── test_api_routes.py # FastAPI /api/chat, /api/report, /api/agents via TestClient
23
+ ├── test_executor.py # execute_cadquery(), sanitize_code(), exports (requires CadQuery)
24
+ ├── test_validator.py # validate_for_cnc() (requires CadQuery)
25
+ └── test_pipeline.py # run_pipeline() end-to-end with MockBackend (requires CadQuery)
26
+ ```
27
+
28
+ **Modified files:**
29
+ - `README.md` — full rewrite
30
+ - `pyproject.toml` — add `[tool.pytest.ini_options]`
31
+ - `agents/prompts.py` — improve routing keyword coverage
32
+
33
+ ---
34
+
35
+ ### Task 1: Test Infrastructure (conftest + pytest config)
36
+
37
+ **Files:**
38
+ - Create: `tests/__init__.py`
39
+ - Create: `tests/conftest.py`
40
+ - Modify: `pyproject.toml`
41
+
42
+ - [ ] **Step 1: Add pytest configuration to pyproject.toml**
43
+
44
+ Add after the `[dependency-groups]` section in `pyproject.toml`:
45
+
46
+ ```toml
47
+ [tool.pytest.ini_options]
48
+ testpaths = ["tests"]
49
+ pythonpath = ["."]
50
+ markers = [
51
+ "requires_cadquery: marks tests that need CadQuery installed",
52
+ ]
53
+ ```
54
+
55
+ - [ ] **Step 2: Create tests/__init__.py**
56
+
57
+ ```python
58
+ ```
59
+
60
+ (Empty file to make `tests` a package.)
61
+
62
+ - [ ] **Step 3: Create tests/conftest.py with shared fixtures**
63
+
64
+ ```python
65
+ """Shared fixtures for NeuralCAD tests."""
66
+
67
+ import pytest
68
+ from pathlib import Path
69
+
70
+
71
+ @pytest.fixture
72
+ def tmp_output_dir(tmp_path):
73
+ """Temporary output directory for model files."""
74
+ out = tmp_path / "output"
75
+ out.mkdir()
76
+ return out
77
+
78
+
79
+ @pytest.fixture
80
+ def sample_history():
81
+ """A typical multi-turn conversation history."""
82
+ return [
83
+ {"role": "user", "content": "I need a servo bracket for an MG996R"},
84
+ {"role": "agent", "agent_id": "design", "content": "I'd suggest an L-bracket with a servo pocket on the vertical face."},
85
+ {"role": "agent", "agent_id": "engineering", "content": "3mm wall thickness in aluminum 6061-T6 should handle the load."},
86
+ {"role": "user", "content": "Make it 60mm wide with M4 base mounting holes"},
87
+ ]
88
+
89
+
90
+ @pytest.fixture
91
+ def empty_design_state():
92
+ """Empty design state dict."""
93
+ return {}
94
+
95
+
96
+ @pytest.fixture
97
+ def populated_design_state():
98
+ """Design state with some decisions already made."""
99
+ return {
100
+ "part_name": "servo_bracket",
101
+ "material": "aluminum 6061",
102
+ "dimensions": {"width": 60.0},
103
+ "features": ["4x M4 holes"],
104
+ "decisions": ["L-bracket form factor"],
105
+ }
106
+
107
+
108
+ class FakeLLMBackend:
109
+ """A controllable fake LLM backend for testing orchestrators."""
110
+
111
+ def __init__(self, response: str = '{"agents": []}'):
112
+ self.response = response
113
+ self.calls: list[list[dict]] = []
114
+
115
+ def generate(self, messages: list[dict]) -> str:
116
+ self.calls.append(messages)
117
+ return self.response
118
+
119
+
120
+ @pytest.fixture
121
+ def fake_backend():
122
+ """FakeLLMBackend factory — call with desired JSON response."""
123
+ def _make(response: str = '{"agents": []}'):
124
+ return FakeLLMBackend(response)
125
+ return _make
126
+ ```
127
+
128
+ - [ ] **Step 4: Run pytest to verify configuration**
129
+
130
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest --co -q 2>&1 | head -5`
131
+ Expected: `no tests ran` (no test files yet, but no errors)
132
+
133
+ - [ ] **Step 5: Commit**
134
+
135
+ ```bash
136
+ git add tests/__init__.py tests/conftest.py pyproject.toml
137
+ git commit -m "test: add pytest config and shared test fixtures"
138
+ ```
139
+
140
+ ---
141
+
142
+ ### Task 2: Test Prompt Building, @Mentions, Keyword Routing, JSON Parsing
143
+
144
+ **Files:**
145
+ - Create: `tests/test_prompts.py`
146
+
147
+ - [ ] **Step 1: Write tests for parse_mentions()**
148
+
149
+ ```python
150
+ """Tests for agents/prompts.py — prompt building, routing, parsing."""
151
+
152
+ from agents.prompts import (
153
+ parse_mentions,
154
+ route_by_keywords,
155
+ parse_orchestrator_response,
156
+ build_orchestrator_system_prompt,
157
+ build_chat_messages,
158
+ CAD_TRIGGER_KEYWORDS,
159
+ )
160
+
161
+
162
+ # ── parse_mentions ────────────────────────────────────────────────────────
163
+
164
+
165
+ class TestParseMentions:
166
+ def test_no_mentions(self):
167
+ cleaned, mentions = parse_mentions("I need a bracket")
168
+ assert cleaned == "I need a bracket"
169
+ assert mentions == []
170
+
171
+ def test_single_mention(self):
172
+ cleaned, mentions = parse_mentions("@design what shape?")
173
+ assert "design" in mentions
174
+ assert "@design" not in cleaned
175
+
176
+ def test_multiple_mentions(self):
177
+ cleaned, mentions = parse_mentions("@design @engineering check this")
178
+ assert "design" in mentions
179
+ assert "engineering" in mentions
180
+ assert "@design" not in cleaned
181
+ assert "@engineering" not in cleaned
182
+
183
+ def test_cad_mention(self):
184
+ cleaned, mentions = parse_mentions("@cad generate a preview")
185
+ assert "cad" in mentions
186
+
187
+ def test_case_insensitive(self):
188
+ cleaned, mentions = parse_mentions("@Design what do you think?")
189
+ assert "design" in mentions
190
+
191
+ def test_mention_mid_sentence(self):
192
+ cleaned, mentions = parse_mentions("Can @engineering check the wall thickness?")
193
+ assert "engineering" in mentions
194
+ assert "Can" in cleaned
195
+ assert "check the wall thickness?" in cleaned
196
+ ```
197
+
198
+ - [ ] **Step 2: Run tests to verify they pass**
199
+
200
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestParseMentions -v`
201
+ Expected: All 6 tests PASS
202
+
203
+ - [ ] **Step 3: Write tests for route_by_keywords()**
204
+
205
+ Append to `tests/test_prompts.py`:
206
+
207
+ ```python
208
+ # ── route_by_keywords ─────────────────────────────────────────────────────
209
+
210
+
211
+ class TestRouteByKeywords:
212
+ def test_design_keywords(self):
213
+ agents = route_by_keywords("I want a sleek design with smooth shape")
214
+ assert "design" in agents
215
+
216
+ def test_engineering_keywords(self):
217
+ agents = route_by_keywords("Use M6 bolts with 3mm wall thickness in aluminum")
218
+ assert "engineering" in agents
219
+
220
+ def test_cnc_keywords(self):
221
+ agents = route_by_keywords("Can this be machined on a 3-axis CNC mill?")
222
+ assert "cnc" in agents
223
+
224
+ def test_cad_trigger(self):
225
+ agents = route_by_keywords("Generate a preview of the part")
226
+ assert "cad" in agents
227
+
228
+ def test_default_when_no_match(self):
229
+ agents = route_by_keywords("hello there")
230
+ assert agents == ["design", "engineering"]
231
+
232
+ def test_max_three_agents(self):
233
+ agents = route_by_keywords(
234
+ "design shape in aluminum for CNC machining, generate preview"
235
+ )
236
+ assert len(agents) <= 3
237
+
238
+ def test_sorted_by_relevance(self):
239
+ agents = route_by_keywords("M4 M6 tolerance clearance aluminum steel wall")
240
+ assert agents[0] == "engineering"
241
+ ```
242
+
243
+ - [ ] **Step 4: Run tests**
244
+
245
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestRouteByKeywords -v`
246
+ Expected: All 7 tests PASS
247
+
248
+ - [ ] **Step 5: Write tests for parse_orchestrator_response()**
249
+
250
+ Append to `tests/test_prompts.py`:
251
+
252
+ ```python
253
+ # ── parse_orchestrator_response ───────────────────────────────────────────
254
+
255
+
256
+ class TestParseOrchestratorResponse:
257
+ def test_valid_json(self):
258
+ resp = '{"agents": [{"id": "design", "message": "Nice bracket."}]}'
259
+ parsed = parse_orchestrator_response(resp)
260
+ assert len(parsed) == 1
261
+ assert parsed[0]["id"] == "design"
262
+ assert parsed[0]["message"] == "Nice bracket."
263
+ assert parsed[0]["code"] is None
264
+
265
+ def test_json_with_code(self):
266
+ resp = '{"agents": [{"id": "cad", "message": "Done.", "code": "result = cq.Workplane().box(10,10,10)"}]}'
267
+ parsed = parse_orchestrator_response(resp)
268
+ assert parsed[0]["code"] == "result = cq.Workplane().box(10,10,10)"
269
+
270
+ def test_json_in_markdown_fence(self):
271
+ resp = '```json\n{"agents": [{"id": "engineering", "message": "Use 3mm walls."}]}\n```'
272
+ parsed = parse_orchestrator_response(resp)
273
+ assert len(parsed) == 1
274
+ assert parsed[0]["id"] == "engineering"
275
+
276
+ def test_multiple_agents(self):
277
+ resp = '{"agents": [{"id": "design", "message": "A"}, {"id": "cnc", "message": "B"}]}'
278
+ parsed = parse_orchestrator_response(resp)
279
+ assert len(parsed) == 2
280
+ assert parsed[0]["id"] == "design"
281
+ assert parsed[1]["id"] == "cnc"
282
+
283
+ def test_invalid_json_fallback(self):
284
+ resp = "I think you should use aluminum."
285
+ parsed = parse_orchestrator_response(resp)
286
+ assert len(parsed) == 1
287
+ assert parsed[0]["id"] == "design"
288
+ assert parsed[0]["message"] == resp
289
+
290
+ def test_empty_agents_fallback(self):
291
+ resp = '{"agents": []}'
292
+ parsed = parse_orchestrator_response(resp)
293
+ # Empty agents list falls back to treating as design message
294
+ assert len(parsed) == 1
295
+ assert parsed[0]["id"] == "design"
296
+
297
+ def test_missing_fields_skipped(self):
298
+ resp = '{"agents": [{"id": "design"}, {"id": "cnc", "message": "OK"}]}'
299
+ parsed = parse_orchestrator_response(resp)
300
+ # First agent missing "message" is skipped
301
+ assert len(parsed) == 1
302
+ assert parsed[0]["id"] == "cnc"
303
+ ```
304
+
305
+ - [ ] **Step 6: Run tests**
306
+
307
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestParseOrchestratorResponse -v`
308
+ Expected: All 7 tests PASS
309
+
310
+ - [ ] **Step 7: Write tests for build_orchestrator_system_prompt() and build_chat_messages()**
311
+
312
+ Append to `tests/test_prompts.py`:
313
+
314
+ ```python
315
+ # ── build_orchestrator_system_prompt ──────────────────────────────────────
316
+
317
+
318
+ class TestBuildOrchestratorSystemPrompt:
319
+ def test_default_agents(self):
320
+ prompt = build_orchestrator_system_prompt()
321
+ assert "Design Agent" in prompt
322
+ assert "Engineering Agent" in prompt
323
+ assert "CNC Agent" in prompt
324
+ assert "CAD Coder" not in prompt
325
+
326
+ def test_specific_agents(self):
327
+ prompt = build_orchestrator_system_prompt(active_agents=["cad"])
328
+ assert "CAD Coder" in prompt
329
+ assert "Design Agent" not in prompt
330
+
331
+ def test_includes_json_format(self):
332
+ prompt = build_orchestrator_system_prompt()
333
+ assert '"agents"' in prompt
334
+ assert "JSON" in prompt
335
+
336
+ def test_cad_context_included(self):
337
+ prompt = build_orchestrator_system_prompt(
338
+ active_agents=["cad"], include_cad_context=True
339
+ )
340
+ assert "CadQuery" in prompt
341
+
342
+
343
+ # ── build_chat_messages ───────────────────────────────────────────────────
344
+
345
+
346
+ class TestBuildChatMessages:
347
+ def test_returns_system_and_user(self):
348
+ msgs = build_chat_messages("hello", [], "You are a bot.")
349
+ assert len(msgs) == 2
350
+ assert msgs[0]["role"] == "system"
351
+ assert msgs[0]["content"] == "You are a bot."
352
+ assert msgs[1]["role"] == "user"
353
+
354
+ def test_history_included_in_user_message(self, sample_history):
355
+ msgs = build_chat_messages("new msg", sample_history, "system prompt")
356
+ user_content = msgs[1]["content"]
357
+ assert "servo bracket" in user_content
358
+ assert "new msg" in user_content
359
+
360
+ def test_design_state_included(self):
361
+ msgs = build_chat_messages(
362
+ "make it wider", [], "system prompt",
363
+ design_state_text="Part: bracket\nMaterial: aluminum"
364
+ )
365
+ user_content = msgs[1]["content"]
366
+ assert "bracket" in user_content
367
+ assert "aluminum" in user_content
368
+
369
+ def test_history_truncation(self):
370
+ long_history = [
371
+ {"role": "user", "content": f"msg {i}"}
372
+ for i in range(50)
373
+ ]
374
+ msgs = build_chat_messages("latest", long_history, "sys", max_history=5)
375
+ user_content = msgs[1]["content"]
376
+ # Should include msg 45-49 but not msg 0
377
+ assert "msg 49" in user_content
378
+ assert "msg 0" not in user_content
379
+ ```
380
+
381
+ - [ ] **Step 8: Run all prompt tests**
382
+
383
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py -v`
384
+ Expected: All tests PASS (approximately 25 tests)
385
+
386
+ - [ ] **Step 9: Commit**
387
+
388
+ ```bash
389
+ git add tests/test_prompts.py
390
+ git commit -m "test: add prompt building, routing, and JSON parsing tests"
391
+ ```
392
+
393
+ ---
394
+
395
+ ### Task 3: Test DesignState and Decision Extraction
396
+
397
+ **Files:**
398
+ - Create: `tests/test_design_state.py`
399
+
400
+ - [ ] **Step 1: Write tests for DesignState model**
401
+
402
+ ```python
403
+ """Tests for agents/design_state.py — state tracking and decision extraction."""
404
+
405
+ from agents.design_state import DesignState, extract_decisions
406
+
407
+
408
+ class TestDesignState:
409
+ def test_empty_render(self):
410
+ state = DesignState()
411
+ assert state.render() == ""
412
+
413
+ def test_render_with_fields(self):
414
+ state = DesignState(
415
+ part_name="bracket",
416
+ material="aluminum 6061",
417
+ dimensions={"width": 60.0, "height": 40.0},
418
+ )
419
+ rendered = state.render()
420
+ assert "bracket" in rendered
421
+ assert "aluminum 6061" in rendered
422
+ assert "width=60.0mm" in rendered
423
+
424
+ def test_render_features(self):
425
+ state = DesignState(features=["4x M6 holes", "fillet"])
426
+ rendered = state.render()
427
+ assert "4x M6 holes" in rendered
428
+
429
+ def test_render_decisions_capped_at_5(self):
430
+ state = DesignState(decisions=[f"decision {i}" for i in range(10)])
431
+ rendered = state.render()
432
+ assert "decision 9" in rendered
433
+ assert "decision 4" not in rendered
434
+ ```
435
+
436
+ - [ ] **Step 2: Run tests**
437
+
438
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_design_state.py::TestDesignState -v`
439
+ Expected: All 4 tests PASS
440
+
441
+ - [ ] **Step 3: Write tests for extract_decisions()**
442
+
443
+ Append to `tests/test_design_state.py`:
444
+
445
+ ```python
446
+ class TestExtractDecisions:
447
+ def test_extracts_material(self):
448
+ responses = [
449
+ {"agent_id": "engineering", "message": "I recommend aluminum 6061 for this application."}
450
+ ]
451
+ state = extract_decisions(responses, DesignState())
452
+ assert "aluminum" in state.material.lower()
453
+
454
+ def test_extracts_dimensions_from_user(self):
455
+ responses = []
456
+ state = extract_decisions(responses, DesignState(), user_message="Make it 60mm wide and 40mm tall")
457
+ assert state.dimensions.get("width") == 60.0
458
+ assert state.dimensions.get("height") == 40.0
459
+
460
+ def test_extracts_fastener_features(self):
461
+ responses = [
462
+ {"agent_id": "engineering", "message": "I'll add 4x M6 clearance holes for mounting."}
463
+ ]
464
+ state = extract_decisions(responses, DesignState())
465
+ assert any("M6" in f for f in state.features)
466
+
467
+ def test_extracts_axis_recommendation(self):
468
+ responses = [
469
+ {"agent_id": "cnc", "message": "This part needs 5-axis machining due to the undercut."}
470
+ ]
471
+ state = extract_decisions(responses, DesignState())
472
+ assert "5-axis" in state.axis_recommendation
473
+
474
+ def test_extracts_part_name(self):
475
+ responses = []
476
+ state = extract_decisions(responses, DesignState(), user_message="I need a servo bracket")
477
+ assert "servo bracket" in state.part_name.lower() or "servo_bracket" in state.part_name.lower()
478
+
479
+ def test_preserves_existing_state(self):
480
+ existing = DesignState(material="steel", dimensions={"width": 50.0})
481
+ responses = [
482
+ {"agent_id": "engineering", "message": "Height should be 30mm."}
483
+ ]
484
+ updated = extract_decisions(responses, existing, user_message="add height")
485
+ # Material preserved, new dimension added
486
+ assert updated.material == "steel"
487
+ assert updated.dimensions.get("width") == 50.0
488
+
489
+ def test_extracts_decisions_from_agreement(self):
490
+ responses = [
491
+ {"agent_id": "design", "message": "I'd recommend an L-bracket form factor for this."}
492
+ ]
493
+ state = extract_decisions(responses, DesignState())
494
+ assert len(state.decisions) > 0
495
+
496
+ def test_no_duplicate_features(self):
497
+ existing = DesignState(features=["4x M6 holes"])
498
+ responses = [
499
+ {"agent_id": "engineering", "message": "The 4x M6 holes are properly specified."}
500
+ ]
501
+ updated = extract_decisions(responses, existing)
502
+ m6_count = sum(1 for f in updated.features if "M6" in f)
503
+ assert m6_count == 1
504
+ ```
505
+
506
+ - [ ] **Step 4: Run tests**
507
+
508
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_design_state.py -v`
509
+ Expected: All 12 tests PASS
510
+
511
+ - [ ] **Step 5: Commit**
512
+
513
+ ```bash
514
+ git add tests/test_design_state.py
515
+ git commit -m "test: add design state and decision extraction tests"
516
+ ```
517
+
518
+ ---
519
+
520
+ ### Task 4: Test MockChatBackend
521
+
522
+ **Files:**
523
+ - Create: `tests/test_mock_orchestrator.py`
524
+
525
+ - [ ] **Step 1: Write tests for MockChatBackend**
526
+
527
+ ```python
528
+ """Tests for agents/orchestrator.py — MockChatBackend and helpers."""
529
+
530
+ from agents.orchestrator import MockChatBackend, _format_response
531
+ from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
532
+
533
+
534
+ class TestFormatResponse:
535
+ def test_returns_all_fields(self):
536
+ resp = _format_response("design", "Hello")
537
+ assert resp["agent_id"] == "design"
538
+ assert resp["agent_name"] == AGENT_NAMES["design"]
539
+ assert resp["message"] == "Hello"
540
+ assert resp["color"] == AGENT_COLORS["design"]
541
+ assert resp["avatar"] == AGENT_AVATARS["design"]
542
+ assert resp["code"] is None
543
+
544
+ def test_includes_code(self):
545
+ resp = _format_response("cad", "Done.", code="result = cq.Workplane().box(10,10,10)")
546
+ assert resp["code"] == "result = cq.Workplane().box(10,10,10)"
547
+
548
+
549
+ class TestMockChatBackend:
550
+ def test_response_shape(self, tmp_output_dir):
551
+ mock = MockChatBackend(output_dir=tmp_output_dir)
552
+ result = mock.chat_turn("I need a bracket", history=[])
553
+ assert "responses" in result
554
+ assert "preview" in result
555
+ assert "design_state" in result
556
+ assert isinstance(result["responses"], list)
557
+ assert len(result["responses"]) > 0
558
+
559
+ def test_bracket_routes_to_design(self, tmp_output_dir):
560
+ mock = MockChatBackend(output_dir=tmp_output_dir)
561
+ result = mock.chat_turn("Design a mounting bracket", history=[])
562
+ agent_ids = [r["agent_id"] for r in result["responses"]]
563
+ assert "design" in agent_ids
564
+
565
+ def test_mention_overrides_routing(self, tmp_output_dir):
566
+ mock = MockChatBackend(output_dir=tmp_output_dir)
567
+ result = mock.chat_turn(
568
+ "What do you think?",
569
+ history=[],
570
+ mentions=["cnc"],
571
+ )
572
+ agent_ids = [r["agent_id"] for r in result["responses"]]
573
+ assert agent_ids == ["cnc"]
574
+
575
+ def test_cad_mention_generates_code(self, tmp_output_dir):
576
+ mock = MockChatBackend(output_dir=tmp_output_dir)
577
+ result = mock.chat_turn(
578
+ "Generate a 50mm cube",
579
+ history=[],
580
+ mentions=["cad"],
581
+ )
582
+ agent_ids = [r["agent_id"] for r in result["responses"]]
583
+ assert "cad" in agent_ids
584
+ cad_resp = next(r for r in result["responses"] if r["agent_id"] == "cad")
585
+ assert cad_resp["code"] is not None
586
+ assert "result" in cad_resp["code"]
587
+
588
+ def test_design_state_updated(self, tmp_output_dir):
589
+ mock = MockChatBackend(output_dir=tmp_output_dir)
590
+ result = mock.chat_turn(
591
+ "Make it 60mm wide in aluminum",
592
+ history=[],
593
+ )
594
+ ds = result["design_state"]
595
+ assert isinstance(ds, dict)
596
+
597
+ def test_engineering_keywords_trigger_engineering(self, tmp_output_dir):
598
+ mock = MockChatBackend(output_dir=tmp_output_dir)
599
+ result = mock.chat_turn("Use M6 bolts with 3mm wall thickness", history=[])
600
+ agent_ids = [r["agent_id"] for r in result["responses"]]
601
+ assert "engineering" in agent_ids
602
+
603
+ def test_cnc_keywords_trigger_cnc(self, tmp_output_dir):
604
+ mock = MockChatBackend(output_dir=tmp_output_dir)
605
+ result = mock.chat_turn("Can this be machined on a CNC mill?", history=[])
606
+ agent_ids = [r["agent_id"] for r in result["responses"]]
607
+ assert "cnc" in agent_ids
608
+
609
+ def test_generic_message_default_agents(self, tmp_output_dir):
610
+ mock = MockChatBackend(output_dir=tmp_output_dir)
611
+ result = mock.chat_turn("Hello there", history=[])
612
+ agent_ids = [r["agent_id"] for r in result["responses"]]
613
+ # Default: design + engineering
614
+ assert "design" in agent_ids
615
+ assert "engineering" in agent_ids
616
+ ```
617
+
618
+ - [ ] **Step 2: Run tests**
619
+
620
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_mock_orchestrator.py -v`
621
+ Expected: All 10 tests PASS
622
+
623
+ - [ ] **Step 3: Commit**
624
+
625
+ ```bash
626
+ git add tests/test_mock_orchestrator.py
627
+ git commit -m "test: add MockChatBackend tests"
628
+ ```
629
+
630
+ ---
631
+
632
+ ### Task 5: Test SingleCallOrchestrator with Fake Backend
633
+
634
+ **Files:**
635
+ - Create: `tests/test_single_call_orchestrator.py`
636
+
637
+ - [ ] **Step 1: Write tests**
638
+
639
+ ```python
640
+ """Tests for SingleCallOrchestrator using a fake LLM backend."""
641
+
642
+ import json
643
+ from agents.orchestrator import SingleCallOrchestrator
644
+ from tests.conftest import FakeLLMBackend
645
+
646
+
647
+ class TestSingleCallOrchestrator:
648
+ def _make_orchestrator(self, response_json: str, tmp_output_dir):
649
+ backend = FakeLLMBackend(response_json)
650
+ return SingleCallOrchestrator(backend=backend, output_dir=tmp_output_dir), backend
651
+
652
+ def test_response_shape(self, tmp_output_dir):
653
+ resp = json.dumps({"agents": [
654
+ {"id": "design", "message": "An L-bracket would work."},
655
+ ]})
656
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
657
+ result = orch.chat_turn("I need a bracket", history=[])
658
+ assert "responses" in result
659
+ assert "preview" in result
660
+ assert "design_state" in result
661
+
662
+ def test_passes_message_to_backend(self, tmp_output_dir):
663
+ resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
664
+ orch, backend = self._make_orchestrator(resp, tmp_output_dir)
665
+ orch.chat_turn("Test message", history=[])
666
+ assert len(backend.calls) == 1
667
+ last_user_msg = backend.calls[0][-1]["content"]
668
+ assert "Test message" in last_user_msg
669
+
670
+ def test_mentions_restrict_agents(self, tmp_output_dir):
671
+ resp = json.dumps({"agents": [{"id": "cnc", "message": "3-axis OK"}]})
672
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
673
+ result = orch.chat_turn("check this", history=[], mentions=["cnc"])
674
+ agent_ids = [r["agent_id"] for r in result["responses"]]
675
+ assert "cnc" in agent_ids
676
+
677
+ def test_invalid_json_fallback(self, tmp_output_dir):
678
+ orch, _ = self._make_orchestrator("not json at all", tmp_output_dir)
679
+ result = orch.chat_turn("help", history=[])
680
+ # Fallback treats entire response as design agent message
681
+ assert len(result["responses"]) > 0
682
+ assert result["responses"][0]["agent_id"] == "design"
683
+
684
+ def test_llm_exception_fallback(self, tmp_output_dir):
685
+ backend = FakeLLMBackend("")
686
+ backend.generate = lambda msgs: (_ for _ in ()).throw(RuntimeError("API error"))
687
+ orch = SingleCallOrchestrator(backend=backend, output_dir=tmp_output_dir)
688
+ result = orch.chat_turn("Design a part", history=[])
689
+ # Should return fallback messages, not crash
690
+ assert len(result["responses"]) > 0
691
+
692
+ def test_unknown_agent_id_filtered(self, tmp_output_dir):
693
+ resp = json.dumps({"agents": [
694
+ {"id": "nonexistent", "message": "I don't exist"},
695
+ {"id": "design", "message": "Real agent"},
696
+ ]})
697
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
698
+ result = orch.chat_turn("test", history=[])
699
+ agent_ids = [r["agent_id"] for r in result["responses"]]
700
+ assert "nonexistent" not in agent_ids
701
+ assert "design" in agent_ids
702
+
703
+ def test_history_forwarded_to_backend(self, tmp_output_dir, sample_history):
704
+ resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
705
+ orch, backend = self._make_orchestrator(resp, tmp_output_dir)
706
+ orch.chat_turn("continue", history=sample_history)
707
+ user_content = backend.calls[0][-1]["content"]
708
+ assert "servo bracket" in user_content.lower() or "MG996R" in user_content
709
+
710
+ def test_design_state_returned(self, tmp_output_dir):
711
+ resp = json.dumps({"agents": [
712
+ {"id": "engineering", "message": "Use aluminum 6061 with 3mm walls."},
713
+ ]})
714
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
715
+ result = orch.chat_turn("material?", history=[])
716
+ assert "design_state" in result
717
+ assert isinstance(result["design_state"], dict)
718
+ ```
719
+
720
+ - [ ] **Step 2: Run tests**
721
+
722
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_single_call_orchestrator.py -v`
723
+ Expected: All 8 tests PASS
724
+
725
+ - [ ] **Step 3: Commit**
726
+
727
+ ```bash
728
+ git add tests/test_single_call_orchestrator.py
729
+ git commit -m "test: add SingleCallOrchestrator tests with fake backend"
730
+ ```
731
+
732
+ ---
733
+
734
+ ### Task 6: Test API Routes
735
+
736
+ **Files:**
737
+ - Create: `tests/test_api_routes.py`
738
+
739
+ - [ ] **Step 1: Write tests for /api/chat, /api/report, /api/agents**
740
+
741
+ ```python
742
+ """Tests for server/routes.py — FastAPI chat API endpoints."""
743
+
744
+ import pytest
745
+ from fastapi.testclient import TestClient
746
+ from server.web import app
747
+
748
+
749
+ client = TestClient(app)
750
+
751
+
752
+ class TestChatEndpoint:
753
+ def test_basic_chat(self):
754
+ resp = client.post("/api/chat", json={
755
+ "message": "I need a bracket",
756
+ "history": [],
757
+ "backend": "mock",
758
+ })
759
+ assert resp.status_code == 200
760
+ data = resp.json()
761
+ assert "responses" in data
762
+ assert len(data["responses"]) > 0
763
+
764
+ def test_chat_with_mentions(self):
765
+ resp = client.post("/api/chat", json={
766
+ "message": "What do you think?",
767
+ "history": [],
768
+ "mentions": ["cnc"],
769
+ "backend": "mock",
770
+ })
771
+ assert resp.status_code == 200
772
+ data = resp.json()
773
+ agent_ids = [r["agent_id"] for r in data["responses"]]
774
+ assert "cnc" in agent_ids
775
+
776
+ def test_chat_with_history(self):
777
+ resp = client.post("/api/chat", json={
778
+ "message": "Make it wider",
779
+ "history": [
780
+ {"role": "user", "content": "I need a bracket"},
781
+ {"role": "agent", "agent_id": "design", "content": "L-bracket suggestion."},
782
+ ],
783
+ "backend": "mock",
784
+ })
785
+ assert resp.status_code == 200
786
+ data = resp.json()
787
+ assert "responses" in data
788
+
789
+ def test_chat_empty_message_rejected(self):
790
+ resp = client.post("/api/chat", json={
791
+ "message": "",
792
+ "history": [],
793
+ "backend": "mock",
794
+ })
795
+ assert resp.status_code == 422 # Pydantic validation error
796
+
797
+ def test_chat_returns_design_state(self):
798
+ resp = client.post("/api/chat", json={
799
+ "message": "60mm wide aluminum bracket",
800
+ "history": [],
801
+ "backend": "mock",
802
+ })
803
+ assert resp.status_code == 200
804
+ data = resp.json()
805
+ assert "design_state" in data
806
+
807
+ def test_chat_at_mention_in_message(self):
808
+ resp = client.post("/api/chat", json={
809
+ "message": "@engineering what thickness?",
810
+ "history": [],
811
+ "backend": "mock",
812
+ })
813
+ assert resp.status_code == 200
814
+ data = resp.json()
815
+ agent_ids = [r["agent_id"] for r in data["responses"]]
816
+ assert "engineering" in agent_ids
817
+
818
+
819
+ class TestReportEndpoint:
820
+ def test_basic_report(self):
821
+ resp = client.post("/api/report", json={
822
+ "part_name": "test_bracket",
823
+ "history": [
824
+ {"role": "agent", "agent_id": "design", "content": "L-bracket design."},
825
+ {"role": "agent", "agent_id": "engineering", "content": "3mm aluminum."},
826
+ {"role": "agent", "agent_id": "cnc", "content": "3-axis OK."},
827
+ ],
828
+ "backend": "mock",
829
+ })
830
+ assert resp.status_code == 200
831
+ data = resp.json()
832
+ assert "report" in data
833
+ assert "test_bracket" in data["report"]
834
+ assert "Design Decisions" in data["report"]
835
+ assert "Engineering Specifications" in data["report"]
836
+ assert "Manufacturing Notes" in data["report"]
837
+
838
+ def test_empty_history(self):
839
+ resp = client.post("/api/report", json={
840
+ "part_name": "empty_part",
841
+ "history": [],
842
+ "backend": "mock",
843
+ })
844
+ assert resp.status_code == 200
845
+ data = resp.json()
846
+ assert "report" in data
847
+
848
+
849
+ class TestAgentsEndpoint:
850
+ def test_list_agents(self):
851
+ resp = client.get("/api/agents")
852
+ assert resp.status_code == 200
853
+ data = resp.json()
854
+ assert "agents" in data
855
+ agent_ids = [a["id"] for a in data["agents"]]
856
+ assert "design" in agent_ids
857
+ assert "engineering" in agent_ids
858
+ assert "cnc" in agent_ids
859
+ assert "cad" in agent_ids
860
+
861
+ def test_agent_has_metadata(self):
862
+ resp = client.get("/api/agents")
863
+ data = resp.json()
864
+ agent = data["agents"][0]
865
+ assert "id" in agent
866
+ assert "name" in agent
867
+ assert "role" in agent
868
+ assert "color" in agent
869
+ assert "avatar" in agent
870
+ ```
871
+
872
+ - [ ] **Step 2: Run tests**
873
+
874
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_api_routes.py -v`
875
+ Expected: All 10 tests PASS
876
+
877
+ - [ ] **Step 3: Commit**
878
+
879
+ ```bash
880
+ git add tests/test_api_routes.py
881
+ git commit -m "test: add FastAPI route tests for chat, report, and agents endpoints"
882
+ ```
883
+
884
+ ---
885
+
886
+ ### Task 7: Test CadQuery Executor and Validator (Integration)
887
+
888
+ **Files:**
889
+ - Create: `tests/test_executor.py`
890
+ - Create: `tests/test_validator.py`
891
+
892
+ - [ ] **Step 1: Write executor tests**
893
+
894
+ ```python
895
+ """Tests for core/executor.py — CadQuery code execution and export.
896
+
897
+ These tests require CadQuery to be installed.
898
+ """
899
+
900
+ import pytest
901
+ from pathlib import Path
902
+ from core.executor import sanitize_code, execute_cadquery, export_step, export_stl, export_all
903
+
904
+ pytestmark = pytest.mark.requires_cadquery
905
+
906
+
907
+ class TestSanitizeCode:
908
+ def test_strips_markdown_fences(self):
909
+ code = "```python\nresult = 1\n```"
910
+ assert "```" not in sanitize_code(code)
911
+
912
+ def test_strips_plain_fences(self):
913
+ code = "```\nresult = 1\n```"
914
+ assert "```" not in sanitize_code(code)
915
+
916
+ def test_removes_cadquery_imports(self):
917
+ code = "import cadquery as cq\nresult = cq.Workplane('XY').box(10,10,10)"
918
+ cleaned = sanitize_code(code)
919
+ assert "import cadquery" not in cleaned
920
+ assert "result" in cleaned
921
+
922
+ def test_removes_math_import(self):
923
+ code = "import math\nresult = cq.Workplane('XY').box(10,10,10)"
924
+ cleaned = sanitize_code(code)
925
+ assert "import math" not in cleaned
926
+
927
+ def test_preserves_valid_code(self):
928
+ code = "result = cq.Workplane('XY').box(10, 20, 30)"
929
+ assert sanitize_code(code) == code
930
+
931
+
932
+ class TestExecuteCadquery:
933
+ def test_simple_box(self):
934
+ result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
935
+ assert result.success is True
936
+ assert result.volume > 0
937
+ assert result.face_count == 6
938
+ assert result.edge_count == 12
939
+ assert len(result.bounding_box) == 3
940
+
941
+ def test_cylinder(self):
942
+ result = execute_cadquery("result = cq.Workplane('XY').cylinder(20, 10)")
943
+ assert result.success is True
944
+ assert result.volume > 0
945
+
946
+ def test_missing_result_variable(self):
947
+ result = execute_cadquery("x = cq.Workplane('XY').box(10,10,10)")
948
+ assert result.success is False
949
+ assert "result" in result.error
950
+
951
+ def test_syntax_error(self):
952
+ result = execute_cadquery("result = cq.Workplane('XY').box(10, 10,")
953
+ assert result.success is False
954
+ assert result.error is not None
955
+
956
+ def test_wrong_type(self):
957
+ result = execute_cadquery("result = 42")
958
+ assert result.success is False
959
+ assert "Workplane" in result.error
960
+
961
+ def test_code_with_markdown_fences(self):
962
+ code = "```python\nimport cadquery as cq\nresult = cq.Workplane('XY').box(5,5,5)\n```"
963
+ result = execute_cadquery(code)
964
+ assert result.success is True
965
+
966
+ def test_summary_on_success(self):
967
+ result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
968
+ summary = result.summary()
969
+ assert "OK" in summary
970
+ assert "Volume" in summary
971
+
972
+ def test_summary_on_failure(self):
973
+ result = execute_cadquery("result = bad_code")
974
+ summary = result.summary()
975
+ assert "FAILED" in summary
976
+
977
+
978
+ class TestExport:
979
+ def test_export_step(self, tmp_path):
980
+ exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
981
+ assert exec_result.success
982
+ path = export_step(exec_result.result, tmp_path / "test.step")
983
+ assert path.exists()
984
+ assert path.suffix == ".step"
985
+
986
+ def test_export_stl(self, tmp_path):
987
+ exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
988
+ assert exec_result.success
989
+ path = export_stl(exec_result.result, tmp_path / "test.stl")
990
+ assert path.exists()
991
+ assert path.suffix == ".stl"
992
+
993
+ def test_export_all(self, tmp_path):
994
+ exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
995
+ assert exec_result.success
996
+ files = export_all(exec_result.result, tmp_path / "part")
997
+ assert files["step"].exists()
998
+ assert files["stl"].exists()
999
+ ```
1000
+
1001
+ - [ ] **Step 2: Run executor tests**
1002
+
1003
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_executor.py -v`
1004
+ Expected: All 13 tests PASS (if CadQuery installed), or all SKIPPED
1005
+
1006
+ - [ ] **Step 3: Write validator tests**
1007
+
1008
+ ```python
1009
+ """Tests for core/validator.py — CNC manufacturability validation.
1010
+
1011
+ These tests require CadQuery to be installed.
1012
+ """
1013
+
1014
+ import pytest
1015
+ from core.executor import execute_cadquery
1016
+ from core.validator import validate_for_cnc, CNCValidationResult, CNCIssue
1017
+
1018
+ pytestmark = pytest.mark.requires_cadquery
1019
+
1020
+
1021
+ def _make_solid(code: str):
1022
+ """Helper to create a CadQuery Workplane from code."""
1023
+ result = execute_cadquery(code)
1024
+ assert result.success, f"Code failed: {result.error}"
1025
+ return result.result
1026
+
1027
+
1028
+ class TestValidateForCnc:
1029
+ def test_simple_box_is_machinable(self):
1030
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
1031
+ val = validate_for_cnc(solid, "test_box")
1032
+ assert val.machinable is True
1033
+ assert val.error_count == 0
1034
+
1035
+ def test_result_has_part_name(self):
1036
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
1037
+ val = validate_for_cnc(solid, "my_part")
1038
+ assert val.part_name == "my_part"
1039
+
1040
+ def test_axis_recommendation_default_3axis(self):
1041
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
1042
+ val = validate_for_cnc(solid)
1043
+ assert "3-axis" in val.axis_recommendation or "3" in val.axis_recommendation
1044
+
1045
+ def test_complex_part_gets_higher_axis(self):
1046
+ # A part with many faces should get a higher axis recommendation
1047
+ code = """
1048
+ result = cq.Workplane('XY').box(50, 50, 50)
1049
+ for i in range(5):
1050
+ result = result.faces('>Z').workplane().pushPoints([(i*8-16, 0)]).hole(3)
1051
+ for i in range(5):
1052
+ result = result.faces('>X').workplane().pushPoints([(i*8-16, 0)]).hole(3)
1053
+ """
1054
+ solid = _make_solid(code)
1055
+ val = validate_for_cnc(solid)
1056
+ # Should have many faces due to holes
1057
+ assert val.part_name is not None
1058
+
1059
+ def test_oversized_part_flagged(self):
1060
+ solid = _make_solid("result = cq.Workplane('XY').box(600, 600, 600)")
1061
+ val = validate_for_cnc(solid, config={"max_part_size_mm": 500.0})
1062
+ assert any(i.category == "Size" for i in val.issues)
1063
+
1064
+ def test_tiny_part_flagged(self):
1065
+ solid = _make_solid("result = cq.Workplane('XY').box(0.5, 0.5, 0.5)")
1066
+ val = validate_for_cnc(solid, config={"min_part_size_mm": 1.0})
1067
+ assert any(i.category == "Size" for i in val.issues)
1068
+
1069
+ def test_summary_format(self):
1070
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
1071
+ val = validate_for_cnc(solid, "test")
1072
+ summary = val.summary()
1073
+ assert isinstance(summary, str)
1074
+ assert "test" in summary
1075
+
1076
+ def test_custom_config(self):
1077
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
1078
+ val = validate_for_cnc(solid, config={"min_wall_thickness_mm": 0.5})
1079
+ assert isinstance(val, CNCValidationResult)
1080
+
1081
+ def test_error_and_warning_counts(self):
1082
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
1083
+ val = validate_for_cnc(solid)
1084
+ assert val.error_count >= 0
1085
+ assert val.warning_count >= 0
1086
+ assert val.error_count + val.warning_count <= len(val.issues)
1087
+ ```
1088
+
1089
+ - [ ] **Step 4: Run validator tests**
1090
+
1091
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_validator.py -v`
1092
+ Expected: All 9 tests PASS
1093
+
1094
+ - [ ] **Step 5: Commit**
1095
+
1096
+ ```bash
1097
+ git add tests/test_executor.py tests/test_validator.py
1098
+ git commit -m "test: add CadQuery executor and CNC validator integration tests"
1099
+ ```
1100
+
1101
+ ---
1102
+
1103
+ ### Task 8: Test Pipeline End-to-End
1104
+
1105
+ **Files:**
1106
+ - Create: `tests/test_pipeline.py`
1107
+
1108
+ - [ ] **Step 1: Write pipeline integration tests**
1109
+
1110
+ ```python
1111
+ """Tests for core/pipeline.py — end-to-end text-to-CNC pipeline.
1112
+
1113
+ These tests require CadQuery to be installed.
1114
+ """
1115
+
1116
+ import pytest
1117
+ from pathlib import Path
1118
+ from core.pipeline import run_pipeline, PipelineResult
1119
+ from core.backends import MockBackend
1120
+
1121
+ pytestmark = pytest.mark.requires_cadquery
1122
+
1123
+
1124
+ class TestRunPipeline:
1125
+ def test_basic_box(self, tmp_output_dir):
1126
+ result = run_pipeline(
1127
+ "A simple 50mm cube",
1128
+ backend=MockBackend(),
1129
+ output_dir=tmp_output_dir,
1130
+ )
1131
+ assert isinstance(result, PipelineResult)
1132
+ assert result.execution.success is True
1133
+ assert result.execution.volume > 0
1134
+
1135
+ def test_exports_files(self, tmp_output_dir):
1136
+ result = run_pipeline(
1137
+ "A 60x40x5mm mounting plate",
1138
+ backend=MockBackend(),
1139
+ output_dir=tmp_output_dir,
1140
+ part_name="test_plate",
1141
+ )
1142
+ assert result.exported_files is not None
1143
+ assert result.exported_files["step"].exists()
1144
+ assert result.exported_files["stl"].exists()
1145
+
1146
+ def test_validation_runs(self, tmp_output_dir):
1147
+ result = run_pipeline(
1148
+ "A 50mm cylinder",
1149
+ backend=MockBackend(),
1150
+ output_dir=tmp_output_dir,
1151
+ validate=True,
1152
+ )
1153
+ assert result.validation is not None
1154
+ assert hasattr(result.validation, "machinable")
1155
+
1156
+ def test_skip_validation(self, tmp_output_dir):
1157
+ result = run_pipeline(
1158
+ "A simple box",
1159
+ backend=MockBackend(),
1160
+ output_dir=tmp_output_dir,
1161
+ validate=False,
1162
+ )
1163
+ assert result.validation is None
1164
+
1165
+ def test_skip_export(self, tmp_output_dir):
1166
+ result = run_pipeline(
1167
+ "A simple box",
1168
+ backend=MockBackend(),
1169
+ output_dir=tmp_output_dir,
1170
+ export=False,
1171
+ )
1172
+ assert result.exported_files is None or len(result.exported_files) == 0
1173
+
1174
+ def test_summary(self, tmp_output_dir):
1175
+ result = run_pipeline(
1176
+ "A 30mm cube",
1177
+ backend=MockBackend(),
1178
+ output_dir=tmp_output_dir,
1179
+ )
1180
+ summary = result.summary()
1181
+ assert isinstance(summary, str)
1182
+
1183
+ def test_default_backend_is_mock(self, tmp_output_dir):
1184
+ # Should work without specifying backend
1185
+ result = run_pipeline(
1186
+ "A basic plate",
1187
+ output_dir=tmp_output_dir,
1188
+ )
1189
+ assert result.execution.success is True
1190
+ ```
1191
+
1192
+ - [ ] **Step 2: Run pipeline tests**
1193
+
1194
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_pipeline.py -v`
1195
+ Expected: All 7 tests PASS
1196
+
1197
+ - [ ] **Step 3: Commit**
1198
+
1199
+ ```bash
1200
+ git add tests/test_pipeline.py
1201
+ git commit -m "test: add end-to-end pipeline integration tests"
1202
+ ```
1203
+
1204
+ ---
1205
+
1206
+ ### Task 9: Update README
1207
+
1208
+ **Files:**
1209
+ - Modify: `README.md`
1210
+
1211
+ - [ ] **Step 1: Rewrite README.md**
1212
+
1213
+ Replace the entire contents of `README.md` with:
1214
+
1215
+ ```markdown
1216
+ ---
1217
+ title: NeuralCAD
1218
+ emoji: ⚙️
1219
+ colorFrom: blue
1220
+ colorTo: indigo
1221
+ sdk: docker
1222
+ app_port: 7860
1223
+ ---
1224
+
1225
+ # NeuralCAD — Multi-Agent CAD Design
1226
+
1227
+ A multi-agent AI system that converts natural language descriptions of mechanical parts into CNC-machinable 3D models (STEP/STL). Four specialized AI agents collaborate with you in a shared chat to design, engineer, validate, and generate CadQuery code.
1228
+
1229
+ ## How It Works
1230
+
1231
+ ```
1232
+ User ──→ Chat Interface ──→ Agent Orchestrator
1233
+
1234
+ ┌───────────────┼───────────────┐
1235
+ │ │ │
1236
+ Design Agent Engineering CNC Agent
1237
+ (form/shape) Agent (manufacturability)
1238
+ │ (specs/dims) │
1239
+ └───────────────┼───────────────┘
1240
+
1241
+ CAD Coder Agent
1242
+ (CadQuery code)
1243
+
1244
+ Execute in Sandbox
1245
+
1246
+ 3D Solid (B-rep)
1247
+ ╱ ╲
1248
+ CNC Validator Exporter
1249
+ (machinability (STEP + STL)
1250
+ checks)
1251
+ ```
1252
+
1253
+ ## Agents
1254
+
1255
+ | Agent | Role | Expertise |
1256
+ |-------|------|-----------|
1257
+ | **Design Agent** | Industrial Designer | Form, aesthetics, ergonomics, shape proposals |
1258
+ | **Engineering Agent** | Mechanical Engineer | Dimensions, tolerances, materials, fastener specs |
1259
+ | **CNC Agent** | Manufacturing Advisor | Tool access, wall thickness, axis requirements, cost |
1260
+ | **CAD Coder** | CadQuery Programmer | Generates valid CadQuery Python code on demand |
1261
+
1262
+ ## Quick Start
1263
+
1264
+ ```bash
1265
+ # Install dependencies
1266
+ pip install -r requirements.txt
1267
+
1268
+ # Run the web app (mock backend, no API key needed)
1269
+ python -m server.web --port 5000
1270
+
1271
+ # Open http://localhost:5000 in your browser
1272
+ ```
1273
+
1274
+ ### With LLM Backends
1275
+
1276
+ ```bash
1277
+ # Gemini (free tier)
1278
+ export GOOGLE_API_KEY=...
1279
+ # Select GEMINI in the web UI backend toggle
1280
+
1281
+ # Claude (recommended for quality)
1282
+ export ANTHROPIC_API_KEY=sk-ant-...
1283
+ # Select CLAUDE in the web UI backend toggle
1284
+
1285
+ # GPT-4o
1286
+ export OPENAI_API_KEY=sk-...
1287
+ ```
1288
+
1289
+ ### CLI Pipeline (Direct)
1290
+
1291
+ ```bash
1292
+ # Mock backend
1293
+ python -m core.pipeline "A mounting bracket with four M6 holes"
1294
+
1295
+ # With Claude
1296
+ python -m core.pipeline "A flanged bearing housing" --backend anthropic
1297
+ ```
1298
+
1299
+ ## Architecture
1300
+
1301
+ ```
1302
+ NeuralCAD/
1303
+ ├── agents/ # Multi-agent orchestration
1304
+ │ ├── definitions.py # Agent roles, colors, personas
1305
+ │ ├── orchestrator.py # Single-call + Mock orchestrators
1306
+ │ ├── crew_orchestrator.py # CrewAI multi-call orchestrator
1307
+ │ ├── prompts.py # System prompts, routing, JSON parsing
1308
+ │ ├── design_state.py # Design decision accumulator
1309
+ │ └── llm_adapter.py # CrewAI LLM adapter
1310
+ ├── core/ # CAD generation pipeline
1311
+ │ ├── backends.py # LLM backends (Mock, Anthropic, OpenAI, Gemini)
1312
+ │ ├── pipeline.py # Text-to-CNC orchestrator + CLI
1313
+ │ ├── executor.py # Sandboxed CadQuery execution + export
1314
+ │ ├── validator.py # CNC manufacturability checker
1315
+ │ └── cadquery_prompts.py # CadQuery system prompt + few-shot examples
1316
+ ├── server/ # Web + MCP servers
1317
+ │ ├── web.py # FastAPI app, static serving
1318
+ │ ├── routes.py # Chat API endpoints
1319
+ │ └── mcp.py # MCP server (Claude Desktop / Claude Code)
1320
+ ├── web/
1321
+ │ └── index.html # Frontend: Three.js viewer + chat panel
1322
+ └── tests/ # Test suite
1323
+ ```
1324
+
1325
+ ### Orchestration Modes
1326
+
1327
+ | Backend | Mode | API Calls/Turn | Use Case |
1328
+ |---------|------|----------------|----------|
1329
+ | Mock | Template-based | 0 | UI development, demos |
1330
+ | Gemini | Single-call | 1 | Free tier, rate-limited |
1331
+ | Anthropic | CrewAI multi-call | 2-4 | Best quality |
1332
+ | OpenAI | CrewAI multi-call | 2-4 | Best quality |
1333
+
1334
+ ### Chat API
1335
+
1336
+ **POST /api/chat** — Multi-agent chat turn
1337
+
1338
+ ```json
1339
+ {
1340
+ "message": "Make it 60mm wide with M4 base mounting",
1341
+ "history": [{"role": "user", "content": "I need a servo bracket"}],
1342
+ "mentions": [],
1343
+ "backend": "mock"
1344
+ }
1345
+ ```
1346
+
1347
+ **POST /api/report** — Generate design report from conversation
1348
+
1349
+ **GET /api/agents** — List available agents and metadata
1350
+
1351
+ ## Features
1352
+
1353
+ - **Multi-agent chat** — 4 specialist agents collaborate on part design
1354
+ - **@mention system** — Direct messages to specific agents (`@design`, `@engineering`, `@cnc`, `@cad`)
1355
+ - **3D preview** — Real-time STL rendering with Three.js (orbit, zoom, pan)
1356
+ - **Design state tracking** — Accumulates decisions across turns (localStorage persistence)
1357
+ - **CNC validation** — Checks wall thickness, pocket ratios, tool access, axis requirements
1358
+ - **Model gallery** — Browse and reload previously generated models
1359
+ - **STEP + STL export** — Download CAM-ready files
1360
+ - **MCP server** — Use from Claude Desktop or Claude Code
1361
+
1362
+ ## MCP Server
1363
+
1364
+ ```bash
1365
+ # Connect to Claude Code
1366
+ claude mcp add text-to-cnc python3 -m server.mcp
1367
+
1368
+ # Run standalone (SSE for remote integrations)
1369
+ python -m server.mcp --transport sse --port 8000
1370
+ ```
1371
+
1372
+ ### MCP Tools
1373
+
1374
+ | Tool | Description |
1375
+ |------|-------------|
1376
+ | `generate_cnc_model` | Text → CadQuery code → 3D solid → STEP/STL |
1377
+ | `validate_cnc_model` | Run manufacturability checks on CadQuery code |
1378
+ | `execute_cadquery_code` | Execute arbitrary CadQuery code |
1379
+ | `chat_turn` | Multi-agent chat turn |
1380
+ | `list_models` | List generated models |
1381
+
1382
+ ## Testing
1383
+
1384
+ ```bash
1385
+ # All tests
1386
+ python -m pytest
1387
+
1388
+ # Pure logic tests only (no CadQuery needed)
1389
+ python -m pytest -m "not requires_cadquery"
1390
+
1391
+ # Integration tests
1392
+ python -m pytest -m requires_cadquery
1393
+
1394
+ # Verbose
1395
+ python -m pytest -v
1396
+ ```
1397
+
1398
+ ## Docker
1399
+
1400
+ ```bash
1401
+ docker compose up --build
1402
+ # Open http://localhost:7860
1403
+ ```
1404
+
1405
+ ## Key Research
1406
+
1407
+ - **Text-to-CadQuery** (2025) — LLM generates CadQuery code directly
1408
+ - **GenCAD** (2024) — Transformer + diffusion for image to CAD
1409
+ - **NURBGen** (2025) — NURBS-based B-rep from text via LLM
1410
+ ```
1411
+
1412
+ - [ ] **Step 2: Verify README renders correctly**
1413
+
1414
+ Run: `cd /home/daniel/NeuralCAD && head -20 README.md`
1415
+ Expected: See the HF Spaces frontmatter and title
1416
+
1417
+ - [ ] **Step 3: Commit**
1418
+
1419
+ ```bash
1420
+ git add README.md
1421
+ git commit -m "docs: rewrite README to document multi-agent architecture"
1422
+ ```
1423
+
1424
+ ---
1425
+
1426
+ ### Task 10: Improve Keyword Routing Accuracy
1427
+
1428
+ **Files:**
1429
+ - Modify: `agents/prompts.py:162-179`
1430
+
1431
+ - [ ] **Step 1: Write a failing test for weak routing**
1432
+
1433
+ Append to `tests/test_prompts.py`:
1434
+
1435
+ ```python
1436
+ class TestRouteByKeywordsImproved:
1437
+ """Tests for improved keyword routing coverage."""
1438
+
1439
+ def test_gear_routes_to_engineering(self):
1440
+ agents = route_by_keywords("I need a spur gear with 20 teeth")
1441
+ assert "engineering" in agents
1442
+
1443
+ def test_bearing_routes_to_engineering(self):
1444
+ agents = route_by_keywords("Design a bearing housing")
1445
+ assert "engineering" in agents
1446
+
1447
+ def test_heatsink_routes_to_engineering(self):
1448
+ agents = route_by_keywords("Create a heatsink with fins")
1449
+ assert "engineering" in agents
1450
+
1451
+ def test_flange_routes_to_engineering(self):
1452
+ agents = route_by_keywords("A pipe flange with bolt holes")
1453
+ assert "engineering" in agents
1454
+
1455
+ def test_servo_bracket_routes_to_design(self):
1456
+ agents = route_by_keywords("Design a servo bracket for a camera gimbal")
1457
+ assert "design" in agents
1458
+
1459
+ def test_cost_routes_to_cnc(self):
1460
+ agents = route_by_keywords("How much would this cost to machine?")
1461
+ assert "cnc" in agents
1462
+
1463
+ def test_surface_finish_routes_to_cnc(self):
1464
+ agents = route_by_keywords("What surface finish can we achieve?")
1465
+ assert "cnc" in agents
1466
+ ```
1467
+
1468
+ - [ ] **Step 2: Run new routing tests to see which fail**
1469
+
1470
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestRouteByKeywordsImproved -v`
1471
+ Expected: Some tests FAIL (gear, bearing, heatsink, flange, surface finish not in keywords)
1472
+
1473
+ - [ ] **Step 3: Add missing keywords to _ROUTING_KEYWORDS**
1474
+
1475
+ In `agents/prompts.py`, replace the `_ROUTING_KEYWORDS` dict (lines 162-179) with:
1476
+
1477
+ ```python
1478
+ _ROUTING_KEYWORDS: dict[str, list[str]] = {
1479
+ "design": [
1480
+ "design", "look", "shape", "style", "form", "aesthetic", "appearance",
1481
+ "layout", "concept", "idea", "propose", "suggest", "bracket", "mount",
1482
+ "enclosure", "housing", "ergonomic", "profile", "contour",
1483
+ ],
1484
+ "engineering": [
1485
+ "dimension", "tolerance", "material", "strength", "load", "stress",
1486
+ "thickness", "wall", "fillet", "radius", "clearance",
1487
+ "m2", "m3", "m4", "m5", "m6", "m8", "m10", "m12",
1488
+ "aluminum", "steel", "brass", "titanium", "nylon",
1489
+ "gear", "bearing", "flange", "heatsink", "fin", "rib",
1490
+ "bolt", "screw", "thread", "torque", "deflection",
1491
+ "hole", "bore", "shaft", "keyway", "spline",
1492
+ ],
1493
+ "cnc": [
1494
+ "machine", "mill", "cnc", "manufacture", "machinable", "axis",
1495
+ "tool", "fixture", "setup", "pocket", "undercut", "access",
1496
+ "3-axis", "5-axis", "cost", "surface finish", "roughness",
1497
+ "endmill", "drill", "tap", "chamfer tool", "deburr",
1498
+ "setup count", "cycle time", "tolerance class",
1499
+ ],
1500
+ "cad": CAD_TRIGGER_KEYWORDS,
1501
+ }
1502
+ ```
1503
+
1504
+ - [ ] **Step 4: Run improved routing tests**
1505
+
1506
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/test_prompts.py::TestRouteByKeywordsImproved -v`
1507
+ Expected: All 7 tests PASS
1508
+
1509
+ - [ ] **Step 5: Run full test suite to check for regressions**
1510
+
1511
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v`
1512
+ Expected: All tests PASS
1513
+
1514
+ - [ ] **Step 6: Commit**
1515
+
1516
+ ```bash
1517
+ git add agents/prompts.py tests/test_prompts.py
1518
+ git commit -m "feat: expand keyword routing vocabulary for better agent selection"
1519
+ ```
1520
+
1521
+ ---
1522
+
1523
+ ### Task 11: Run Full Test Suite and Verify
1524
+
1525
+ - [ ] **Step 1: Run the complete test suite**
1526
+
1527
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short`
1528
+ Expected: All tests PASS. Target count: ~70+ tests.
1529
+
1530
+ - [ ] **Step 2: Run with marker filter**
1531
+
1532
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -m "not requires_cadquery" -v`
1533
+ Expected: Pure-logic tests pass independently.
1534
+
1535
+ - [ ] **Step 3: Check test coverage summary**
1536
+
1537
+ Run: `cd /home/daniel/NeuralCAD && python -m pytest tests/ -v --tb=short 2>&1 | tail -5`
1538
+ Expected: Summary line showing total pass count.
docs/superpowers/specs/2026-04-08-multi-agent-chat-design.md ADDED
@@ -0,0 +1,390 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuralCAD Multi-Agent Chat Design
2
+
3
+ ## Context
4
+
5
+ NeuralCAD currently uses a single-prompt flow: user describes a part, one LLM call generates CadQuery code, and the result renders in a 3D viewer. This works for simple parts but doesn't support iterative design refinement.
6
+
7
+ The goal is to replace this with a **multi-agent chat experience** where 4 specialized AI agents (Design, Engineering, CNC, CAD Coder) collaborate with the user in a shared conversation to plan and refine a mechanical part before generating the 3D model. The user drives the conversation, agents contribute their expertise, and the user can request a 3D preview on demand.
8
+
9
+ ## Agent Definitions
10
+
11
+ Four agents participate in a shared group chat. Each has a distinct role, color, and expertise:
12
+
13
+ | Agent | ID | Color | Avatar | Role |
14
+ |-------|----|-------|--------|------|
15
+ | Design Agent | `design` | `#7c3aed` (purple) | DA | Industrial/product design: shape, form, aesthetics, ergonomics. Asks about intent, proposes form factors, considers user experience. |
16
+ | Engineering Agent | `engineering` | `#00b4d8` (cyan) | EA | Structural/mechanical: dimensions, tolerances, materials, stress analysis, fastener specs (M3/M4/M6 clearance holes). |
17
+ | CNC/Manufacturing Agent | `cnc` | `#00e676` (green) | CA | Manufacturability: tool access, wall thickness, pocket aspect ratios, axis requirements, fixturing, cost implications. |
18
+ | CAD Coder Agent | `cad` | `#ffab40` (amber) | CC | Code generation: takes the agreed design and produces CadQuery Python code. Only responds when a preview is requested. |
19
+
20
+ ## Orchestration Architecture
21
+
22
+ ### Hybrid Approach: CrewAI Agents + Custom Orchestrator
23
+
24
+ Use **CrewAI** for agent definitions (roles, goals, backstories) and the `BaseLLM` adapter pattern, but implement **two orchestration modes**:
25
+
26
+ #### Single-Call Mode (Gemini Free Tier / Mock)
27
+
28
+ One LLM call per user turn. The system prompt contains all agent personas and routing rules. The LLM returns a structured JSON response:
29
+
30
+ ```json
31
+ {
32
+ "agents": [
33
+ {"id": "design", "message": "For an MG996R servo, I'd suggest an L-bracket..."},
34
+ {"id": "engineering", "message": "3mm wall thickness in aluminum 6061..."}
35
+ ]
36
+ }
37
+ ```
38
+
39
+ The orchestrator system prompt instructs the LLM to:
40
+ - Analyze the user's message and conversation context
41
+ - Select 1-3 relevant agents to respond (never all four unless appropriate)
42
+ - Generate each agent's response in character
43
+ - Only include the `cad` agent when the user explicitly requests a preview
44
+ - When `cad` responds, include a `code` field with valid CadQuery Python
45
+
46
+ If the user @mentions specific agents, the system prompt is modified to only include those agents.
47
+
48
+ **Fallback**: If JSON parsing fails, use rule-based keyword matching to select agents and re-call the LLM with a simpler prompt for just those agents.
49
+
50
+ #### Multi-Call Mode (Anthropic / OpenAI)
51
+
52
+ CrewAI's hierarchical process with a manager agent. Each agent gets its own LLM call with focused context. The manager routes based on conversation state. Better quality but uses 2-4 API calls per turn.
53
+
54
+ #### Mode Selection
55
+
56
+ | Backend | Mode | Reason |
57
+ |---------|------|--------|
58
+ | `mock` | Template-based | No LLM call. Returns canned agent responses based on keyword matching. For the CAD Coder agent, delegates to the existing MockBackend which generates CadQuery code from prompt parsing. Useful for UI development and demos without API keys. |
59
+ | `gemini` | Single-call | Free tier rate limits (15 RPM) |
60
+ | `anthropic` | Multi-call | Paid API, better quality |
61
+ | `openai` | Multi-call | Paid API, better quality |
62
+
63
+ ### @Mention System
64
+
65
+ Users can direct messages to specific agents by typing `@design`, `@engineering`, `@cnc`, or `@cad` in their message.
66
+
67
+ - Frontend parses @mentions from the message text before sending
68
+ - @mentions are sent as a `mentions` array in the API request
69
+ - When mentions are present:
70
+ - Single-call mode: system prompt only includes mentioned agents' personas
71
+ - Multi-call mode: only mentioned agents are activated in the crew
72
+ - When no mentions: orchestrator decides which agents respond
73
+ - `@cad` triggers CAD code generation (same as clicking the preview button)
74
+
75
+ ### Agent Prompt Structure
76
+
77
+ Each agent has:
78
+ - **System persona**: role description, expertise, communication style
79
+ - **Conversation context**: last N messages from the chat history
80
+ - **User message**: the current message with @mention context
81
+
82
+ The CAD Coder agent additionally receives:
83
+ - The full CadQuery system prompt (from `cadquery_system_prompt.py`)
84
+ - Few-shot examples of CadQuery code
85
+ - A summary of design decisions from the conversation so far
86
+
87
+ ## Chat API
88
+
89
+ ### Endpoint: `POST /api/chat`
90
+
91
+ **Request:**
92
+ ```json
93
+ {
94
+ "history": [
95
+ {"role": "user", "content": "I need a servo bracket"},
96
+ {"role": "design", "content": "What type of servo?"},
97
+ {"role": "user", "content": "MG996R, for a camera gimbal"}
98
+ ],
99
+ "message": "Make it 60mm wide with M4 base mounting",
100
+ "mentions": [],
101
+ "backend": "gemini"
102
+ }
103
+ ```
104
+
105
+ **Response:**
106
+ ```json
107
+ {
108
+ "responses": [
109
+ {
110
+ "agent_id": "design",
111
+ "agent_name": "Design Agent",
112
+ "message": "L-bracket with servo pocket on vertical face...",
113
+ "color": "#7c3aed",
114
+ "avatar": "DA"
115
+ },
116
+ {
117
+ "agent_id": "engineering",
118
+ "agent_name": "Engineering Agent",
119
+ "message": "3mm walls, 5mm fillet on the L-bend...",
120
+ "color": "#00b4d8",
121
+ "avatar": "EA"
122
+ }
123
+ ],
124
+ "preview": null
125
+ }
126
+ ```
127
+
128
+ **Response with preview (when CAD Coder responds):**
129
+ ```json
130
+ {
131
+ "responses": [
132
+ {
133
+ "agent_id": "cad",
134
+ "agent_name": "CAD Coder",
135
+ "message": "Model generated successfully.",
136
+ "color": "#ffab40",
137
+ "avatar": "CC",
138
+ "code": "import cadquery as cq\nresult = cq.Workplane('XY')..."
139
+ }
140
+ ],
141
+ "preview": {
142
+ "part_name": "servo_bracket",
143
+ "stl_url": "/api/models/servo_bracket.stl",
144
+ "step_url": "/api/models/servo_bracket.step",
145
+ "execution": {
146
+ "success": true,
147
+ "volume_mm3": 4230.5,
148
+ "bounding_box_mm": [60.0, 43.0, 25.0],
149
+ "face_count": 34,
150
+ "edge_count": 52
151
+ },
152
+ "validation": {
153
+ "machinable": true,
154
+ "axis_recommendation": "3-axis",
155
+ "issues": []
156
+ }
157
+ }
158
+ }
159
+ ```
160
+
161
+ ### State Management
162
+
163
+ - **Stateless backend**: frontend sends full conversation history with each request
164
+ - **Backend truncation**: history truncated to last 30 messages to stay within token limits
165
+ - **No sessions**: no server-side session storage needed
166
+ - **Gallery persistence**: saved models stored in the `output/` directory with metadata JSON files
167
+
168
+ ### Existing Endpoints (Preserved)
169
+
170
+ - `GET /api/models` — list generated models
171
+ - `GET /api/models/{name}.stl` — download STL
172
+ - `GET /api/models/{name}.step` — download STEP
173
+ - `GET /api/capabilities` — server status
174
+
175
+ ### New Endpoint: `POST /api/report`
176
+
177
+ Generates a design report document. Requires conversation history since the backend is stateless.
178
+
179
+ **Request:**
180
+ ```json
181
+ {
182
+ "part_name": "servo_bracket",
183
+ "history": [...],
184
+ "backend": "gemini"
185
+ }
186
+ ```
187
+
188
+ The LLM summarizes the conversation into a report containing:
189
+ - Design decisions extracted from conversation
190
+ - Final dimensions and specifications
191
+ - CNC validation results and axis recommendation
192
+ - Agent recommendations summary
193
+
194
+ For `mock` backend, the report is assembled from the last CAD Coder response metadata without an LLM call.
195
+
196
+ ## Frontend Design
197
+
198
+ ### Layout: Fullscreen 3D Viewer + Slide-out Chat
199
+
200
+ The 3D viewer occupies the **entire viewport** as the primary element. The chat panel slides in/out from the right side.
201
+
202
+ ```
203
+ +--[TopBar: Logo | Backend Toggle | Status]------------------+
204
+ | | CHAT |
205
+ | | PANEL |
206
+ | FULLSCREEN 3D VIEWER | (340px)|
207
+ | (Three.js WebGL) | |
208
+ | | [msgs] |
209
+ | [Geo Stats] [CNC Badge] | [msgs] |
210
+ | | [msgs] |
211
+ | |--------|
212
+ | [STEP] [STL] [Report] |[input] |
213
+ +----------------------------------------------------+--------+
214
+ ```
215
+
216
+ ### 3D Viewer
217
+
218
+ - Same Three.js setup as current (STLLoader, OrbitControls, MeshPhongMaterial)
219
+ - Fullscreen, edge-to-edge behind the chat panel
220
+ - Semi-transparent overlays for geo stats (top-left), CNC badge (top-right of viewer area), downloads (bottom-left)
221
+ - Empty state shows a subtle prompt: "Start a conversation to design your part"
222
+ - When model is loaded: auto-centers, fits camera to bounding box, slow auto-rotate when idle
223
+
224
+ ### Chat Panel
225
+
226
+ - **Width**: 340px, slides in from right
227
+ - **Background**: semi-transparent (`rgba(10,14,20,0.92)`) with `backdrop-filter: blur(16px)` so the 3D model is visible behind
228
+ - **Collapse/expand**: toggle button (chevron) in chat header, or floating pill at bottom center when collapsed
229
+ - **Agent dots**: row of 4 colored dots in the header showing active agents
230
+
231
+ #### Message Rendering
232
+
233
+ - **User messages**: right-aligned, dark blue bubble (`#1a2a3a`), rounded corners
234
+ - **Agent messages**: left-aligned with colored avatar circle (24px), agent label above message text in agent's color
235
+ - **CAD Coder messages**: distinct background (`rgba(255,171,64,0.08)`) with "View CadQuery code" link
236
+
237
+ #### Input Area
238
+
239
+ - Text input with placeholder "Type your message..."
240
+ - **@mention autocomplete**: typing `@` shows a dropdown with agent names, selecting inserts `@design` etc.
241
+ - **Preview button** (eye icon, amber `#ffab40`): triggers CAD Coder to generate 3D model from current conversation
242
+ - **Send button** (arrow icon, cyan `#00b4d8`): sends the message
243
+ - **Ctrl/Cmd+Enter**: keyboard shortcut to send
244
+
245
+ #### Quick Examples
246
+
247
+ On first load (empty chat), show example conversation starters as clickable chips:
248
+ - "Design a mounting bracket for an MG996R servo"
249
+ - "I need a spur gear with 20 teeth"
250
+ - "Create a heatsink for a 30mm cylinder"
251
+ - "Design a pipe flange with M8 bolt holes"
252
+
253
+ These insert the text into the chat input and auto-send.
254
+
255
+ ### Gallery
256
+
257
+ - Accessed via a button in the top bar (not a tab)
258
+ - Opens as a modal/dropdown overlay
259
+ - Shows previously generated models as cards with thumbnail, name, face count, CNC status
260
+ - Click to load model into the 3D viewer
261
+
262
+ ### Backend Toggle
263
+
264
+ - Same as current: MOCK / GEMINI / CLAUDE radio buttons in the top bar
265
+ - Changing backend affects which orchestration mode is used (single-call vs multi-call)
266
+
267
+ ## Refactored File Structure
268
+
269
+ ```
270
+ NeuralCAD/
271
+ ├── agents/
272
+ │ ├── __init__.py
273
+ │ ├── definitions.py # CrewAI Agent + Task definitions for all 4 agents
274
+ │ ├── orchestrator.py # Single-call JSON orchestrator (Gemini/Mock mode)
275
+ │ ├── crew_orchestrator.py # Multi-call CrewAI hierarchical process (Anthropic/OpenAI)
276
+ │ ├── llm_adapter.py # CrewAI BaseLLM wrapper around LLMBackend
277
+ │ └── prompts.py # Agent system prompts, personas, routing rules
278
+ ├── core/
279
+ │ ├── __init__.py
280
+ │ ├── backends.py # LLMBackend base + AnthropicBackend, OpenAIBackend,
281
+ │ │ # GeminiBackend, MockBackend (extracted from pipeline.py)
282
+ │ ├── executor.py # Sandboxed CadQuery execution (from code_executor.py)
283
+ │ ├── validator.py # CNC validation (from cnc_validator.py)
284
+ │ ├── cadquery_prompts.py # CadQuery system prompt + few-shot examples
285
+ │ │ # (from cadquery_system_prompt.py)
286
+ │ └── pipeline.py # run_pipeline() for CAD generation
287
+ │ │ # (simplified, called by CAD Coder agent)
288
+ ├── server/
289
+ │ ├── __init__.py
290
+ │ ├── web.py # FastAPI app, static file serving (from web_server.py)
291
+ │ ├── mcp.py # MCP server + tools (from mcp_server.py)
292
+ │ └── routes.py # /api/chat, /api/report endpoints
293
+ ├── web/
294
+ │ └── index.html # Complete rewrite: fullscreen 3D viewer + slide-out chat
295
+ ├── pyproject.toml # + crewai dependency
296
+ ├── Dockerfile # Updated for new structure
297
+ ├── docker-compose.yml # Same services, updated paths
298
+ └── entrypoint.sh # Updated entry point
299
+ ```
300
+
301
+ ### Key Refactoring Notes
302
+
303
+ - `pipeline.py` (922 lines) is split into `core/backends.py` (LLM backends), `core/pipeline.py` (run_pipeline), and `agents/` (orchestration)
304
+ - `code_executor.py` → `core/executor.py` (unchanged logic)
305
+ - `cnc_validator.py` → `core/validator.py` (unchanged logic)
306
+ - `cadquery_system_prompt.py` → `core/cadquery_prompts.py` (unchanged logic)
307
+ - `web_server.py` → `server/web.py` + `server/routes.py`
308
+ - `mcp_server.py` → `server/mcp.py` (add `chat_turn` MCP tool for Claude Desktop)
309
+ - `web/index.html` → complete rewrite
310
+
311
+ ## Data Flow: Chat Turn
312
+
313
+ ```
314
+ 1. User types message in chat UI
315
+ 2. Frontend parses @mentions from message text
316
+ 3. POST /api/chat { history, message, mentions, backend }
317
+ |
318
+ 4. Backend selects orchestration mode:
319
+ ├── gemini/mock → Single-call orchestrator
320
+ │ ├── Build system prompt with agent personas + routing rules
321
+ │ ├── Include conversation history (last 30 msgs)
322
+ │ ├── If @mentions: only include mentioned agent personas
323
+ │ ├── LLMBackend.generate(messages) → JSON string
324
+ │ ├── Parse JSON → list of agent responses
325
+ │ └── Fallback: keyword routing + simpler re-call if JSON fails
326
+
327
+ └── anthropic/openai → CrewAI hierarchical process
328
+ ├── Manager agent routes to relevant agents
329
+ ├── Each agent gets own LLM call via NeuralCADLLMAdapter
330
+ └── Collect responses from all activated agents
331
+ |
332
+ 5. If CAD Coder agent responded with code:
333
+ ├── execute_cadquery(code) → ExecutionResult
334
+ ├── export_step() + export_stl() → files in output/
335
+ ├── validate_for_cnc() → CNCValidationResult
336
+ └── Build preview object with URLs and metadata
337
+ |
338
+ 6. Return JSON response → Frontend renders agent messages
339
+ |
340
+ 7. If preview present:
341
+ ├── Load STL into Three.js viewer
342
+ ├── Show geo stats overlay
343
+ ├── Show CNC badge
344
+ └── Enable download buttons
345
+ ```
346
+
347
+ ## MCP Compatibility
348
+
349
+ Add a new `chat_turn` MCP tool alongside existing tools:
350
+
351
+ ```python
352
+ @mcp.tool()
353
+ async def chat_turn(
354
+ message: str,
355
+ history: list[dict] | None = None,
356
+ mentions: list[str] | None = None,
357
+ backend: str = "gemini"
358
+ ) -> dict:
359
+ """Multi-agent chat turn for collaborative CAD design."""
360
+ ```
361
+
362
+ Existing MCP tools (`generate_cnc_model`, `validate_cnc_model`, `execute_cadquery_code`, `list_models`) remain unchanged for backward compatibility with Claude Desktop.
363
+
364
+ ## Verification Plan
365
+
366
+ ### Unit Tests
367
+ - Test single-call orchestrator JSON parsing with valid and malformed responses
368
+ - Test @mention parsing in frontend
369
+ - Test keyword-based fallback routing
370
+ - Test LLM adapter wraps LLMBackend correctly
371
+
372
+ ### Integration Tests
373
+ - Full chat turn: user message → agent responses → verify correct agents selected
374
+ - Preview generation: chat with `@cad` → verify STL/STEP files created
375
+ - Backend switching: verify single-call mode for Gemini, multi-call for Anthropic
376
+ - Conversation history truncation at 30 messages
377
+
378
+ ### Manual Testing
379
+ - Open web UI, start a conversation about a servo bracket
380
+ - Verify agents respond with appropriate expertise
381
+ - Use @mentions to direct messages to specific agents
382
+ - Click preview button → verify 3D model loads
383
+ - Download STEP and STL files
384
+ - Collapse/expand chat panel
385
+ - Test with Gemini free tier (rate limit behavior)
386
+ - Test quick example conversation starters
387
+
388
+ ### MCP Testing
389
+ - Call `chat_turn` tool from Claude Desktop
390
+ - Verify existing MCP tools still work
docs/superpowers/specs/2026-04-08-uv-docker-deploy-design.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuralCAD — uv, Docker, and HF Spaces Deployment
2
+
3
+ ## Overview
4
+
5
+ Set up astral uv as the Python package manager, containerize with Docker, and deploy the full pipeline (web server + MCP CAD server) to Hugging Face Spaces for a free investor demo.
6
+
7
+ ## 1. uv Setup
8
+
9
+ ### pyproject.toml
10
+
11
+ Replace `requirements.txt` with `pyproject.toml` as the single source of truth for dependencies:
12
+
13
+ ```toml
14
+ [project]
15
+ name = "neuralcad"
16
+ version = "1.0.0"
17
+ description = "Text-to-CNC pipeline: natural language to machinable 3D models"
18
+ requires-python = ">=3.10"
19
+ dependencies = [
20
+ "cadquery>=2.7.0",
21
+ "cadquery-ocp>=7.8.0",
22
+ "numpy>=1.24.0",
23
+ "trimesh>=4.0.0",
24
+ "anthropic>=0.25.0",
25
+ "openai>=1.30.0",
26
+ "mcp>=1.0.0",
27
+ "fastapi>=0.110.0",
28
+ "uvicorn>=0.29.0",
29
+ "python-multipart>=0.0.9",
30
+ ]
31
+
32
+ [dependency-groups]
33
+ dev = ["ruff", "pytest"]
34
+ ```
35
+
36
+ ### Lockfile
37
+
38
+ Run `uv lock` to generate `uv.lock` for reproducible installs. Commit `uv.lock` to git.
39
+
40
+ ### Workflow
41
+
42
+ - `uv sync` — install all dependencies
43
+ - `uv run python web_server.py` — run web server
44
+ - `uv run python mcp_server.py --transport sse` — run MCP server
45
+ - `uv sync --group dev` — install dev tools
46
+
47
+ ### Migration
48
+
49
+ - `requirements.txt` is kept for backward compatibility but marked with a comment pointing to `pyproject.toml` as the source of truth.
50
+
51
+ ## 2. Docker
52
+
53
+ ### Dockerfile (multi-stage)
54
+
55
+ **Stage 1: builder**
56
+ - Base: `python:3.11-slim`
57
+ - Install uv via the official installer
58
+ - Copy `pyproject.toml` + `uv.lock`
59
+ - Run `uv sync --frozen --no-dev` to install dependencies into `.venv`
60
+ - This stage is cached — only rebuilds when dependencies change
61
+
62
+ **Stage 2: runtime**
63
+ - Base: `python:3.11-slim`
64
+ - Copy `.venv` from builder (contains all installed packages)
65
+ - Copy application source code
66
+ - Set `PATH` to include `.venv/bin`
67
+ - Expose port 7860 (HF Spaces convention)
68
+ - Run `entrypoint.sh`
69
+
70
+ ### entrypoint.sh
71
+
72
+ ```bash
73
+ #!/bin/bash
74
+ # Start MCP CAD server in background
75
+ python mcp_server.py --transport sse --port 8000 &
76
+
77
+ # Wait for MCP server to be ready
78
+ sleep 3
79
+
80
+ # Start web server in foreground on HF Spaces port
81
+ export MCP_SERVER_URL=http://localhost:8000/sse
82
+ exec python web_server.py --host 0.0.0.0 --port ${PORT:-7860}
83
+ ```
84
+
85
+ ### docker-compose.yml (local dev)
86
+
87
+ Two services for development:
88
+
89
+ ```yaml
90
+ services:
91
+ mcp-server:
92
+ build: .
93
+ command: python mcp_server.py --transport sse --port 8000
94
+ ports:
95
+ - "8000:8000"
96
+ volumes:
97
+ - ./output:/app/output
98
+
99
+ web:
100
+ build: .
101
+ command: python web_server.py --host 0.0.0.0 --port 5000
102
+ ports:
103
+ - "5000:5000"
104
+ environment:
105
+ MCP_SERVER_URL: http://mcp-server:8000/sse
106
+ depends_on:
107
+ - mcp-server
108
+ volumes:
109
+ - ./output:/app/output
110
+ ```
111
+
112
+ ### .dockerignore
113
+
114
+ ```
115
+ .git
116
+ .gitignore
117
+ __pycache__
118
+ *.pyc
119
+ output/
120
+ .superpowers/
121
+ docs/
122
+ *.md
123
+ .env
124
+ ```
125
+
126
+ ## 3. Hugging Face Spaces Deployment
127
+
128
+ ### Space Configuration
129
+
130
+ HF Spaces reads metadata from a YAML header in `README.md`:
131
+
132
+ ```yaml
133
+ ---
134
+ title: NeuralCAD
135
+ emoji: ⚙️
136
+ colorFrom: blue
137
+ colorTo: cyan
138
+ sdk: docker
139
+ app_port: 7860
140
+ ---
141
+ ```
142
+
143
+ This tells HF to build the Dockerfile and route traffic to port 7860.
144
+
145
+ ### How it works
146
+
147
+ 1. Push repo to HF (or link GitHub repo)
148
+ 2. HF builds the Docker image
149
+ 3. Container starts: `entrypoint.sh` launches MCP + web servers
150
+ 4. Public URL: `https://callmedaniel-neuralcad.hf.space`
151
+
152
+ ### Free tier constraints
153
+
154
+ - 16GB RAM, 2 vCPU (sufficient for CadQuery)
155
+ - 50GB disk (sufficient for OpenCascade)
156
+ - Sleeps after ~15min inactivity
157
+ - Wakes on HTTP request (~30s cold start for CadQuery container)
158
+ - No persistent storage across rebuilds (output/ is ephemeral)
159
+
160
+ ### Environment variables
161
+
162
+ For live LLM generation (optional), set as HF Space secrets:
163
+ - `ANTHROPIC_API_KEY` — enables Claude backend
164
+ - `OPENAI_API_KEY` — enables GPT-4o backend
165
+
166
+ Mock backend always works without API keys.
167
+
168
+ ## New/Modified Files
169
+
170
+ | File | Action | Purpose |
171
+ |------|--------|---------|
172
+ | `pyproject.toml` | Create | Project metadata + dependencies for uv |
173
+ | `uv.lock` | Generate | Lockfile (via `uv lock`) |
174
+ | `Dockerfile` | Create | Multi-stage production build |
175
+ | `docker-compose.yml` | Create | Local dev with two services |
176
+ | `.dockerignore` | Create | Exclude files from Docker build |
177
+ | `entrypoint.sh` | Create | Container startup (MCP bg + web fg) |
178
+ | `README.md` | Modify | Add HF Spaces YAML header |
179
+ | `.gitignore` | Modify | Add `.venv/`, `uv.lock` pattern notes |
180
+ | `requirements.txt` | Modify | Add comment pointing to pyproject.toml |
181
+
182
+ ## Verification
183
+
184
+ 1. **uv**: `uv sync && uv run python -c "import cadquery; print('ok')"` — deps install and CadQuery loads
185
+ 2. **Docker local**: `docker compose up --build` → open http://localhost:5000 → click quick example → 3D model renders
186
+ 3. **Docker single**: `docker build -t neuralcad . && docker run -p 7860:7860 neuralcad` → open http://localhost:7860
187
+ 4. **HF Spaces**: push to HF repo → Space builds → open public URL → demo works
docs/superpowers/specs/2026-04-08-web-demo-design.md ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # NeuralCAD Web Demo — Design Spec
2
+
3
+ ## Overview
4
+
5
+ A web-based investor demo for the NeuralCAD text-to-CNC pipeline. Users type a part description, the system generates CadQuery code via an LLM, executes it, validates for CNC manufacturability, and displays the resulting 3D model in an interactive viewer — all in the browser.
6
+
7
+ ## Architecture
8
+
9
+ ```
10
+ Browser (index.html)
11
+ │ fetch() REST
12
+
13
+ FastAPI web server (web_server.py, port 5000)
14
+ │ MCP SSE client
15
+
16
+ MCP CAD server (mcp_server.py --transport sse, port 8000)
17
+ │ Python imports
18
+
19
+ Pipeline (pipeline.py → code_executor → cnc_validator)
20
+ ```
21
+
22
+ **Two separate processes:**
23
+ 1. `mcp_server.py --transport sse --port 8000` — the CAD engine
24
+ 2. `web_server.py` — FastAPI server that proxies browser requests to the MCP server and serves the frontend
25
+
26
+ The web server uses the `mcp` Python SDK's SSE client to call MCP tools on the CAD server. This decouples the web layer from the CAD environment (CadQuery/OpenCascade).
27
+
28
+ ## New Files
29
+
30
+ | File | Purpose |
31
+ |------|---------|
32
+ | `web_server.py` | FastAPI app: REST endpoints, MCP SSE client, static file serving |
33
+ | `web/index.html` | Single-file frontend: Tailwind CDN + Three.js CDN + vanilla JS |
34
+
35
+ No changes to existing pipeline files.
36
+
37
+ ## API Endpoints (web_server.py)
38
+
39
+ | Method | Path | Description |
40
+ |--------|------|-------------|
41
+ | `GET /` | Serves `web/index.html` | |
42
+ | `POST /api/generate` | `{ prompt, part_name?, backend? }` → calls MCP `generate_cnc_model` tool → returns JSON result | |
43
+ | `POST /api/generate-image` | `{ image (multipart), text_hint?, part_name?, backend? }` → calls MCP `generate_from_image` tool → returns JSON result | |
44
+ | `POST /api/validate` | `{ code, part_name? }` → calls MCP `validate_cnc_model` tool → returns JSON result | |
45
+ | `GET /api/models` | Calls MCP `list_models` tool → returns JSON list | |
46
+ | `GET /api/models/{name}.stl` | Serves the STL file from `output/` directory so Three.js can load it | |
47
+ | `GET /api/models/{name}.step` | Serves the STEP file for download | |
48
+ | `GET /api/capabilities` | Reads MCP `text-to-cnc://capabilities` resource → returns available backends | |
49
+
50
+ ### MCP Client Integration
51
+
52
+ The web server connects to the MCP SSE server at startup. Each API endpoint translates the REST request into an MCP `call_tool` invocation:
53
+
54
+ ```python
55
+ # Pseudocode
56
+ async def generate(request):
57
+ result = await mcp_client.call_tool("generate_cnc_model", {
58
+ "prompt": request.prompt,
59
+ "backend": request.backend or "mock",
60
+ "part_name": request.part_name or "",
61
+ })
62
+ return JSONResponse(json.loads(result))
63
+ ```
64
+
65
+ ### Configuration
66
+
67
+ - `MCP_SERVER_URL` env var (default: `http://localhost:8000/sse`) — MCP SSE endpoint
68
+ - The MCP server URL is configurable so the CAD server can run on a different host
69
+
70
+ ## Frontend (web/index.html)
71
+
72
+ ### Layout: Stacked Hero
73
+
74
+ Single HTML file, no build step. Dependencies via CDN:
75
+ - Tailwind CSS (styling)
76
+ - Three.js + STLLoader + OrbitControls (3D viewer)
77
+ - highlight.js (code syntax highlighting, optional)
78
+
79
+ ### Page Structure
80
+
81
+ 1. **Top Bar** — NeuralCAD logo, backend toggle (Mock / API), version
82
+ 2. **Hero 3D Viewer** (~60% viewport height)
83
+ - Three.js scene with dark background and subtle grid
84
+ - OrbitControls for rotate/zoom/pan
85
+ - Geometry stats overlay (volume, faces, edges, bounding box)
86
+ - CNC status badge (machinable/not, axis recommendation)
87
+ - STEP/STL download buttons
88
+ 3. **Bottom Tabbed Panel** (~40% viewport height)
89
+ - **Generate tab**: text input, "Generate Model" button, image upload button, quick example buttons (bracket, gear, default)
90
+ - **Code tab**: syntax-highlighted CadQuery code output
91
+ - **Validation tab**: CNC manufacturability report (issues list, severity icons)
92
+ - **Gallery tab**: previously generated models (click to load in viewer)
93
+
94
+ ### Interaction Flow
95
+
96
+ 1. User types a part description (or clicks a quick example)
97
+ 2. Clicks "Generate Model"
98
+ 3. Frontend shows loading state (spinner in 3D viewer, "Generating..." on button)
99
+ 4. `POST /api/generate` with prompt + selected backend
100
+ 5. Response arrives with: generated_code, execution results, validation, exported_files
101
+ 6. Frontend updates all panels:
102
+ - 3D viewer loads STL from `/api/models/{name}.stl`
103
+ - Code tab shows generated CadQuery code
104
+ - Validation tab shows CNC report
105
+ - Geometry overlay updates with volume/faces/edges
106
+ - Download buttons activate with STEP/STL links
107
+ 7. Model is added to Gallery tab
108
+
109
+ ### Image Upload Flow
110
+
111
+ 1. User clicks the image/camera button
112
+ 2. File picker opens (accept: image/*)
113
+ 3. Selected image is sent as multipart form to `POST /api/generate-image`
114
+ 4. Same response handling as text generation
115
+
116
+ ### Quick Examples
117
+
118
+ Pre-defined prompts that demonstrate the system reliably (using mock backend):
119
+ - "A mounting bracket with four M6 bolt holes" → bracket mock
120
+ - "A spur gear with 20 teeth" → gear mock
121
+ - "A parametric box with holes and fillets" → default mock
122
+
123
+ ### Theming
124
+
125
+ Dark theme (matches the CAD/engineering aesthetic):
126
+ - Background: near-black (#0a0a1a)
127
+ - Panels: dark navy (#12122a)
128
+ - Accent: blue (#3b82f6)
129
+ - Success: green (#4ade80)
130
+ - Text: light gray (#e2e8f0) / muted (#8b8ba7)
131
+
132
+ ### 3D Viewer Details
133
+
134
+ - **Renderer**: Three.js WebGLRenderer with antialiasing
135
+ - **Loader**: STLLoader fetches from `/api/models/{name}.stl`
136
+ - **Material**: MeshPhongMaterial with blue-gray tone, slight metallic feel
137
+ - **Lighting**: Ambient + two directional lights for good depth perception
138
+ - **Controls**: OrbitControls (drag = rotate, scroll = zoom, right-drag = pan)
139
+ - **Camera**: Auto-fit to bounding box after model load
140
+ - **Background**: Dark gradient with faint grid overlay
141
+
142
+ ## Startup
143
+
144
+ ```bash
145
+ # Terminal 1: Start the CAD server
146
+ python mcp_server.py --transport sse --port 8000
147
+
148
+ # Terminal 2: Start the web server
149
+ python web_server.py
150
+ # → Serving at http://localhost:5000
151
+ ```
152
+
153
+ Or a convenience script:
154
+ ```bash
155
+ python web_server.py --start-mcp
156
+ # Launches MCP server as subprocess, then starts FastAPI
157
+ ```
158
+
159
+ ## Dependencies (additions to requirements.txt)
160
+
161
+ ```
162
+ fastapi>=0.110.0
163
+ uvicorn>=0.29.0
164
+ ```
165
+
166
+ The `mcp` package (already in requirements.txt) includes the SSE client via `httpx` and `httpx-sse` as transitive dependencies. No additional SSE libraries needed.
167
+
168
+ ## Verification
169
+
170
+ 1. Start MCP server: `python mcp_server.py --transport sse --port 8000`
171
+ 2. Start web server: `python web_server.py`
172
+ 3. Open `http://localhost:5000` in browser
173
+ 4. Click "Mounting bracket" quick example → 3D model appears in viewer
174
+ 5. Switch to Code tab → CadQuery code is displayed
175
+ 6. Switch to Validation tab → CNC report shows "Machinable"
176
+ 7. Click STEP/STL download buttons → files download
177
+ 8. Toggle backend to "API", type a custom prompt, click Generate → live LLM generation works
178
+ 9. Upload an image → image-to-model flow works
entrypoint.sh ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ set -e
3
+
4
+ echo "=== NeuralCAD Container Starting ==="
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
12
+ sleep 3
13
+
14
+ if ! kill -0 $MCP_PID 2>/dev/null; then
15
+ echo "ERROR: MCP server failed to start"
16
+ exit 1
17
+ fi
18
+
19
+ echo "MCP server running (PID $MCP_PID)"
20
+
21
+ # Start web server in foreground
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 ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "neuralcad"
3
+ version = "1.0.0"
4
+ description = "Text-to-CNC pipeline: natural language to machinable 3D models"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "cadquery>=2.7.0",
8
+ "cadquery-ocp>=7.8.0",
9
+ "numpy>=1.24.0",
10
+ "trimesh>=4.0.0",
11
+ "anthropic>=0.25.0",
12
+ "openai>=1.30.0",
13
+ "google-genai>=1.0.0",
14
+ "crewai>=0.100.0",
15
+ "mcp>=1.0.0",
16
+ "fastapi>=0.110.0",
17
+ "uvicorn>=0.29.0",
18
+ "python-multipart>=0.0.9",
19
+ ]
20
+
21
+ [dependency-groups]
22
+ dev = ["ruff", "pytest"]
23
+
24
+ [tool.pytest.ini_options]
25
+ testpaths = ["tests"]
26
+ pythonpath = ["."]
27
+ markers = [
28
+ "requires_cadquery: marks tests that need CadQuery installed",
29
+ ]
requirements.txt CHANGED
@@ -1,7 +1,13 @@
 
 
1
  cadquery>=2.7.0
2
  cadquery-ocp>=7.8.0
3
  numpy>=1.24.0
4
  trimesh>=4.0.0
5
  anthropic>=0.25.0
6
  openai>=1.30.0
 
7
  mcp>=1.0.0
 
 
 
 
1
+ # Dependency source of truth is pyproject.toml — use `uv sync` to install.
2
+ # This file is kept for environments that don't use uv.
3
  cadquery>=2.7.0
4
  cadquery-ocp>=7.8.0
5
  numpy>=1.24.0
6
  trimesh>=4.0.0
7
  anthropic>=0.25.0
8
  openai>=1.30.0
9
+ google-genai>=1.0.0
10
  mcp>=1.0.0
11
+ fastapi>=0.110.0
12
+ uvicorn>=0.29.0
13
+ python-multipart>=0.0.9
server/__init__.py ADDED
File without changes
mcp_server.py → server/mcp.py RENAMED
@@ -11,8 +11,8 @@ Tools:
11
  - list_models: List previously generated models in the output dir
12
 
13
  Usage:
14
- python mcp_server.py # stdio transport (default)
15
- python mcp_server.py --transport sse # SSE transport on port 8000
16
  """
17
 
18
  import json
@@ -22,12 +22,9 @@ from pathlib import Path
22
 
23
  from mcp.server.fastmcp import FastMCP
24
 
25
- # Ensure the project modules are importable
26
- sys.path.insert(0, str(Path(__file__).parent))
27
-
28
- from cadquery_system_prompt import build_messages, CADQUERY_SYSTEM_PROMPT
29
- from code_executor import ExecutionResult, execute_cadquery, export_all, sanitize_code
30
- from cnc_validator import validate_for_cnc, CNCValidationResult
31
 
32
  # ── Server Setup ──────────────────────────────────────────────────────────
33
 
@@ -40,7 +37,7 @@ mcp = FastMCP(
40
  ),
41
  )
42
 
43
- DEFAULT_OUTPUT_DIR = Path(__file__).parent / "output"
44
  DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
45
 
46
 
@@ -48,12 +45,16 @@ DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
48
 
49
  def get_backend(backend_name: str = "mock"):
50
  """Get the appropriate LLM backend."""
51
- from pipeline import MockBackend, AnthropicBackend, OpenAIBackend
52
 
53
- if backend_name == "anthropic" and os.environ.get("ANTHROPIC_API_KEY"):
 
 
54
  return AnthropicBackend()
55
  elif backend_name == "openai" and os.environ.get("OPENAI_API_KEY"):
56
  return OpenAIBackend()
 
 
57
  else:
58
  return MockBackend()
59
 
@@ -91,7 +92,7 @@ def generate_cnc_model(
91
  - validation: CNC manufacturability analysis
92
  - exported_files: Paths to generated STEP/STL files
93
  """
94
- from pipeline import run_pipeline
95
 
96
  if not part_name:
97
  part_name = prompt[:40].strip().replace(" ", "_").lower()
@@ -287,6 +288,165 @@ def list_models(output_dir: str = "") -> str:
287
  }, indent=2)
288
 
289
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  # ── Resource: System prompt (for transparency) ───────────────────────────
291
 
292
  @mcp.resource("text-to-cnc://system-prompt")
@@ -298,11 +458,13 @@ def get_system_prompt() -> str:
298
  @mcp.resource("text-to-cnc://capabilities")
299
  def get_capabilities() -> str:
300
  """Server capabilities and configuration."""
301
- backends = ["mock (always available)"]
302
  if os.environ.get("ANTHROPIC_API_KEY"):
303
  backends.append("anthropic (API key detected)")
304
  if os.environ.get("OPENAI_API_KEY"):
305
  backends.append("openai (API key detected)")
 
 
306
 
307
  return json.dumps({
308
  "name": "text-to-cnc",
 
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
 
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
 
 
37
  ),
38
  )
39
 
40
+ DEFAULT_OUTPUT_DIR = Path(__file__).parent.parent / "output"
41
  DEFAULT_OUTPUT_DIR.mkdir(exist_ok=True)
42
 
43
 
 
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
 
 
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()
 
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")
 
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",
server/routes.py ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat API routes for multi-agent design conversation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from fastapi import APIRouter
9
+ from fastapi.responses import JSONResponse
10
+ from pydantic import BaseModel, Field
11
+
12
+ from agents.orchestrator import get_orchestrator
13
+ from agents.crew_orchestrator import CrewOrchestrator
14
+ from agents.prompts import parse_mentions
15
+ from agents.definitions import AGENTS
16
+
17
+ router = APIRouter()
18
+
19
+ OUTPUT_DIR = Path(__file__).parent.parent / "output"
20
+
21
+
22
+ # ── Request / response models ─────────────────────────────────────────────
23
+
24
+
25
+ class ChatMessage(BaseModel):
26
+ role: str # "user" or "agent"
27
+ agent_id: str = ""
28
+ content: str = ""
29
+
30
+
31
+ class ChatRequest(BaseModel):
32
+ message: str = Field(..., min_length=1)
33
+ history: list[ChatMessage] = Field(default_factory=list)
34
+ mentions: list[str] = Field(default_factory=list)
35
+ backend: str = "mock"
36
+ design_state: dict = Field(default_factory=dict)
37
+
38
+
39
+ class ReportRequest(BaseModel):
40
+ part_name: str = "part"
41
+ history: list[ChatMessage] = Field(default_factory=list)
42
+ backend: str = "mock"
43
+
44
+
45
+ # ── Endpoints ──────────────────────────────────────────────────────────────
46
+
47
+
48
+ @router.post("/api/chat")
49
+ async def chat(body: ChatRequest):
50
+ """Multi-agent chat turn."""
51
+ message = body.message.strip()
52
+
53
+ # Convert validated models back to dicts for the orchestrator
54
+ history = [m.model_dump() for m in body.history]
55
+ backend_name = body.backend
56
+
57
+ # Parse @mentions from message if not provided
58
+ raw_mentions = body.mentions
59
+ if not raw_mentions:
60
+ message, raw_mentions = parse_mentions(message)
61
+
62
+ mentions = raw_mentions if raw_mentions else None
63
+
64
+ # Select orchestrator based on backend
65
+ if backend_name in ("anthropic", "openai", "gemini"):
66
+ orchestrator = CrewOrchestrator(
67
+ backend_name=backend_name, output_dir=OUTPUT_DIR
68
+ )
69
+ else:
70
+ orchestrator = get_orchestrator(backend_name, output_dir=OUTPUT_DIR)
71
+
72
+ # Run chat turn
73
+ try:
74
+ result = orchestrator.chat_turn(
75
+ message=message,
76
+ history=history,
77
+ mentions=mentions,
78
+ design_state=body.design_state,
79
+ )
80
+ return JSONResponse(result)
81
+ except Exception as e:
82
+ return JSONResponse(
83
+ {"error": "An internal error occurred. Please try again."},
84
+ status_code=500,
85
+ )
86
+
87
+
88
+ @router.post("/api/report")
89
+ async def report(body: ReportRequest):
90
+ """Generate a design report from conversation history."""
91
+ part_name = body.part_name
92
+ history = [m.model_dump() for m in body.history]
93
+
94
+ # Build report from conversation
95
+ report_sections = [f"# Design Report: {part_name}\n"]
96
+
97
+ design_decisions = []
98
+ engineering_specs = []
99
+ cnc_notes = []
100
+
101
+ for msg in history:
102
+ agent_id = msg.get("agent_id", "")
103
+ content = msg.get("content", "")
104
+ if agent_id == "design":
105
+ design_decisions.append(content)
106
+ elif agent_id == "engineering":
107
+ engineering_specs.append(content)
108
+ elif agent_id == "cnc":
109
+ cnc_notes.append(content)
110
+
111
+ if design_decisions:
112
+ report_sections.append("## Design Decisions")
113
+ for d in design_decisions:
114
+ report_sections.append(f"- {d}")
115
+
116
+ if engineering_specs:
117
+ report_sections.append("\n## Engineering Specifications")
118
+ for s in engineering_specs:
119
+ report_sections.append(f"- {s}")
120
+
121
+ if cnc_notes:
122
+ report_sections.append("\n## Manufacturing Notes")
123
+ for n in cnc_notes:
124
+ report_sections.append(f"- {n}")
125
+
126
+ stl_path = OUTPUT_DIR / f"{part_name}.stl"
127
+ step_path = OUTPUT_DIR / f"{part_name}.step"
128
+
129
+ report_sections.append("\n## Exported Files")
130
+ report_sections.append(f"- STEP: {'Available' if step_path.exists() else 'Not generated'}")
131
+ report_sections.append(f"- STL: {'Available' if stl_path.exists() else 'Not generated'}")
132
+
133
+ return JSONResponse({
134
+ "part_name": part_name,
135
+ "report": "\n".join(report_sections),
136
+ })
137
+
138
+
139
+ @router.get("/api/agents")
140
+ async def list_agents():
141
+ """List available agents and their metadata."""
142
+ return JSONResponse({
143
+ "agents": [
144
+ {
145
+ "id": agent.id,
146
+ "name": agent.name,
147
+ "role": agent.role,
148
+ "color": agent.color,
149
+ "avatar": agent.avatar,
150
+ }
151
+ for agent in AGENTS.values()
152
+ ]
153
+ })
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)
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared fixtures for NeuralCAD tests."""
2
+
3
+ import pytest
4
+ from pathlib import Path
5
+
6
+
7
+ @pytest.fixture
8
+ def tmp_output_dir(tmp_path):
9
+ """Temporary output directory for model files."""
10
+ out = tmp_path / "output"
11
+ out.mkdir()
12
+ return out
13
+
14
+
15
+ @pytest.fixture
16
+ def sample_history():
17
+ """A typical multi-turn conversation history."""
18
+ return [
19
+ {"role": "user", "content": "I need a servo bracket for an MG996R"},
20
+ {"role": "agent", "agent_id": "design", "content": "I'd suggest an L-bracket with a servo pocket on the vertical face."},
21
+ {"role": "agent", "agent_id": "engineering", "content": "3mm wall thickness in aluminum 6061-T6 should handle the load."},
22
+ {"role": "user", "content": "Make it 60mm wide with M4 base mounting holes"},
23
+ ]
24
+
25
+
26
+ @pytest.fixture
27
+ def empty_design_state():
28
+ """Empty design state dict."""
29
+ return {}
30
+
31
+
32
+ @pytest.fixture
33
+ def populated_design_state():
34
+ """Design state with some decisions already made."""
35
+ return {
36
+ "part_name": "servo_bracket",
37
+ "material": "aluminum 6061",
38
+ "dimensions": {"width": 60.0},
39
+ "features": ["4x M4 holes"],
40
+ "decisions": ["L-bracket form factor"],
41
+ }
42
+
43
+
44
+ class FakeLLMBackend:
45
+ """A controllable fake LLM backend for testing orchestrators."""
46
+
47
+ def __init__(self, response: str = '{"agents": []}'):
48
+ self.response = response
49
+ self.calls: list[list[dict]] = []
50
+
51
+ def generate(self, messages: list[dict]) -> str:
52
+ self.calls.append(messages)
53
+ return self.response
54
+
55
+
56
+ @pytest.fixture
57
+ def fake_backend():
58
+ """FakeLLMBackend factory — call with desired JSON response."""
59
+ def _make(response: str = '{"agents": []}'):
60
+ return FakeLLMBackend(response)
61
+ return _make
tests/test_api_routes.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for server/routes.py — FastAPI chat API endpoints."""
2
+
3
+ from fastapi.testclient import TestClient
4
+ from server.web import app
5
+
6
+
7
+ client = TestClient(app)
8
+
9
+
10
+ class TestChatEndpoint:
11
+ def test_basic_chat(self):
12
+ resp = client.post("/api/chat", json={
13
+ "message": "I need a bracket",
14
+ "history": [],
15
+ "backend": "mock",
16
+ })
17
+ assert resp.status_code == 200
18
+ data = resp.json()
19
+ assert "responses" in data
20
+ assert len(data["responses"]) > 0
21
+
22
+ def test_chat_with_mentions(self):
23
+ resp = client.post("/api/chat", json={
24
+ "message": "What do you think?",
25
+ "history": [],
26
+ "mentions": ["cnc"],
27
+ "backend": "mock",
28
+ })
29
+ assert resp.status_code == 200
30
+ data = resp.json()
31
+ agent_ids = [r["agent_id"] for r in data["responses"]]
32
+ assert "cnc" in agent_ids
33
+
34
+ def test_chat_with_history(self):
35
+ resp = client.post("/api/chat", json={
36
+ "message": "Make it wider",
37
+ "history": [
38
+ {"role": "user", "content": "I need a bracket"},
39
+ {"role": "agent", "agent_id": "design", "content": "L-bracket suggestion."},
40
+ ],
41
+ "backend": "mock",
42
+ })
43
+ assert resp.status_code == 200
44
+ data = resp.json()
45
+ assert "responses" in data
46
+
47
+ def test_chat_empty_message_rejected(self):
48
+ resp = client.post("/api/chat", json={
49
+ "message": "",
50
+ "history": [],
51
+ "backend": "mock",
52
+ })
53
+ assert resp.status_code == 422
54
+
55
+ def test_chat_returns_design_state(self):
56
+ resp = client.post("/api/chat", json={
57
+ "message": "60mm wide aluminum bracket",
58
+ "history": [],
59
+ "backend": "mock",
60
+ })
61
+ assert resp.status_code == 200
62
+ data = resp.json()
63
+ assert "design_state" in data
64
+
65
+ def test_chat_at_mention_in_message(self):
66
+ resp = client.post("/api/chat", json={
67
+ "message": "@engineering what thickness?",
68
+ "history": [],
69
+ "backend": "mock",
70
+ })
71
+ assert resp.status_code == 200
72
+ data = resp.json()
73
+ agent_ids = [r["agent_id"] for r in data["responses"]]
74
+ assert "engineering" in agent_ids
75
+
76
+
77
+ class TestReportEndpoint:
78
+ def test_basic_report(self):
79
+ resp = client.post("/api/report", json={
80
+ "part_name": "test_bracket",
81
+ "history": [
82
+ {"role": "agent", "agent_id": "design", "content": "L-bracket design."},
83
+ {"role": "agent", "agent_id": "engineering", "content": "3mm aluminum."},
84
+ {"role": "agent", "agent_id": "cnc", "content": "3-axis OK."},
85
+ ],
86
+ "backend": "mock",
87
+ })
88
+ assert resp.status_code == 200
89
+ data = resp.json()
90
+ assert "report" in data
91
+ assert "test_bracket" in data["report"]
92
+ assert "Design Decisions" in data["report"]
93
+ assert "Engineering Specifications" in data["report"]
94
+ assert "Manufacturing Notes" in data["report"]
95
+
96
+ def test_empty_history(self):
97
+ resp = client.post("/api/report", json={
98
+ "part_name": "empty_part",
99
+ "history": [],
100
+ "backend": "mock",
101
+ })
102
+ assert resp.status_code == 200
103
+ data = resp.json()
104
+ assert "report" in data
105
+
106
+
107
+ class TestAgentsEndpoint:
108
+ def test_list_agents(self):
109
+ resp = client.get("/api/agents")
110
+ assert resp.status_code == 200
111
+ data = resp.json()
112
+ assert "agents" in data
113
+ agent_ids = [a["id"] for a in data["agents"]]
114
+ assert "design" in agent_ids
115
+ assert "engineering" in agent_ids
116
+ assert "cnc" in agent_ids
117
+ assert "cad" in agent_ids
118
+
119
+ def test_agent_has_metadata(self):
120
+ resp = client.get("/api/agents")
121
+ data = resp.json()
122
+ agent = data["agents"][0]
123
+ assert "id" in agent
124
+ assert "name" in agent
125
+ assert "role" in agent
126
+ assert "color" in agent
127
+ assert "avatar" in agent
tests/test_design_state.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agents/design_state.py — state tracking and decision extraction."""
2
+
3
+ from agents.design_state import DesignState, extract_decisions
4
+
5
+
6
+ class TestDesignState:
7
+ def test_empty_render(self):
8
+ state = DesignState()
9
+ assert state.render() == ""
10
+
11
+ def test_render_with_fields(self):
12
+ state = DesignState(
13
+ part_name="bracket",
14
+ material="aluminum 6061",
15
+ dimensions={"width": 60.0, "height": 40.0},
16
+ )
17
+ rendered = state.render()
18
+ assert "bracket" in rendered
19
+ assert "aluminum 6061" in rendered
20
+ assert "width=60.0mm" in rendered
21
+
22
+ def test_render_features(self):
23
+ state = DesignState(features=["4x M6 holes", "fillet"])
24
+ rendered = state.render()
25
+ assert "4x M6 holes" in rendered
26
+
27
+ def test_render_decisions_capped_at_5(self):
28
+ state = DesignState(decisions=[f"decision {i}" for i in range(10)])
29
+ rendered = state.render()
30
+ assert "decision 9" in rendered
31
+ assert "decision 4" not in rendered
32
+
33
+
34
+ class TestExtractDecisions:
35
+ def test_extracts_material(self):
36
+ responses = [
37
+ {"agent_id": "engineering", "message": "I recommend aluminum 6061 for this application."}
38
+ ]
39
+ state = extract_decisions(responses, DesignState())
40
+ assert "aluminum" in state.material.lower()
41
+
42
+ def test_extracts_dimensions_from_user(self):
43
+ responses = []
44
+ state = extract_decisions(responses, DesignState(), user_message="Make it 60mm wide and 40mm high")
45
+ assert state.dimensions.get("width") == 60.0
46
+ assert state.dimensions.get("height") == 40.0
47
+
48
+ def test_extracts_fastener_features(self):
49
+ responses = [
50
+ {"agent_id": "engineering", "message": "I'll add 4x M6 clearance holes for mounting."}
51
+ ]
52
+ state = extract_decisions(responses, DesignState())
53
+ assert any("M6" in f for f in state.features)
54
+
55
+ def test_extracts_axis_recommendation(self):
56
+ responses = [
57
+ {"agent_id": "cnc", "message": "This part needs 5-axis machining due to the undercut."}
58
+ ]
59
+ state = extract_decisions(responses, DesignState())
60
+ assert "5-axis" in state.axis_recommendation
61
+
62
+ def test_extracts_part_name(self):
63
+ responses = []
64
+ state = extract_decisions(responses, DesignState(), user_message="I need a servo bracket with M4 holes")
65
+ assert "servo bracket" in state.part_name.lower()
66
+
67
+ def test_preserves_existing_state(self):
68
+ existing = DesignState(material="steel", dimensions={"width": 50.0})
69
+ responses = [
70
+ {"agent_id": "engineering", "message": "Height should be 30mm."}
71
+ ]
72
+ updated = extract_decisions(responses, existing, user_message="add height")
73
+ assert updated.material == "steel"
74
+ assert updated.dimensions.get("width") == 50.0
75
+
76
+ def test_extracts_decisions_from_agreement(self):
77
+ responses = [
78
+ {"agent_id": "design", "message": "I'd recommend an L-bracket form factor for this."}
79
+ ]
80
+ state = extract_decisions(responses, DesignState())
81
+ assert len(state.decisions) > 0
82
+
83
+ def test_no_duplicate_features(self):
84
+ existing = DesignState(features=["4x M6 holes"])
85
+ responses = [
86
+ {"agent_id": "engineering", "message": "The 4x M6 holes are properly specified."}
87
+ ]
88
+ updated = extract_decisions(responses, existing)
89
+ m6_count = sum(1 for f in updated.features if "M6" in f)
90
+ assert m6_count == 1
tests/test_executor.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core/executor.py — CadQuery code execution and export.
2
+
3
+ These tests require CadQuery to be installed.
4
+ """
5
+
6
+ import pytest
7
+ from pathlib import Path
8
+ from core.executor import sanitize_code, execute_cadquery, export_step, export_stl, export_all
9
+
10
+ pytestmark = pytest.mark.requires_cadquery
11
+
12
+
13
+ class TestSanitizeCode:
14
+ def test_strips_markdown_fences(self):
15
+ code = "```python\nresult = 1\n```"
16
+ assert "```" not in sanitize_code(code)
17
+
18
+ def test_strips_plain_fences(self):
19
+ code = "```\nresult = 1\n```"
20
+ assert "```" not in sanitize_code(code)
21
+
22
+ def test_removes_cadquery_imports(self):
23
+ code = "import cadquery as cq\nresult = cq.Workplane('XY').box(10,10,10)"
24
+ cleaned = sanitize_code(code)
25
+ assert "import cadquery" not in cleaned
26
+ assert "result" in cleaned
27
+
28
+ def test_removes_math_import(self):
29
+ code = "import math\nresult = cq.Workplane('XY').box(10,10,10)"
30
+ cleaned = sanitize_code(code)
31
+ assert "import math" not in cleaned
32
+
33
+ def test_preserves_valid_code(self):
34
+ code = "result = cq.Workplane('XY').box(10, 20, 30)"
35
+ assert sanitize_code(code) == code
36
+
37
+
38
+ class TestExecuteCadquery:
39
+ def test_simple_box(self):
40
+ result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
41
+ assert result.success is True
42
+ assert result.volume > 0
43
+ assert result.face_count == 6
44
+ assert result.edge_count == 12
45
+ assert len(result.bounding_box) == 3
46
+
47
+ def test_cylinder(self):
48
+ result = execute_cadquery("result = cq.Workplane('XY').cylinder(20, 10)")
49
+ assert result.success is True
50
+ assert result.volume > 0
51
+
52
+ def test_missing_result_variable(self):
53
+ result = execute_cadquery("x = cq.Workplane('XY').box(10,10,10)")
54
+ assert result.success is False
55
+ assert "result" in result.error
56
+
57
+ def test_syntax_error(self):
58
+ result = execute_cadquery("result = cq.Workplane('XY').box(10, 10,")
59
+ assert result.success is False
60
+ assert result.error is not None
61
+
62
+ def test_wrong_type(self):
63
+ result = execute_cadquery("result = 42")
64
+ assert result.success is False
65
+ assert "Workplane" in result.error
66
+
67
+ def test_code_with_markdown_fences(self):
68
+ code = "```python\nimport cadquery as cq\nresult = cq.Workplane('XY').box(5,5,5)\n```"
69
+ result = execute_cadquery(code)
70
+ assert result.success is True
71
+
72
+ def test_summary_on_success(self):
73
+ result = execute_cadquery("result = cq.Workplane('XY').box(10, 20, 30)")
74
+ summary = result.summary()
75
+ assert "OK" in summary
76
+ assert "Volume" in summary
77
+
78
+ def test_summary_on_failure(self):
79
+ result = execute_cadquery("result = bad_code")
80
+ summary = result.summary()
81
+ assert "FAILED" in summary
82
+
83
+
84
+ class TestExport:
85
+ def test_export_step(self, tmp_path):
86
+ exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
87
+ assert exec_result.success
88
+ path = export_step(exec_result.result, tmp_path / "test.step")
89
+ assert path.exists()
90
+ assert path.suffix == ".step"
91
+
92
+ def test_export_stl(self, tmp_path):
93
+ exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
94
+ assert exec_result.success
95
+ path = export_stl(exec_result.result, tmp_path / "test.stl")
96
+ assert path.exists()
97
+ assert path.suffix == ".stl"
98
+
99
+ def test_export_all(self, tmp_path):
100
+ exec_result = execute_cadquery("result = cq.Workplane('XY').box(10,10,10)")
101
+ assert exec_result.success
102
+ files = export_all(exec_result.result, tmp_path / "part")
103
+ assert files["step"].exists()
104
+ assert files["stl"].exists()
tests/test_mock_orchestrator.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agents/orchestrator.py — MockChatBackend and helpers."""
2
+
3
+ from agents.orchestrator import MockChatBackend, _format_response
4
+ from agents.definitions import AGENTS, AGENT_COLORS, AGENT_NAMES, AGENT_AVATARS
5
+
6
+
7
+ class TestFormatResponse:
8
+ def test_returns_all_fields(self):
9
+ resp = _format_response("design", "Hello")
10
+ assert resp["agent_id"] == "design"
11
+ assert resp["agent_name"] == AGENT_NAMES["design"]
12
+ assert resp["message"] == "Hello"
13
+ assert resp["color"] == AGENT_COLORS["design"]
14
+ assert resp["avatar"] == AGENT_AVATARS["design"]
15
+ assert resp["code"] is None
16
+
17
+ def test_includes_code(self):
18
+ resp = _format_response("cad", "Done.", code="result = cq.Workplane().box(10,10,10)")
19
+ assert resp["code"] == "result = cq.Workplane().box(10,10,10)"
20
+
21
+
22
+ class TestMockChatBackend:
23
+ def test_response_shape(self, tmp_output_dir):
24
+ mock = MockChatBackend(output_dir=tmp_output_dir)
25
+ result = mock.chat_turn("I need a bracket", history=[])
26
+ assert "responses" in result
27
+ assert "preview" in result
28
+ assert "design_state" in result
29
+ assert isinstance(result["responses"], list)
30
+ assert len(result["responses"]) > 0
31
+
32
+ def test_bracket_routes_to_design(self, tmp_output_dir):
33
+ mock = MockChatBackend(output_dir=tmp_output_dir)
34
+ result = mock.chat_turn("Design a mounting bracket", history=[])
35
+ agent_ids = [r["agent_id"] for r in result["responses"]]
36
+ assert "design" in agent_ids
37
+
38
+ def test_mention_overrides_routing(self, tmp_output_dir):
39
+ mock = MockChatBackend(output_dir=tmp_output_dir)
40
+ result = mock.chat_turn(
41
+ "What do you think?",
42
+ history=[],
43
+ mentions=["cnc"],
44
+ )
45
+ agent_ids = [r["agent_id"] for r in result["responses"]]
46
+ assert agent_ids == ["cnc"]
47
+
48
+ def test_cad_mention_generates_code(self, tmp_output_dir):
49
+ mock = MockChatBackend(output_dir=tmp_output_dir)
50
+ result = mock.chat_turn(
51
+ "Generate a 50mm cube",
52
+ history=[],
53
+ mentions=["cad"],
54
+ )
55
+ agent_ids = [r["agent_id"] for r in result["responses"]]
56
+ assert "cad" in agent_ids
57
+ cad_resp = next(r for r in result["responses"] if r["agent_id"] == "cad")
58
+ assert cad_resp["code"] is not None
59
+ assert "result" in cad_resp["code"]
60
+
61
+ def test_design_state_updated(self, tmp_output_dir):
62
+ mock = MockChatBackend(output_dir=tmp_output_dir)
63
+ result = mock.chat_turn(
64
+ "Make it 60mm wide in aluminum",
65
+ history=[],
66
+ )
67
+ ds = result["design_state"]
68
+ assert isinstance(ds, dict)
69
+
70
+ def test_engineering_keywords_trigger_engineering(self, tmp_output_dir):
71
+ mock = MockChatBackend(output_dir=tmp_output_dir)
72
+ result = mock.chat_turn("Use M6 bolts with 3mm wall thickness", history=[])
73
+ agent_ids = [r["agent_id"] for r in result["responses"]]
74
+ assert "engineering" in agent_ids
75
+
76
+ def test_cnc_keywords_trigger_cnc(self, tmp_output_dir):
77
+ mock = MockChatBackend(output_dir=tmp_output_dir)
78
+ result = mock.chat_turn("Can this be machined on a CNC mill?", history=[])
79
+ agent_ids = [r["agent_id"] for r in result["responses"]]
80
+ assert "cnc" in agent_ids
81
+
82
+ def test_generic_message_default_agents(self, tmp_output_dir):
83
+ mock = MockChatBackend(output_dir=tmp_output_dir)
84
+ result = mock.chat_turn("Hello there", history=[])
85
+ agent_ids = [r["agent_id"] for r in result["responses"]]
86
+ assert "design" in agent_ids
87
+ assert "engineering" in agent_ids
tests/test_pipeline.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core/pipeline.py — end-to-end text-to-CNC pipeline.
2
+
3
+ These tests require CadQuery to be installed.
4
+ """
5
+
6
+ import pytest
7
+ from pathlib import Path
8
+ from core.pipeline import run_pipeline, PipelineResult
9
+ from core.backends import MockBackend
10
+
11
+ pytestmark = pytest.mark.requires_cadquery
12
+
13
+
14
+ class TestRunPipeline:
15
+ def test_basic_box(self, tmp_output_dir):
16
+ result = run_pipeline(
17
+ "A simple 50mm cube",
18
+ backend=MockBackend(),
19
+ output_dir=tmp_output_dir,
20
+ )
21
+ assert isinstance(result, PipelineResult)
22
+ assert result.execution.success is True
23
+ assert result.execution.volume > 0
24
+
25
+ def test_exports_files(self, tmp_output_dir):
26
+ result = run_pipeline(
27
+ "A 60x40x5mm mounting plate",
28
+ backend=MockBackend(),
29
+ output_dir=tmp_output_dir,
30
+ part_name="test_plate",
31
+ )
32
+ assert result.exported_files is not None
33
+ assert result.exported_files["step"].exists()
34
+ assert result.exported_files["stl"].exists()
35
+
36
+ def test_validation_runs(self, tmp_output_dir):
37
+ result = run_pipeline(
38
+ "A 50mm cylinder",
39
+ backend=MockBackend(),
40
+ output_dir=tmp_output_dir,
41
+ validate=True,
42
+ )
43
+ assert result.validation is not None
44
+ assert hasattr(result.validation, "machinable")
45
+
46
+ def test_skip_validation(self, tmp_output_dir):
47
+ result = run_pipeline(
48
+ "A simple box",
49
+ backend=MockBackend(),
50
+ output_dir=tmp_output_dir,
51
+ validate=False,
52
+ )
53
+ assert result.validation is None
54
+
55
+ def test_skip_export(self, tmp_output_dir):
56
+ result = run_pipeline(
57
+ "A simple box",
58
+ backend=MockBackend(),
59
+ output_dir=tmp_output_dir,
60
+ export=False,
61
+ )
62
+ assert result.exported_files is None or len(result.exported_files) == 0
63
+
64
+ def test_summary(self, tmp_output_dir):
65
+ result = run_pipeline(
66
+ "A 30mm cube",
67
+ backend=MockBackend(),
68
+ output_dir=tmp_output_dir,
69
+ )
70
+ summary = result.summary()
71
+ assert isinstance(summary, str)
72
+
73
+ def test_default_backend_is_mock(self, tmp_output_dir):
74
+ result = run_pipeline(
75
+ "A basic plate",
76
+ output_dir=tmp_output_dir,
77
+ )
78
+ assert result.execution.success is True
tests/test_prompts.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for agents/prompts.py — prompt building, routing, parsing."""
2
+
3
+ from agents.prompts import (
4
+ parse_mentions,
5
+ route_by_keywords,
6
+ parse_orchestrator_response,
7
+ build_orchestrator_system_prompt,
8
+ build_chat_messages,
9
+ CAD_TRIGGER_KEYWORDS,
10
+ )
11
+
12
+
13
+ class TestParseMentions:
14
+ def test_no_mentions(self):
15
+ cleaned, mentions = parse_mentions("I need a bracket")
16
+ assert cleaned == "I need a bracket"
17
+ assert mentions == []
18
+
19
+ def test_single_mention(self):
20
+ cleaned, mentions = parse_mentions("@design what shape?")
21
+ assert "design" in mentions
22
+ assert "@design" not in cleaned
23
+
24
+ def test_multiple_mentions(self):
25
+ cleaned, mentions = parse_mentions("@design @engineering check this")
26
+ assert "design" in mentions
27
+ assert "engineering" in mentions
28
+ assert "@design" not in cleaned
29
+ assert "@engineering" not in cleaned
30
+
31
+ def test_cad_mention(self):
32
+ cleaned, mentions = parse_mentions("@cad generate a preview")
33
+ assert "cad" in mentions
34
+
35
+ def test_case_insensitive(self):
36
+ cleaned, mentions = parse_mentions("@Design what do you think?")
37
+ assert "design" in mentions
38
+
39
+ def test_mention_mid_sentence(self):
40
+ cleaned, mentions = parse_mentions("Can @engineering check the wall thickness?")
41
+ assert "engineering" in mentions
42
+ assert "Can" in cleaned
43
+ assert "check the wall thickness?" in cleaned
44
+
45
+
46
+ class TestRouteByKeywords:
47
+ def test_design_keywords(self):
48
+ agents = route_by_keywords("I want a sleek design with smooth shape")
49
+ assert "design" in agents
50
+
51
+ def test_engineering_keywords(self):
52
+ agents = route_by_keywords("Use M6 bolts with 3mm wall thickness in aluminum")
53
+ assert "engineering" in agents
54
+
55
+ def test_cnc_keywords(self):
56
+ agents = route_by_keywords("Can this be machined on a 3-axis CNC mill?")
57
+ assert "cnc" in agents
58
+
59
+ def test_cad_trigger(self):
60
+ agents = route_by_keywords("Generate a preview of the part")
61
+ assert "cad" in agents
62
+
63
+ def test_default_when_no_match(self):
64
+ agents = route_by_keywords("hello there")
65
+ assert agents == ["design", "engineering"]
66
+
67
+ def test_max_three_agents(self):
68
+ agents = route_by_keywords(
69
+ "design shape in aluminum for CNC machining, generate preview"
70
+ )
71
+ assert len(agents) <= 3
72
+
73
+ def test_sorted_by_relevance(self):
74
+ agents = route_by_keywords("M4 M6 tolerance clearance aluminum steel wall")
75
+ assert agents[0] == "engineering"
76
+
77
+
78
+ class TestParseOrchestratorResponse:
79
+ def test_valid_json(self):
80
+ resp = '{"agents": [{"id": "design", "message": "Nice bracket."}]}'
81
+ parsed = parse_orchestrator_response(resp)
82
+ assert len(parsed) == 1
83
+ assert parsed[0]["id"] == "design"
84
+ assert parsed[0]["message"] == "Nice bracket."
85
+ assert parsed[0]["code"] is None
86
+
87
+ def test_json_with_code(self):
88
+ resp = '{"agents": [{"id": "cad", "message": "Done.", "code": "result = cq.Workplane().box(10,10,10)"}]}'
89
+ parsed = parse_orchestrator_response(resp)
90
+ assert parsed[0]["code"] == "result = cq.Workplane().box(10,10,10)"
91
+
92
+ def test_json_in_markdown_fence(self):
93
+ resp = '```json\n{"agents": [{"id": "engineering", "message": "Use 3mm walls."}]}\n```'
94
+ parsed = parse_orchestrator_response(resp)
95
+ assert len(parsed) == 1
96
+ assert parsed[0]["id"] == "engineering"
97
+
98
+ def test_multiple_agents(self):
99
+ resp = '{"agents": [{"id": "design", "message": "A"}, {"id": "cnc", "message": "B"}]}'
100
+ parsed = parse_orchestrator_response(resp)
101
+ assert len(parsed) == 2
102
+ assert parsed[0]["id"] == "design"
103
+ assert parsed[1]["id"] == "cnc"
104
+
105
+ def test_invalid_json_fallback(self):
106
+ resp = "I think you should use aluminum."
107
+ parsed = parse_orchestrator_response(resp)
108
+ assert len(parsed) == 1
109
+ assert parsed[0]["id"] == "design"
110
+ assert parsed[0]["message"] == resp
111
+
112
+ def test_empty_agents_fallback(self):
113
+ resp = '{"agents": []}'
114
+ parsed = parse_orchestrator_response(resp)
115
+ assert len(parsed) == 1
116
+ assert parsed[0]["id"] == "design"
117
+
118
+ def test_missing_fields_skipped(self):
119
+ resp = '{"agents": [{"id": "design"}, {"id": "cnc", "message": "OK"}]}'
120
+ parsed = parse_orchestrator_response(resp)
121
+ assert len(parsed) == 1
122
+ assert parsed[0]["id"] == "cnc"
123
+
124
+
125
+ class TestBuildOrchestratorSystemPrompt:
126
+ def test_default_agents(self):
127
+ prompt = build_orchestrator_system_prompt()
128
+ assert "Design Agent" in prompt
129
+ assert "Engineering Agent" in prompt
130
+ assert "CNC Agent" in prompt
131
+ assert '### CAD Coder' not in prompt # persona block excluded
132
+
133
+ def test_specific_agents(self):
134
+ prompt = build_orchestrator_system_prompt(active_agents=["cad"])
135
+ assert "CAD Coder" in prompt
136
+ assert "Design Agent" not in prompt
137
+
138
+ def test_includes_json_format(self):
139
+ prompt = build_orchestrator_system_prompt()
140
+ assert '"agents"' in prompt
141
+ assert "JSON" in prompt
142
+
143
+ def test_cad_context_included(self):
144
+ prompt = build_orchestrator_system_prompt(
145
+ active_agents=["cad"], include_cad_context=True
146
+ )
147
+ assert "CadQuery" in prompt
148
+
149
+
150
+ class TestBuildChatMessages:
151
+ def test_returns_system_and_user(self):
152
+ msgs = build_chat_messages("hello", [], "You are a bot.")
153
+ assert len(msgs) == 2
154
+ assert msgs[0]["role"] == "system"
155
+ assert msgs[0]["content"] == "You are a bot."
156
+ assert msgs[1]["role"] == "user"
157
+
158
+ def test_history_included_in_user_message(self, sample_history):
159
+ msgs = build_chat_messages("new msg", sample_history, "system prompt")
160
+ user_content = msgs[1]["content"]
161
+ assert "servo bracket" in user_content
162
+ assert "new msg" in user_content
163
+
164
+ def test_design_state_included(self):
165
+ msgs = build_chat_messages(
166
+ "make it wider", [], "system prompt",
167
+ design_state_text="Part: bracket\nMaterial: aluminum"
168
+ )
169
+ user_content = msgs[1]["content"]
170
+ assert "bracket" in user_content
171
+ assert "aluminum" in user_content
172
+
173
+ def test_history_truncation(self):
174
+ long_history = [
175
+ {"role": "user", "content": f"msg {i}"}
176
+ for i in range(50)
177
+ ]
178
+ msgs = build_chat_messages("latest", long_history, "sys", max_history=5)
179
+ user_content = msgs[1]["content"]
180
+ assert "msg 49" in user_content
181
+ assert "msg 0" not in user_content
182
+
183
+
184
+ # ── Improved routing coverage ───────────────────────���─────────────────────
185
+
186
+
187
+ class TestRouteByKeywordsImproved:
188
+ """Tests for expanded keyword routing vocabulary."""
189
+
190
+ def test_gear_routes_to_engineering(self):
191
+ agents = route_by_keywords("I need a spur gear with 20 teeth")
192
+ assert "engineering" in agents
193
+
194
+ def test_bearing_routes_to_engineering(self):
195
+ agents = route_by_keywords("Design a bearing housing")
196
+ assert "engineering" in agents
197
+
198
+ def test_heatsink_routes_to_engineering(self):
199
+ agents = route_by_keywords("Create a heatsink with fins")
200
+ assert "engineering" in agents
201
+
202
+ def test_flange_routes_to_engineering(self):
203
+ agents = route_by_keywords("A pipe flange with bolt holes")
204
+ assert "engineering" in agents
205
+
206
+ def test_servo_bracket_routes_to_design(self):
207
+ agents = route_by_keywords("Design a servo bracket for a camera gimbal")
208
+ assert "design" in agents
209
+
210
+ def test_cost_routes_to_cnc(self):
211
+ agents = route_by_keywords("How much would this cost to machine?")
212
+ assert "cnc" in agents
213
+
214
+ def test_surface_finish_routes_to_cnc(self):
215
+ agents = route_by_keywords("What surface finish can we achieve?")
216
+ assert "cnc" in agents
tests/test_single_call_orchestrator.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for SingleCallOrchestrator using a fake LLM backend."""
2
+
3
+ import json
4
+ from agents.orchestrator import SingleCallOrchestrator
5
+ from tests.conftest import FakeLLMBackend
6
+
7
+
8
+ class TestSingleCallOrchestrator:
9
+ def _make_orchestrator(self, response_json: str, tmp_output_dir):
10
+ backend = FakeLLMBackend(response_json)
11
+ return SingleCallOrchestrator(backend=backend, output_dir=tmp_output_dir), backend
12
+
13
+ def test_response_shape(self, tmp_output_dir):
14
+ resp = json.dumps({"agents": [
15
+ {"id": "design", "message": "An L-bracket would work."},
16
+ ]})
17
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
18
+ result = orch.chat_turn("I need a bracket", history=[])
19
+ assert "responses" in result
20
+ assert "preview" in result
21
+ assert "design_state" in result
22
+
23
+ def test_passes_message_to_backend(self, tmp_output_dir):
24
+ resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
25
+ orch, backend = self._make_orchestrator(resp, tmp_output_dir)
26
+ orch.chat_turn("Test message", history=[])
27
+ assert len(backend.calls) == 1
28
+ last_user_msg = backend.calls[0][-1]["content"]
29
+ assert "Test message" in last_user_msg
30
+
31
+ def test_mentions_restrict_agents(self, tmp_output_dir):
32
+ resp = json.dumps({"agents": [{"id": "cnc", "message": "3-axis OK"}]})
33
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
34
+ result = orch.chat_turn("check this", history=[], mentions=["cnc"])
35
+ agent_ids = [r["agent_id"] for r in result["responses"]]
36
+ assert "cnc" in agent_ids
37
+
38
+ def test_invalid_json_fallback(self, tmp_output_dir):
39
+ orch, _ = self._make_orchestrator("not json at all", tmp_output_dir)
40
+ result = orch.chat_turn("help", history=[])
41
+ assert len(result["responses"]) > 0
42
+ assert result["responses"][0]["agent_id"] == "design"
43
+
44
+ def test_llm_exception_fallback(self, tmp_output_dir):
45
+ class RaisingBackend:
46
+ def generate(self, messages):
47
+ raise RuntimeError("API error")
48
+
49
+ orch = SingleCallOrchestrator(backend=RaisingBackend(), output_dir=tmp_output_dir)
50
+ result = orch.chat_turn("Design a part", history=[])
51
+ assert len(result["responses"]) > 0
52
+
53
+ def test_unknown_agent_id_filtered(self, tmp_output_dir):
54
+ resp = json.dumps({"agents": [
55
+ {"id": "nonexistent", "message": "I don't exist"},
56
+ {"id": "design", "message": "Real agent"},
57
+ ]})
58
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
59
+ result = orch.chat_turn("test", history=[])
60
+ agent_ids = [r["agent_id"] for r in result["responses"]]
61
+ assert "nonexistent" not in agent_ids
62
+ assert "design" in agent_ids
63
+
64
+ def test_history_forwarded_to_backend(self, tmp_output_dir, sample_history):
65
+ resp = json.dumps({"agents": [{"id": "design", "message": "OK"}]})
66
+ orch, backend = self._make_orchestrator(resp, tmp_output_dir)
67
+ orch.chat_turn("continue", history=sample_history)
68
+ user_content = backend.calls[0][-1]["content"]
69
+ assert "servo bracket" in user_content.lower() or "MG996R" in user_content
70
+
71
+ def test_design_state_returned(self, tmp_output_dir):
72
+ resp = json.dumps({"agents": [
73
+ {"id": "engineering", "message": "Use aluminum 6061 with 3mm walls."},
74
+ ]})
75
+ orch, _ = self._make_orchestrator(resp, tmp_output_dir)
76
+ result = orch.chat_turn("material?", history=[])
77
+ assert "design_state" in result
78
+ assert isinstance(result["design_state"], dict)
tests/test_validator.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for core/validator.py — CNC manufacturability validation.
2
+
3
+ These tests require CadQuery to be installed.
4
+ """
5
+
6
+ import pytest
7
+ from core.executor import execute_cadquery
8
+ from core.validator import validate_for_cnc, CNCValidationResult, CNCIssue
9
+
10
+ pytestmark = pytest.mark.requires_cadquery
11
+
12
+
13
+ def _make_solid(code: str):
14
+ """Helper to create a CadQuery Workplane from code."""
15
+ result = execute_cadquery(code)
16
+ assert result.success, f"Code failed: {result.error}"
17
+ return result.result
18
+
19
+
20
+ class TestValidateForCnc:
21
+ def test_simple_box_is_machinable(self):
22
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
23
+ val = validate_for_cnc(solid, "test_box")
24
+ assert val.machinable is True
25
+ assert val.error_count == 0
26
+
27
+ def test_result_has_part_name(self):
28
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
29
+ val = validate_for_cnc(solid, "my_part")
30
+ assert val.part_name == "my_part"
31
+
32
+ def test_axis_recommendation_default_3axis(self):
33
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
34
+ val = validate_for_cnc(solid)
35
+ assert "3-axis" in val.axis_recommendation or "3" in val.axis_recommendation
36
+
37
+ def test_complex_part_gets_higher_axis(self):
38
+ code = '''
39
+ result = cq.Workplane('XY').box(50, 50, 50)
40
+ for i in range(5):
41
+ result = result.faces('>Z').workplane().pushPoints([(i*8-16, 0)]).hole(3)
42
+ for i in range(5):
43
+ result = result.faces('>X').workplane().pushPoints([(i*8-16, 0)]).hole(3)
44
+ '''
45
+ solid = _make_solid(code)
46
+ val = validate_for_cnc(solid)
47
+ assert val.part_name is not None
48
+
49
+ def test_oversized_part_flagged(self):
50
+ solid = _make_solid("result = cq.Workplane('XY').box(600, 600, 600)")
51
+ val = validate_for_cnc(solid, config={"max_part_size_mm": 500.0})
52
+ assert any(i.category == "Size" for i in val.issues)
53
+
54
+ def test_tiny_part_flagged(self):
55
+ solid = _make_solid("result = cq.Workplane('XY').box(0.5, 0.5, 0.5)")
56
+ val = validate_for_cnc(solid, config={"min_part_size_mm": 1.0})
57
+ assert any(i.category == "Size" for i in val.issues)
58
+
59
+ def test_summary_format(self):
60
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
61
+ val = validate_for_cnc(solid, "test")
62
+ summary = val.summary()
63
+ assert isinstance(summary, str)
64
+ assert "test" in summary
65
+
66
+ def test_custom_config(self):
67
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
68
+ val = validate_for_cnc(solid, config={"min_wall_thickness_mm": 0.5})
69
+ assert isinstance(val, CNCValidationResult)
70
+
71
+ def test_error_and_warning_counts(self):
72
+ solid = _make_solid("result = cq.Workplane('XY').box(50, 30, 10)")
73
+ val = validate_for_cnc(solid)
74
+ assert val.error_count >= 0
75
+ assert val.warning_count >= 0
76
+ assert val.error_count + val.warning_count <= len(val.issues)
uv.lock ADDED
The diff for this file is too large to render. See raw diff
 
web/index.html ADDED
@@ -0,0 +1,1983 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
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>
10
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/loaders/STLLoader.js"></script>
11
+ <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
12
+
13
+ <!-- Fonts -->
14
+ <link rel="preconnect" href="https://fonts.googleapis.com">
15
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
16
+
17
+ <style>
18
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
19
+
20
+ :root {
21
+ --bg-void: #06080c;
22
+ --bg-panel: #0c1018;
23
+ --bg-surface: #111822;
24
+ --bg-input: #0a0f16;
25
+ --border: #1c2636;
26
+ --border-active: #2a3a52;
27
+ --text-primary: #c8d6e5;
28
+ --text-secondary: #5a7089;
29
+ --text-muted: #3a4d63;
30
+ --accent: #00b4d8;
31
+ --accent-glow: rgba(0, 180, 216, 0.15);
32
+ --accent-dim: #007a94;
33
+ --success: #00e676;
34
+ --success-glow: rgba(0, 230, 118, 0.12);
35
+ --warning: #ffab40;
36
+ --error: #ff5252;
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 {
48
+ height: 100%;
49
+ overflow: hidden;
50
+ background: var(--bg-void);
51
+ color: var(--text-primary);
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;
75
+ background: var(--bg-panel);
76
+ border-bottom: 1px solid var(--border);
77
+ display: flex;
78
+ align-items: center;
79
+ justify-content: space-between;
80
+ padding: 0 16px;
81
+ z-index: 100;
82
+ position: relative;
83
+ }
84
+
85
+ #topbar::after {
86
+ content: '';
87
+ position: absolute;
88
+ bottom: -1px;
89
+ left: 0; right: 0;
90
+ height: 1px;
91
+ background: linear-gradient(90deg, transparent, var(--accent-dim), transparent);
92
+ opacity: 0.4;
93
+ }
94
+
95
+ .logo {
96
+ display: flex;
97
+ align-items: center;
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 {
108
+ font-family: var(--font-mono);
109
+ font-weight: 600;
110
+ font-size: 14px;
111
+ letter-spacing: 2px;
112
+ color: var(--text-primary);
113
+ text-transform: uppercase;
114
+ }
115
+
116
+ .logo-sub {
117
+ font-family: var(--font-mono);
118
+ font-size: 9px;
119
+ color: var(--text-muted);
120
+ letter-spacing: 3px;
121
+ text-transform: uppercase;
122
+ margin-left: 8px;
123
+ padding-left: 8px;
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;
136
+ gap: 12px;
137
+ }
138
+
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;
146
+ overflow: hidden;
147
+ font-family: var(--font-mono);
148
+ font-size: 11px;
149
+ }
150
+
151
+ .backend-toggle button {
152
+ all: unset;
153
+ padding: 4px 12px;
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);
165
+ }
166
+
167
+ .backend-toggle button:hover:not(.active) {
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);
196
+ animation: pulse-dot 2s ease-in-out infinite;
197
+ }
198
+
199
+ @keyframes pulse-dot {
200
+ 0%, 100% { opacity: 1; }
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;
221
+ min-height: 0;
222
+ }
223
+
224
+ #viewer-canvas {
225
+ width: 100%;
226
+ height: 100%;
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;
239
+ padding: 10px 14px;
240
+ font-family: var(--font-mono);
241
+ font-size: 11px;
242
+ line-height: 1.7;
243
+ backdrop-filter: blur(8px);
244
+ display: none;
245
+ }
246
+
247
+ #geo-stats.visible { display: block; }
248
+
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);
268
+ font-size: 10px;
269
+ font-weight: 500;
270
+ padding: 4px 10px;
271
+ border-radius: 3px;
272
+ letter-spacing: 0.5px;
273
+ backdrop-filter: blur(8px);
274
+ }
275
+
276
+ .badge-success {
277
+ background: var(--success-glow);
278
+ border: 1px solid rgba(0, 230, 118, 0.3);
279
+ color: var(--success);
280
+ }
281
+
282
+ .badge-warning {
283
+ background: rgba(255, 171, 64, 0.1);
284
+ border: 1px solid rgba(255, 171, 64, 0.3);
285
+ color: var(--warning);
286
+ }
287
+
288
+ .badge-error {
289
+ background: rgba(255, 82, 82, 0.1);
290
+ border: 1px solid rgba(255, 82, 82, 0.3);
291
+ color: var(--error);
292
+ }
293
+
294
+ .badge-info {
295
+ background: var(--accent-glow);
296
+ border: 1px solid rgba(0, 180, 216, 0.3);
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
+ }
309
+
310
+ #download-btns.visible { display: flex; }
311
+
312
+ .dl-btn {
313
+ font-family: var(--font-mono);
314
+ font-size: 10px;
315
+ font-weight: 500;
316
+ padding: 5px 14px;
317
+ border-radius: 3px;
318
+ background: rgba(6, 8, 12, 0.85);
319
+ border: 1px solid var(--border);
320
+ color: var(--text-secondary);
321
+ cursor: pointer;
322
+ text-decoration: none;
323
+ transition: all 0.2s;
324
+ backdrop-filter: blur(8px);
325
+ letter-spacing: 0.5px;
326
+ }
327
+
328
+ .dl-btn:hover {
329
+ border-color: var(--accent-dim);
330
+ color: var(--accent);
331
+ }
332
+
333
+ /* Viewer hint */
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;
357
+ flex-direction: column;
358
+ gap: 16px;
359
+ background: rgba(6, 8, 12, 0.7);
360
+ backdrop-filter: blur(4px);
361
+ }
362
+
363
+ #viewer-loading.visible { display: flex; }
364
+
365
+ .spinner {
366
+ width: 36px; height: 36px;
367
+ border: 2px solid var(--border);
368
+ border-top-color: var(--accent);
369
+ border-radius: 50%;
370
+ animation: spin 0.8s linear infinite;
371
+ }
372
+
373
+ @keyframes spin { to { transform: rotate(360deg); } }
374
+
375
+ .loading-text {
376
+ font-family: var(--font-mono);
377
+ font-size: 11px;
378
+ color: var(--text-secondary);
379
+ letter-spacing: 1px;
380
+ }
381
+
382
+ /* Empty state */
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
+
395
+ .empty-icon {
396
+ width: 64px; height: 64px;
397
+ border: 2px solid var(--border);
398
+ border-radius: 8px;
399
+ display: flex;
400
+ align-items: center;
401
+ justify-content: center;
402
+ transform: rotate(45deg);
403
+ opacity: 0.5;
404
+ }
405
+
406
+ .empty-icon-inner {
407
+ width: 20px; height: 20px;
408
+ border: 2px solid var(--text-muted);
409
+ border-radius: 2px;
410
+ transform: rotate(-45deg);
411
+ opacity: 0.3;
412
+ }
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; }
950
+ .st { color: #c3e88d; }
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 {
1035
+ border-color: var(--accent-dim);
1036
+ background: var(--bg-input);
1037
+ }
1038
+
1039
+ .gallery-card-name {
1040
+ font-family: var(--font-mono);
1041
+ font-size: 11px;
1042
+ font-weight: 500;
1043
+ color: var(--text-primary);
1044
+ white-space: nowrap;
1045
+ overflow: hidden;
1046
+ text-overflow: ellipsis;
1047
+ }
1048
+
1049
+ .gallery-card-meta {
1050
+ font-family: var(--font-mono);
1051
+ font-size: 9px;
1052
+ color: var(--text-muted);
1053
+ display: flex;
1054
+ gap: 8px;
1055
+ }
1056
+
1057
+ /* ---- ANIMATIONS ---- */
1058
+
1059
+ @keyframes fade-in-up {
1060
+ from { opacity: 0; transform: translateY(8px); }
1061
+ to { opacity: 1; transform: translateY(0); }
1062
+ }
1063
+
1064
+ .fade-in {
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
+ <button onclick="newDesign()" title="New Design" style="background:none;border:1px solid var(--border);border-radius:4px;color:var(--text-secondary);padding:2px 8px;font-size:10px;cursor:pointer;margin-left:8px;">NEW</button>
1147
+ <div class="agent-dots">
1148
+ <div class="agent-dot" style="background: var(--agent-design);" title="Design Agent"></div>
1149
+ <div class="agent-dot" style="background: var(--agent-engineering);" title="Engineering Agent"></div>
1150
+ <div class="agent-dot" style="background: var(--agent-cnc);" title="CNC Agent"></div>
1151
+ <div class="agent-dot" style="background: var(--agent-cad);" title="CAD Coder Agent"></div>
1152
+ </div>
1153
+ </div>
1154
+ </div>
1155
+
1156
+ <div id="chat-messages">
1157
+ <div class="quick-examples" id="quick-examples">
1158
+ <div class="quick-examples-label">Quick Start</div>
1159
+ <div class="quick-chips">
1160
+ <button class="quick-chip" onclick="quickSend('Design a servo bracket')">Design a servo bracket</button>
1161
+ <button class="quick-chip" onclick="quickSend('I need a spur gear')">I need a spur gear</button>
1162
+ <button class="quick-chip" onclick="quickSend('Create a heatsink')">Create a heatsink</button>
1163
+ <button class="quick-chip" onclick="quickSend('Design a pipe flange')">Design a pipe flange</button>
1164
+ </div>
1165
+ </div>
1166
+ </div>
1167
+
1168
+ <div class="chat-input-area" style="position: relative;">
1169
+ <div id="mention-dropdown">
1170
+ <div class="mention-option" data-agent="design" onclick="insertMention('design')">
1171
+ <div class="mention-dot" style="background: var(--agent-design);"></div>
1172
+ <span class="mention-name">@design</span>
1173
+ <span class="mention-role">Design Agent</span>
1174
+ </div>
1175
+ <div class="mention-option" data-agent="engineering" onclick="insertMention('engineering')">
1176
+ <div class="mention-dot" style="background: var(--agent-engineering);"></div>
1177
+ <span class="mention-name">@engineering</span>
1178
+ <span class="mention-role">Engineering Agent</span>
1179
+ </div>
1180
+ <div class="mention-option" data-agent="cnc" onclick="insertMention('cnc')">
1181
+ <div class="mention-dot" style="background: var(--agent-cnc);"></div>
1182
+ <span class="mention-name">@cnc</span>
1183
+ <span class="mention-role">CNC Agent</span>
1184
+ </div>
1185
+ <div class="mention-option" data-agent="cad" onclick="insertMention('cad')">
1186
+ <div class="mention-dot" style="background: var(--agent-cad);"></div>
1187
+ <span class="mention-name">@cad</span>
1188
+ <span class="mention-role">CAD Coder</span>
1189
+ </div>
1190
+ </div>
1191
+ <div class="chat-input-row">
1192
+ <textarea id="chat-input" rows="1" placeholder="Type your message..."></textarea>
1193
+ <button class="chat-btn chat-btn-preview" onclick="sendPreview()" title="Generate 3D preview">
1194
+ <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>
1195
+ </button>
1196
+ <button class="chat-btn chat-btn-send" onclick="sendFromInput()" title="Send message">
1197
+ <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>
1198
+ </button>
1199
+ </div>
1200
+ <div class="chat-shortcut-hint">Ctrl+Enter to send</div>
1201
+ </div>
1202
+ </div>
1203
+
1204
+ </div>
1205
+ </div>
1206
+
1207
+ <!-- Floating open pill (when chat collapsed) -->
1208
+ <div id="chat-open-pill" onclick="toggleChat()">
1209
+ <span>Open Chat</span>
1210
+ <div class="pill-dots">
1211
+ <div class="pill-dot" style="background: var(--agent-design);"></div>
1212
+ <div class="pill-dot" style="background: var(--agent-engineering);"></div>
1213
+ <div class="pill-dot" style="background: var(--agent-cnc);"></div>
1214
+ <div class="pill-dot" style="background: var(--agent-cad);"></div>
1215
+ </div>
1216
+ <span>&#9654;</span>
1217
+ </div>
1218
+
1219
+ <!-- Code Viewer Modal -->
1220
+ <div id="code-modal">
1221
+ <div class="code-modal-inner">
1222
+ <div class="code-modal-header">
1223
+ <span class="code-modal-title">CadQuery Code</span>
1224
+ <button class="code-modal-close" onclick="closeCodeModal()">&times;</button>
1225
+ </div>
1226
+ <pre id="code-display"></pre>
1227
+ </div>
1228
+ </div>
1229
+
1230
+ <!-- Gallery Modal -->
1231
+ <div id="gallery-modal">
1232
+ <div class="gallery-modal-inner">
1233
+ <div class="gallery-modal-header">
1234
+ <span class="gallery-modal-title">Model Gallery</span>
1235
+ <button class="code-modal-close" onclick="closeGallery()">&times;</button>
1236
+ </div>
1237
+ <div class="gallery-grid" id="gallery-grid">
1238
+ <div class="gallery-empty">No models generated yet.</div>
1239
+ </div>
1240
+ </div>
1241
+ </div>
1242
+
1243
+ <script>
1244
+ // ── STATE ─────────────────────────────────────────────
1245
+
1246
+ let currentBackend = 'mock';
1247
+ let chatHistory = [];
1248
+ let designState = {};
1249
+ let chatPanelOpen = true;
1250
+ let currentPartName = '';
1251
+ let currentCode = '';
1252
+ let scene, camera, renderer, controls, currentMesh, gridHelper;
1253
+ const galleryItems = [];
1254
+ let mentionActive = false;
1255
+ let mentionIndex = 0;
1256
+
1257
+ // Persist/restore from localStorage
1258
+ function saveState() {
1259
+ try {
1260
+ localStorage.setItem('neuralcad_history', JSON.stringify(chatHistory));
1261
+ localStorage.setItem('neuralcad_state', JSON.stringify(designState));
1262
+ } catch (e) { /* quota exceeded, ignore */ }
1263
+ }
1264
+
1265
+ function loadState() {
1266
+ try {
1267
+ const h = localStorage.getItem('neuralcad_history');
1268
+ const s = localStorage.getItem('neuralcad_state');
1269
+ if (h) chatHistory = JSON.parse(h);
1270
+ if (s) designState = JSON.parse(s);
1271
+ } catch (e) { /* corrupted, ignore */ }
1272
+ }
1273
+
1274
+ function clearState() {
1275
+ chatHistory = [];
1276
+ designState = {};
1277
+ localStorage.removeItem('neuralcad_history');
1278
+ localStorage.removeItem('neuralcad_state');
1279
+ }
1280
+
1281
+ function newDesign() {
1282
+ if (!confirm('Start a new design? Current conversation will be cleared.')) return;
1283
+ clearState();
1284
+ // Clear chat UI
1285
+ const msgs = document.getElementById('chat-messages');
1286
+ if (msgs) msgs.innerHTML = '';
1287
+ // Show examples again
1288
+ const examples = document.getElementById('quick-examples');
1289
+ if (examples) examples.style.display = '';
1290
+ // Clear 3D viewer
1291
+ if (currentMesh) {
1292
+ scene.remove(currentMesh);
1293
+ currentMesh.geometry.dispose();
1294
+ currentMesh.material.dispose();
1295
+ currentMesh = null;
1296
+ }
1297
+ // Hide overlays
1298
+ const geo = document.getElementById('geo-stats');
1299
+ if (geo) geo.classList.remove('visible');
1300
+ const cnc = document.getElementById('cnc-badge');
1301
+ if (cnc) cnc.classList.remove('visible');
1302
+ const dl = document.getElementById('download-btns');
1303
+ if (dl) dl.classList.remove('visible');
1304
+ // Show empty state
1305
+ const empty = document.getElementById('viewer-empty');
1306
+ if (empty) empty.style.display = '';
1307
+ }
1308
+
1309
+ const AGENTS = {
1310
+ design: { name: 'Design', color: '#7c3aed', avatar: 'D' },
1311
+ engineering: { name: 'Engineering', color: '#00b4d8', avatar: 'E' },
1312
+ cnc: { name: 'CNC', color: '#00e676', avatar: 'C' },
1313
+ cad: { name: 'CAD Coder', color: '#ffab40', avatar: '{}' },
1314
+ };
1315
+
1316
+ // ── THREE.JS SETUP ────────────────────────────────────
1317
+
1318
+ function initViewer() {
1319
+ const canvas = document.getElementById('viewer-canvas');
1320
+ const container = document.getElementById('viewer-container');
1321
+
1322
+ scene = new THREE.Scene();
1323
+
1324
+ // Camera
1325
+ camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 10000);
1326
+ camera.position.set(120, 80, 120);
1327
+
1328
+ // Renderer
1329
+ renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
1330
+ renderer.setPixelRatio(window.devicePixelRatio);
1331
+ renderer.setSize(container.clientWidth, container.clientHeight);
1332
+ renderer.setClearColor(0x06080c, 1);
1333
+ renderer.shadowMap.enabled = true;
1334
+
1335
+ // Lights
1336
+ const ambient = new THREE.AmbientLight(0x334466, 0.6);
1337
+ scene.add(ambient);
1338
+
1339
+ const dirLight1 = new THREE.DirectionalLight(0xddeeff, 0.8);
1340
+ dirLight1.position.set(100, 150, 100);
1341
+ dirLight1.castShadow = true;
1342
+ scene.add(dirLight1);
1343
+
1344
+ const dirLight2 = new THREE.DirectionalLight(0x8899bb, 0.4);
1345
+ dirLight2.position.set(-80, 60, -60);
1346
+ scene.add(dirLight2);
1347
+
1348
+ const rimLight = new THREE.DirectionalLight(0x00b4d8, 0.15);
1349
+ rimLight.position.set(0, -50, 100);
1350
+ scene.add(rimLight);
1351
+
1352
+ // Grid helper
1353
+ gridHelper = new THREE.GridHelper(400, 40, 0x1a2636, 0x111822);
1354
+ gridHelper.position.y = -0.5;
1355
+ scene.add(gridHelper);
1356
+
1357
+ // Controls
1358
+ controls = new THREE.OrbitControls(camera, renderer.domElement);
1359
+ controls.enableDamping = true;
1360
+ controls.dampingFactor = 0.08;
1361
+ controls.rotateSpeed = 0.6;
1362
+ controls.minDistance = 10;
1363
+ controls.maxDistance = 2000;
1364
+
1365
+ // Handle resize
1366
+ const ro = new ResizeObserver(() => {
1367
+ const w = container.clientWidth;
1368
+ const h = container.clientHeight;
1369
+ camera.aspect = w / h;
1370
+ camera.updateProjectionMatrix();
1371
+ renderer.setSize(w, h);
1372
+ });
1373
+ ro.observe(container);
1374
+
1375
+ animate();
1376
+ }
1377
+
1378
+ function animate() {
1379
+ requestAnimationFrame(animate);
1380
+ controls.update();
1381
+ renderer.render(scene, camera);
1382
+ }
1383
+
1384
+ function loadSTL(url) {
1385
+ return new Promise((resolve, reject) => {
1386
+ const loader = new THREE.STLLoader();
1387
+ loader.load(url, (geometry) => {
1388
+ if (currentMesh) {
1389
+ scene.remove(currentMesh);
1390
+ currentMesh.geometry.dispose();
1391
+ currentMesh.material.dispose();
1392
+ }
1393
+
1394
+ const material = new THREE.MeshPhongMaterial({
1395
+ color: 0x7799aa,
1396
+ specular: 0x445566,
1397
+ shininess: 60,
1398
+ flatShading: false,
1399
+ });
1400
+
1401
+ const mesh = new THREE.Mesh(geometry, material);
1402
+ mesh.castShadow = true;
1403
+ mesh.receiveShadow = true;
1404
+
1405
+ geometry.computeBoundingBox();
1406
+ const center = new THREE.Vector3();
1407
+ geometry.boundingBox.getCenter(center);
1408
+ mesh.position.sub(center);
1409
+
1410
+ scene.add(mesh);
1411
+ currentMesh = mesh;
1412
+
1413
+ // Fit camera
1414
+ const size = new THREE.Vector3();
1415
+ geometry.boundingBox.getSize(size);
1416
+ const maxDim = Math.max(size.x, size.y, size.z);
1417
+ const dist = maxDim * 2.5;
1418
+ camera.position.set(dist * 0.7, dist * 0.5, dist * 0.7);
1419
+ controls.target.set(0, 0, 0);
1420
+ controls.update();
1421
+
1422
+ // Update grid to match model scale
1423
+ if (gridHelper) {
1424
+ gridHelper.position.y = -size.y / 2 - 0.5;
1425
+ }
1426
+
1427
+ document.getElementById('viewer-empty').style.display = 'none';
1428
+ resolve();
1429
+ }, undefined, reject);
1430
+ });
1431
+ }
1432
+
1433
+ // ── BACKEND TOGGLE ────────────────────────────────────
1434
+
1435
+ function setBackend(name) {
1436
+ currentBackend = name;
1437
+ document.getElementById('btn-mock').classList.toggle('active', name === 'mock');
1438
+ document.getElementById('btn-gemini').classList.toggle('active', name === 'gemini');
1439
+ document.getElementById('btn-claude').classList.toggle('active', name === 'anthropic');
1440
+ }
1441
+
1442
+ // ── CHAT PANEL TOGGLE ─────────────────────────────────
1443
+
1444
+ function toggleChat() {
1445
+ chatPanelOpen = !chatPanelOpen;
1446
+ const panel = document.getElementById('chat-panel');
1447
+ const pill = document.getElementById('chat-open-pill');
1448
+ const toggle = document.getElementById('chat-toggle');
1449
+
1450
+ if (chatPanelOpen) {
1451
+ panel.classList.remove('collapsed');
1452
+ pill.classList.remove('visible');
1453
+ toggle.innerHTML = '&#9664;';
1454
+ document.body.classList.add('chat-open');
1455
+ } else {
1456
+ panel.classList.add('collapsed');
1457
+ pill.classList.add('visible');
1458
+ toggle.innerHTML = '&#9654;';
1459
+ document.body.classList.remove('chat-open');
1460
+ }
1461
+ }
1462
+
1463
+ // ── CHAT MESSAGING ────────────────────────────────────
1464
+
1465
+ async function sendMessage(text) {
1466
+ if (!text.trim()) return;
1467
+
1468
+ // Parse @mentions
1469
+ const mentions = [];
1470
+ const mentionRegex = /@(design|engineering|cnc|cad)\b/gi;
1471
+ let match;
1472
+ while ((match = mentionRegex.exec(text)) !== null) {
1473
+ mentions.push(match[1].toLowerCase());
1474
+ }
1475
+ const cleanedText = text.replace(mentionRegex, '').trim();
1476
+
1477
+ // Hide quick examples
1478
+ const examples = document.getElementById('quick-examples');
1479
+ if (examples) examples.style.display = 'none';
1480
+
1481
+ // Add user message to UI
1482
+ addMessage({ role: 'user', content: text });
1483
+
1484
+ // Show typing
1485
+ showTyping();
1486
+
1487
+ try {
1488
+ // Send history WITHOUT the current message (backend appends it)
1489
+ const resp = await fetch('/api/chat', {
1490
+ method: 'POST',
1491
+ headers: { 'Content-Type': 'application/json' },
1492
+ body: JSON.stringify({
1493
+ message: cleanedText,
1494
+ history: chatHistory,
1495
+ mentions: mentions,
1496
+ backend: currentBackend,
1497
+ design_state: designState,
1498
+ }),
1499
+ });
1500
+
1501
+ // Add to history AFTER sending (so it's included in future turns)
1502
+ chatHistory.push({ role: 'user', content: text });
1503
+ saveState();
1504
+ const data = await resp.json();
1505
+
1506
+ hideTyping();
1507
+
1508
+ // Add agent responses
1509
+ for (const r of data.responses) {
1510
+ addMessage({
1511
+ role: 'agent',
1512
+ agent_id: r.agent_id,
1513
+ agent_name: r.agent_name,
1514
+ content: r.message,
1515
+ color: r.color,
1516
+ avatar: r.avatar,
1517
+ code: r.code,
1518
+ });
1519
+ chatHistory.push({ role: 'agent', agent_id: r.agent_id, content: r.message });
1520
+ }
1521
+
1522
+ if (data.design_state) {
1523
+ designState = data.design_state;
1524
+ }
1525
+ saveState();
1526
+
1527
+ // If preview available, load 3D model
1528
+ if (data.preview && data.preview.success) {
1529
+ setViewerLoading(true, 'LOADING 3D MODEL...');
1530
+ try {
1531
+ await loadSTL(data.preview.stl_url);
1532
+ } catch (e) {
1533
+ console.warn('STL load failed:', e);
1534
+ }
1535
+ setViewerLoading(false);
1536
+ updateGeoStats(data.preview.execution);
1537
+ updateCNCBadge(data.preview.validation);
1538
+ updateDownloads(data.preview.part_name);
1539
+
1540
+ if (data.preview.part_name) {
1541
+ currentPartName = data.preview.part_name;
1542
+ addToGallery(data.preview);
1543
+ }
1544
+ }
1545
+ } catch (err) {
1546
+ hideTyping();
1547
+ addMessage({
1548
+ role: 'agent',
1549
+ agent_id: 'system',
1550
+ agent_name: 'System',
1551
+ content: 'Error: ' + err.message,
1552
+ color: '#ff5252',
1553
+ avatar: '!',
1554
+ });
1555
+ }
1556
+ }
1557
+
1558
+ function sendFromInput() {
1559
+ const input = document.getElementById('chat-input');
1560
+ const text = input.value.trim();
1561
+ if (!text) return;
1562
+ input.value = '';
1563
+ input.style.height = 'auto';
1564
+ closeMentionDropdown();
1565
+ sendMessage(text);
1566
+ }
1567
+
1568
+ function sendPreview() {
1569
+ sendMessage('@cad Generate a 3D preview based on our discussion');
1570
+ }
1571
+
1572
+ function quickSend(text) {
1573
+ const examples = document.getElementById('quick-examples');
1574
+ if (examples) examples.style.display = 'none';
1575
+ sendMessage(text);
1576
+ }
1577
+
1578
+ // ── MESSAGE RENDERING ─────────────────────────────────
1579
+
1580
+ function addMessage(msg) {
1581
+ const container = document.getElementById('chat-messages');
1582
+
1583
+ const el = document.createElement('div');
1584
+
1585
+ if (msg.role === 'user') {
1586
+ el.className = 'msg msg-user';
1587
+ el.innerHTML = '<div class="msg-bubble">' + escapeHtml(msg.content) + '</div>';
1588
+ } else {
1589
+ const agentId = msg.agent_id || 'system';
1590
+ const agentInfo = AGENTS[agentId] || { name: msg.agent_name || 'Agent', color: msg.color || '#5a7089', avatar: '?' };
1591
+ const color = msg.color || agentInfo.color;
1592
+ const avatar = msg.avatar || agentInfo.avatar;
1593
+ const name = msg.agent_name || agentInfo.name;
1594
+ const isCad = agentId === 'cad';
1595
+
1596
+ el.className = 'msg msg-agent';
1597
+
1598
+ let html = '<div class="msg-avatar" style="background: ' + color + ';">' + avatar + '</div>';
1599
+ html += '<div class="msg-agent-body">';
1600
+ html += '<div class="msg-agent-name" style="color: ' + color + ';">' + escapeHtml(name) + '</div>';
1601
+ html += '<div class="msg-agent-bubble' + (isCad ? ' cad-bubble' : '') + '">' + escapeHtml(msg.content);
1602
+
1603
+ if (msg.code) {
1604
+ currentCode = msg.code;
1605
+ html += '<br><a class="msg-view-code" onclick="openCodeModal()">&#9654; View code</a>';
1606
+ }
1607
+
1608
+ html += '</div></div>';
1609
+ el.innerHTML = html;
1610
+ }
1611
+
1612
+ container.appendChild(el);
1613
+ scrollChatToBottom();
1614
+ }
1615
+
1616
+ function showTyping() {
1617
+ const container = document.getElementById('chat-messages');
1618
+ const el = document.createElement('div');
1619
+ el.className = 'typing-indicator';
1620
+ el.id = 'typing-indicator';
1621
+ el.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div><span class="typing-label">Agents are thinking...</span>';
1622
+ container.appendChild(el);
1623
+ scrollChatToBottom();
1624
+ }
1625
+
1626
+ function hideTyping() {
1627
+ const el = document.getElementById('typing-indicator');
1628
+ if (el) el.remove();
1629
+ }
1630
+
1631
+ function scrollChatToBottom() {
1632
+ const container = document.getElementById('chat-messages');
1633
+ requestAnimationFrame(() => {
1634
+ container.scrollTop = container.scrollHeight;
1635
+ });
1636
+ }
1637
+
1638
+ // ── @MENTION AUTOCOMPLETE ─────────────────────────────
1639
+
1640
+ const mentionAgents = ['design', 'engineering', 'cnc', 'cad'];
1641
+
1642
+ function handleInputForMention(e) {
1643
+ const input = document.getElementById('chat-input');
1644
+ const val = input.value;
1645
+ const pos = input.selectionStart;
1646
+
1647
+ // Find @ before cursor
1648
+ const before = val.substring(0, pos);
1649
+ const atMatch = before.match(/@(\w*)$/);
1650
+
1651
+ if (atMatch) {
1652
+ const query = atMatch[1].toLowerCase();
1653
+ const filtered = mentionAgents.filter(a => a.startsWith(query));
1654
+
1655
+ if (filtered.length > 0) {
1656
+ showMentionDropdown(filtered);
1657
+ mentionActive = true;
1658
+ return;
1659
+ }
1660
+ }
1661
+
1662
+ closeMentionDropdown();
1663
+ }
1664
+
1665
+ function showMentionDropdown(filtered) {
1666
+ const dropdown = document.getElementById('mention-dropdown');
1667
+ const options = dropdown.querySelectorAll('.mention-option');
1668
+ let visibleCount = 0;
1669
+
1670
+ options.forEach(opt => {
1671
+ const agent = opt.dataset.agent;
1672
+ if (filtered.includes(agent)) {
1673
+ opt.style.display = 'flex';
1674
+ visibleCount++;
1675
+ } else {
1676
+ opt.style.display = 'none';
1677
+ }
1678
+ });
1679
+
1680
+ if (visibleCount > 0) {
1681
+ dropdown.classList.add('visible');
1682
+ mentionIndex = 0;
1683
+ updateMentionHighlight();
1684
+ }
1685
+ }
1686
+
1687
+ function closeMentionDropdown() {
1688
+ document.getElementById('mention-dropdown').classList.remove('visible');
1689
+ mentionActive = false;
1690
+ }
1691
+
1692
+ function updateMentionHighlight() {
1693
+ const options = Array.from(document.querySelectorAll('#mention-dropdown .mention-option'))
1694
+ .filter(o => o.style.display !== 'none');
1695
+ options.forEach((o, i) => o.classList.toggle('active', i === mentionIndex));
1696
+ }
1697
+
1698
+ function insertMention(agent) {
1699
+ const input = document.getElementById('chat-input');
1700
+ const val = input.value;
1701
+ const pos = input.selectionStart;
1702
+ const before = val.substring(0, pos);
1703
+ const after = val.substring(pos);
1704
+ const atPos = before.lastIndexOf('@');
1705
+
1706
+ input.value = before.substring(0, atPos) + '@' + agent + ' ' + after;
1707
+ input.focus();
1708
+ const newPos = atPos + agent.length + 2;
1709
+ input.setSelectionRange(newPos, newPos);
1710
+ closeMentionDropdown();
1711
+ }
1712
+
1713
+ // ── UI UPDATES ────────────────────────────────────────
1714
+
1715
+ function setViewerLoading(on, msg) {
1716
+ const el = document.getElementById('viewer-loading');
1717
+ if (on) {
1718
+ el.classList.add('visible');
1719
+ document.getElementById('loading-msg').textContent = msg || 'GENERATING...';
1720
+ } else {
1721
+ el.classList.remove('visible');
1722
+ }
1723
+ }
1724
+
1725
+ function updateGeoStats(exec) {
1726
+ if (!exec || !exec.success) return;
1727
+ const el = document.getElementById('geo-stats');
1728
+ el.classList.add('visible');
1729
+
1730
+ const vol = exec.volume_mm3;
1731
+ document.getElementById('stat-volume').textContent =
1732
+ vol > 1000 ? (vol / 1000).toFixed(1) + ' cm\u00B3' : vol.toFixed(1) + ' mm\u00B3';
1733
+
1734
+ const bbox = exec.bounding_box_mm;
1735
+ if (bbox && bbox.length === 3) {
1736
+ document.getElementById('stat-bbox').textContent =
1737
+ bbox.map(v => v.toFixed(1)).join(' \u00D7 ') + ' mm';
1738
+ }
1739
+
1740
+ document.getElementById('stat-faces').textContent = exec.face_count || '\u2014';
1741
+ document.getElementById('stat-edges').textContent = exec.edge_count || '\u2014';
1742
+ }
1743
+
1744
+ function updateCNCBadge(validation) {
1745
+ const el = document.getElementById('cnc-badge');
1746
+ if (!validation) { el.classList.remove('visible'); return; }
1747
+ el.classList.add('visible');
1748
+
1749
+ const cncBadge = document.getElementById('badge-cnc');
1750
+ if (validation.machinable) {
1751
+ cncBadge.className = 'badge badge-success';
1752
+ cncBadge.textContent = '\u2713 CNC MACHINABLE';
1753
+ } else {
1754
+ cncBadge.className = 'badge badge-error';
1755
+ cncBadge.textContent = '\u2717 NOT MACHINABLE';
1756
+ }
1757
+
1758
+ const axisBadge = document.getElementById('badge-axis');
1759
+ axisBadge.textContent = (validation.axis_recommendation || '').toUpperCase();
1760
+ }
1761
+
1762
+ function updateDownloads(partName) {
1763
+ const el = document.getElementById('download-btns');
1764
+ if (!partName) { el.classList.remove('visible'); return; }
1765
+ el.classList.add('visible');
1766
+
1767
+ document.getElementById('dl-step').href = '/api/models/' + partName + '.step';
1768
+ document.getElementById('dl-stl').href = '/api/models/' + partName + '.stl';
1769
+ document.getElementById('dl-report').href = '/api/models/' + partName + '_report.json';
1770
+ }
1771
+
1772
+ // ── CODE MODAL ────────────────────────────────────────
1773
+
1774
+ function openCodeModal() {
1775
+ const modal = document.getElementById('code-modal');
1776
+ const display = document.getElementById('code-display');
1777
+
1778
+ if (currentCode) {
1779
+ display.innerHTML = highlightPython(currentCode);
1780
+ } else {
1781
+ display.textContent = 'No code available.';
1782
+ }
1783
+
1784
+ modal.classList.add('visible');
1785
+ }
1786
+
1787
+ function closeCodeModal() {
1788
+ document.getElementById('code-modal').classList.remove('visible');
1789
+ }
1790
+
1791
+ function highlightPython(code) {
1792
+ let escaped = code
1793
+ .replace(/&/g, '&amp;')
1794
+ .replace(/</g, '&lt;')
1795
+ .replace(/>/g, '&gt;');
1796
+
1797
+ escaped = escaped.replace(/(#.*$)/gm, '<span class="cm">$1</span>');
1798
+ escaped = escaped.replace(/("""[\s\S]*?"""|'''[\s\S]*?'''|"[^"\n]*"|'[^'\n]*')/g, '<span class="st">$1</span>');
1799
+
1800
+ 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;
1801
+ escaped = escaped.replace(kw, '<span class="kw">$1</span>');
1802
+ escaped = escaped.replace(/\b(\d+\.?\d*)\b/g, '<span class="nu">$1</span>');
1803
+ escaped = escaped.replace(/\.([a-zA-Z_]\w*)\(/g, '.<span class="fn">$1</span>(');
1804
+
1805
+ return escaped;
1806
+ }
1807
+
1808
+ // ── GALLERY ───────────────────────────────────────────
1809
+
1810
+ function addToGallery(data) {
1811
+ galleryItems.unshift({
1812
+ name: data.part_name,
1813
+ volume: data.execution?.volume_mm3,
1814
+ faces: data.execution?.face_count,
1815
+ machinable: data.validation?.machinable,
1816
+ });
1817
+ }
1818
+
1819
+ function openGallery() {
1820
+ renderGallery();
1821
+ document.getElementById('gallery-modal').classList.add('visible');
1822
+ }
1823
+
1824
+ function closeGallery() {
1825
+ document.getElementById('gallery-modal').classList.remove('visible');
1826
+ }
1827
+
1828
+ function renderGallery() {
1829
+ const grid = document.getElementById('gallery-grid');
1830
+
1831
+ if (galleryItems.length === 0) {
1832
+ grid.innerHTML = '<div class="gallery-empty">No models generated yet.</div>';
1833
+ return;
1834
+ }
1835
+
1836
+ let html = '';
1837
+ for (const item of galleryItems) {
1838
+ html += '<button class="gallery-card fade-in" onclick="loadGalleryItem(\'' + escapeHtml(item.name) + '\')">';
1839
+ html += '<div class="gallery-card-name">' + escapeHtml(item.name) + '</div>';
1840
+ html += '<div class="gallery-card-meta">';
1841
+ if (item.faces) html += '<span>' + item.faces + ' faces</span>';
1842
+ if (item.machinable !== undefined) {
1843
+ html += '<span style="color:' + (item.machinable ? 'var(--success)' : 'var(--error)') + '">'
1844
+ + (item.machinable ? '\u2713 CNC' : '\u2717 CNC') + '</span>';
1845
+ }
1846
+ html += '</div></button>';
1847
+ }
1848
+
1849
+ grid.innerHTML = html;
1850
+ }
1851
+
1852
+ async function loadGalleryItem(name) {
1853
+ closeGallery();
1854
+ setViewerLoading(true, 'LOADING MODEL...');
1855
+ try {
1856
+ await loadSTL('/api/models/' + name + '.stl');
1857
+ } catch (e) {
1858
+ console.warn('Failed to load:', e);
1859
+ }
1860
+ setViewerLoading(false);
1861
+ }
1862
+
1863
+ // ── UTILS ─────────────────────────────────────────────
1864
+
1865
+ function escapeHtml(str) {
1866
+ const div = document.createElement('div');
1867
+ div.textContent = str;
1868
+ return div.innerHTML;
1869
+ }
1870
+
1871
+ // ── SERVER STATUS CHECK ───────────────────────────────
1872
+
1873
+ async function checkServer() {
1874
+ try {
1875
+ const resp = await fetch('/api/capabilities');
1876
+ const dot = document.getElementById('status-dot');
1877
+ if (resp.ok) {
1878
+ dot.style.background = 'var(--success)';
1879
+ dot.style.boxShadow = '0 0 6px var(--success)';
1880
+ dot.title = 'Server Connected';
1881
+ } else {
1882
+ dot.style.background = 'var(--warning)';
1883
+ dot.style.boxShadow = '0 0 6px var(--warning)';
1884
+ dot.title = 'Server Error';
1885
+ }
1886
+ } catch {
1887
+ const dot = document.getElementById('status-dot');
1888
+ dot.style.background = 'var(--error)';
1889
+ dot.style.boxShadow = '0 0 6px var(--error)';
1890
+ dot.title = 'Server Offline';
1891
+ }
1892
+ }
1893
+
1894
+ // ── KEYBOARD / INPUT EVENTS ──────────────────────────
1895
+
1896
+ const chatInput = document.getElementById('chat-input');
1897
+
1898
+ chatInput.addEventListener('input', (e) => {
1899
+ // Auto-resize
1900
+ chatInput.style.height = 'auto';
1901
+ chatInput.style.height = Math.min(chatInput.scrollHeight, 120) + 'px';
1902
+
1903
+ // Check for @mention
1904
+ handleInputForMention(e);
1905
+ });
1906
+
1907
+ chatInput.addEventListener('keydown', (e) => {
1908
+ if (mentionActive) {
1909
+ const dropdown = document.getElementById('mention-dropdown');
1910
+ const visibleOptions = Array.from(dropdown.querySelectorAll('.mention-option'))
1911
+ .filter(o => o.style.display !== 'none');
1912
+
1913
+ if (e.key === 'ArrowDown') {
1914
+ e.preventDefault();
1915
+ mentionIndex = (mentionIndex + 1) % visibleOptions.length;
1916
+ updateMentionHighlight();
1917
+ return;
1918
+ }
1919
+ if (e.key === 'ArrowUp') {
1920
+ e.preventDefault();
1921
+ mentionIndex = (mentionIndex - 1 + visibleOptions.length) % visibleOptions.length;
1922
+ updateMentionHighlight();
1923
+ return;
1924
+ }
1925
+ if (e.key === 'Enter' || e.key === 'Tab') {
1926
+ e.preventDefault();
1927
+ const agent = visibleOptions[mentionIndex]?.dataset.agent;
1928
+ if (agent) insertMention(agent);
1929
+ return;
1930
+ }
1931
+ if (e.key === 'Escape') {
1932
+ closeMentionDropdown();
1933
+ return;
1934
+ }
1935
+ }
1936
+
1937
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
1938
+ e.preventDefault();
1939
+ sendFromInput();
1940
+ }
1941
+
1942
+ // Regular enter sends (without shift)
1943
+ if (e.key === 'Enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
1944
+ e.preventDefault();
1945
+ sendFromInput();
1946
+ }
1947
+ });
1948
+
1949
+ // Close modals on backdrop click
1950
+ document.getElementById('code-modal').addEventListener('click', (e) => {
1951
+ if (e.target === document.getElementById('code-modal')) closeCodeModal();
1952
+ });
1953
+
1954
+ document.getElementById('gallery-modal').addEventListener('click', (e) => {
1955
+ if (e.target === document.getElementById('gallery-modal')) closeGallery();
1956
+ });
1957
+
1958
+ // Escape to close modals
1959
+ document.addEventListener('keydown', (e) => {
1960
+ if (e.key === 'Escape') {
1961
+ closeCodeModal();
1962
+ closeGallery();
1963
+ }
1964
+ });
1965
+
1966
+ // ── INIT ──────────────────────────────────────────────
1967
+
1968
+ initViewer();
1969
+ checkServer();
1970
+ setInterval(checkServer, 15000);
1971
+
1972
+ loadState();
1973
+ // Re-render restored messages
1974
+ if (chatHistory.length > 0) {
1975
+ const examples = document.getElementById('quick-examples');
1976
+ if (examples) examples.style.display = 'none';
1977
+ for (const msg of chatHistory) {
1978
+ addMessage(msg);
1979
+ }
1980
+ }
1981
+ </script>
1982
+ </body>
1983
+ </html>