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