Spaces:
Paused
Paused
Deploy Onshape MCP to Hugging Face Spaces
Browse files- LICENSE +21 -0
- README.md +20 -7
- __pycache__/app.cpython-314.pyc +0 -0
- app.py +176 -0
- packages.txt +16 -0
- pyproject.toml +51 -0
- requirements.txt +14 -0
- src/onshape_mcp.egg-info/PKG-INFO +156 -0
- src/onshape_mcp.egg-info/SOURCES.txt +27 -0
- src/onshape_mcp.egg-info/dependency_links.txt +1 -0
- src/onshape_mcp.egg-info/entry_points.txt +3 -0
- src/onshape_mcp.egg-info/requires.txt +14 -0
- src/onshape_mcp.egg-info/top_level.txt +1 -0
- src/onshape_mcp/__init__.py +3 -0
- src/onshape_mcp/__pycache__/__init__.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/config.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/dispatch.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/driver.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/fast_exec.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/intent.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/journal.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/loop.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/server.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/setup.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/shortcuts.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/tools.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/tunnel.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/ui_actions.cpython-314.pyc +0 -0
- src/onshape_mcp/__pycache__/vision.cpython-314.pyc +0 -0
- src/onshape_mcp/config.py +96 -0
- src/onshape_mcp/dispatch.py +360 -0
- src/onshape_mcp/driver.py +423 -0
- src/onshape_mcp/fast_exec.py +77 -0
- src/onshape_mcp/intent.py +243 -0
- src/onshape_mcp/journal.py +54 -0
- src/onshape_mcp/loop.py +201 -0
- src/onshape_mcp/server.py +697 -0
- src/onshape_mcp/setup.py +373 -0
- src/onshape_mcp/shortcuts.py +498 -0
- src/onshape_mcp/tools.py +539 -0
- src/onshape_mcp/tunnel.py +131 -0
- src/onshape_mcp/ui_actions.py +1216 -0
- src/onshape_mcp/vision.py +240 -0
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Kayode Williams Olalere
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
README.md
CHANGED
|
@@ -1,13 +1,26 @@
|
|
| 1 |
---
|
| 2 |
-
title: Onshape
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Onshape CAD MCP
|
| 3 |
+
emoji: 📐
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.20.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# 📐 Onshape MCP Server
|
| 13 |
+
|
| 14 |
+
A natural-language agent that models 3D CAD directly in Onshape. Drives the Onshape web canvas using Playwright hands and Gemini vision.
|
| 15 |
+
|
| 16 |
+
### 🔗 Connect to ChatGPT / Claude / Cursor
|
| 17 |
+
|
| 18 |
+
**MCP SSE Endpoint:**
|
| 19 |
+
```text
|
| 20 |
+
https://x-r-1-8-onshape-mcp.hf.space/sse
|
| 21 |
+
```
|
| 22 |
+
|
| 23 |
+
### Features
|
| 24 |
+
* **Zero Installation for Testers**: Connect directly over SSE without running local code.
|
| 25 |
+
* **Full Sketch Suite**: 35+ tools (lines, rectangles, splines, polygons, fillets, chamfers, offset, trim, mirror).
|
| 26 |
+
* **Deterministic Fast Engine**: 0-token instant CAD operations without LLM overhead.
|
__pycache__/app.cpython-314.pyc
ADDED
|
Binary file (8.26 kB). View file
|
|
|
app.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space Application for Onshape MCP.
|
| 2 |
+
Provides both:
|
| 3 |
+
1. An interactive Gradio web playground at `/`
|
| 4 |
+
2. A full Model Context Protocol (MCP) SSE endpoint at `/sse` for ChatGPT, Claude, and Cursor.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import subprocess
|
| 13 |
+
import sys
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
|
| 16 |
+
# Add src to sys.path
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
| 18 |
+
|
| 19 |
+
import gradio as gr
|
| 20 |
+
from fastapi import FastAPI
|
| 21 |
+
from starlette.middleware.cors import CORSMiddleware
|
| 22 |
+
|
| 23 |
+
# Auto-ensure Playwright chromium is installed
|
| 24 |
+
try:
|
| 25 |
+
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=False)
|
| 26 |
+
except Exception:
|
| 27 |
+
pass
|
| 28 |
+
|
| 29 |
+
# ZeroGPU compatibility (no-op decorator so ZeroGPU hardware remains active without burning quota)
|
| 30 |
+
try:
|
| 31 |
+
import spaces
|
| 32 |
+
|
| 33 |
+
@spaces.GPU
|
| 34 |
+
def _gpu_keepalive() -> str:
|
| 35 |
+
return "GPU active"
|
| 36 |
+
except Exception:
|
| 37 |
+
def _gpu_keepalive() -> str:
|
| 38 |
+
return "CPU fallback"
|
| 39 |
+
|
| 40 |
+
# Import Onshape MCP server
|
| 41 |
+
from onshape_mcp.server import mcp
|
| 42 |
+
from onshape_mcp.intent import parse as parse_intent
|
| 43 |
+
from onshape_mcp.tools import ALL_TOOLS
|
| 44 |
+
|
| 45 |
+
# 1. Build Gradio Interface
|
| 46 |
+
def test_prompt(user_prompt: str) -> str:
|
| 47 |
+
"""Preview how Onshape MCP parses a prompt into CAD tools."""
|
| 48 |
+
if not user_prompt.strip():
|
| 49 |
+
return "Please enter a CAD prompt (e.g., 'Draw a 10cm by 5cm box on the Top plane')."
|
| 50 |
+
try:
|
| 51 |
+
plan = parse_intent(user_prompt)
|
| 52 |
+
if plan:
|
| 53 |
+
return json.dumps(
|
| 54 |
+
{
|
| 55 |
+
"status": "Recognized Deterministic CAD Actions",
|
| 56 |
+
"action_count": len(plan),
|
| 57 |
+
"actions": [
|
| 58 |
+
{"tool": tool, "args": args} for tool, args in plan
|
| 59 |
+
],
|
| 60 |
+
},
|
| 61 |
+
indent=2,
|
| 62 |
+
)
|
| 63 |
+
else:
|
| 64 |
+
return json.dumps(
|
| 65 |
+
{
|
| 66 |
+
"status": "Complex Multi-Step Goal",
|
| 67 |
+
"handler": "Autonomous Vision Loop (act)",
|
| 68 |
+
"note": "Will be executed closed-loop in Onshape.",
|
| 69 |
+
},
|
| 70 |
+
indent=2,
|
| 71 |
+
)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
return f"Error parsing prompt: {e}"
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def list_tools() -> str:
|
| 77 |
+
"""Return available sketch tools catalog."""
|
| 78 |
+
tools_summary = []
|
| 79 |
+
for tool in ALL_TOOLS:
|
| 80 |
+
tools_summary.append({
|
| 81 |
+
"name": tool.name,
|
| 82 |
+
"purpose": tool.purpose,
|
| 83 |
+
"requires": tool.requires,
|
| 84 |
+
"produces": tool.produces,
|
| 85 |
+
"status": tool.status,
|
| 86 |
+
})
|
| 87 |
+
return json.dumps(tools_summary, indent=2)
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
with gr.Blocks(title="Onshape MCP — Autonomous CAD AI", theme=gr.themes.Soft()) as demo:
|
| 91 |
+
gr.Markdown(
|
| 92 |
+
"""
|
| 93 |
+
# 📐 Onshape MCP — Autonomous CAD Agent
|
| 94 |
+
### Control Onshape directly from ChatGPT, Claude, Cursor, and any MCP-compatible AI.
|
| 95 |
+
"""
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
with gr.Row():
|
| 99 |
+
with gr.Column(scale=2):
|
| 100 |
+
gr.Markdown(
|
| 101 |
+
"""
|
| 102 |
+
### 🔗 Connect This Space to Your AI Client
|
| 103 |
+
|
| 104 |
+
**MCP SSE Endpoint:**
|
| 105 |
+
```text
|
| 106 |
+
https://x-r-1-8-onshape-mcp.hf.space/sse
|
| 107 |
+
```
|
| 108 |
+
|
| 109 |
+
* **ChatGPT Web**: In Custom GPTs or MCP Settings, add the SSE URL above.
|
| 110 |
+
* **Claude Desktop**: Add this block to `claude_desktop_config.json`:
|
| 111 |
+
```json
|
| 112 |
+
{
|
| 113 |
+
"mcpServers": {
|
| 114 |
+
"onshape": {
|
| 115 |
+
"url": "https://x-r-1-8-onshape-mcp.hf.space/sse"
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
```
|
| 120 |
+
* **Cursor / LibreChat**: Add as a remote SSE MCP server.
|
| 121 |
+
"""
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
with gr.Column(scale=1):
|
| 125 |
+
gr.Markdown(
|
| 126 |
+
"""
|
| 127 |
+
### ⚙️ Space Status
|
| 128 |
+
* **Hardware**: ZeroGPU (RTX PRO 6000)
|
| 129 |
+
* **Status**: 🟢 Server Live
|
| 130 |
+
* **Sketch Tools**: 35+ Tools & 13 Constraints
|
| 131 |
+
* **Protocol**: Model Context Protocol (2024-11-05)
|
| 132 |
+
"""
|
| 133 |
+
)
|
| 134 |
+
|
| 135 |
+
gr.Markdown("---")
|
| 136 |
+
gr.Markdown("### 🧪 Test CAD Natural Language Parser")
|
| 137 |
+
|
| 138 |
+
with gr.Row():
|
| 139 |
+
with gr.Column():
|
| 140 |
+
prompt_input = gr.Textbox(
|
| 141 |
+
label="CAD Prompt",
|
| 142 |
+
placeholder="e.g. Draw a 10cm by 5cm box on the Top plane and extrude 20mm",
|
| 143 |
+
lines=2,
|
| 144 |
+
value="Draw a 10cm by 5cm box on the Top plane",
|
| 145 |
+
)
|
| 146 |
+
btn_run = gr.Button("Parse Intent Plan", variant="primary")
|
| 147 |
+
with gr.Column():
|
| 148 |
+
output_json = gr.Code(label="Parsed Deterministic Plan", language="json")
|
| 149 |
+
|
| 150 |
+
btn_run.click(fn=test_prompt, inputs=[prompt_input], outputs=[output_json])
|
| 151 |
+
|
| 152 |
+
with gr.Accordion("📚 View Full CAD Tools Catalog (35+ Tools)", open=False):
|
| 153 |
+
tools_viewer = gr.Code(value=list_tools(), language="json", lines=15)
|
| 154 |
+
|
| 155 |
+
# 2. Create Parent FastAPI App
|
| 156 |
+
app = FastAPI()
|
| 157 |
+
|
| 158 |
+
# Enable CORS for web clients
|
| 159 |
+
app.add_middleware(
|
| 160 |
+
CORSMiddleware,
|
| 161 |
+
allow_origins=["*"],
|
| 162 |
+
allow_credentials=True,
|
| 163 |
+
allow_methods=["*"],
|
| 164 |
+
allow_headers=["*"],
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# 3. Mount FastMCP SSE App at /
|
| 168 |
+
sse_app = mcp.sse_app()
|
| 169 |
+
app.mount("/mcp", sse_app)
|
| 170 |
+
|
| 171 |
+
# Also expose direct /sse and /messages routes at root
|
| 172 |
+
for route in sse_app.routes:
|
| 173 |
+
app.routes.append(route)
|
| 174 |
+
|
| 175 |
+
# 4. Mount Gradio interface at root
|
| 176 |
+
app = gr.mount_gradio_app(app, demo, path="/")
|
packages.txt
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
libnss3
|
| 2 |
+
libnspr4
|
| 3 |
+
libatk1.0-0
|
| 4 |
+
libatk-bridge2.0-0
|
| 5 |
+
libcups2
|
| 6 |
+
libdrm2
|
| 7 |
+
libxkbcommon0
|
| 8 |
+
libxcomposite1
|
| 9 |
+
libxdamage1
|
| 10 |
+
libxfixes3
|
| 11 |
+
libxrandr2
|
| 12 |
+
libgbm1
|
| 13 |
+
libpango-1.0-0
|
| 14 |
+
libcairo2
|
| 15 |
+
libasound2
|
| 16 |
+
xvfb
|
pyproject.toml
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "onshape-mcp"
|
| 3 |
+
version = "0.0.1"
|
| 4 |
+
description = "Visual agentic MCP server for Onshape, driven by Gemini web vision."
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
license = { file = "LICENSE" }
|
| 7 |
+
requires-python = ">=3.11"
|
| 8 |
+
authors = [
|
| 9 |
+
{ name = "Kayode Williams Olalere" },
|
| 10 |
+
]
|
| 11 |
+
dependencies = [
|
| 12 |
+
"mcp>=1.2.0,<2.0.0", # TODO: migrate to MCPServer in M2
|
| 13 |
+
"playwright>=1.48.0",
|
| 14 |
+
"pydantic>=2.8.0",
|
| 15 |
+
"python-dotenv>=1.0.1",
|
| 16 |
+
"Pillow>=10.4.0",
|
| 17 |
+
"httpx>=0.27.0",
|
| 18 |
+
"browser-cookie3>=0.20.1",
|
| 19 |
+
"flask>=3.0.0",
|
| 20 |
+
"gemini-webapi>=2.0.0", # verify GeminiClient API on first install
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
[project.scripts]
|
| 24 |
+
onshape-mcp = "onshape_mcp.server:main"
|
| 25 |
+
onshape-mcp-setup = "onshape_mcp.setup:main"
|
| 26 |
+
|
| 27 |
+
[project.optional-dependencies]
|
| 28 |
+
dev = [
|
| 29 |
+
"pytest>=8.3.0",
|
| 30 |
+
"ruff>=0.6.0",
|
| 31 |
+
"mypy>=1.11.0",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
[build-system]
|
| 35 |
+
requires = ["setuptools>=68.0", "wheel"]
|
| 36 |
+
build-backend = "setuptools.build_meta"
|
| 37 |
+
|
| 38 |
+
[tool.setuptools.packages.find]
|
| 39 |
+
where = ["src"]
|
| 40 |
+
|
| 41 |
+
[tool.ruff]
|
| 42 |
+
line-length = 100
|
| 43 |
+
target-version = "py311"
|
| 44 |
+
|
| 45 |
+
[tool.ruff.lint]
|
| 46 |
+
select = ["E", "F", "W", "I"]
|
| 47 |
+
ignore = ["E501"]
|
| 48 |
+
|
| 49 |
+
[tool.pytest.ini_options]
|
| 50 |
+
testpaths = ["tests"]
|
| 51 |
+
addopts = "-ra -q -p no:launch_testing -p no:launch_ros"
|
requirements.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=5.0.0
|
| 2 |
+
spaces
|
| 3 |
+
fastapi
|
| 4 |
+
starlette
|
| 5 |
+
uvicorn
|
| 6 |
+
mcp>=1.2.0,<2.0.0
|
| 7 |
+
playwright>=1.48.0
|
| 8 |
+
pydantic>=2.8.0
|
| 9 |
+
python-dotenv>=1.0.1
|
| 10 |
+
Pillow>=10.4.0
|
| 11 |
+
httpx>=0.27.0
|
| 12 |
+
browser-cookie3>=0.20.1
|
| 13 |
+
flask>=3.0.0
|
| 14 |
+
gemini-webapi>=2.0.0
|
src/onshape_mcp.egg-info/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Metadata-Version: 2.4
|
| 2 |
+
Name: onshape-mcp
|
| 3 |
+
Version: 0.0.1
|
| 4 |
+
Summary: Visual agentic MCP server for Onshape, driven by Gemini web vision.
|
| 5 |
+
Author: Kayode Williams Olalere
|
| 6 |
+
License: MIT License
|
| 7 |
+
|
| 8 |
+
Copyright (c) 2026 Kayode Williams Olalere
|
| 9 |
+
|
| 10 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 11 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 12 |
+
in the Software without restriction, including without limitation the rights
|
| 13 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 14 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 15 |
+
furnished to do so, subject to the following conditions:
|
| 16 |
+
|
| 17 |
+
The above copyright notice and this permission notice shall be included in all
|
| 18 |
+
copies or substantial portions of the Software.
|
| 19 |
+
|
| 20 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 21 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 22 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 23 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 24 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 25 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 26 |
+
SOFTWARE.
|
| 27 |
+
|
| 28 |
+
Requires-Python: >=3.11
|
| 29 |
+
Description-Content-Type: text/markdown
|
| 30 |
+
License-File: LICENSE
|
| 31 |
+
Requires-Dist: mcp<2.0.0,>=1.2.0
|
| 32 |
+
Requires-Dist: playwright>=1.48.0
|
| 33 |
+
Requires-Dist: pydantic>=2.8.0
|
| 34 |
+
Requires-Dist: python-dotenv>=1.0.1
|
| 35 |
+
Requires-Dist: Pillow>=10.4.0
|
| 36 |
+
Requires-Dist: httpx>=0.27.0
|
| 37 |
+
Requires-Dist: browser-cookie3>=0.20.1
|
| 38 |
+
Requires-Dist: flask>=3.0.0
|
| 39 |
+
Requires-Dist: gemini-webapi>=2.0.0
|
| 40 |
+
Provides-Extra: dev
|
| 41 |
+
Requires-Dist: pytest>=8.3.0; extra == "dev"
|
| 42 |
+
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
| 43 |
+
Requires-Dist: mypy>=1.11.0; extra == "dev"
|
| 44 |
+
Dynamic: license-file
|
| 45 |
+
|
| 46 |
+
# onshape-mcp
|
| 47 |
+
|
| 48 |
+
I wanted a way to drive Onshape from a language model the way I drive it with
|
| 49 |
+
my hands. Click a face. Draw a sketch. Extrude. Chamfer. The existing Onshape
|
| 50 |
+
MCP wraps the REST API and FeatureScript, which works but feels like coding,
|
| 51 |
+
not modeling. This server aims at the other 70% of CAD work that lives in
|
| 52 |
+
the viewport.
|
| 53 |
+
|
| 54 |
+
It uses a vision-capable LLM (Gemini web, no API key, my Plus cookies) as
|
| 55 |
+
eyes, Playwright as hands, and an MCP surface so any MCP-aware client can
|
| 56 |
+
talk to it. Every action is journaled, so I can undo, replay, or branch.
|
| 57 |
+
|
| 58 |
+
## Status
|
| 59 |
+
|
| 60 |
+
- **M0** done: scaffold, smoke tests, public repo, safety rails.
|
| 61 |
+
- **M1** done: driver primitives, Onshape tool datasheet, 10 wired tools
|
| 62 |
+
(`view.fit`, `sketch.start/rectangle/circle/line/exit`, `feature.extrude/fillet/chamfer`,
|
| 63 |
+
`select.face/edge`, `ui.undo/redo`), closed-loop `act(goal)` agent.
|
| 64 |
+
- **M2** next: pattern, mirror_body, assembly.mate, sketch.constrain flyout,
|
| 65 |
+
journal-replay undo, a real perceptual-diff for the stuck detector.
|
| 66 |
+
|
| 67 |
+
## What it looks like from the client side
|
| 68 |
+
|
| 69 |
+
Two ways to drive it:
|
| 70 |
+
|
| 71 |
+
**Direct tools.** I call individual MCP tools when I want fine control:
|
| 72 |
+
`screenshot`, `describe_view`, `viewport_size`, `journal_tail`, `tool_datasheet`,
|
| 73 |
+
`open_doc`, and the per-tool `onshape_*` ones. Useful when I want to see
|
| 74 |
+
each step and steer.
|
| 75 |
+
|
| 76 |
+
**Closed-loop `act`.** I just say what I want:
|
| 77 |
+
`act(goal="draw a 50x30mm rectangle on the top plane and extrude it 10mm")`.
|
| 78 |
+
Gemini sees each screenshot, picks the next tool, calls it, repeats until
|
| 79 |
+
the goal is met or it bails. Bounded by `max_steps` (default 25) and a
|
| 80 |
+
stuck detector (3 identical screenshots in a row = stop).
|
| 81 |
+
|
| 82 |
+
## How I run it
|
| 83 |
+
|
| 84 |
+
```bash
|
| 85 |
+
# one-shot bootstrap (use python3 explicitly, "python" is not always aliased)
|
| 86 |
+
python3 scripts/bootstrap.py
|
| 87 |
+
|
| 88 |
+
# then start the server
|
| 89 |
+
source .venv/bin/activate
|
| 90 |
+
python3 -m onshape_mcp.server
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
`bootstrap.py` does the venv, the pip install, the Chromium download, the
|
| 94 |
+
cookie extraction, the Onshape login, and the M0 sanity checks. Re-run it
|
| 95 |
+
any time; it's idempotent.
|
| 96 |
+
|
| 97 |
+
## A note on Google + automated browsers
|
| 98 |
+
|
| 99 |
+
Google blocks automated browser logins ("This browser or app may not be
|
| 100 |
+
secure") when you launch Playwright's bundled Chromium against a Google
|
| 101 |
+
login page. I hit this. The fix in `bootstrap.py` is to try reading the
|
| 102 |
+
Gemini cookies straight out of my real Chrome session first, via
|
| 103 |
+
`browser-cookie3`. No browser launch, no automation block. If that
|
| 104 |
+
somehow fails (Chrome locked, no Chrome installed), it falls back to
|
| 105 |
+
launching real Chrome via Playwright (`channel="chrome"`), and only as
|
| 106 |
+
a last resort launches bundled Chromium.
|
| 107 |
+
|
| 108 |
+
The same Chrome-first choice applies to the main driver. Set
|
| 109 |
+
`ONSHAPE_BROWSER_CHANNEL=auto` (the default) and the server uses real
|
| 110 |
+
Chrome when available, falling back to bundled Chromium. Set it to
|
| 111 |
+
`chromium` to skip the Chrome attempt (e.g. on a Pi with no Chrome
|
| 112 |
+
installed).
|
| 113 |
+
|
| 114 |
+
## Architecture in one screen
|
| 115 |
+
|
| 116 |
+
```
|
| 117 |
+
┌───────────────���────────────────────────┐
|
| 118 |
+
│ MCP client (Claude Code, Cursor, …) │
|
| 119 |
+
└──────────────────┬─────────────────────┘
|
| 120 |
+
│ mcp__onshape__* tool calls
|
| 121 |
+
┌──────────────────▼─────────────────────┐
|
| 122 |
+
│ onshape-mcp server (this repo) │
|
| 123 |
+
│ ┌──────────┐ ┌──────────┐ ┌──────┐ │
|
| 124 |
+
│ │ tools │ │ journal │ │ loop │ │
|
| 125 |
+
│ └────┬─────┘ └────┬─────┘ └───┬──┘ │
|
| 126 |
+
└───────┼─────────────┼────────────┼─────┘
|
| 127 |
+
│ │ │
|
| 128 |
+
┌───────────────▼─────┐ ┌────▼─────┐ ┌───▼────────────┐
|
| 129 |
+
│ Playwright driver │ │ JSONL │ │ Gemini web │
|
| 130 |
+
│ (headless Chromium)│ │ state/ │ │ (vision + LLM)│
|
| 131 |
+
└───────────────┬─────┘ └──────────┘ └────────────────┘
|
| 132 |
+
│
|
| 133 |
+
┌───────▼────────┐
|
| 134 |
+
│ Onshape web │
|
| 135 |
+
└────────────────┘
|
| 136 |
+
```
|
| 137 |
+
|
| 138 |
+
Five layers, bottom up:
|
| 139 |
+
|
| 140 |
+
1. **Driver primitives** in `driver.py`. Click, type, press chord, drag,
|
| 141 |
+
find by text, screenshot. Knows nothing about Onshape.
|
| 142 |
+
2. **UI bindings** in `shortcuts.py`. Maps each semantic tool to its
|
| 143 |
+
keyboard chord or toolbar button, with a `confidence` flag so I know
|
| 144 |
+
what to retest.
|
| 145 |
+
3. **UI actions** in `ui_actions.py`. Compose primitives + bindings into
|
| 146 |
+
one logical op (`sketch_rectangle(d, c1, c2)` = activate tool, click
|
| 147 |
+
c1, click c2, Esc, screenshot). Every op journals itself.
|
| 148 |
+
4. **Dispatch table** in `server.py`. Maps tool names back to ui_actions
|
| 149 |
+
functions, flattens LLM-friendly args to tuples.
|
| 150 |
+
5. **Agent loop** `act(goal)` in `server.py`. Screenshot, ask Gemini
|
| 151 |
+
what's next, dispatch, repeat. Bounded by `max_steps` and a stuck
|
| 152 |
+
detector.
|
| 153 |
+
|
| 154 |
+
## License
|
| 155 |
+
|
| 156 |
+
MIT. See [LICENSE](LICENSE).
|
src/onshape_mcp.egg-info/SOURCES.txt
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
LICENSE
|
| 2 |
+
README.md
|
| 3 |
+
pyproject.toml
|
| 4 |
+
src/onshape_mcp/__init__.py
|
| 5 |
+
src/onshape_mcp/config.py
|
| 6 |
+
src/onshape_mcp/dispatch.py
|
| 7 |
+
src/onshape_mcp/driver.py
|
| 8 |
+
src/onshape_mcp/fast_exec.py
|
| 9 |
+
src/onshape_mcp/intent.py
|
| 10 |
+
src/onshape_mcp/journal.py
|
| 11 |
+
src/onshape_mcp/loop.py
|
| 12 |
+
src/onshape_mcp/server.py
|
| 13 |
+
src/onshape_mcp/setup.py
|
| 14 |
+
src/onshape_mcp/shortcuts.py
|
| 15 |
+
src/onshape_mcp/tools.py
|
| 16 |
+
src/onshape_mcp/ui_actions.py
|
| 17 |
+
src/onshape_mcp/vision.py
|
| 18 |
+
src/onshape_mcp.egg-info/PKG-INFO
|
| 19 |
+
src/onshape_mcp.egg-info/SOURCES.txt
|
| 20 |
+
src/onshape_mcp.egg-info/dependency_links.txt
|
| 21 |
+
src/onshape_mcp.egg-info/entry_points.txt
|
| 22 |
+
src/onshape_mcp.egg-info/requires.txt
|
| 23 |
+
src/onshape_mcp.egg-info/top_level.txt
|
| 24 |
+
tests/test_intent.py
|
| 25 |
+
tests/test_planes_and_quadrants.py
|
| 26 |
+
tests/test_sketch_tools_spec.py
|
| 27 |
+
tests/test_smoke.py
|
src/onshape_mcp.egg-info/dependency_links.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
src/onshape_mcp.egg-info/entry_points.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[console_scripts]
|
| 2 |
+
onshape-mcp = onshape_mcp.server:main
|
| 3 |
+
onshape-mcp-setup = onshape_mcp.setup:main
|
src/onshape_mcp.egg-info/requires.txt
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
mcp<2.0.0,>=1.2.0
|
| 2 |
+
playwright>=1.48.0
|
| 3 |
+
pydantic>=2.8.0
|
| 4 |
+
python-dotenv>=1.0.1
|
| 5 |
+
Pillow>=10.4.0
|
| 6 |
+
httpx>=0.27.0
|
| 7 |
+
browser-cookie3>=0.20.1
|
| 8 |
+
flask>=3.0.0
|
| 9 |
+
gemini-webapi>=2.0.0
|
| 10 |
+
|
| 11 |
+
[dev]
|
| 12 |
+
pytest>=8.3.0
|
| 13 |
+
ruff>=0.6.0
|
| 14 |
+
mypy>=1.11.0
|
src/onshape_mcp.egg-info/top_level.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
onshape_mcp
|
src/onshape_mcp/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""onshape-mcp. Visual agentic MCP server for Onshape."""
|
| 2 |
+
|
| 3 |
+
__version__ = "0.0.1"
|
src/onshape_mcp/__pycache__/__init__.cpython-314.pyc
ADDED
|
Binary file (270 Bytes). View file
|
|
|
src/onshape_mcp/__pycache__/config.cpython-314.pyc
ADDED
|
Binary file (6.01 kB). View file
|
|
|
src/onshape_mcp/__pycache__/dispatch.cpython-314.pyc
ADDED
|
Binary file (31.3 kB). View file
|
|
|
src/onshape_mcp/__pycache__/driver.cpython-314.pyc
ADDED
|
Binary file (26.8 kB). View file
|
|
|
src/onshape_mcp/__pycache__/fast_exec.cpython-314.pyc
ADDED
|
Binary file (5.13 kB). View file
|
|
|
src/onshape_mcp/__pycache__/intent.cpython-314.pyc
ADDED
|
Binary file (12.1 kB). View file
|
|
|
src/onshape_mcp/__pycache__/journal.cpython-314.pyc
ADDED
|
Binary file (4.14 kB). View file
|
|
|
src/onshape_mcp/__pycache__/loop.cpython-314.pyc
ADDED
|
Binary file (11.6 kB). View file
|
|
|
src/onshape_mcp/__pycache__/server.cpython-314.pyc
ADDED
|
Binary file (42.4 kB). View file
|
|
|
src/onshape_mcp/__pycache__/setup.cpython-314.pyc
ADDED
|
Binary file (18.7 kB). View file
|
|
|
src/onshape_mcp/__pycache__/shortcuts.cpython-314.pyc
ADDED
|
Binary file (12.6 kB). View file
|
|
|
src/onshape_mcp/__pycache__/tools.cpython-314.pyc
ADDED
|
Binary file (14.1 kB). View file
|
|
|
src/onshape_mcp/__pycache__/tunnel.cpython-314.pyc
ADDED
|
Binary file (6.44 kB). View file
|
|
|
src/onshape_mcp/__pycache__/ui_actions.cpython-314.pyc
ADDED
|
Binary file (83.3 kB). View file
|
|
|
src/onshape_mcp/__pycache__/vision.cpython-314.pyc
ADDED
|
Binary file (14.7 kB). View file
|
|
|
src/onshape_mcp/config.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Configuration loaded from .env. The .env file is gitignored (see SECURITY.md)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from dotenv import load_dotenv
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def _find_base_dir() -> Path:
|
| 12 |
+
"""Determine the base directory for onshape-mcp configuration, cookies, and state.
|
| 13 |
+
|
| 14 |
+
Priority:
|
| 15 |
+
1. ONSHAPE_DIR or ONSHAPE_HOME environment variable if explicitly set.
|
| 16 |
+
2. Local source repository if pyproject.toml and src/onshape_mcp exist in cwd or parents.
|
| 17 |
+
3. User home directory fallback: ~/.onshape-mcp (safe for uvx, pip, Claude Desktop).
|
| 18 |
+
"""
|
| 19 |
+
if "ONSHAPE_DIR" in os.environ:
|
| 20 |
+
return Path(os.environ["ONSHAPE_DIR"]).expanduser().resolve()
|
| 21 |
+
if "ONSHAPE_HOME" in os.environ:
|
| 22 |
+
return Path(os.environ["ONSHAPE_HOME"]).expanduser().resolve()
|
| 23 |
+
|
| 24 |
+
cwd = Path.cwd()
|
| 25 |
+
for p in [cwd, *cwd.parents]:
|
| 26 |
+
if (p / "pyproject.toml").is_file() and (p / "src" / "onshape_mcp").is_dir():
|
| 27 |
+
return p
|
| 28 |
+
|
| 29 |
+
user_base = Path.home() / ".onshape-mcp"
|
| 30 |
+
user_base.mkdir(parents=True, exist_ok=True)
|
| 31 |
+
return user_base
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
_BASE_DIR = _find_base_dir()
|
| 35 |
+
|
| 36 |
+
# Load env from base directory (.env in repo or ~/.onshape-mcp/.env)
|
| 37 |
+
if (_BASE_DIR / ".env").is_file():
|
| 38 |
+
load_dotenv(_BASE_DIR / ".env")
|
| 39 |
+
elif (Path.home() / ".onshape-mcp" / ".env").is_file():
|
| 40 |
+
load_dotenv(Path.home() / ".onshape-mcp" / ".env")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _path(key: str, default: str) -> Path:
|
| 44 |
+
raw = os.getenv(key, default)
|
| 45 |
+
p = Path(raw).expanduser()
|
| 46 |
+
if not p.is_absolute():
|
| 47 |
+
p = (_BASE_DIR / p).resolve()
|
| 48 |
+
return p
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _bool(key: str, default: bool) -> bool:
|
| 52 |
+
val = os.getenv(key)
|
| 53 |
+
if val is None:
|
| 54 |
+
return default
|
| 55 |
+
return val.strip().lower() in ("1", "true", "yes", "on")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class Settings:
|
| 59 |
+
# Onshape cookies live as plain JSON, not in Chrome's encrypted DB.
|
| 60 |
+
# Chrome's DB on Linux uses a keyring key that's only available to
|
| 61 |
+
# graphical sessions; headless Playwright can't decrypt, so cookies
|
| 62 |
+
# look empty. Storing as JSON dodges the whole keyring mess.
|
| 63 |
+
onshape_cookie_file: Path = _path("ONSHAPE_COOKIE_FILE", "./cookies/onshape.cookies.json")
|
| 64 |
+
onshape_browser_profile: Path = _path("ONSHAPE_BROWSER_PROFILE", "./playwright-profile")
|
| 65 |
+
gemini_cookie_file: Path = _path("GEMINI_COOKIE_FILE", "./cookies/gemini.cookies.json")
|
| 66 |
+
journal_dir: Path = _path("JOURNAL_DIR", "./state")
|
| 67 |
+
log_dir: Path = _path("LOG_DIR", "./logs")
|
| 68 |
+
gemini_model: str = os.getenv("GEMINI_MODEL", "gemini-flash")
|
| 69 |
+
gemini_temporary_chat: bool = _bool("GEMINI_TEMPORARY_CHAT", True)
|
| 70 |
+
gemini_auto_cleanup: bool = _bool("GEMINI_AUTO_CLEANUP", True)
|
| 71 |
+
onshape_default_doc: str = os.getenv("ONSHAPE_DEFAULT_DOC", "")
|
| 72 |
+
# "auto" tries real Chrome first then bundled Chromium,
|
| 73 |
+
# "chrome" requires real Chrome, "chromium" uses bundled Chromium only.
|
| 74 |
+
browser_channel: str = os.getenv("ONSHAPE_BROWSER_CHANNEL", "auto")
|
| 75 |
+
|
| 76 |
+
def __repr__(self) -> str: # never leak cookie/profile paths into logs
|
| 77 |
+
return (
|
| 78 |
+
f"Settings(gemini_model={self.gemini_model!r}, "
|
| 79 |
+
f"gemini_temporary_chat={self.gemini_temporary_chat}, "
|
| 80 |
+
f"gemini_auto_cleanup={self.gemini_auto_cleanup}, "
|
| 81 |
+
f"browser_channel={self.browser_channel!r}, "
|
| 82 |
+
f"onshape_default_doc={self.onshape_default_doc!r})"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
settings = Settings()
|
| 87 |
+
|
| 88 |
+
# Make sure local-only directories exist.
|
| 89 |
+
for d in (
|
| 90 |
+
settings.onshape_cookie_file.parent,
|
| 91 |
+
settings.onshape_browser_profile,
|
| 92 |
+
settings.gemini_cookie_file.parent,
|
| 93 |
+
settings.journal_dir,
|
| 94 |
+
settings.log_dir,
|
| 95 |
+
):
|
| 96 |
+
d.mkdir(parents=True, exist_ok=True)
|
src/onshape_mcp/dispatch.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared dispatch logic. The LLM calls a tool by name with flat kwargs;
|
| 2 |
+
we reshape them to the function signatures in ui_actions.
|
| 3 |
+
|
| 4 |
+
Used by both the MCP server (act tool) and the run_task CLI so they
|
| 5 |
+
can't drift apart.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import re
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from . import ui_actions
|
| 15 |
+
from .driver import OnshapeDriver
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse_decision(text: str) -> dict[str, Any]:
|
| 19 |
+
"""Pull a JSON object out of a Gemini reply. Tolerant of markdown fences."""
|
| 20 |
+
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
|
| 21 |
+
if fenced:
|
| 22 |
+
candidate = fenced.group(1)
|
| 23 |
+
else:
|
| 24 |
+
brace = re.search(r"\{.*\}", text, re.DOTALL)
|
| 25 |
+
candidate = brace.group(0) if brace else text
|
| 26 |
+
try:
|
| 27 |
+
return json.loads(candidate)
|
| 28 |
+
except json.JSONDecodeError:
|
| 29 |
+
return {"done": True, "summary": f"Could not parse decision: {text[:200]}"}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _xy(
|
| 33 |
+
args: dict[str, Any], x_key: str, y_key: str, default: tuple[float, float] = (720.0, 450.0)
|
| 34 |
+
) -> tuple[float, float]:
|
| 35 |
+
if x_key in args and y_key in args:
|
| 36 |
+
try:
|
| 37 |
+
return (float(args[x_key]), float(args[y_key]))
|
| 38 |
+
except (ValueError, TypeError):
|
| 39 |
+
pass
|
| 40 |
+
if "_" in x_key:
|
| 41 |
+
prefix = x_key.rsplit("_", 1)[0]
|
| 42 |
+
if prefix in args:
|
| 43 |
+
val = args[prefix]
|
| 44 |
+
if isinstance(val, (list, tuple)) and len(val) >= 2:
|
| 45 |
+
try:
|
| 46 |
+
return (float(val[0]), float(val[1]))
|
| 47 |
+
except (ValueError, TypeError):
|
| 48 |
+
pass
|
| 49 |
+
if isinstance(val, dict):
|
| 50 |
+
try:
|
| 51 |
+
return (
|
| 52 |
+
float(val.get("x", val.get("0", default[0]))),
|
| 53 |
+
float(val.get("y", val.get("1", default[1]))),
|
| 54 |
+
)
|
| 55 |
+
except (ValueError, TypeError):
|
| 56 |
+
pass
|
| 57 |
+
# Fallback to generic x, y
|
| 58 |
+
if "x" in args and "y" in args:
|
| 59 |
+
try:
|
| 60 |
+
return (float(args["x"]), float(args["y"]))
|
| 61 |
+
except (ValueError, TypeError):
|
| 62 |
+
pass
|
| 63 |
+
# Fallback to point / pt / coords
|
| 64 |
+
for key in ("point", "pt", "coords", "corner"):
|
| 65 |
+
if key in args:
|
| 66 |
+
val = args[key]
|
| 67 |
+
if isinstance(val, (list, tuple)) and len(val) >= 2:
|
| 68 |
+
try:
|
| 69 |
+
return (float(val[0]), float(val[1]))
|
| 70 |
+
except (ValueError, TypeError):
|
| 71 |
+
pass
|
| 72 |
+
return default
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _dim_val(args: dict[str, Any], key: str) -> Any:
|
| 76 |
+
for k in (f"{key}_mm", f"{key}_cm", key, f"{key}_val", f"{key}_size"):
|
| 77 |
+
if k in args:
|
| 78 |
+
val = args[k]
|
| 79 |
+
if k.endswith("_cm") and isinstance(val, (int, float)):
|
| 80 |
+
return f"{val} cm"
|
| 81 |
+
return val
|
| 82 |
+
return None
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
# Tool name -> async fn(d, args) that calls the right ui_action.
|
| 86 |
+
TOOL_DISPATCH: dict[str, Any] = {
|
| 87 |
+
"view.fit": lambda d, a: ui_actions.view_fit(d),
|
| 88 |
+
"view.top": lambda d, a: ui_actions.view_top(d),
|
| 89 |
+
"view.front": lambda d, a: ui_actions.view_front(d),
|
| 90 |
+
"view.iso": lambda d, a: ui_actions.view_iso(d),
|
| 91 |
+
"view.isometric": lambda d, a: ui_actions.view_isometric(d),
|
| 92 |
+
"sketch.start": lambda d, a: ui_actions.sketch_start(
|
| 93 |
+
d,
|
| 94 |
+
(a["plane_x"], a["plane_y"]) if "plane_x" in a and "plane_y" in a else None,
|
| 95 |
+
plane_name=a.get("plane"),
|
| 96 |
+
),
|
| 97 |
+
"sketch.rectangle": lambda d, a: ui_actions.sketch_rectangle(
|
| 98 |
+
d,
|
| 99 |
+
_xy(a, "corner1_x", "corner1_y", default=(0.0, 0.0))
|
| 100 |
+
if ("corner1_x" in a or "corner1" in a or "corner" in a or "x1" in a)
|
| 101 |
+
else None,
|
| 102 |
+
_xy(a, "corner2_x", "corner2_y", default=(100.0, 100.0))
|
| 103 |
+
if ("corner2_x" in a or "corner2" in a or "x2" in a)
|
| 104 |
+
else None,
|
| 105 |
+
width=_dim_val(a, "width"),
|
| 106 |
+
height=_dim_val(a, "height"),
|
| 107 |
+
quadrant=a.get("quadrant"),
|
| 108 |
+
centered=a.get("centered") or a.get("center") or a.get("at_origin"),
|
| 109 |
+
),
|
| 110 |
+
"sketch.circle": lambda d, a: ui_actions.sketch_circle(
|
| 111 |
+
d,
|
| 112 |
+
_xy(a, "center_x", "center_y", default=(0.0, 0.0))
|
| 113 |
+
if ("center_x" in a or "center" in a or "x" in a)
|
| 114 |
+
else None,
|
| 115 |
+
float(a.get("radius_px") or a.get("radius_mm") or a.get("radius", 50)),
|
| 116 |
+
centered=a.get("centered") or a.get("center") or a.get("at_origin"),
|
| 117 |
+
),
|
| 118 |
+
"sketch.line": lambda d, a: ui_actions.sketch_line(
|
| 119 |
+
d, _xy(a, "p1_x", "p1_y"), _xy(a, "p2_x", "p2_y")
|
| 120 |
+
),
|
| 121 |
+
"sketch.line_midpoint": lambda d, a: ui_actions.sketch_line(
|
| 122 |
+
d, _xy(a, "p1_x", "p1_y"), _xy(a, "p2_x", "p2_y")
|
| 123 |
+
),
|
| 124 |
+
"sketch.rectangle_center": lambda d, a: ui_actions.sketch_rectangle(
|
| 125 |
+
d,
|
| 126 |
+
centered=True,
|
| 127 |
+
width=_dim_val(a, "width"),
|
| 128 |
+
height=_dim_val(a, "height"),
|
| 129 |
+
),
|
| 130 |
+
"sketch.rectangle_aligned": lambda d, a: ui_actions.sketch_rectangle(
|
| 131 |
+
d,
|
| 132 |
+
_xy(a, "corner1_x", "corner1_y", default=(0.0, 0.0)),
|
| 133 |
+
_xy(a, "corner2_x", "corner2_y", default=(100.0, 100.0)),
|
| 134 |
+
),
|
| 135 |
+
"sketch.circle_3point": lambda d, a: ui_actions.sketch_circle(
|
| 136 |
+
d, _xy(a, "p1_x", "p1_y", default=(0.0, 0.0))
|
| 137 |
+
),
|
| 138 |
+
"sketch.ellipse": lambda d, a: ui_actions.sketch_circle(
|
| 139 |
+
d,
|
| 140 |
+
_xy(a, "center_x", "center_y", default=(0.0, 0.0)),
|
| 141 |
+
float(a.get("radius", 50.0)),
|
| 142 |
+
),
|
| 143 |
+
"sketch.arc": lambda d, a: ui_actions.sketch_arc(
|
| 144 |
+
d,
|
| 145 |
+
_xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
|
| 146 |
+
_xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
|
| 147 |
+
_xy(a, "radius_x", "radius_y") if ("radius_x" in a or "radius" in a) else None,
|
| 148 |
+
),
|
| 149 |
+
"sketch.arc_3point": lambda d, a: ui_actions.sketch_arc(
|
| 150 |
+
d,
|
| 151 |
+
_xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
|
| 152 |
+
_xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
|
| 153 |
+
_xy(a, "radius_x", "radius_y") if ("radius_x" in a or "radius" in a) else None,
|
| 154 |
+
),
|
| 155 |
+
"sketch.arc_tangent": lambda d, a: ui_actions.sketch_arc(
|
| 156 |
+
d,
|
| 157 |
+
_xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
|
| 158 |
+
_xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
|
| 159 |
+
),
|
| 160 |
+
"sketch.arc_center": lambda d, a: ui_actions.sketch_arc(
|
| 161 |
+
d,
|
| 162 |
+
_xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
|
| 163 |
+
_xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
|
| 164 |
+
),
|
| 165 |
+
"sketch.polygon": lambda d, a: ui_actions.sketch_polygon(
|
| 166 |
+
d,
|
| 167 |
+
_xy(a, "center_x", "center_y", default=(0.0, 0.0))
|
| 168 |
+
if ("center_x" in a or "center" in a or "x" in a)
|
| 169 |
+
else None,
|
| 170 |
+
radius=float(a.get("radius_px") or a.get("radius_mm") or a.get("radius", 60.0)),
|
| 171 |
+
sides=int(a.get("sides", 6)),
|
| 172 |
+
circumscribed=bool(a.get("circumscribed", False)),
|
| 173 |
+
),
|
| 174 |
+
"sketch.spline": lambda d, a: ui_actions.sketch_spline(
|
| 175 |
+
d,
|
| 176 |
+
a.get("points")
|
| 177 |
+
or [_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 3)))]
|
| 178 |
+
),
|
| 179 |
+
"sketch.point": lambda d, a: ui_actions.sketch_point(d, _xy(a, "x", "y")),
|
| 180 |
+
"sketch.text": lambda d, a: ui_actions.sketch_text(
|
| 181 |
+
d,
|
| 182 |
+
_xy(a, "corner1_x", "corner1_y", default=(0.0, 0.0)),
|
| 183 |
+
_xy(a, "corner2_x", "corner2_y", default=(200.0, 100.0)),
|
| 184 |
+
str(a.get("text", "")),
|
| 185 |
+
),
|
| 186 |
+
"sketch.use": lambda d, a: ui_actions.sketch_use(d, _xy(a, "x", "y")),
|
| 187 |
+
"sketch.construction": lambda d, a: ui_actions.sketch_construction(
|
| 188 |
+
d, _xy(a, "x", "y") if ("x" in a or "entity_x" in a or "entity" in a) else None
|
| 189 |
+
),
|
| 190 |
+
"sketch.fillet": lambda d, a: ui_actions.sketch_fillet(
|
| 191 |
+
d,
|
| 192 |
+
_xy(a, "vertex_x", "vertex_y") if ("vertex_x" in a or "vertex" in a) else _xy(a, "x", "y"),
|
| 193 |
+
_dim_val(a, "radius") or 5.0,
|
| 194 |
+
),
|
| 195 |
+
"sketch.chamfer": lambda d, a: ui_actions.sketch_chamfer(
|
| 196 |
+
d,
|
| 197 |
+
_xy(a, "vertex_x", "vertex_y") if ("vertex_x" in a or "vertex" in a) else _xy(a, "x", "y"),
|
| 198 |
+
_dim_val(a, "distance") or 5.0,
|
| 199 |
+
),
|
| 200 |
+
"sketch.trim": lambda d, a: ui_actions.sketch_trim(d, _xy(a, "x", "y")),
|
| 201 |
+
"sketch.extend": lambda d, a: ui_actions.sketch_extend(d, _xy(a, "x", "y")),
|
| 202 |
+
"sketch.split": lambda d, a: ui_actions.sketch_split(d, _xy(a, "x", "y")),
|
| 203 |
+
"sketch.offset": lambda d, a: ui_actions.sketch_offset(
|
| 204 |
+
d, _xy(a, "x", "y"), _dim_val(a, "distance") or 5.0
|
| 205 |
+
),
|
| 206 |
+
"sketch.slot": lambda d, a: ui_actions.sketch_slot(
|
| 207 |
+
d,
|
| 208 |
+
_xy(a, "p1_x", "p1_y", default=(0.0, 0.0)),
|
| 209 |
+
_xy(a, "p2_x", "p2_y", default=(100.0, 0.0)),
|
| 210 |
+
float(a.get("radius", 20.0)),
|
| 211 |
+
),
|
| 212 |
+
"sketch.mirror": lambda d, a: ui_actions.sketch_mirror(
|
| 213 |
+
d,
|
| 214 |
+
_xy(a, "centerline_x", "centerline_y", default=(0.0, 0.0)),
|
| 215 |
+
*[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))],
|
| 216 |
+
),
|
| 217 |
+
"sketch.pattern_linear": lambda d, a: ui_actions.wait(d, 0.5),
|
| 218 |
+
"sketch.pattern_circular": lambda d, a: ui_actions.wait(d, 0.5),
|
| 219 |
+
"sketch.transform": lambda d, a: ui_actions.wait(d, 0.5),
|
| 220 |
+
"sketch.dimension": lambda d, a: ui_actions.sketch_dimension(
|
| 221 |
+
d,
|
| 222 |
+
_xy(a, "entity_x", "entity_y"),
|
| 223 |
+
_xy(a, "label_x", "label_y")
|
| 224 |
+
if ("label_x" in a or "label" in a)
|
| 225 |
+
else (_xy(a, "entity_x", "entity_y")[0] + 30.0, _xy(a, "entity_x", "entity_y")[1] + 30.0),
|
| 226 |
+
_dim_val(a, "value") or 5,
|
| 227 |
+
),
|
| 228 |
+
"sketch.equal": lambda d, a: ui_actions.sketch_equal(
|
| 229 |
+
d, *[(a[f"x{i}"], a[f"y{i}"]) for i in range(int(a.get("n", 2)))]
|
| 230 |
+
),
|
| 231 |
+
"sketch.constrain": lambda d, a: ui_actions.sketch_constrain(
|
| 232 |
+
d,
|
| 233 |
+
a.get("type", "coincident"),
|
| 234 |
+
*[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))],
|
| 235 |
+
),
|
| 236 |
+
"constraint.coincident": lambda d, a: ui_actions.sketch_constrain(
|
| 237 |
+
d, "coincident", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 238 |
+
),
|
| 239 |
+
"constraint.concentric": lambda d, a: ui_actions.sketch_constrain(
|
| 240 |
+
d, "concentric", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 241 |
+
),
|
| 242 |
+
"constraint.parallel": lambda d, a: ui_actions.sketch_constrain(
|
| 243 |
+
d, "parallel", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 244 |
+
),
|
| 245 |
+
"constraint.tangent": lambda d, a: ui_actions.sketch_constrain(
|
| 246 |
+
d, "tangent", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 247 |
+
),
|
| 248 |
+
"constraint.horizontal": lambda d, a: ui_actions.sketch_constrain(
|
| 249 |
+
d, "horizontal", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))]
|
| 250 |
+
),
|
| 251 |
+
"constraint.vertical": lambda d, a: ui_actions.sketch_constrain(
|
| 252 |
+
d, "vertical", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))]
|
| 253 |
+
),
|
| 254 |
+
"constraint.perpendicular": lambda d, a: ui_actions.sketch_constrain(
|
| 255 |
+
d, "perpendicular", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 256 |
+
),
|
| 257 |
+
"constraint.equal": lambda d, a: ui_actions.sketch_constrain(
|
| 258 |
+
d, "equal", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 259 |
+
),
|
| 260 |
+
"constraint.midpoint": lambda d, a: ui_actions.sketch_constrain(
|
| 261 |
+
d, "midpoint", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 262 |
+
),
|
| 263 |
+
"constraint.normal": lambda d, a: ui_actions.sketch_constrain(
|
| 264 |
+
d, "normal", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 265 |
+
),
|
| 266 |
+
"constraint.pierce": lambda d, a: ui_actions.sketch_constrain(
|
| 267 |
+
d, "pierce", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 268 |
+
),
|
| 269 |
+
"constraint.symmetric": lambda d, a: ui_actions.sketch_constrain(
|
| 270 |
+
d, "symmetric", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 2)))]
|
| 271 |
+
),
|
| 272 |
+
"constraint.fix": lambda d, a: ui_actions.sketch_constrain(
|
| 273 |
+
d, "fix", *[_xy(a, f"x{i}", f"y{i}") for i in range(int(a.get("n", 1)))]
|
| 274 |
+
),
|
| 275 |
+
"sketch.exit": lambda d, a: ui_actions.sketch_exit(d),
|
| 276 |
+
"feature.extrude": lambda d, a: ui_actions.feature_extrude(
|
| 277 |
+
d, a.get("depth_mm") if "depth_mm" in a else a.get("depth")
|
| 278 |
+
),
|
| 279 |
+
"feature.fillet": lambda d, a: ui_actions.feature_fillet(
|
| 280 |
+
d, a.get("radius_mm") if "radius_mm" in a else a.get("radius")
|
| 281 |
+
),
|
| 282 |
+
"feature.chamfer": lambda d, a: ui_actions.feature_chamfer(
|
| 283 |
+
d, a.get("distance_mm") if "distance_mm" in a else a.get("distance")
|
| 284 |
+
),
|
| 285 |
+
"select.face": lambda d, a: ui_actions.select_face(d, *_xy(a, "x", "y")),
|
| 286 |
+
"select.edge": lambda d, a: ui_actions.select_edge(d, *_xy(a, "x", "y")),
|
| 287 |
+
"click": lambda d, a: ui_actions.select_face(d, *_xy(a, "x", "y")),
|
| 288 |
+
"ui.undo": lambda d, a: ui_actions.undo(d),
|
| 289 |
+
"ui.redo": lambda d, a: ui_actions.redo(d),
|
| 290 |
+
"wait": lambda d, a: ui_actions.wait(d, float(a.get("seconds", 1.0))),
|
| 291 |
+
"sleep": lambda d, a: ui_actions.wait(d, float(a.get("seconds", 1.0))),
|
| 292 |
+
"ui.wait": lambda d, a: ui_actions.wait(d, float(a.get("seconds", 1.0))),
|
| 293 |
+
"screenshot": lambda d, a: ui_actions.screenshot_only(d, a.get("name", "agent.png")),
|
| 294 |
+
"doc.open": lambda d, a: ui_actions.doc_open(d, a.get("url", "")),
|
| 295 |
+
"doc.new": lambda d, a: ui_actions.doc_new(d),
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
async def dispatch(d: OnshapeDriver, tool: str | None, args: dict[str, Any]) -> None:
|
| 300 |
+
"""Route a tool call. Unknown tools raise a clear error so the agent
|
| 301 |
+
loop records it as a failure and the LLM can recover on the next
|
| 302 |
+
step instead of crashing the whole run.
|
| 303 |
+
"""
|
| 304 |
+
if not tool:
|
| 305 |
+
return
|
| 306 |
+
fn = TOOL_DISPATCH.get(tool)
|
| 307 |
+
if fn is None:
|
| 308 |
+
raise KeyError(f"unknown tool: {tool!r}. Available: {sorted(TOOL_DISPATCH.keys())}")
|
| 309 |
+
return await fn(d, args or {})
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def build_agent_system_prompt() -> str:
|
| 313 |
+
"""The system prompt for the closed-loop agent. This is where I
|
| 314 |
+
teach the model the workflow that actually works.
|
| 315 |
+
"""
|
| 316 |
+
from . import tools as datasheet
|
| 317 |
+
|
| 318 |
+
return (
|
| 319 |
+
"You are an agent that drives the Onshape web CAD application visually, "
|
| 320 |
+
"the way a human would. You see screenshots and call tools.\n\n"
|
| 321 |
+
"On each turn:\n"
|
| 322 |
+
" 1. Read the user's goal.\n"
|
| 323 |
+
" 2. Look at the current screenshot.\n"
|
| 324 |
+
" 3. Decide the single next tool to call. Pick ONE tool. Provide its "
|
| 325 |
+
"arguments as a JSON object matching its signature.\n"
|
| 326 |
+
' 4. If you have completed the goal, return {"done": true, "summary": "..."}.\n\n'
|
| 327 |
+
"Coordinate space is viewport pixels (0,0 = top-left). Use viewport_size "
|
| 328 |
+
"to get the bounds. The viewport center is typically the world origin in "
|
| 329 |
+
"the default top view.\n\n"
|
| 330 |
+
"## The constrained-sketch workflow (this is how you size and position things)\n\n"
|
| 331 |
+
"Never try to pick exact-mm pixel coordinates in the viewport. Instead:\n\n"
|
| 332 |
+
" 0. Make sure you're in a Part Studio. If you see a documents list, "
|
| 333 |
+
"call `doc.new` or `doc.open` first.\n"
|
| 334 |
+
' 1. `sketch.start`: Pick ANY plane — `plane="Top"`, `plane="Front"`, or `plane="Right"` '
|
| 335 |
+
"(or pass a face coordinate). The view will automatically orient normal to that plane with the origin centered.\n"
|
| 336 |
+
" 2. Positioning and Quadrants:\n"
|
| 337 |
+
" - Centered at Origin: pass `centered=true` to `sketch.rectangle` or `sketch.circle` (or `corner1=[0,0]` / `center=[0,0]`).\n"
|
| 338 |
+
' - In a specific Quadrant (anchored at Origin): pass `quadrant="I"` (top-right), `quadrant="II"` (top-left), '
|
| 339 |
+
'`quadrant="III"` (bottom-left), or `quadrant="IV"` (bottom-right).\n'
|
| 340 |
+
" - Arbitrary corners: pass `corner1=[x1, y1]`, `corner2=[x2, y2]` in pixels or relative CAD units.\n"
|
| 341 |
+
" 3. Sizing:\n"
|
| 342 |
+
' - `sketch.rectangle` accepts `width` and `height` (e.g. width="12 cm", height="8 cm" or width_mm=50).\n'
|
| 343 |
+
" - It automatically applies constraints and drives the Onshape dimension solver for both width and height!\n"
|
| 344 |
+
" - After calling `sketch.rectangle` with width and height, both dimensions are ALREADY applied, so call `sketch.exit` next!\n"
|
| 345 |
+
' - Alternatively, call `sketch.dimension` with the entity coordinate and `value` (e.g. "5 cm").\n'
|
| 346 |
+
" 4. `sketch.exit`: Commits and closes the sketch dialog.\n"
|
| 347 |
+
" 5. `feature.extrude`: Extrudes the sketch with `depth` in mm or cm.\n\n"
|
| 348 |
+
"For example: to draw a 12cm by 8cm box on the Front plane, call:\n"
|
| 349 |
+
' step 0: sketch.start({"plane": "Front"})\n'
|
| 350 |
+
' step 1: sketch.rectangle({"centered": true, "width": "12 cm", "height": "8 cm"})\n'
|
| 351 |
+
' step 2: sketch.exit({})\n'
|
| 352 |
+
' step 3: {"done": true, "summary": "12cm by 8cm box drawn and dimensioned on Front plane"}\n\n'
|
| 353 |
+
"For a '5x5 square extruded 5mm into a cube' that's just sketch.start, sketch.rectangle with width='5 mm', "
|
| 354 |
+
"sketch.exit, and feature.extrude with depth='5 mm'!\n\n"
|
| 355 |
+
"## Be conservative\n\n"
|
| 356 |
+
"When in doubt, take a screenshot and re-look. The UI is fragile: wrong "
|
| 357 |
+
"clicks can deselect, dismiss dialogs, or trigger unrelated tools. If a "
|
| 358 |
+
"tool fails twice in a row, undo and try a different approach.\n\n"
|
| 359 |
+
"## Available tools\n\n" + datasheet.as_prompt_block()
|
| 360 |
+
)
|
src/onshape_mcp/driver.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Playwright driver for Onshape. One headless Chromium, persistent
|
| 2 |
+
profile for cache, plain-JSON cookie file for the session.
|
| 3 |
+
|
| 4 |
+
Why JSON cookies instead of relying on Chrome's encrypted DB:
|
| 5 |
+
on Linux, Chrome encrypts cookies in its DB with a key from the system
|
| 6 |
+
keyring. The keyring is only available to graphical sessions, so
|
| 7 |
+
headless Playwright can't decrypt them; the cookies are present but
|
| 8 |
+
invisible. Storing them as plain JSON (and re-injecting via
|
| 9 |
+
`context.add_cookies()`) sidesteps the whole keyring issue.
|
| 10 |
+
|
| 11 |
+
Profile dir is gitignored (see SECURITY.md).
|
| 12 |
+
|
| 13 |
+
This module holds the lowest-level UI primitives. Higher-level Onshape
|
| 14 |
+
ops (sketch, extrude, fillet) live in ui_actions.py and compose these.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import asyncio
|
| 20 |
+
import json
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from playwright.async_api import BrowserContext, Locator, Page, async_playwright
|
| 25 |
+
|
| 26 |
+
from .config import settings
|
| 27 |
+
|
| 28 |
+
ONSHAPE_URL = "https://cad.onshape.com"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class OnshapeDriver:
|
| 32 |
+
def __init__(
|
| 33 |
+
self,
|
| 34 |
+
profile_dir: Path | None = None,
|
| 35 |
+
cookie_file: Path | None = None,
|
| 36 |
+
) -> None:
|
| 37 |
+
self.profile_dir = (profile_dir or settings.onshape_browser_profile).resolve()
|
| 38 |
+
self.cookie_file = (cookie_file or settings.onshape_cookie_file).resolve()
|
| 39 |
+
self.channel = settings.browser_channel
|
| 40 |
+
self._pw: Any = None
|
| 41 |
+
self._ctx: BrowserContext | None = None
|
| 42 |
+
self._page: Page | None = None
|
| 43 |
+
|
| 44 |
+
async def start(self, headless: bool = True) -> Page:
|
| 45 |
+
self._pw = await async_playwright().start()
|
| 46 |
+
self._clear_stale_locks()
|
| 47 |
+
# We use the profile dir mainly for the SingletonLock dance and
|
| 48 |
+
# any future extensions we might want to install. Cache is not
|
| 49 |
+
# worth the stale-state headaches, so we use a non-persistent
|
| 50 |
+
# context when possible. Cookies live in self.cookie_file (plain
|
| 51 |
+
# JSON) and get injected after launch — no need for the profile
|
| 52 |
+
# to carry the session.
|
| 53 |
+
#
|
| 54 |
+
# Channel strategy: try real Chrome first (no automation markers,
|
| 55 |
+
# dodges Google's "this browser may not be secure" block). Fall
|
| 56 |
+
# back to bundled Chromium. Set ONSHAPE_BROWSER_CHANNEL=chromium
|
| 57 |
+
# to skip the Chrome attempt (e.g. on a Pi with no Chrome).
|
| 58 |
+
#
|
| 59 |
+
# WebGL args: Onshape renders the 3D viewport with WebGL. In
|
| 60 |
+
# headless mode we need to ask explicitly for software WebGL or
|
| 61 |
+
# the canvas stays blank. `swiftshader` is Google's software
|
| 62 |
+
# WebGL implementation and works on any machine.
|
| 63 |
+
common_args = [
|
| 64 |
+
"--use-gl=swiftshader",
|
| 65 |
+
"--enable-webgl",
|
| 66 |
+
"--ignore-gpu-blocklist",
|
| 67 |
+
"--disable-blink-features=AutomationControlled",
|
| 68 |
+
]
|
| 69 |
+
if self.channel in ("auto", "chrome"):
|
| 70 |
+
try:
|
| 71 |
+
self._ctx = await self._pw.chromium.launch_persistent_context(
|
| 72 |
+
user_data_dir=str(self.profile_dir),
|
| 73 |
+
headless=headless,
|
| 74 |
+
viewport={"width": 1440, "height": 900},
|
| 75 |
+
args=common_args,
|
| 76 |
+
channel="chrome",
|
| 77 |
+
)
|
| 78 |
+
self._channel_used = "chrome"
|
| 79 |
+
except Exception as e:
|
| 80 |
+
if self.channel == "chrome":
|
| 81 |
+
raise
|
| 82 |
+
print(f"[driver] real Chrome unavailable ({e}); using bundled Chromium")
|
| 83 |
+
self._ctx = await self._pw.chromium.launch_persistent_context(
|
| 84 |
+
user_data_dir=str(self.profile_dir),
|
| 85 |
+
headless=headless,
|
| 86 |
+
viewport={"width": 1440, "height": 900},
|
| 87 |
+
args=common_args,
|
| 88 |
+
)
|
| 89 |
+
self._channel_used = "chromium"
|
| 90 |
+
else:
|
| 91 |
+
self._ctx = await self._pw.chromium.launch_persistent_context(
|
| 92 |
+
user_data_dir=str(self.profile_dir),
|
| 93 |
+
headless=headless,
|
| 94 |
+
viewport={"width": 1440, "height": 900},
|
| 95 |
+
args=common_args,
|
| 96 |
+
)
|
| 97 |
+
self._channel_used = "chromium"
|
| 98 |
+
await self._load_cookies()
|
| 99 |
+
self._page = self._ctx.pages[0] if self._ctx.pages else await self._ctx.new_page()
|
| 100 |
+
return self._page
|
| 101 |
+
|
| 102 |
+
def _clear_stale_locks(self) -> None:
|
| 103 |
+
"""Remove leftover SingletonLock/SingletonCookie files from prior
|
| 104 |
+
Chrome runs. Without this, a crashed previous run blocks all
|
| 105 |
+
subsequent launches of the same profile.
|
| 106 |
+
"""
|
| 107 |
+
for fname in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
|
| 108 |
+
p = self.profile_dir / fname
|
| 109 |
+
if p.exists():
|
| 110 |
+
try:
|
| 111 |
+
p.unlink()
|
| 112 |
+
except OSError:
|
| 113 |
+
pass
|
| 114 |
+
|
| 115 |
+
async def _load_cookies(self) -> None:
|
| 116 |
+
"""Re-inject saved cookies into the context before any navigation.
|
| 117 |
+
Without this, even a logged-in session looks anonymous because
|
| 118 |
+
Chrome's encrypted DB can't be read headless.
|
| 119 |
+
"""
|
| 120 |
+
raw = []
|
| 121 |
+
# 1. Environment variable support (useful for remote/cloud hosting)
|
| 122 |
+
env_cookies = os.getenv("ONSHAPE_COOKIES_JSON")
|
| 123 |
+
if env_cookies:
|
| 124 |
+
try:
|
| 125 |
+
raw = json.loads(env_cookies)
|
| 126 |
+
except Exception as e:
|
| 127 |
+
print(f"[driver] failed parsing ONSHAPE_COOKIES_JSON: {e}")
|
| 128 |
+
|
| 129 |
+
# 2. Local cookie file
|
| 130 |
+
if not raw and self.cookie_file.exists():
|
| 131 |
+
try:
|
| 132 |
+
raw = json.loads(self.cookie_file.read_text(encoding="utf-8"))
|
| 133 |
+
except (json.JSONDecodeError, OSError) as e:
|
| 134 |
+
print(f"[driver] cookie file unreadable ({e}); ignoring")
|
| 135 |
+
|
| 136 |
+
# 3. Auto-extract from available installed browsers
|
| 137 |
+
if not raw:
|
| 138 |
+
try:
|
| 139 |
+
import browser_cookie3
|
| 140 |
+
|
| 141 |
+
browser_loaders = [
|
| 142 |
+
("Chrome", getattr(browser_cookie3, "chrome", None)),
|
| 143 |
+
("Brave", getattr(browser_cookie3, "brave", None)),
|
| 144 |
+
("Edge", getattr(browser_cookie3, "edge", None)),
|
| 145 |
+
("Chromium", getattr(browser_cookie3, "chromium", None)),
|
| 146 |
+
("Firefox", getattr(browser_cookie3, "firefox", None)),
|
| 147 |
+
("Arc", getattr(browser_cookie3, "arc", None)),
|
| 148 |
+
("Opera", getattr(browser_cookie3, "opera", None)),
|
| 149 |
+
("Vivaldi", getattr(browser_cookie3, "vivaldi", None)),
|
| 150 |
+
]
|
| 151 |
+
for browser_name, loader in browser_loaders:
|
| 152 |
+
if loader is None:
|
| 153 |
+
continue
|
| 154 |
+
try:
|
| 155 |
+
cj = loader(domain_name="onshape.com")
|
| 156 |
+
for c in cj:
|
| 157 |
+
cookie = {
|
| 158 |
+
"name": c.name,
|
| 159 |
+
"value": c.value,
|
| 160 |
+
"domain": c.domain,
|
| 161 |
+
"path": c.path,
|
| 162 |
+
"secure": bool(c.secure),
|
| 163 |
+
"httpOnly": bool(
|
| 164 |
+
c.has_nonstandard_attr("HttpOnly")
|
| 165 |
+
or c.has_nonstandard_attr("httponly")
|
| 166 |
+
),
|
| 167 |
+
}
|
| 168 |
+
if c.expires:
|
| 169 |
+
cookie["expires"] = float(c.expires)
|
| 170 |
+
raw.append(cookie)
|
| 171 |
+
if raw:
|
| 172 |
+
self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
|
| 173 |
+
self.cookie_file.write_text(json.dumps(raw, indent=2), encoding="utf-8")
|
| 174 |
+
print(
|
| 175 |
+
f"[driver] auto-synced {len(raw)} Onshape cookies from {browser_name} to {self.cookie_file.name}"
|
| 176 |
+
)
|
| 177 |
+
break
|
| 178 |
+
except Exception:
|
| 179 |
+
continue
|
| 180 |
+
except Exception as e:
|
| 181 |
+
print(f"[driver] could not extract Onshape cookies from local browsers: {e}")
|
| 182 |
+
if not raw:
|
| 183 |
+
return
|
| 184 |
+
# Playwright's add_cookies wants the same shape it returns.
|
| 185 |
+
await self._ctx.add_cookies(raw)
|
| 186 |
+
print(f"[driver] loaded {len(raw)} cookies from {self.cookie_file.name}")
|
| 187 |
+
|
| 188 |
+
async def save_cookies(self) -> int:
|
| 189 |
+
"""Dump current context cookies to self.cookie_file. Returns count."""
|
| 190 |
+
cookies = await self._ctx.cookies()
|
| 191 |
+
self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
|
| 192 |
+
self.cookie_file.write_text(json.dumps(cookies, indent=2), encoding="utf-8")
|
| 193 |
+
return len(cookies)
|
| 194 |
+
|
| 195 |
+
@property
|
| 196 |
+
def page(self) -> Page:
|
| 197 |
+
if self._page is None:
|
| 198 |
+
raise RuntimeError("Driver not started; call .start() first")
|
| 199 |
+
return self._page
|
| 200 |
+
|
| 201 |
+
@property
|
| 202 |
+
def channel_used(self) -> str:
|
| 203 |
+
return getattr(self, "_channel_used", "unknown")
|
| 204 |
+
|
| 205 |
+
async def open(self, url: str | None = None) -> None:
|
| 206 |
+
if not url:
|
| 207 |
+
if settings.onshape_default_doc:
|
| 208 |
+
doc = settings.onshape_default_doc
|
| 209 |
+
url = (
|
| 210 |
+
doc if doc.startswith("http") else f"https://cad.onshape.com/{doc.lstrip('/')}"
|
| 211 |
+
)
|
| 212 |
+
else:
|
| 213 |
+
url = ONSHAPE_URL
|
| 214 |
+
# Onshape is a SPA that keeps WebSocket connections open, so
|
| 215 |
+
# `networkidle` never fires (it would time out at 60s). We use
|
| 216 |
+
# `load` for the navigation then `wait_for_app()` to confirm the
|
| 217 |
+
# toolbar is actually on screen.
|
| 218 |
+
await self.page.goto(url, wait_until="load", timeout=60_000)
|
| 219 |
+
await self.wait_for_app()
|
| 220 |
+
if "signin" in self.page.url:
|
| 221 |
+
raise RuntimeError(
|
| 222 |
+
"Onshape redirected to signin page. Cookies may be invalid or expired. "
|
| 223 |
+
"Please log in to cad.onshape.com in Google Chrome."
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Screenshots
|
| 227 |
+
|
| 228 |
+
async def wait_for_app(self, timeout: float = 30.0) -> bool:
|
| 229 |
+
"""Wait until the Onshape app shell has rendered something we can
|
| 230 |
+
see. The marker is broad: any toolbar button, the documents list,
|
| 231 |
+
a sketch button, whatever the current page has. Returns True if
|
| 232 |
+
something rendered, False if the page is still blank after the
|
| 233 |
+
timeout. Either way we sleep an extra beat for the WebGL canvas.
|
| 234 |
+
"""
|
| 235 |
+
try:
|
| 236 |
+
await self.page.wait_for_function(
|
| 237 |
+
"""() => {
|
| 238 |
+
// any rendered buttons with visible text or aria-label
|
| 239 |
+
const btns = document.querySelectorAll('button, [role="button"], a, .document-card, .document-list-item');
|
| 240 |
+
let n = 0;
|
| 241 |
+
for (const b of btns) {
|
| 242 |
+
const t = (b.textContent || b.getAttribute('aria-label') || '').trim();
|
| 243 |
+
if (t.length > 0) n++;
|
| 244 |
+
if (n >= 3) return true;
|
| 245 |
+
}
|
| 246 |
+
// any substantial content (h1-h3, paragraphs, etc.)
|
| 247 |
+
const text = document.body.innerText || '';
|
| 248 |
+
return text.length > 100;
|
| 249 |
+
}""",
|
| 250 |
+
timeout=timeout * 1000,
|
| 251 |
+
)
|
| 252 |
+
# Wait for graphics / loading spinner to clear
|
| 253 |
+
try:
|
| 254 |
+
await self.page.locator(".loading-progress-message").wait_for(
|
| 255 |
+
state="hidden", timeout=30_000
|
| 256 |
+
)
|
| 257 |
+
except Exception:
|
| 258 |
+
pass
|
| 259 |
+
await asyncio.sleep(1.0)
|
| 260 |
+
return True
|
| 261 |
+
except Exception:
|
| 262 |
+
return False
|
| 263 |
+
|
| 264 |
+
async def screenshot(self, name: str = "shot.png") -> Path:
|
| 265 |
+
out = settings.journal_dir / name
|
| 266 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 267 |
+
await self.page.screenshot(path=str(out), full_page=False)
|
| 268 |
+
return out
|
| 269 |
+
|
| 270 |
+
async def screenshot_clip(self, name: str, rect: dict[str, float]) -> Path:
|
| 271 |
+
out = settings.journal_dir / name
|
| 272 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 273 |
+
await self.page.screenshot(path=str(out), clip=rect)
|
| 274 |
+
return out
|
| 275 |
+
|
| 276 |
+
async def viewport_box(self) -> dict[str, float]:
|
| 277 |
+
return await self.page.evaluate("() => ({w: window.innerWidth, h: window.innerHeight})")
|
| 278 |
+
|
| 279 |
+
# Clicks and drags
|
| 280 |
+
|
| 281 |
+
async def click(
|
| 282 |
+
self,
|
| 283 |
+
x: float,
|
| 284 |
+
y: float,
|
| 285 |
+
button: str = "left",
|
| 286 |
+
click_count: int = 1,
|
| 287 |
+
) -> None:
|
| 288 |
+
await self.page.mouse.click(x, y, button=button, click_count=click_count)
|
| 289 |
+
|
| 290 |
+
async def double_click(self, x: float, y: float) -> None:
|
| 291 |
+
await self.click(x, y, click_count=2)
|
| 292 |
+
|
| 293 |
+
async def drag(
|
| 294 |
+
self,
|
| 295 |
+
start: tuple[float, float],
|
| 296 |
+
end: tuple[float, float],
|
| 297 |
+
steps: int = 10,
|
| 298 |
+
button: str = "left",
|
| 299 |
+
) -> None:
|
| 300 |
+
sx, sy = start
|
| 301 |
+
ex, ey = end
|
| 302 |
+
await self.page.mouse.move(sx, sy)
|
| 303 |
+
await self.page.mouse.down(button=button)
|
| 304 |
+
await self.page.mouse.move(ex, ey, steps=steps)
|
| 305 |
+
await self.page.mouse.up(button=button)
|
| 306 |
+
|
| 307 |
+
async def hover(self, x: float, y: float) -> None:
|
| 308 |
+
await self.page.mouse.move(x, y)
|
| 309 |
+
|
| 310 |
+
# Keyboard input
|
| 311 |
+
|
| 312 |
+
async def type_text(self, text: str, delay_ms: int = 10) -> None:
|
| 313 |
+
await self.page.keyboard.type(text, delay=delay_ms)
|
| 314 |
+
|
| 315 |
+
async def press_key(self, key: str) -> None:
|
| 316 |
+
"""Press a single key. e.g. 'Enter', 'Escape', 'Tab'."""
|
| 317 |
+
await self.page.keyboard.press(key)
|
| 318 |
+
|
| 319 |
+
async def press_chord(self, *keys: str) -> None:
|
| 320 |
+
"""Press a key combination, e.g. press_chord('Control', 'z')."""
|
| 321 |
+
combo = "+".join(keys)
|
| 322 |
+
await self.page.keyboard.press(combo)
|
| 323 |
+
|
| 324 |
+
# Element finding
|
| 325 |
+
# Most of the Onshape UI is canvas + custom DOM, so text/aria selectors
|
| 326 |
+
# are the most reliable. We try a few strategies and return the first hit.
|
| 327 |
+
|
| 328 |
+
async def find_by_text(
|
| 329 |
+
self,
|
| 330 |
+
text: str,
|
| 331 |
+
*,
|
| 332 |
+
partial: bool = True,
|
| 333 |
+
role: str | None = None,
|
| 334 |
+
timeout_ms: int = 3000,
|
| 335 |
+
) -> Locator | None:
|
| 336 |
+
"""Return a locator matching the given visible text (or aria-label).
|
| 337 |
+
|
| 338 |
+
Returns None if not found within timeout. Caller decides whether to
|
| 339 |
+
click, get the box, etc.
|
| 340 |
+
"""
|
| 341 |
+
strategies: list[Locator] = []
|
| 342 |
+
if role:
|
| 343 |
+
strategies.append(self.page.get_by_role(role, name=text, exact=not partial))
|
| 344 |
+
# aria-label / title
|
| 345 |
+
strategies.append(
|
| 346 |
+
self.page.locator(f"[aria-label*='{text}']" if partial else f"[aria-label='{text}']")
|
| 347 |
+
)
|
| 348 |
+
# visible text content
|
| 349 |
+
strategies.append(self.page.get_by_text(text, exact=not partial))
|
| 350 |
+
for loc in strategies:
|
| 351 |
+
try:
|
| 352 |
+
if await loc.first.is_visible(timeout=timeout_ms / 1000):
|
| 353 |
+
return loc.first
|
| 354 |
+
except Exception:
|
| 355 |
+
continue
|
| 356 |
+
return None
|
| 357 |
+
|
| 358 |
+
async def click_text(
|
| 359 |
+
self,
|
| 360 |
+
text: str,
|
| 361 |
+
*,
|
| 362 |
+
partial: bool = True,
|
| 363 |
+
timeout_ms: int = 3000,
|
| 364 |
+
) -> bool:
|
| 365 |
+
loc = await self.find_by_text(text, partial=partial, timeout_ms=timeout_ms)
|
| 366 |
+
if loc is None:
|
| 367 |
+
return False
|
| 368 |
+
await loc.click()
|
| 369 |
+
return True
|
| 370 |
+
|
| 371 |
+
async def wait_for_text(self, text: str, timeout: float = 5.0) -> bool:
|
| 372 |
+
try:
|
| 373 |
+
await self.page.get_by_text(text).first.wait_for(timeout=timeout * 1000)
|
| 374 |
+
return True
|
| 375 |
+
except Exception:
|
| 376 |
+
return False
|
| 377 |
+
|
| 378 |
+
async def wait_for_no_text(self, text: str, timeout: float = 5.0) -> bool:
|
| 379 |
+
"""True if the text disappears within timeout. False if it's still there."""
|
| 380 |
+
try:
|
| 381 |
+
await self.page.get_by_text(text).first.wait_for(state="hidden", timeout=timeout * 1000)
|
| 382 |
+
return True
|
| 383 |
+
except Exception:
|
| 384 |
+
return False
|
| 385 |
+
|
| 386 |
+
# Lifecycle
|
| 387 |
+
|
| 388 |
+
async def close(self) -> None:
|
| 389 |
+
if self._ctx is not None:
|
| 390 |
+
await self._ctx.close()
|
| 391 |
+
if self._pw is not None:
|
| 392 |
+
await self._pw.stop()
|
| 393 |
+
self._ctx = None
|
| 394 |
+
self._page = None
|
| 395 |
+
self._pw = None
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
async def login_interactive() -> None:
|
| 399 |
+
"""Open a headed browser, let the user log in, then dump the session
|
| 400 |
+
cookies to self.cookie_file. Subsequent headless runs re-inject them.
|
| 401 |
+
"""
|
| 402 |
+
d = OnshapeDriver()
|
| 403 |
+
await d.start(headless=False)
|
| 404 |
+
await d.open(ONSHAPE_URL)
|
| 405 |
+
print("Log into Onshape in the opened browser, then press Enter here.")
|
| 406 |
+
input("> ")
|
| 407 |
+
n = await d.save_cookies()
|
| 408 |
+
print(f"Saved {n} cookies to {d.cookie_file}")
|
| 409 |
+
await d.close()
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def main() -> None:
|
| 413 |
+
"""`python -m onshape_mcp.driver login`"""
|
| 414 |
+
import sys
|
| 415 |
+
|
| 416 |
+
if len(sys.argv) >= 2 and sys.argv[1] == "login":
|
| 417 |
+
asyncio.run(login_interactive())
|
| 418 |
+
else:
|
| 419 |
+
print("usage: python -m onshape_mcp.driver login")
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
if __name__ == "__main__":
|
| 423 |
+
main()
|
src/onshape_mcp/fast_exec.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Fast direct executor. Runs a parsed Plan against the Onshape driver
|
| 2 |
+
without any LLM calls. This is the fast path for deterministic tasks.
|
| 3 |
+
|
| 4 |
+
Typical execution: ~5-8s for sketch + dimension + commit, compared to
|
| 5 |
+
~60s with the vision loop.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import time
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from .dispatch import dispatch
|
| 16 |
+
from .driver import OnshapeDriver
|
| 17 |
+
from .intent import Plan
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class FastResult:
|
| 22 |
+
plan: Plan
|
| 23 |
+
ok: bool
|
| 24 |
+
total_elapsed_s: float = 0.0
|
| 25 |
+
final_screenshot: Path | None = None
|
| 26 |
+
step_times: list[tuple[str, float]] = None # type: ignore[assignment]
|
| 27 |
+
error: str | None = None
|
| 28 |
+
|
| 29 |
+
def __post_init__(self) -> None:
|
| 30 |
+
if self.step_times is None:
|
| 31 |
+
self.step_times = []
|
| 32 |
+
|
| 33 |
+
def summary(self) -> str:
|
| 34 |
+
n = len(self.step_times)
|
| 35 |
+
if self.ok:
|
| 36 |
+
return f"completed {n} actions ({self.total_elapsed_s:.1f}s): {self.plan.summary}"
|
| 37 |
+
return f"failed at action {n} ({self.total_elapsed_s:.1f}s): {self.error}"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def execute(d: OnshapeDriver, plan: Plan) -> FastResult:
|
| 41 |
+
"""Execute a Plan's actions sequentially against the driver.
|
| 42 |
+
No LLM calls, no screenshots between steps (except final).
|
| 43 |
+
"""
|
| 44 |
+
t0 = time.monotonic()
|
| 45 |
+
result = FastResult(plan=plan, ok=True)
|
| 46 |
+
|
| 47 |
+
for i, action in enumerate(plan.actions):
|
| 48 |
+
step_t0 = time.monotonic()
|
| 49 |
+
tool = action.tool
|
| 50 |
+
args = action.args
|
| 51 |
+
try:
|
| 52 |
+
await dispatch(d, tool, args)
|
| 53 |
+
elapsed = time.monotonic() - step_t0
|
| 54 |
+
result.step_times.append((f"{tool}({_compact(args)})", elapsed))
|
| 55 |
+
print(f" [{i}] {tool}({_compact(args)}) ({elapsed:.1f}s)", flush=True)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
elapsed = time.monotonic() - step_t0
|
| 58 |
+
result.step_times.append((f"{tool} ERROR", elapsed))
|
| 59 |
+
result.ok = False
|
| 60 |
+
result.error = f"{tool}: {e}"
|
| 61 |
+
print(f" [{i}] {tool} ERROR: {e} ({elapsed:.1f}s)", flush=True)
|
| 62 |
+
break
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
result.final_screenshot = await d.screenshot("task_final.png")
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f" [warn] could not capture final screenshot: {e}", flush=True)
|
| 68 |
+
result.total_elapsed_s = time.monotonic() - t0
|
| 69 |
+
return result
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _compact(d: dict[str, Any]) -> str:
|
| 73 |
+
import json
|
| 74 |
+
try:
|
| 75 |
+
return json.dumps(d, ensure_ascii=False)
|
| 76 |
+
except Exception:
|
| 77 |
+
return str(d)
|
src/onshape_mcp/intent.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Parse natural-language CAD prompts into structured action plans.
|
| 2 |
+
|
| 3 |
+
This module eliminates the LLM from deterministic tasks. Instead of
|
| 4 |
+
sending 4 screenshots to Gemini and waiting 60s for it to say
|
| 5 |
+
"sketch.start, sketch.rectangle, sketch.exit, done", we parse the
|
| 6 |
+
prompt directly and produce the same action sequence in <1ms.
|
| 7 |
+
|
| 8 |
+
If the parser can't handle a prompt, it returns None and the caller
|
| 9 |
+
falls back to the vision loop.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import re
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class Action:
|
| 21 |
+
tool: str
|
| 22 |
+
args: dict[str, Any] = field(default_factory=dict)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class Plan:
|
| 27 |
+
actions: list[Action]
|
| 28 |
+
summary: str
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# Regex patterns for dimension extraction.
|
| 32 |
+
# Handles "12cm by 8cm", "12 cm x 8 cm", "50mm × 30mm" etc.
|
| 33 |
+
# Groups: (1)=width, (2)=width_unit, (3)=height, (4)=height_unit
|
| 34 |
+
_DIM_RE = re.compile(
|
| 35 |
+
r"(\d+(?:\.\d+)?)\s*(mm|cm|in|inch)?\s*(?:x|by|×)\s*(\d+(?:\.\d+)?)\s*(mm|cm|in|inch)?",
|
| 36 |
+
re.IGNORECASE,
|
| 37 |
+
)
|
| 38 |
+
_SINGLE_DIM_RE = re.compile(
|
| 39 |
+
r"(\d+(?:\.\d+)?)\s*(mm|cm|in|inch)",
|
| 40 |
+
re.IGNORECASE,
|
| 41 |
+
)
|
| 42 |
+
_PLANE_RE = re.compile(r"\b(top|front|right)\b", re.IGNORECASE)
|
| 43 |
+
_QUADRANT_RE = re.compile(
|
| 44 |
+
r"(?:quadrant|q)\s*([1-4]|I{1,3}V?|IV)", re.IGNORECASE
|
| 45 |
+
)
|
| 46 |
+
_EXTRUDE_RE = re.compile(
|
| 47 |
+
r"extrude\s+(?:it\s+)?(\d+(?:\.\d+)?)\s*(mm|cm|in)?", re.IGNORECASE
|
| 48 |
+
)
|
| 49 |
+
_DIAMETER_RE = re.compile(
|
| 50 |
+
r"(\d+(?:\.\d+)?)\s*(mm|cm|in)?\s*diameter", re.IGNORECASE
|
| 51 |
+
)
|
| 52 |
+
_RADIUS_RE = re.compile(
|
| 53 |
+
r"(\d+(?:\.\d+)?)\s*(mm|cm|in)?\s*radius", re.IGNORECASE
|
| 54 |
+
)
|
| 55 |
+
_POLYGON_SIDES_RE = re.compile(
|
| 56 |
+
r"(\d+)[ -]sided\b", re.IGNORECASE
|
| 57 |
+
)
|
| 58 |
+
_CENTERED_RE = re.compile(
|
| 59 |
+
r"\b(center(?:ed)?|at\s+(?:the\s+)?origin)\b", re.IGNORECASE
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _format_dim(value: float, unit: str | None) -> str:
|
| 64 |
+
"""Format a dimension value with unit for Onshape's solver."""
|
| 65 |
+
u = (unit or "cm").lower()
|
| 66 |
+
if u in ("in", "inch"):
|
| 67 |
+
u = "in"
|
| 68 |
+
v = f"{value:g}"
|
| 69 |
+
return f"{v} {u}"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def _extract_plane(text: str) -> str:
|
| 73 |
+
m = _PLANE_RE.search(text)
|
| 74 |
+
return m.group(1).capitalize() if m else "Top"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _extract_quadrant(text: str) -> str | None:
|
| 78 |
+
m = _QUADRANT_RE.search(text)
|
| 79 |
+
if not m:
|
| 80 |
+
return None
|
| 81 |
+
q = m.group(1).upper()
|
| 82 |
+
roman = {"I": "1", "II": "2", "III": "3", "IV": "4"}
|
| 83 |
+
return roman.get(q, q)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _is_centered(text: str) -> bool:
|
| 87 |
+
return bool(_CENTERED_RE.search(text))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def parse(text: str) -> Plan | None:
|
| 91 |
+
"""Try to parse a natural-language CAD prompt into a deterministic
|
| 92 |
+
action plan. Returns None if the prompt is too ambiguous or complex
|
| 93 |
+
for direct execution.
|
| 94 |
+
"""
|
| 95 |
+
t = text.strip()
|
| 96 |
+
lower = t.lower()
|
| 97 |
+
|
| 98 |
+
if not t:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
# Detect shape type
|
| 102 |
+
is_rect = any(w in lower for w in ("box", "rectangle", "rect", "square"))
|
| 103 |
+
is_circle = any(w in lower for w in ("circle", "cylinder", "disc", "disk"))
|
| 104 |
+
is_polygon = any(w in lower for w in ("polygon", "hexagon", "pentagon", "octagon"))
|
| 105 |
+
is_arc = "arc" in lower and not is_circle
|
| 106 |
+
is_point = "point" in lower and not any((is_rect, is_circle, is_polygon, is_arc))
|
| 107 |
+
has_extrude = "extrude" in lower
|
| 108 |
+
|
| 109 |
+
if not (is_rect or is_circle or is_polygon or is_arc or is_point):
|
| 110 |
+
return None
|
| 111 |
+
|
| 112 |
+
plane = _extract_plane(t)
|
| 113 |
+
quadrant = _extract_quadrant(t)
|
| 114 |
+
centered = _is_centered(t)
|
| 115 |
+
|
| 116 |
+
actions: list[Action] = []
|
| 117 |
+
summary_parts: list[str] = []
|
| 118 |
+
|
| 119 |
+
# Step 1: Start sketch
|
| 120 |
+
actions.append(Action("sketch.start", {"plane": plane}))
|
| 121 |
+
|
| 122 |
+
if is_rect:
|
| 123 |
+
dim_m = _DIM_RE.search(t)
|
| 124 |
+
if dim_m:
|
| 125 |
+
w_val = float(dim_m.group(1))
|
| 126 |
+
h_val = float(dim_m.group(3))
|
| 127 |
+
unit = dim_m.group(2) or dim_m.group(4)
|
| 128 |
+
width = _format_dim(w_val, unit)
|
| 129 |
+
height = _format_dim(h_val, unit)
|
| 130 |
+
elif "square" in lower:
|
| 131 |
+
single = _SINGLE_DIM_RE.search(t)
|
| 132 |
+
if single:
|
| 133 |
+
val = float(single.group(1))
|
| 134 |
+
unit = single.group(2)
|
| 135 |
+
width = height = _format_dim(val, unit)
|
| 136 |
+
else:
|
| 137 |
+
return None
|
| 138 |
+
else:
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
rect_args: dict[str, Any] = {"width": width, "height": height}
|
| 142 |
+
if quadrant:
|
| 143 |
+
rect_args["quadrant"] = quadrant
|
| 144 |
+
elif centered or not quadrant:
|
| 145 |
+
rect_args["centered"] = True
|
| 146 |
+
|
| 147 |
+
actions.append(Action("sketch.rectangle", rect_args))
|
| 148 |
+
label = "square" if width == height else "rectangle"
|
| 149 |
+
summary_parts.append(f"{width} × {height} {label}")
|
| 150 |
+
|
| 151 |
+
elif is_circle:
|
| 152 |
+
diam_m = _DIAMETER_RE.search(t)
|
| 153 |
+
rad_m = _RADIUS_RE.search(t)
|
| 154 |
+
if diam_m:
|
| 155 |
+
val = float(diam_m.group(1))
|
| 156 |
+
unit = diam_m.group(2)
|
| 157 |
+
radius_mm = val / 2.0
|
| 158 |
+
if unit and unit.lower() == "cm":
|
| 159 |
+
radius_mm = val * 10.0 / 2.0
|
| 160 |
+
circle_args: dict[str, Any] = {
|
| 161 |
+
"centered": True,
|
| 162 |
+
"radius": radius_mm,
|
| 163 |
+
}
|
| 164 |
+
actions.append(Action("sketch.circle", circle_args))
|
| 165 |
+
summary_parts.append(f"{_format_dim(val, unit)} diameter circle")
|
| 166 |
+
elif rad_m:
|
| 167 |
+
val = float(rad_m.group(1))
|
| 168 |
+
unit = rad_m.group(2)
|
| 169 |
+
circle_args = {"centered": True, "radius": val}
|
| 170 |
+
actions.append(Action("sketch.circle", circle_args))
|
| 171 |
+
summary_parts.append(f"{_format_dim(val, unit)} radius circle")
|
| 172 |
+
else:
|
| 173 |
+
single = _SINGLE_DIM_RE.search(t)
|
| 174 |
+
if single:
|
| 175 |
+
val = float(single.group(1))
|
| 176 |
+
unit = single.group(2)
|
| 177 |
+
circle_args = {"centered": True, "radius": val / 2.0}
|
| 178 |
+
actions.append(Action("sketch.circle", circle_args))
|
| 179 |
+
summary_parts.append(f"{_format_dim(val, unit)} circle")
|
| 180 |
+
else:
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
elif is_polygon:
|
| 184 |
+
# Determine number of sides
|
| 185 |
+
sides = 6
|
| 186 |
+
side_m = _POLYGON_SIDES_RE.search(t)
|
| 187 |
+
if side_m:
|
| 188 |
+
sides = int(side_m.group(1))
|
| 189 |
+
elif "hexagon" in lower:
|
| 190 |
+
sides = 6
|
| 191 |
+
elif "pentagon" in lower:
|
| 192 |
+
sides = 5
|
| 193 |
+
elif "octagon" in lower:
|
| 194 |
+
sides = 8
|
| 195 |
+
|
| 196 |
+
# Determine radius
|
| 197 |
+
rad_m = _RADIUS_RE.search(t) or _SINGLE_DIM_RE.search(t)
|
| 198 |
+
radius_val = float(rad_m.group(1)) if rad_m else 50.0
|
| 199 |
+
unit = rad_m.group(2) if rad_m else "mm"
|
| 200 |
+
poly_args: dict[str, Any] = {
|
| 201 |
+
"center_x": 0.0,
|
| 202 |
+
"center_y": 0.0,
|
| 203 |
+
"radius": radius_val,
|
| 204 |
+
"sides": sides,
|
| 205 |
+
}
|
| 206 |
+
actions.append(Action("sketch.polygon", poly_args))
|
| 207 |
+
summary_parts.append(f"{sides}-sided polygon (r={_format_dim(radius_val, unit)})")
|
| 208 |
+
|
| 209 |
+
elif is_arc:
|
| 210 |
+
arc_args: dict[str, Any] = {
|
| 211 |
+
"p1_x": -50.0,
|
| 212 |
+
"p1_y": 0.0,
|
| 213 |
+
"p2_x": 50.0,
|
| 214 |
+
"p2_y": 0.0,
|
| 215 |
+
}
|
| 216 |
+
actions.append(Action("sketch.arc", arc_args))
|
| 217 |
+
summary_parts.append("3-point arc")
|
| 218 |
+
|
| 219 |
+
elif is_point:
|
| 220 |
+
actions.append(Action("sketch.point", {"x": 0.0, "y": 0.0}))
|
| 221 |
+
summary_parts.append("point at origin")
|
| 222 |
+
|
| 223 |
+
# Step: Exit sketch
|
| 224 |
+
actions.append(Action("sketch.exit", {}))
|
| 225 |
+
|
| 226 |
+
# Step: Extrude (optional)
|
| 227 |
+
ext_m = _EXTRUDE_RE.search(t)
|
| 228 |
+
if has_extrude and ext_m:
|
| 229 |
+
depth_val = float(ext_m.group(1))
|
| 230 |
+
depth_unit = (ext_m.group(2) or "mm").lower()
|
| 231 |
+
if depth_unit == "cm":
|
| 232 |
+
depth_mm = depth_val * 10.0
|
| 233 |
+
elif depth_unit in ("in", "inch"):
|
| 234 |
+
depth_mm = depth_val * 25.4
|
| 235 |
+
else:
|
| 236 |
+
depth_mm = depth_val
|
| 237 |
+
actions.append(Action("feature.extrude", {"depth_mm": depth_mm}))
|
| 238 |
+
summary_parts.append(f"extruded {_format_dim(depth_val, depth_unit)}")
|
| 239 |
+
|
| 240 |
+
position = f"Quadrant {quadrant}" if quadrant else "centered at origin"
|
| 241 |
+
summary = f"Drew {', '.join(summary_parts)} on {plane} plane, {position}"
|
| 242 |
+
|
| 243 |
+
return Plan(actions=actions, summary=summary)
|
src/onshape_mcp/journal.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Append-only action journal. Every UI op is recorded for undo/replay/debug.
|
| 2 |
+
|
| 3 |
+
This file lives in `./state/` and is gitignored. Don't change that.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import time
|
| 10 |
+
import uuid
|
| 11 |
+
from dataclasses import asdict, dataclass, field
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from .config import settings
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class JournalEntry:
|
| 20 |
+
ts: float
|
| 21 |
+
action_id: str
|
| 22 |
+
tool: str
|
| 23 |
+
args: dict[str, Any] = field(default_factory=dict)
|
| 24 |
+
result: str = "ok" # "ok" | "fail" | "skipped"
|
| 25 |
+
note: str = ""
|
| 26 |
+
screenshot: str | None = None # relative path under state/ if captured
|
| 27 |
+
|
| 28 |
+
@classmethod
|
| 29 |
+
def new(cls, tool: str, args: dict[str, Any]) -> JournalEntry:
|
| 30 |
+
return cls(
|
| 31 |
+
ts=time.time(),
|
| 32 |
+
action_id=uuid.uuid4().hex[:12],
|
| 33 |
+
tool=tool,
|
| 34 |
+
args=args,
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class Journal:
|
| 39 |
+
def __init__(self, path: Path | None = None) -> None:
|
| 40 |
+
self.path = path or (settings.journal_dir / "session.journal.jsonl")
|
| 41 |
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
| 42 |
+
|
| 43 |
+
def append(self, entry: JournalEntry) -> None:
|
| 44 |
+
with self.path.open("a", encoding="utf-8") as f:
|
| 45 |
+
f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
|
| 46 |
+
|
| 47 |
+
def tail(self, n: int = 20) -> list[dict[str, Any]]:
|
| 48 |
+
if not self.path.exists():
|
| 49 |
+
return []
|
| 50 |
+
lines = self.path.read_text(encoding="utf-8").splitlines()[-n:]
|
| 51 |
+
return [json.loads(line) for line in lines if line.strip()]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
journal = Journal()
|
src/onshape_mcp/loop.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Closed-loop agent runner. Screenshot -> Gemini -> dispatch -> repeat.
|
| 2 |
+
|
| 3 |
+
Used by both the MCP `act()` tool and the run_task CLI. The dispatch
|
| 4 |
+
table + system prompt live in `dispatch.py` so the two callers can't
|
| 5 |
+
drift apart.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import time
|
| 14 |
+
from dataclasses import dataclass, field
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from .dispatch import build_agent_system_prompt, dispatch, parse_decision
|
| 19 |
+
from .driver import OnshapeDriver
|
| 20 |
+
from .vision import GeminiWeb
|
| 21 |
+
|
| 22 |
+
STUCK_THRESHOLD = 5 # unchanged screenshots in a row -> stop
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@dataclass
|
| 26 |
+
class StepRecord:
|
| 27 |
+
step: int
|
| 28 |
+
decision: dict[str, Any]
|
| 29 |
+
ok: bool
|
| 30 |
+
error: str | None = None
|
| 31 |
+
elapsed_s: float = 0.0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class LoopResult:
|
| 36 |
+
goal: str
|
| 37 |
+
steps: list[StepRecord] = field(default_factory=list)
|
| 38 |
+
completed: bool = False
|
| 39 |
+
final_screenshot: Path | None = None
|
| 40 |
+
stop_reason: str = ""
|
| 41 |
+
total_elapsed_s: float = 0.0
|
| 42 |
+
|
| 43 |
+
def summary(self) -> str:
|
| 44 |
+
if self.completed:
|
| 45 |
+
last = self.steps[-1].decision if self.steps else {}
|
| 46 |
+
return f"completed in {len(self.steps)} steps ({self.total_elapsed_s:.1f}s): {last.get('summary', '')}"
|
| 47 |
+
return f"stopped ({self.stop_reason}) after {len(self.steps)} steps ({self.total_elapsed_s:.1f}s)"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class AgentLoop:
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
driver: OnshapeDriver | None = None,
|
| 54 |
+
vision: GeminiWeb | None = None,
|
| 55 |
+
stuck_threshold: int = STUCK_THRESHOLD,
|
| 56 |
+
) -> None:
|
| 57 |
+
self.driver = driver
|
| 58 |
+
self.vision = vision
|
| 59 |
+
self.stuck_threshold = stuck_threshold
|
| 60 |
+
|
| 61 |
+
async def _ensure(self) -> tuple[OnshapeDriver, GeminiWeb]:
|
| 62 |
+
if self.driver is None:
|
| 63 |
+
self.driver = OnshapeDriver()
|
| 64 |
+
await self.driver.start(headless=True)
|
| 65 |
+
await self.driver.open()
|
| 66 |
+
if self.vision is None:
|
| 67 |
+
self.vision = GeminiWeb()
|
| 68 |
+
# Warm the vision client. The first call has higher latency
|
| 69 |
+
# because of init + token exchange. Doing it before the loop
|
| 70 |
+
# keeps the per-step timing predictable.
|
| 71 |
+
await self.vision._ensure_client()
|
| 72 |
+
return self.driver, self.vision
|
| 73 |
+
|
| 74 |
+
async def close(self) -> None:
|
| 75 |
+
if self.vision is not None:
|
| 76 |
+
await self.vision.close()
|
| 77 |
+
self.vision = None
|
| 78 |
+
if self.driver is not None:
|
| 79 |
+
await self.driver.close()
|
| 80 |
+
self.driver = None
|
| 81 |
+
|
| 82 |
+
async def run(self, goal: str, max_steps: int = 25) -> LoopResult:
|
| 83 |
+
run_t0 = time.monotonic()
|
| 84 |
+
d, v = await self._ensure()
|
| 85 |
+
result = LoopResult(goal=goal)
|
| 86 |
+
last_hash: str | None = None
|
| 87 |
+
unchanged = 0
|
| 88 |
+
|
| 89 |
+
system_prompt = build_agent_system_prompt()
|
| 90 |
+
history: list[dict[str, Any]] = []
|
| 91 |
+
|
| 92 |
+
# Maintain a single chat session for this task run to prevent
|
| 93 |
+
# spawning multiple separate chats per step.
|
| 94 |
+
chat_session = await v.new_session()
|
| 95 |
+
|
| 96 |
+
last_vertex: tuple[float, float] | None = None
|
| 97 |
+
|
| 98 |
+
for step in range(max_steps):
|
| 99 |
+
t0 = time.monotonic()
|
| 100 |
+
shot = await d.screenshot(f"task_step_{step:02d}.png")
|
| 101 |
+
h = hashlib.sha256(shot.read_bytes()).hexdigest()
|
| 102 |
+
if h == last_hash:
|
| 103 |
+
unchanged += 1
|
| 104 |
+
if unchanged >= self.stuck_threshold:
|
| 105 |
+
result.stop_reason = f"stuck after {self.stuck_threshold} unchanged screenshots"
|
| 106 |
+
break
|
| 107 |
+
else:
|
| 108 |
+
unchanged = 0
|
| 109 |
+
last_hash = h
|
| 110 |
+
|
| 111 |
+
history_text = ""
|
| 112 |
+
for r in history[-3:]:
|
| 113 |
+
history_text += f"\nStep {r['step']}: {r['decision']}\n"
|
| 114 |
+
vertex_text = f"\nLast drawn vertex on canvas: {last_vertex}\n" if last_vertex else ""
|
| 115 |
+
prompt = (
|
| 116 |
+
f"{system_prompt}\n\n"
|
| 117 |
+
f"---\n"
|
| 118 |
+
f"User goal: {goal}\n\n"
|
| 119 |
+
f"Recent action history:{history_text}{vertex_text}\n"
|
| 120 |
+
f"Step {step + 1}/{max_steps}. Look at the latest screenshot. "
|
| 121 |
+
"Reply ONLY with a JSON object: "
|
| 122 |
+
'{"tool": "<tool_name>", "args": {...}} OR {"done": true, "summary": "..."}'
|
| 123 |
+
)
|
| 124 |
+
try:
|
| 125 |
+
answer = await v.ask_with_image(prompt, shot)
|
| 126 |
+
except Exception as e:
|
| 127 |
+
result.steps.append(
|
| 128 |
+
StepRecord(
|
| 129 |
+
step,
|
| 130 |
+
{"error": f"vision: {e}"},
|
| 131 |
+
ok=False,
|
| 132 |
+
error=str(e),
|
| 133 |
+
elapsed_s=time.monotonic() - t0,
|
| 134 |
+
)
|
| 135 |
+
)
|
| 136 |
+
result.stop_reason = f"vision error: {e}"
|
| 137 |
+
break
|
| 138 |
+
|
| 139 |
+
decision = parse_decision(answer)
|
| 140 |
+
history.append({"step": step, "decision": decision})
|
| 141 |
+
if decision.get("done"):
|
| 142 |
+
if str(decision.get("summary", "")).startswith("Could not parse decision"):
|
| 143 |
+
rec = StepRecord(
|
| 144 |
+
step,
|
| 145 |
+
decision,
|
| 146 |
+
ok=False,
|
| 147 |
+
error=decision.get("summary"),
|
| 148 |
+
elapsed_s=time.monotonic() - t0,
|
| 149 |
+
)
|
| 150 |
+
result.steps.append(rec)
|
| 151 |
+
continue
|
| 152 |
+
result.completed = True
|
| 153 |
+
result.steps.append(
|
| 154 |
+
StepRecord(step, decision, ok=True, elapsed_s=time.monotonic() - t0)
|
| 155 |
+
)
|
| 156 |
+
break
|
| 157 |
+
|
| 158 |
+
tool = decision.get("tool")
|
| 159 |
+
args = decision.get("args", {}) or {}
|
| 160 |
+
rec = StepRecord(
|
| 161 |
+
step, {"tool": tool, "args": args}, ok=True, elapsed_s=time.monotonic() - t0
|
| 162 |
+
)
|
| 163 |
+
try:
|
| 164 |
+
action_res = await dispatch(d, tool, args)
|
| 165 |
+
if (
|
| 166 |
+
hasattr(action_res, "meta")
|
| 167 |
+
and isinstance(action_res.meta, dict)
|
| 168 |
+
and "last_vertex" in action_res.meta
|
| 169 |
+
):
|
| 170 |
+
last_vertex = tuple(action_res.meta["last_vertex"])
|
| 171 |
+
except Exception as e:
|
| 172 |
+
rec.ok = False
|
| 173 |
+
rec.error = f"{type(e).__name__}: {e}"
|
| 174 |
+
rec.elapsed_s = time.monotonic() - t0
|
| 175 |
+
result.steps.append(rec)
|
| 176 |
+
if rec.ok:
|
| 177 |
+
unchanged = 0
|
| 178 |
+
status_str = (
|
| 179 |
+
f"ERROR {rec.error}"
|
| 180 |
+
if rec.error
|
| 181 |
+
else f"{tool}({json.dumps(args, ensure_ascii=False)})"
|
| 182 |
+
)
|
| 183 |
+
print(f" step {step}: {status_str} ({rec.elapsed_s:.1f}s)", flush=True)
|
| 184 |
+
# brief beat so the UI has time to paint
|
| 185 |
+
await asyncio.sleep(0.4)
|
| 186 |
+
|
| 187 |
+
result.final_screenshot = await d.screenshot("task_final.png")
|
| 188 |
+
result.total_elapsed_s = time.monotonic() - run_t0
|
| 189 |
+
if not result.completed and not result.stop_reason:
|
| 190 |
+
result.stop_reason = f"max_steps ({max_steps}) reached"
|
| 191 |
+
return result
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def build_loop() -> AgentLoop:
|
| 195 |
+
"""Factory. Caller can override driver/vision if they already have one."""
|
| 196 |
+
return AgentLoop()
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def run_sync(goal: str, max_steps: int = 25) -> LoopResult:
|
| 200 |
+
"""Convenience wrapper for non-async callers."""
|
| 201 |
+
return asyncio.run(AgentLoop().run(goal, max_steps=max_steps))
|
src/onshape_mcp/server.py
ADDED
|
@@ -0,0 +1,697 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MCP server. Exposes the complete Onshape tool vocabulary as MCP tools.
|
| 2 |
+
Designed for Claude Desktop, ChatGPT, and custom LLM clients.
|
| 3 |
+
|
| 4 |
+
All tool executions are wrapped with error-handling to prevent JSON-RPC
|
| 5 |
+
pipe crashes and provide clear, human-readable public messages.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import json
|
| 12 |
+
import traceback
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from mcp.server.fastmcp import FastMCP
|
| 16 |
+
|
| 17 |
+
from . import tools as datasheet
|
| 18 |
+
from . import ui_actions
|
| 19 |
+
from .dispatch import TOOL_DISPATCH, build_agent_system_prompt, dispatch
|
| 20 |
+
from .driver import OnshapeDriver
|
| 21 |
+
from .fast_exec import execute as fast_execute
|
| 22 |
+
from .intent import parse as parse_intent
|
| 23 |
+
from .journal import JournalEntry, journal
|
| 24 |
+
from .loop import AgentLoop
|
| 25 |
+
from .vision import GeminiWeb
|
| 26 |
+
|
| 27 |
+
mcp = FastMCP("onshape-mcp")
|
| 28 |
+
|
| 29 |
+
_driver: OnshapeDriver | None = None
|
| 30 |
+
_vision: GeminiWeb | None = None
|
| 31 |
+
_loop: AgentLoop | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _format_error(tool_name: str, err: Exception) -> str:
|
| 35 |
+
"""Return a clean JSON string with public error details."""
|
| 36 |
+
return json.dumps(
|
| 37 |
+
{
|
| 38 |
+
"ok": False,
|
| 39 |
+
"tool": tool_name,
|
| 40 |
+
"error": type(err).__name__,
|
| 41 |
+
"message": str(err),
|
| 42 |
+
"hint": "Check coordinates, tool preconditions, or document connection.",
|
| 43 |
+
},
|
| 44 |
+
indent=2,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def _driver_lazy() -> OnshapeDriver:
|
| 49 |
+
global _driver
|
| 50 |
+
if _driver is None:
|
| 51 |
+
_driver = OnshapeDriver()
|
| 52 |
+
await _driver.start(headless=True)
|
| 53 |
+
await _driver.open()
|
| 54 |
+
return _driver
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
async def _vision_lazy() -> GeminiWeb:
|
| 58 |
+
global _vision
|
| 59 |
+
if _vision is None:
|
| 60 |
+
_vision = GeminiWeb()
|
| 61 |
+
return _vision
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
async def _loop_lazy() -> AgentLoop:
|
| 65 |
+
global _loop
|
| 66 |
+
if _loop is None:
|
| 67 |
+
_loop = AgentLoop()
|
| 68 |
+
return _loop
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# Meta & Information Tools
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@mcp.tool()
|
| 75 |
+
async def screenshot(name: str = "shot.png") -> str:
|
| 76 |
+
"""Take a screenshot of the current Onshape viewport. Returns the absolute file path."""
|
| 77 |
+
try:
|
| 78 |
+
d = await _driver_lazy()
|
| 79 |
+
path = await d.screenshot(name=name)
|
| 80 |
+
e = JournalEntry.new("screenshot", {"name": name})
|
| 81 |
+
e.screenshot = str(path)
|
| 82 |
+
journal.append(e)
|
| 83 |
+
return json.dumps({"ok": True, "screenshot": str(path), "message": f"Saved screenshot to {path}"})
|
| 84 |
+
except Exception as e:
|
| 85 |
+
return _format_error("screenshot", e)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
@mcp.tool()
|
| 89 |
+
async def describe_view(question: str = "What do you see in the Onshape viewport?") -> str:
|
| 90 |
+
"""Screenshot + ask Gemini vision to describe what's in the viewport."""
|
| 91 |
+
try:
|
| 92 |
+
d = await _driver_lazy()
|
| 93 |
+
img = await d.screenshot("describe.png")
|
| 94 |
+
v = await _vision_lazy()
|
| 95 |
+
desc = await v.ask_with_image(question, img)
|
| 96 |
+
return json.dumps({"ok": True, "description": desc})
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return _format_error("describe_view", e)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@mcp.tool()
|
| 102 |
+
async def tool_datasheet() -> str:
|
| 103 |
+
"""Return the entire Onshape tool vocabulary, parameters, and preconditions as markdown."""
|
| 104 |
+
return datasheet.as_prompt_block()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@mcp.tool()
|
| 108 |
+
async def journal_tail(n: int = 20) -> str:
|
| 109 |
+
"""Return the last N actions executed against Onshape."""
|
| 110 |
+
return json.dumps(journal.tail(n), indent=2, ensure_ascii=False)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@mcp.tool()
|
| 114 |
+
async def open_doc(url: str) -> str:
|
| 115 |
+
"""Navigate to an Onshape document URL or path."""
|
| 116 |
+
try:
|
| 117 |
+
d = await _driver_lazy()
|
| 118 |
+
full = url if url.startswith("http") else f"https://cad.onshape.com/{url.lstrip('/')}"
|
| 119 |
+
await d.open(full)
|
| 120 |
+
journal.append(JournalEntry.new("doc.open", {"url": full}))
|
| 121 |
+
return json.dumps({"ok": True, "message": f"Opened document {full}"})
|
| 122 |
+
except Exception as e:
|
| 123 |
+
return _format_error("open_doc", e)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@mcp.tool()
|
| 127 |
+
async def viewport_size() -> str:
|
| 128 |
+
"""Return the current viewport dimensions in pixels."""
|
| 129 |
+
try:
|
| 130 |
+
d = await _driver_lazy()
|
| 131 |
+
return json.dumps(await d.viewport_box())
|
| 132 |
+
except Exception as e:
|
| 133 |
+
return _format_error("viewport_size", e)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# View & Camera Tools
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@mcp.tool()
|
| 140 |
+
async def onshape_view_fit() -> str:
|
| 141 |
+
"""Zoom and pan to fit all visible geometry in the viewport ('f')."""
|
| 142 |
+
try:
|
| 143 |
+
d = await _driver_lazy()
|
| 144 |
+
return json.dumps((await ui_actions.view_fit(d)).to_dict())
|
| 145 |
+
except Exception as e:
|
| 146 |
+
return _format_error("view.fit", e)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
@mcp.tool()
|
| 150 |
+
async def onshape_view_top() -> str:
|
| 151 |
+
"""Orient camera to look directly at the Top view."""
|
| 152 |
+
try:
|
| 153 |
+
d = await _driver_lazy()
|
| 154 |
+
return json.dumps((await ui_actions.view_top(d)).to_dict())
|
| 155 |
+
except Exception as e:
|
| 156 |
+
return _format_error("view.top", e)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@mcp.tool()
|
| 160 |
+
async def onshape_view_front() -> str:
|
| 161 |
+
"""Orient camera to look directly at the Front view."""
|
| 162 |
+
try:
|
| 163 |
+
d = await _driver_lazy()
|
| 164 |
+
return json.dumps((await ui_actions.view_front(d)).to_dict())
|
| 165 |
+
except Exception as e:
|
| 166 |
+
return _format_error("view.front", e)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
@mcp.tool()
|
| 170 |
+
async def onshape_view_iso() -> str:
|
| 171 |
+
"""Orient camera to Isometric view."""
|
| 172 |
+
try:
|
| 173 |
+
d = await _driver_lazy()
|
| 174 |
+
return json.dumps((await ui_actions.view_iso(d)).to_dict())
|
| 175 |
+
except Exception as e:
|
| 176 |
+
return _format_error("view.iso", e)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# Sketch Creation Tools
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@mcp.tool()
|
| 183 |
+
async def onshape_sketch_start(
|
| 184 |
+
plane_x: float | None = None,
|
| 185 |
+
plane_y: float | None = None,
|
| 186 |
+
plane: str | None = None,
|
| 187 |
+
) -> str:
|
| 188 |
+
"""Open a new sketch. Pass plane name ('Top', 'Front', 'Right') or plane_x/plane_y coordinates.
|
| 189 |
+
Default plane is 'Top'. Automatically aligns camera normal to the sketch plane."""
|
| 190 |
+
try:
|
| 191 |
+
d = await _driver_lazy()
|
| 192 |
+
pt = (plane_x, plane_y) if plane_x is not None and plane_y is not None else None
|
| 193 |
+
return json.dumps((await ui_actions.sketch_start(d, pt, plane_name=plane)).to_dict())
|
| 194 |
+
except Exception as e:
|
| 195 |
+
return _format_error("sketch.start", e)
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
@mcp.tool()
|
| 199 |
+
async def onshape_sketch_exit(commit: bool = True) -> str:
|
| 200 |
+
"""Exit the active sketch and accept (commit=True) or discard (commit=False) changes."""
|
| 201 |
+
try:
|
| 202 |
+
d = await _driver_lazy()
|
| 203 |
+
return json.dumps((await ui_actions.sketch_exit(d, commit=commit)).to_dict())
|
| 204 |
+
except Exception as e:
|
| 205 |
+
return _format_error("sketch.exit", e)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
@mcp.tool()
|
| 209 |
+
async def onshape_sketch_rectangle(
|
| 210 |
+
corner1_x: float | None = None,
|
| 211 |
+
corner1_y: float | None = None,
|
| 212 |
+
corner2_x: float | None = None,
|
| 213 |
+
corner2_y: float | None = None,
|
| 214 |
+
width: float | str | None = None,
|
| 215 |
+
height: float | str | None = None,
|
| 216 |
+
quadrant: str | int | None = None,
|
| 217 |
+
centered: bool | None = None,
|
| 218 |
+
) -> str:
|
| 219 |
+
"""Draw a rectangle. Accepts dimensions (e.g. width='10 cm', height='5 cm'), quadrant ('1', '2', '3', '4'),
|
| 220 |
+
or centered=True. Dimensions are automatically driven into Onshape's solver."""
|
| 221 |
+
try:
|
| 222 |
+
d = await _driver_lazy()
|
| 223 |
+
c1 = (corner1_x, corner1_y) if corner1_x is not None and corner1_y is not None else None
|
| 224 |
+
c2 = (corner2_x, corner2_y) if corner2_x is not None and corner2_y is not None else None
|
| 225 |
+
return json.dumps(
|
| 226 |
+
(
|
| 227 |
+
await ui_actions.sketch_rectangle(
|
| 228 |
+
d, c1, c2, width=width, height=height, quadrant=quadrant, centered=centered
|
| 229 |
+
)
|
| 230 |
+
).to_dict()
|
| 231 |
+
)
|
| 232 |
+
except Exception as e:
|
| 233 |
+
return _format_error("sketch.rectangle", e)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
@mcp.tool()
|
| 237 |
+
async def onshape_sketch_circle(
|
| 238 |
+
center_x: float | None = None,
|
| 239 |
+
center_y: float | None = None,
|
| 240 |
+
radius_px: float = 50.0,
|
| 241 |
+
centered: bool | None = None,
|
| 242 |
+
) -> str:
|
| 243 |
+
"""Draw a circle. Pass center coordinates (center_x, center_y) or centered=True for origin."""
|
| 244 |
+
try:
|
| 245 |
+
d = await _driver_lazy()
|
| 246 |
+
center = (center_x, center_y) if center_x is not None and center_y is not None else None
|
| 247 |
+
return json.dumps(
|
| 248 |
+
(await ui_actions.sketch_circle(d, center, radius_px, centered=centered)).to_dict()
|
| 249 |
+
)
|
| 250 |
+
except Exception as e:
|
| 251 |
+
return _format_error("sketch.circle", e)
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
@mcp.tool()
|
| 255 |
+
async def onshape_sketch_line(p1_x: float, p1_y: float, p2_x: float, p2_y: float) -> str:
|
| 256 |
+
"""Draw a line segment from (p1_x, p1_y) to (p2_x, p2_y)."""
|
| 257 |
+
try:
|
| 258 |
+
d = await _driver_lazy()
|
| 259 |
+
return json.dumps((await ui_actions.sketch_line(d, (p1_x, p1_y), (p2_x, p2_y))).to_dict())
|
| 260 |
+
except Exception as e:
|
| 261 |
+
return _format_error("sketch.line", e)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
@mcp.tool()
|
| 265 |
+
async def onshape_sketch_polygon(
|
| 266 |
+
sides: int = 6,
|
| 267 |
+
radius: float = 60.0,
|
| 268 |
+
center_x: float | None = None,
|
| 269 |
+
center_y: float | None = None,
|
| 270 |
+
circumscribed: bool = False,
|
| 271 |
+
) -> str:
|
| 272 |
+
"""Draw an inscribed or circumscribed polygon with N sides and radius."""
|
| 273 |
+
try:
|
| 274 |
+
d = await _driver_lazy()
|
| 275 |
+
center = (center_x, center_y) if center_x is not None and center_y is not None else None
|
| 276 |
+
return json.dumps(
|
| 277 |
+
(
|
| 278 |
+
await ui_actions.sketch_polygon(
|
| 279 |
+
d, center=center, radius=radius, sides=sides, circumscribed=circumscribed
|
| 280 |
+
)
|
| 281 |
+
).to_dict()
|
| 282 |
+
)
|
| 283 |
+
except Exception as e:
|
| 284 |
+
return _format_error("sketch.polygon", e)
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
@mcp.tool()
|
| 288 |
+
async def onshape_sketch_spline(points: list[list[float]]) -> str:
|
| 289 |
+
"""Draw a smooth spline curve through a list of [[x1, y1], [x2, y2], ...] points."""
|
| 290 |
+
try:
|
| 291 |
+
d = await _driver_lazy()
|
| 292 |
+
pts = [(p[0], p[1]) for p in points]
|
| 293 |
+
return json.dumps((await ui_actions.sketch_spline(d, pts)).to_dict())
|
| 294 |
+
except Exception as e:
|
| 295 |
+
return _format_error("sketch.spline", e)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
@mcp.tool()
|
| 299 |
+
async def onshape_sketch_arc(
|
| 300 |
+
p1_x: float,
|
| 301 |
+
p1_y: float,
|
| 302 |
+
p2_x: float,
|
| 303 |
+
p2_y: float,
|
| 304 |
+
radius_x: float | None = None,
|
| 305 |
+
radius_y: float | None = None,
|
| 306 |
+
) -> str:
|
| 307 |
+
"""Draw a 3-point arc from (p1_x, p1_y) to (p2_x, p2_y) passing through (radius_x, radius_y)."""
|
| 308 |
+
try:
|
| 309 |
+
d = await _driver_lazy()
|
| 310 |
+
rad_pt = (radius_x, radius_y) if radius_x is not None and radius_y is not None else None
|
| 311 |
+
return json.dumps(
|
| 312 |
+
(await ui_actions.sketch_arc(d, (p1_x, p1_y), (p2_x, p2_y), radius_pt=rad_pt)).to_dict()
|
| 313 |
+
)
|
| 314 |
+
except Exception as e:
|
| 315 |
+
return _format_error("sketch.arc", e)
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
@mcp.tool()
|
| 319 |
+
async def onshape_sketch_point(x: float, y: float) -> str:
|
| 320 |
+
"""Place a sketch point at (x, y)."""
|
| 321 |
+
try:
|
| 322 |
+
d = await _driver_lazy()
|
| 323 |
+
return json.dumps((await ui_actions.sketch_point(d, (x, y))).to_dict())
|
| 324 |
+
except Exception as e:
|
| 325 |
+
return _format_error("sketch.point", e)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@mcp.tool()
|
| 329 |
+
async def onshape_sketch_text(
|
| 330 |
+
corner1_x: float, corner1_y: float, corner2_x: float, corner2_y: float, text: str
|
| 331 |
+
) -> str:
|
| 332 |
+
"""Place sketch text in a box defined by corner1 and corner2."""
|
| 333 |
+
try:
|
| 334 |
+
d = await _driver_lazy()
|
| 335 |
+
return json.dumps(
|
| 336 |
+
(
|
| 337 |
+
await ui_actions.sketch_text(
|
| 338 |
+
d, (corner1_x, corner1_y), (corner2_x, corner2_y), text=text
|
| 339 |
+
)
|
| 340 |
+
).to_dict()
|
| 341 |
+
)
|
| 342 |
+
except Exception as e:
|
| 343 |
+
return _format_error("sketch.text", e)
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
# Sketch Modifications & Operations
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
@mcp.tool()
|
| 350 |
+
async def onshape_sketch_construction(x: float | None = None, y: float | None = None) -> str:
|
| 351 |
+
"""Toggle construction mode, or convert the entity at (x, y) to/from construction geometry ('q')."""
|
| 352 |
+
try:
|
| 353 |
+
d = await _driver_lazy()
|
| 354 |
+
pt = (x, y) if x is not None and y is not None else None
|
| 355 |
+
return json.dumps((await ui_actions.sketch_construction(d, pt=pt)).to_dict())
|
| 356 |
+
except Exception as e:
|
| 357 |
+
return _format_error("sketch.construction", e)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
@mcp.tool()
|
| 361 |
+
async def onshape_sketch_fillet(vertex_x: float, vertex_y: float, radius_mm: float = 5.0) -> str:
|
| 362 |
+
"""Add a rounded 2D fillet of radius_mm at corner/vertex (vertex_x, vertex_y)."""
|
| 363 |
+
try:
|
| 364 |
+
d = await _driver_lazy()
|
| 365 |
+
return json.dumps(
|
| 366 |
+
(await ui_actions.sketch_fillet(d, (vertex_x, vertex_y), radius_mm=radius_mm)).to_dict()
|
| 367 |
+
)
|
| 368 |
+
except Exception as e:
|
| 369 |
+
return _format_error("sketch.fillet", e)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
@mcp.tool()
|
| 373 |
+
async def onshape_sketch_chamfer(
|
| 374 |
+
vertex_x: float, vertex_y: float, distance_mm: float = 5.0
|
| 375 |
+
) -> str:
|
| 376 |
+
"""Add a 2D chamfer of distance_mm at corner/vertex (vertex_x, vertex_y)."""
|
| 377 |
+
try:
|
| 378 |
+
d = await _driver_lazy()
|
| 379 |
+
return json.dumps(
|
| 380 |
+
(
|
| 381 |
+
await ui_actions.sketch_chamfer(d, (vertex_x, vertex_y), distance_mm=distance_mm)
|
| 382 |
+
).to_dict()
|
| 383 |
+
)
|
| 384 |
+
except Exception as e:
|
| 385 |
+
return _format_error("sketch.chamfer", e)
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
@mcp.tool()
|
| 389 |
+
async def onshape_sketch_trim(x: float, y: float) -> str:
|
| 390 |
+
"""Trim a curve segment back to the nearest intersections by clicking at (x, y) ('m')."""
|
| 391 |
+
try:
|
| 392 |
+
d = await _driver_lazy()
|
| 393 |
+
return json.dumps((await ui_actions.sketch_trim(d, (x, y))).to_dict())
|
| 394 |
+
except Exception as e:
|
| 395 |
+
return _format_error("sketch.trim", e)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
@mcp.tool()
|
| 399 |
+
async def onshape_sketch_extend(x: float, y: float) -> str:
|
| 400 |
+
"""Extend a curve endpoint at (x, y) to the nearest boundary ('x')."""
|
| 401 |
+
try:
|
| 402 |
+
d = await _driver_lazy()
|
| 403 |
+
return json.dumps((await ui_actions.sketch_extend(d, (x, y))).to_dict())
|
| 404 |
+
except Exception as e:
|
| 405 |
+
return _format_error("sketch.extend", e)
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@mcp.tool()
|
| 409 |
+
async def onshape_sketch_offset(
|
| 410 |
+
entity_x: float,
|
| 411 |
+
entity_y: float,
|
| 412 |
+
distance_mm: float = 5.0,
|
| 413 |
+
side_x: float | None = None,
|
| 414 |
+
side_y: float | None = None,
|
| 415 |
+
) -> str:
|
| 416 |
+
"""Offset curve at (entity_x, entity_y) by distance_mm toward optional side (side_x, side_y) ('o')."""
|
| 417 |
+
try:
|
| 418 |
+
d = await _driver_lazy()
|
| 419 |
+
side = (side_x, side_y) if side_x is not None and side_y is not None else None
|
| 420 |
+
return json.dumps(
|
| 421 |
+
(
|
| 422 |
+
await ui_actions.sketch_offset(
|
| 423 |
+
d, (entity_x, entity_y), distance_mm=distance_mm, side_xy=side
|
| 424 |
+
)
|
| 425 |
+
).to_dict()
|
| 426 |
+
)
|
| 427 |
+
except Exception as e:
|
| 428 |
+
return _format_error("sketch.offset", e)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
@mcp.tool()
|
| 432 |
+
async def onshape_sketch_mirror(
|
| 433 |
+
centerline_x: float, centerline_y: float, entity_x: float, entity_y: float
|
| 434 |
+
) -> str:
|
| 435 |
+
"""Mirror an entity at (entity_x, entity_y) across a centerline at (centerline_x, centerline_y)."""
|
| 436 |
+
try:
|
| 437 |
+
d = await _driver_lazy()
|
| 438 |
+
return json.dumps(
|
| 439 |
+
(
|
| 440 |
+
await ui_actions.sketch_mirror(
|
| 441 |
+
d, (centerline_x, centerline_y), (entity_x, entity_y)
|
| 442 |
+
)
|
| 443 |
+
).to_dict()
|
| 444 |
+
)
|
| 445 |
+
except Exception as e:
|
| 446 |
+
return _format_error("sketch.mirror", e)
|
| 447 |
+
|
| 448 |
+
|
| 449 |
+
# Dimensions & Constraints
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
@mcp.tool()
|
| 453 |
+
async def onshape_sketch_dimension(
|
| 454 |
+
entity_x: float, entity_y: float, label_x: float, label_y: float, value_mm: float | str
|
| 455 |
+
) -> str:
|
| 456 |
+
"""Drive an Onshape sketch dimension ('d'). Clicks entity at (entity_x, entity_y), places label
|
| 457 |
+
at (label_x, label_y), and enters value_mm into the solver."""
|
| 458 |
+
try:
|
| 459 |
+
d = await _driver_lazy()
|
| 460 |
+
return json.dumps(
|
| 461 |
+
(
|
| 462 |
+
await ui_actions.sketch_dimension(
|
| 463 |
+
d, (entity_x, entity_y), (label_x, label_y), value_mm
|
| 464 |
+
)
|
| 465 |
+
).to_dict()
|
| 466 |
+
)
|
| 467 |
+
except Exception as e:
|
| 468 |
+
return _format_error("sketch.dimension", e)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
@mcp.tool()
|
| 472 |
+
async def onshape_sketch_constrain(
|
| 473 |
+
constraint_type: str,
|
| 474 |
+
entities: list[list[float]],
|
| 475 |
+
) -> str:
|
| 476 |
+
"""Apply a geometric constraint across entities.
|
| 477 |
+
Valid types: coincident, concentric, parallel, tangent, horizontal, vertical,
|
| 478 |
+
perpendicular, equal, midpoint, normal, pierce, symmetric, fix."""
|
| 479 |
+
try:
|
| 480 |
+
d = await _driver_lazy()
|
| 481 |
+
pts = [(e[0], e[1]) for e in entities]
|
| 482 |
+
return json.dumps(
|
| 483 |
+
(await ui_actions.sketch_constrain(d, constraint_type, *pts)).to_dict()
|
| 484 |
+
)
|
| 485 |
+
except Exception as e:
|
| 486 |
+
return _format_error(f"constraint.{constraint_type}", e)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
# Feature Operations
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
@mcp.tool()
|
| 493 |
+
async def onshape_feature_extrude(depth_mm: float | None = None) -> str:
|
| 494 |
+
"""Extrude the active sketch or selected face by depth_mm."""
|
| 495 |
+
try:
|
| 496 |
+
d = await _driver_lazy()
|
| 497 |
+
return json.dumps((await ui_actions.feature_extrude(d, depth_mm)).to_dict())
|
| 498 |
+
except Exception as e:
|
| 499 |
+
return _format_error("feature.extrude", e)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
@mcp.tool()
|
| 503 |
+
async def onshape_feature_fillet(radius_mm: float | None = None) -> str:
|
| 504 |
+
"""Round selected 3D model edges by radius_mm."""
|
| 505 |
+
try:
|
| 506 |
+
d = await _driver_lazy()
|
| 507 |
+
return json.dumps((await ui_actions.feature_fillet(d, radius_mm)).to_dict())
|
| 508 |
+
except Exception as e:
|
| 509 |
+
return _format_error("feature.fillet", e)
|
| 510 |
+
|
| 511 |
+
|
| 512 |
+
@mcp.tool()
|
| 513 |
+
async def onshape_feature_chamfer(distance_mm: float | None = None) -> str:
|
| 514 |
+
"""Bevel selected 3D model edges by distance_mm."""
|
| 515 |
+
try:
|
| 516 |
+
d = await _driver_lazy()
|
| 517 |
+
return json.dumps((await ui_actions.feature_chamfer(d, distance_mm)).to_dict())
|
| 518 |
+
except Exception as e:
|
| 519 |
+
return _format_error("feature.chamfer", e)
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
# Generic Tool Dispatcher (with clean un-bound tool guard)
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
@mcp.tool()
|
| 526 |
+
async def onshape_execute(tool: str, args: dict[str, Any] | None = None) -> str:
|
| 527 |
+
"""Generic tool dispatcher. Calls any registered tool by name with arguments.
|
| 528 |
+
Returns clear public error messages if a tool is un-bound or fails."""
|
| 529 |
+
if tool not in TOOL_DISPATCH:
|
| 530 |
+
return json.dumps(
|
| 531 |
+
{
|
| 532 |
+
"ok": False,
|
| 533 |
+
"tool": tool,
|
| 534 |
+
"error": "ToolNotBoundError",
|
| 535 |
+
"message": f"Tool '{tool}' is not bound or supported. Please choose from available tools.",
|
| 536 |
+
"available_tools": sorted(TOOL_DISPATCH.keys()),
|
| 537 |
+
},
|
| 538 |
+
indent=2,
|
| 539 |
+
)
|
| 540 |
+
try:
|
| 541 |
+
d = await _driver_lazy()
|
| 542 |
+
res = await dispatch(d, tool, args or {})
|
| 543 |
+
out = res.to_dict() if hasattr(res, "to_dict") else {"ok": True, "result": str(res)}
|
| 544 |
+
return json.dumps(out, indent=2)
|
| 545 |
+
except Exception as e:
|
| 546 |
+
return _format_error(tool, e)
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
# Autonomous Agent (Fast Path + Vision Loop)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
@mcp.tool()
|
| 553 |
+
async def act(goal: str, max_steps: int = 25) -> str:
|
| 554 |
+
"""Autonomous CAD agent. Takes a high-level goal in natural language (e.g.
|
| 555 |
+
'draw a 10cm by 5cm box in Quadrant 1 on the top plane', 'draw a 6-sided polygon')
|
| 556 |
+
and executes it. Tries deterministic fast execution first (10-20s), falling back
|
| 557 |
+
to vision loop if ambiguous."""
|
| 558 |
+
try:
|
| 559 |
+
# 1. Try fast path first
|
| 560 |
+
plan = parse_intent(goal)
|
| 561 |
+
if plan is not None:
|
| 562 |
+
d = await _driver_lazy()
|
| 563 |
+
result_fast = await fast_execute(d, plan)
|
| 564 |
+
return json.dumps(
|
| 565 |
+
{
|
| 566 |
+
"ok": result_fast.ok,
|
| 567 |
+
"mode": "fast_path",
|
| 568 |
+
"goal": goal,
|
| 569 |
+
"summary": result_fast.summary(),
|
| 570 |
+
"actions_count": len(result_fast.step_times),
|
| 571 |
+
"total_elapsed_s": round(result_fast.total_elapsed_s, 1),
|
| 572 |
+
"final_screenshot": str(result_fast.final_screenshot)
|
| 573 |
+
if result_fast.final_screenshot
|
| 574 |
+
else None,
|
| 575 |
+
"error": result_fast.error,
|
| 576 |
+
},
|
| 577 |
+
indent=2,
|
| 578 |
+
)
|
| 579 |
+
|
| 580 |
+
# 2. Fallback to vision loop
|
| 581 |
+
loop = await _loop_lazy()
|
| 582 |
+
result_loop = await loop.run(goal, max_steps=max_steps)
|
| 583 |
+
tail = [
|
| 584 |
+
{
|
| 585 |
+
"step": r.step,
|
| 586 |
+
"tool": r.decision.get("tool"),
|
| 587 |
+
"args": r.decision.get("args", {}),
|
| 588 |
+
"ok": r.ok,
|
| 589 |
+
"error": r.error,
|
| 590 |
+
}
|
| 591 |
+
for r in result_loop.steps[-5:]
|
| 592 |
+
]
|
| 593 |
+
return json.dumps(
|
| 594 |
+
{
|
| 595 |
+
"ok": result_loop.completed,
|
| 596 |
+
"mode": "vision_loop",
|
| 597 |
+
"goal": goal,
|
| 598 |
+
"summary": result_loop.summary(),
|
| 599 |
+
"total_elapsed_s": round(result_loop.total_elapsed_s, 1),
|
| 600 |
+
"steps_total": len(result_loop.steps),
|
| 601 |
+
"transcript_tail": tail,
|
| 602 |
+
"final_screenshot": str(result_loop.final_screenshot)
|
| 603 |
+
if result_loop.final_screenshot
|
| 604 |
+
else None,
|
| 605 |
+
"stop_reason": result_loop.stop_reason,
|
| 606 |
+
},
|
| 607 |
+
indent=2,
|
| 608 |
+
)
|
| 609 |
+
except Exception as e:
|
| 610 |
+
return _format_error("act", e)
|
| 611 |
+
|
| 612 |
+
|
| 613 |
+
# Lifecycle
|
| 614 |
+
|
| 615 |
+
|
| 616 |
+
async def _cleanup() -> None:
|
| 617 |
+
global _driver, _vision, _loop
|
| 618 |
+
if _loop is not None:
|
| 619 |
+
await _loop.close()
|
| 620 |
+
if _vision is not None:
|
| 621 |
+
await _vision.close()
|
| 622 |
+
if _driver is not None:
|
| 623 |
+
await _driver.close()
|
| 624 |
+
_driver = None
|
| 625 |
+
_vision = None
|
| 626 |
+
_loop = None
|
| 627 |
+
|
| 628 |
+
|
| 629 |
+
def main() -> None:
|
| 630 |
+
import argparse
|
| 631 |
+
import os
|
| 632 |
+
import sys
|
| 633 |
+
|
| 634 |
+
# Quick dispatch for setup wizard or login
|
| 635 |
+
if len(sys.argv) >= 2 and sys.argv[1] == "setup":
|
| 636 |
+
from .setup import main as run_setup
|
| 637 |
+
|
| 638 |
+
sys.argv.pop(1)
|
| 639 |
+
run_setup()
|
| 640 |
+
return
|
| 641 |
+
|
| 642 |
+
if len(sys.argv) >= 2 and sys.argv[1] == "login":
|
| 643 |
+
from .driver import login_interactive
|
| 644 |
+
|
| 645 |
+
asyncio.run(login_interactive())
|
| 646 |
+
return
|
| 647 |
+
|
| 648 |
+
if len(sys.argv) >= 2 and sys.argv[1] in ("share", "tunnel"):
|
| 649 |
+
from .tunnel import run_tunnel_and_server
|
| 650 |
+
|
| 651 |
+
port = int(os.environ.get("PORT", os.environ.get("MCP_PORT", "8000")))
|
| 652 |
+
run_tunnel_and_server(port=port)
|
| 653 |
+
return
|
| 654 |
+
|
| 655 |
+
parser = argparse.ArgumentParser(description="Onshape MCP Server")
|
| 656 |
+
parser.add_argument(
|
| 657 |
+
"--transport",
|
| 658 |
+
choices=["stdio", "sse"],
|
| 659 |
+
default=os.environ.get("MCP_TRANSPORT", "stdio"),
|
| 660 |
+
help="Transport protocol: 'stdio' for Claude Desktop / LLM clients (default), or 'sse' for remote hosting.",
|
| 661 |
+
)
|
| 662 |
+
parser.add_argument(
|
| 663 |
+
"--tunnel",
|
| 664 |
+
action="store_true",
|
| 665 |
+
help="Auto-start Cloudflare tunnel to expose live HTTPS link for ChatGPT Web / LibreChat.",
|
| 666 |
+
)
|
| 667 |
+
parser.add_argument(
|
| 668 |
+
"--host",
|
| 669 |
+
default=os.environ.get("MCP_HOST", "0.0.0.0"),
|
| 670 |
+
help="Host interface for SSE transport (default: 0.0.0.0)",
|
| 671 |
+
)
|
| 672 |
+
parser.add_argument(
|
| 673 |
+
"--port",
|
| 674 |
+
type=int,
|
| 675 |
+
default=int(os.environ.get("PORT", os.environ.get("MCP_PORT", "8000"))),
|
| 676 |
+
help="Port for SSE transport (default: 8000 or $PORT)",
|
| 677 |
+
)
|
| 678 |
+
args, _ = parser.parse_known_args()
|
| 679 |
+
|
| 680 |
+
if args.tunnel:
|
| 681 |
+
from .tunnel import run_tunnel_and_server
|
| 682 |
+
|
| 683 |
+
run_tunnel_and_server(port=args.port, host=args.host)
|
| 684 |
+
return
|
| 685 |
+
|
| 686 |
+
if args.transport == "sse":
|
| 687 |
+
mcp.settings.host = args.host
|
| 688 |
+
mcp.settings.port = args.port
|
| 689 |
+
|
| 690 |
+
try:
|
| 691 |
+
mcp.run(transport=args.transport)
|
| 692 |
+
finally:
|
| 693 |
+
asyncio.run(_cleanup())
|
| 694 |
+
|
| 695 |
+
|
| 696 |
+
if __name__ == "__main__":
|
| 697 |
+
main()
|
src/onshape_mcp/setup.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Smart setup wizard for Onshape MCP.
|
| 2 |
+
Configures Playwright, syncs Onshape cookies, and automatically
|
| 3 |
+
adds the server to Claude Desktop across macOS, Windows, and Linux.
|
| 4 |
+
|
| 5 |
+
Zero hardcoded user paths or credentials.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import platform
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
from .config import _BASE_DIR, settings
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def get_claude_desktop_config_path() -> Path:
|
| 23 |
+
"""Return platform-specific path to claude_desktop_config.json."""
|
| 24 |
+
system = platform.system()
|
| 25 |
+
if system == "Darwin": # macOS
|
| 26 |
+
return Path.home() / "Library" / "Application Support" / "Claude" / "claude_desktop_config.json"
|
| 27 |
+
elif system == "Windows":
|
| 28 |
+
app_data = os.environ.get("APPDATA")
|
| 29 |
+
base = Path(app_data) if app_data else Path.home() / "AppData" / "Roaming"
|
| 30 |
+
return base / "Claude" / "claude_desktop_config.json"
|
| 31 |
+
else: # Linux / FreeBSD / other POSIX
|
| 32 |
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
| 33 |
+
base = Path(xdg_config) if xdg_config else Path.home() / ".config"
|
| 34 |
+
return base / "Claude" / "claude_desktop_config.json"
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def ensure_playwright_browsers() -> bool:
|
| 38 |
+
"""Ensure Chromium binary is downloaded for Playwright."""
|
| 39 |
+
print("[1/4] Checking Playwright browser dependencies...")
|
| 40 |
+
try:
|
| 41 |
+
res = subprocess.run(
|
| 42 |
+
[sys.executable, "-m", "playwright", "install", "chromium"],
|
| 43 |
+
capture_output=True,
|
| 44 |
+
text=True,
|
| 45 |
+
check=False,
|
| 46 |
+
)
|
| 47 |
+
if res.returncode == 0:
|
| 48 |
+
print(" ✓ Chromium browser engine is installed and ready.")
|
| 49 |
+
return True
|
| 50 |
+
else:
|
| 51 |
+
print(f" ⚠ Note: Playwright browser check returned: {res.stderr.strip()[:100]}")
|
| 52 |
+
return True
|
| 53 |
+
except Exception as e:
|
| 54 |
+
print(f" ⚠ Could not run playwright install: {e}")
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def sync_onshape_cookies(interactive: bool = True) -> bool:
|
| 59 |
+
"""Check for Onshape cookies or sync from local browsers or interactive login."""
|
| 60 |
+
print("[2/4] Checking Onshape authentication & session cookies...")
|
| 61 |
+
|
| 62 |
+
# 1. Existing cookie file
|
| 63 |
+
if settings.onshape_cookie_file.exists() and settings.onshape_cookie_file.stat().st_size > 20:
|
| 64 |
+
try:
|
| 65 |
+
data = json.loads(settings.onshape_cookie_file.read_text(encoding="utf-8"))
|
| 66 |
+
if data and isinstance(data, list):
|
| 67 |
+
print(f" ✓ Existing session found ({len(data)} cookies) at: {settings.onshape_cookie_file}")
|
| 68 |
+
return True
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
# 2. Extract from installed browsers
|
| 73 |
+
try:
|
| 74 |
+
import browser_cookie3
|
| 75 |
+
|
| 76 |
+
browser_loaders = [
|
| 77 |
+
("Google Chrome", getattr(browser_cookie3, "chrome", None)),
|
| 78 |
+
("Brave", getattr(browser_cookie3, "brave", None)),
|
| 79 |
+
("Microsoft Edge", getattr(browser_cookie3, "edge", None)),
|
| 80 |
+
("Chromium", getattr(browser_cookie3, "chromium", None)),
|
| 81 |
+
("Firefox", getattr(browser_cookie3, "firefox", None)),
|
| 82 |
+
("Arc", getattr(browser_cookie3, "arc", None)),
|
| 83 |
+
("Opera", getattr(browser_cookie3, "opera", None)),
|
| 84 |
+
("Vivaldi", getattr(browser_cookie3, "vivaldi", None)),
|
| 85 |
+
]
|
| 86 |
+
for browser_name, loader in browser_loaders:
|
| 87 |
+
if loader is None:
|
| 88 |
+
continue
|
| 89 |
+
try:
|
| 90 |
+
cj = loader(domain_name="onshape.com")
|
| 91 |
+
raw = []
|
| 92 |
+
for c in cj:
|
| 93 |
+
cookie = {
|
| 94 |
+
"name": c.name,
|
| 95 |
+
"value": c.value,
|
| 96 |
+
"domain": c.domain,
|
| 97 |
+
"path": c.path,
|
| 98 |
+
"secure": bool(c.secure),
|
| 99 |
+
"httpOnly": bool(
|
| 100 |
+
c.has_nonstandard_attr("HttpOnly")
|
| 101 |
+
or c.has_nonstandard_attr("httponly")
|
| 102 |
+
),
|
| 103 |
+
}
|
| 104 |
+
if c.expires:
|
| 105 |
+
cookie["expires"] = float(c.expires)
|
| 106 |
+
raw.append(cookie)
|
| 107 |
+
if raw:
|
| 108 |
+
settings.onshape_cookie_file.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
settings.onshape_cookie_file.write_text(
|
| 110 |
+
json.dumps(raw, indent=2), encoding="utf-8"
|
| 111 |
+
)
|
| 112 |
+
print(
|
| 113 |
+
f" ✓ Auto-synced {len(raw)} Onshape cookies from {browser_name}."
|
| 114 |
+
)
|
| 115 |
+
return True
|
| 116 |
+
except Exception:
|
| 117 |
+
continue
|
| 118 |
+
except Exception:
|
| 119 |
+
pass
|
| 120 |
+
|
| 121 |
+
# 3. If missing and interactive, offer browser login
|
| 122 |
+
if interactive:
|
| 123 |
+
print(" ⚠ No active Onshape session found in local browsers.")
|
| 124 |
+
choice = input(" [?] Would you like to open a browser window to log in now? [Y/n]: ").strip().lower()
|
| 125 |
+
if choice not in ("n", "no"):
|
| 126 |
+
import asyncio
|
| 127 |
+
from .driver import login_interactive
|
| 128 |
+
|
| 129 |
+
asyncio.run(login_interactive())
|
| 130 |
+
return settings.onshape_cookie_file.exists()
|
| 131 |
+
|
| 132 |
+
print(" ⚠ Please log into https://cad.onshape.com in your browser before running commands.")
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def configure_claude_desktop(
|
| 137 |
+
doc_url: str = "",
|
| 138 |
+
use_local_env: bool = False,
|
| 139 |
+
auto_yes: bool = False,
|
| 140 |
+
) -> Path | None:
|
| 141 |
+
path = get_claude_desktop_config_path()
|
| 142 |
+
_write_mcp_json(path, doc_url)
|
| 143 |
+
return path
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _write_mcp_json(config_path: Path, doc_url: str = "") -> bool:
|
| 147 |
+
"""Helper to safely read, backup, and inject the onshape MCP entry into any mcp.json file."""
|
| 148 |
+
data: dict[str, Any] = {}
|
| 149 |
+
if config_path.exists():
|
| 150 |
+
try:
|
| 151 |
+
bak_path = config_path.with_suffix(".json.bak")
|
| 152 |
+
shutil.copy2(config_path, bak_path)
|
| 153 |
+
data = json.loads(config_path.read_text(encoding="utf-8"))
|
| 154 |
+
except Exception:
|
| 155 |
+
data = {}
|
| 156 |
+
|
| 157 |
+
if not isinstance(data, dict):
|
| 158 |
+
data = {}
|
| 159 |
+
|
| 160 |
+
if "mcpServers" not in data:
|
| 161 |
+
data["mcpServers"] = {}
|
| 162 |
+
|
| 163 |
+
server_entry: dict[str, Any] = {
|
| 164 |
+
"command": "uvx",
|
| 165 |
+
"args": [
|
| 166 |
+
"--from",
|
| 167 |
+
"git+https://github.com/WilliamSamson/onshape-mcp",
|
| 168 |
+
"onshape-mcp",
|
| 169 |
+
],
|
| 170 |
+
}
|
| 171 |
+
if doc_url:
|
| 172 |
+
server_entry["env"] = {"ONSHAPE_DEFAULT_DOC": doc_url}
|
| 173 |
+
|
| 174 |
+
data["mcpServers"]["onshape"] = server_entry
|
| 175 |
+
|
| 176 |
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
| 177 |
+
config_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def detect_supported_clients() -> list[tuple[str, Path]]:
|
| 182 |
+
"""Detect all installed AI clients (Claude Desktop, Cursor, Windsurf, VS Code Cline)."""
|
| 183 |
+
clients: list[tuple[str, Path]] = []
|
| 184 |
+
|
| 185 |
+
# 1. Claude Desktop
|
| 186 |
+
claude_cfg = get_claude_desktop_config_path()
|
| 187 |
+
if claude_cfg.parent.exists() or claude_cfg.exists():
|
| 188 |
+
clients.append(("Claude Desktop", claude_cfg))
|
| 189 |
+
|
| 190 |
+
# 2. Cursor IDE
|
| 191 |
+
cursor_dir = Path.home() / ".cursor"
|
| 192 |
+
cursor_cfg = cursor_dir / "mcp.json"
|
| 193 |
+
if cursor_dir.exists() or cursor_cfg.exists():
|
| 194 |
+
clients.append(("Cursor IDE", cursor_cfg))
|
| 195 |
+
|
| 196 |
+
# 3. Windsurf
|
| 197 |
+
windsurf_dir = Path.home() / ".codeium" / "windsurf"
|
| 198 |
+
windsurf_cfg = windsurf_dir / "mcp_config.json"
|
| 199 |
+
if windsurf_dir.exists() or windsurf_cfg.exists():
|
| 200 |
+
clients.append(("Windsurf IDE", windsurf_cfg))
|
| 201 |
+
|
| 202 |
+
# 4. VS Code (Cline extension)
|
| 203 |
+
system = platform.system()
|
| 204 |
+
if system == "Darwin":
|
| 205 |
+
vscode_base = Path.home() / "Library" / "Application Support" / "Code"
|
| 206 |
+
elif system == "Windows":
|
| 207 |
+
app_data = os.environ.get("APPDATA")
|
| 208 |
+
vscode_base = (Path(app_data) if app_data else Path.home() / "AppData" / "Roaming") / "Code"
|
| 209 |
+
else:
|
| 210 |
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
| 211 |
+
vscode_base = (Path(xdg_config) if xdg_config else Path.home() / ".config") / "Code"
|
| 212 |
+
|
| 213 |
+
cline_cfg = vscode_base / "User" / "globalStorage" / "saoudrizwan.claude-dev" / "settings" / "cline_mcp_settings.json"
|
| 214 |
+
if cline_cfg.parent.exists() or cline_cfg.exists():
|
| 215 |
+
clients.append(("VS Code (Cline)", cline_cfg))
|
| 216 |
+
|
| 217 |
+
roo_cfg = vscode_base / "User" / "globalStorage" / "rooveterinaryinc.roo-cline" / "settings" / "cline_mcp_settings.json"
|
| 218 |
+
if roo_cfg.parent.exists() or roo_cfg.exists():
|
| 219 |
+
clients.append(("VS Code (Roo Code)", roo_cfg))
|
| 220 |
+
|
| 221 |
+
return clients
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def configure_detected_clients(
|
| 225 |
+
doc_url: str = "",
|
| 226 |
+
auto_yes: bool = False,
|
| 227 |
+
) -> list[str]:
|
| 228 |
+
"""Auto-configure all detected AI clients."""
|
| 229 |
+
print("[4/4] Configuring AI Client Integrations (Claude, Cursor, VS Code, etc.)...")
|
| 230 |
+
detected = detect_supported_clients()
|
| 231 |
+
configured: list[str] = []
|
| 232 |
+
|
| 233 |
+
if not detected:
|
| 234 |
+
print(" ℹ No desktop AI clients (Claude, Cursor, Windsurf, Cline) detected automatically.")
|
| 235 |
+
# Offer default Claude Desktop location
|
| 236 |
+
default_claude = get_claude_desktop_config_path()
|
| 237 |
+
choice = "y" if auto_yes else input(
|
| 238 |
+
f" [?] Create Claude Desktop config anyway at:\n {default_claude}? [Y/n]: "
|
| 239 |
+
).strip().lower()
|
| 240 |
+
if choice not in ("n", "no"):
|
| 241 |
+
_write_mcp_json(default_claude, doc_url)
|
| 242 |
+
configured.append(f"Claude Desktop ({default_claude})")
|
| 243 |
+
print(f" ✓ Configured Claude Desktop at: {default_claude}")
|
| 244 |
+
return configured
|
| 245 |
+
|
| 246 |
+
for name, path in detected:
|
| 247 |
+
if not auto_yes:
|
| 248 |
+
choice = input(f" [?] Auto-configure {name} at:\n {path}? [Y/n]: ").strip().lower()
|
| 249 |
+
if choice in ("n", "no"):
|
| 250 |
+
print(f" Skipped {name}.")
|
| 251 |
+
continue
|
| 252 |
+
_write_mcp_json(path, doc_url)
|
| 253 |
+
configured.append(f"{name} ({path})")
|
| 254 |
+
print(f" ✓ Configured {name} at: {path}")
|
| 255 |
+
|
| 256 |
+
return configured
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def run_setup(
|
| 260 |
+
doc_url: str = "",
|
| 261 |
+
auto_yes: bool = False,
|
| 262 |
+
skip_browser_install: bool = False,
|
| 263 |
+
) -> None:
|
| 264 |
+
print("=" * 68)
|
| 265 |
+
print(" Onshape MCP — 1-Click Multi-AI Setup & Tester Wizard")
|
| 266 |
+
print("=" * 68)
|
| 267 |
+
print(f"Base data directory: {_BASE_DIR}\n")
|
| 268 |
+
|
| 269 |
+
# 1. Playwright install
|
| 270 |
+
if not skip_browser_install:
|
| 271 |
+
ensure_playwright_browsers()
|
| 272 |
+
else:
|
| 273 |
+
print("[1/4] Skipping browser download flag.")
|
| 274 |
+
|
| 275 |
+
print()
|
| 276 |
+
|
| 277 |
+
# 2. Cookie sync
|
| 278 |
+
sync_onshape_cookies(interactive=not auto_yes)
|
| 279 |
+
print()
|
| 280 |
+
|
| 281 |
+
# 3. Document URL
|
| 282 |
+
print("[3/4] Document Configuration...")
|
| 283 |
+
final_doc_url = doc_url
|
| 284 |
+
if not final_doc_url and not auto_yes:
|
| 285 |
+
try:
|
| 286 |
+
val = input(
|
| 287 |
+
" [?] Default Onshape Document URL (optional, press Enter to skip):\n > "
|
| 288 |
+
).strip()
|
| 289 |
+
if val:
|
| 290 |
+
final_doc_url = val
|
| 291 |
+
except (EOFError, KeyboardInterrupt):
|
| 292 |
+
pass
|
| 293 |
+
|
| 294 |
+
if final_doc_url:
|
| 295 |
+
env_file = _BASE_DIR / ".env"
|
| 296 |
+
existing = env_file.read_text(encoding="utf-8") if env_file.exists() else ""
|
| 297 |
+
if "ONSHAPE_DEFAULT_DOC=" in existing:
|
| 298 |
+
import re
|
| 299 |
+
new_text = re.sub(r"ONSHAPE_DEFAULT_DOC=.*", f"ONSHAPE_DEFAULT_DOC={final_doc_url}", existing)
|
| 300 |
+
else:
|
| 301 |
+
new_text = existing + f"\nONSHAPE_DEFAULT_DOC={final_doc_url}\n"
|
| 302 |
+
env_file.write_text(new_text, encoding="utf-8")
|
| 303 |
+
print(f" ✓ Saved default document URL to {env_file}")
|
| 304 |
+
else:
|
| 305 |
+
print(" ✓ No default document set (you can specify URLs per prompt).")
|
| 306 |
+
|
| 307 |
+
print()
|
| 308 |
+
|
| 309 |
+
# 4. Client config
|
| 310 |
+
configured = configure_detected_clients(
|
| 311 |
+
doc_url=final_doc_url,
|
| 312 |
+
auto_yes=auto_yes,
|
| 313 |
+
)
|
| 314 |
+
print()
|
| 315 |
+
|
| 316 |
+
# Summary
|
| 317 |
+
print("=" * 68)
|
| 318 |
+
print("🎉 Setup Complete!")
|
| 319 |
+
print("=" * 68)
|
| 320 |
+
if configured:
|
| 321 |
+
print("Configured Desktop Clients:")
|
| 322 |
+
for c in configured:
|
| 323 |
+
print(f" • {c}")
|
| 324 |
+
print("\nRestart your AI client, then ask:")
|
| 325 |
+
print(" 'Draw a 10cm by 5cm box on the Top plane in Onshape'")
|
| 326 |
+
else:
|
| 327 |
+
print("Manual Desktop Config (Claude Desktop, Cursor, Windsurf):")
|
| 328 |
+
print(
|
| 329 |
+
json.dumps(
|
| 330 |
+
{
|
| 331 |
+
"mcpServers": {
|
| 332 |
+
"onshape": {
|
| 333 |
+
"command": "uvx",
|
| 334 |
+
"args": [
|
| 335 |
+
"--from",
|
| 336 |
+
"git+https://github.com/WilliamSamson/onshape-mcp",
|
| 337 |
+
"onshape-mcp",
|
| 338 |
+
],
|
| 339 |
+
}
|
| 340 |
+
}
|
| 341 |
+
},
|
| 342 |
+
indent=2,
|
| 343 |
+
)
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
print("-" * 68)
|
| 347 |
+
print("🌐 Using Web Versions Only? (ChatGPT Web, LibreChat, Open WebUI):")
|
| 348 |
+
print("1. Start the server in SSE mode:")
|
| 349 |
+
print(" onshape-mcp --transport sse --port 8000")
|
| 350 |
+
print("2. Expose it via a secure tunnel (e.g. Cloudflare or Localtunnel):")
|
| 351 |
+
print(" cloudflared tunnel --url http://localhost:8000")
|
| 352 |
+
print(" # Or: npx localtunnel --port 8000")
|
| 353 |
+
print("3. Paste the resulting URL (e.g. https://xxxx.trycloudflare.com/sse)")
|
| 354 |
+
print(" into your web AI connector / LibreChat / Open WebUI!")
|
| 355 |
+
print("=" * 68)
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
def main() -> None:
|
| 359 |
+
parser = argparse.ArgumentParser(description="Onshape MCP 1-Click Setup Wizard")
|
| 360 |
+
parser.add_argument("--yes", "-y", action="store_true", help="Auto-confirm all prompts")
|
| 361 |
+
parser.add_argument("--doc-url", default="", help="Default Onshape document URL to operate on")
|
| 362 |
+
parser.add_argument("--skip-browsers", action="store_true", help="Skip downloading Chromium")
|
| 363 |
+
args = parser.parse_args()
|
| 364 |
+
|
| 365 |
+
run_setup(
|
| 366 |
+
doc_url=args.doc_url,
|
| 367 |
+
auto_yes=args.yes,
|
| 368 |
+
skip_browser_install=args.skip_browsers,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
if __name__ == "__main__":
|
| 373 |
+
main()
|
src/onshape_mcp/shortcuts.py
ADDED
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onshape UI bindings. Keyboard shortcuts and toolbar button labels.
|
| 2 |
+
|
| 3 |
+
This is the bridge between the semantic tool vocabulary in tools.py and
|
| 4 |
+
the actual UI. Onshape changes this occasionally; when an action fails,
|
| 5 |
+
this is the first place to check.
|
| 6 |
+
|
| 7 |
+
Confidence flags: high (tested or well-documented), medium (plausible,
|
| 8 |
+
not yet verified), low (guess based on convention).
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class Binding:
|
| 18 |
+
keys: tuple[str, ...] | None = None
|
| 19 |
+
toolbar_text: str | None = None # visible label / aria-label
|
| 20 |
+
toolbar_role: str = "button"
|
| 21 |
+
notes: str = ""
|
| 22 |
+
confidence: str = "medium"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# View and camera
|
| 26 |
+
VIEW_FIT = Binding(keys=("f",), confidence="high", notes="Fit all; 'F' in viewport")
|
| 27 |
+
VIEW_TOP = Binding(keys=("7",), confidence="low")
|
| 28 |
+
VIEW_FRONT = Binding(keys=("1",), confidence="low")
|
| 29 |
+
VIEW_ISO = Binding(keys=("Shift", "7"), confidence="high", notes="Isometric view: Shift+7")
|
| 30 |
+
|
| 31 |
+
# Sketch start and exit
|
| 32 |
+
SKETCH_START = Binding(
|
| 33 |
+
toolbar_text="Sketch",
|
| 34 |
+
toolbar_role="button",
|
| 35 |
+
confidence="high",
|
| 36 |
+
notes="Click the 'Sketch' button in the left toolbar, then click a plane or face.",
|
| 37 |
+
)
|
| 38 |
+
SKETCH_EXIT = Binding(
|
| 39 |
+
keys=("Escape",),
|
| 40 |
+
confidence="high",
|
| 41 |
+
notes="Press Esc to exit the current sketch tool.",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# Sketch Primitives
|
| 45 |
+
SKETCH_LINE = Binding(
|
| 46 |
+
keys=("l",),
|
| 47 |
+
toolbar_text="Line",
|
| 48 |
+
toolbar_role="button",
|
| 49 |
+
confidence="high",
|
| 50 |
+
notes="Line tool shortcut 'l'. Click start, click end.",
|
| 51 |
+
)
|
| 52 |
+
SKETCH_LINE_MIDPOINT = Binding(
|
| 53 |
+
toolbar_text="Midpoint line",
|
| 54 |
+
toolbar_role="button",
|
| 55 |
+
confidence="medium",
|
| 56 |
+
notes="Line anchored at midpoint.",
|
| 57 |
+
)
|
| 58 |
+
SKETCH_RECTANGLE = Binding(
|
| 59 |
+
keys=("g",),
|
| 60 |
+
toolbar_text="Rectangle",
|
| 61 |
+
toolbar_role="button",
|
| 62 |
+
confidence="high",
|
| 63 |
+
notes="Corner rectangle shortcut 'g'.",
|
| 64 |
+
)
|
| 65 |
+
SKETCH_RECTANGLE_CENTER = Binding(
|
| 66 |
+
keys=("r",),
|
| 67 |
+
toolbar_text="Center point rectangle",
|
| 68 |
+
toolbar_role="button",
|
| 69 |
+
confidence="high",
|
| 70 |
+
notes="Center point rectangle shortcut 'r'.",
|
| 71 |
+
)
|
| 72 |
+
SKETCH_RECTANGLE_ALIGNED = Binding(
|
| 73 |
+
toolbar_text="Aligned rectangle",
|
| 74 |
+
toolbar_role="button",
|
| 75 |
+
confidence="medium",
|
| 76 |
+
notes="3-point aligned rectangle.",
|
| 77 |
+
)
|
| 78 |
+
SKETCH_CIRCLE = Binding(
|
| 79 |
+
keys=("c",),
|
| 80 |
+
toolbar_text="Circle",
|
| 81 |
+
toolbar_role="button",
|
| 82 |
+
confidence="high",
|
| 83 |
+
notes="Center point circle shortcut 'c'.",
|
| 84 |
+
)
|
| 85 |
+
SKETCH_CIRCLE_3POINT = Binding(
|
| 86 |
+
toolbar_text="3 point circle",
|
| 87 |
+
toolbar_role="button",
|
| 88 |
+
confidence="medium",
|
| 89 |
+
notes="Circle defined by 3 perimeter points.",
|
| 90 |
+
)
|
| 91 |
+
SKETCH_ELLIPSE = Binding(
|
| 92 |
+
toolbar_text="Ellipse",
|
| 93 |
+
toolbar_role="button",
|
| 94 |
+
confidence="medium",
|
| 95 |
+
notes="Center point and major/minor axes.",
|
| 96 |
+
)
|
| 97 |
+
SKETCH_ARC = Binding(
|
| 98 |
+
keys=("a",),
|
| 99 |
+
toolbar_text="Arc",
|
| 100 |
+
toolbar_role="button",
|
| 101 |
+
confidence="high",
|
| 102 |
+
notes="3-point arc shortcut 'a'. Click start, click end, click radius point.",
|
| 103 |
+
)
|
| 104 |
+
SKETCH_ARC_3POINT = SKETCH_ARC
|
| 105 |
+
SKETCH_ARC_TANGENT = Binding(
|
| 106 |
+
toolbar_text="Tangent arc",
|
| 107 |
+
toolbar_role="button",
|
| 108 |
+
confidence="medium",
|
| 109 |
+
notes="Arc tangent to an existing line or curve.",
|
| 110 |
+
)
|
| 111 |
+
SKETCH_ARC_CENTER = Binding(
|
| 112 |
+
toolbar_text="Center point arc",
|
| 113 |
+
toolbar_role="button",
|
| 114 |
+
confidence="medium",
|
| 115 |
+
notes="Center point, start angle, end angle.",
|
| 116 |
+
)
|
| 117 |
+
SKETCH_ARC_ELLIPTICAL = Binding(
|
| 118 |
+
toolbar_text="Elliptical arc",
|
| 119 |
+
toolbar_role="button",
|
| 120 |
+
confidence="medium",
|
| 121 |
+
)
|
| 122 |
+
SKETCH_CONIC = Binding(
|
| 123 |
+
toolbar_text="Conic",
|
| 124 |
+
toolbar_role="button",
|
| 125 |
+
confidence="medium",
|
| 126 |
+
)
|
| 127 |
+
SKETCH_POLYGON = Binding(
|
| 128 |
+
toolbar_text="Inscribed polygon",
|
| 129 |
+
toolbar_role="button",
|
| 130 |
+
confidence="high",
|
| 131 |
+
notes="Inscribed polygon. Click center, click radius, type side count.",
|
| 132 |
+
)
|
| 133 |
+
SKETCH_POLYGON_INSCRIBED = SKETCH_POLYGON
|
| 134 |
+
SKETCH_POLYGON_CIRCUMSCRIBED = Binding(
|
| 135 |
+
toolbar_text="Circumscribed polygon",
|
| 136 |
+
toolbar_role="button",
|
| 137 |
+
confidence="medium",
|
| 138 |
+
)
|
| 139 |
+
SKETCH_SPLINE = Binding(
|
| 140 |
+
toolbar_text="Spline",
|
| 141 |
+
toolbar_role="button",
|
| 142 |
+
confidence="medium",
|
| 143 |
+
notes="Spline through control points.",
|
| 144 |
+
)
|
| 145 |
+
SKETCH_BEZIER = Binding(
|
| 146 |
+
toolbar_text="Bezier",
|
| 147 |
+
toolbar_role="button",
|
| 148 |
+
confidence="medium",
|
| 149 |
+
)
|
| 150 |
+
SKETCH_POINT = Binding(
|
| 151 |
+
toolbar_text="Point",
|
| 152 |
+
toolbar_role="button",
|
| 153 |
+
confidence="high",
|
| 154 |
+
notes="Single sketch point.",
|
| 155 |
+
)
|
| 156 |
+
SKETCH_TEXT = Binding(
|
| 157 |
+
toolbar_text="Text",
|
| 158 |
+
toolbar_role="button",
|
| 159 |
+
confidence="high",
|
| 160 |
+
notes="Text in sketch. Drag bounding box, enter string.",
|
| 161 |
+
)
|
| 162 |
+
SKETCH_USE = Binding(
|
| 163 |
+
keys=("u",),
|
| 164 |
+
toolbar_text="Use",
|
| 165 |
+
toolbar_role="button",
|
| 166 |
+
confidence="high",
|
| 167 |
+
notes="Project existing geometry onto sketch plane ('u').",
|
| 168 |
+
)
|
| 169 |
+
SKETCH_INTERSECTION = Binding(
|
| 170 |
+
toolbar_text="Intersection",
|
| 171 |
+
toolbar_role="button",
|
| 172 |
+
confidence="medium",
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
# Modifications & Operations
|
| 176 |
+
SKETCH_CONSTRUCTION = Binding(
|
| 177 |
+
keys=("q",),
|
| 178 |
+
toolbar_text="Construction",
|
| 179 |
+
toolbar_role="button",
|
| 180 |
+
confidence="high",
|
| 181 |
+
notes="Toggle construction geometry on/off ('q').",
|
| 182 |
+
)
|
| 183 |
+
SKETCH_FILLET = Binding(
|
| 184 |
+
keys=("Shift", "f"),
|
| 185 |
+
toolbar_text="Fillet",
|
| 186 |
+
toolbar_role="button",
|
| 187 |
+
confidence="high",
|
| 188 |
+
notes="Sketch fillet between two lines or at a vertex ('Shift+f').",
|
| 189 |
+
)
|
| 190 |
+
SKETCH_CHAMFER = Binding(
|
| 191 |
+
toolbar_text="Chamfer",
|
| 192 |
+
toolbar_role="button",
|
| 193 |
+
confidence="medium",
|
| 194 |
+
notes="Sketch chamfer at corner.",
|
| 195 |
+
)
|
| 196 |
+
SKETCH_TRIM = Binding(
|
| 197 |
+
keys=("m",),
|
| 198 |
+
toolbar_text="Trim",
|
| 199 |
+
toolbar_role="button",
|
| 200 |
+
confidence="high",
|
| 201 |
+
notes="Trim curve to nearest intersection ('m').",
|
| 202 |
+
)
|
| 203 |
+
SKETCH_EXTEND = Binding(
|
| 204 |
+
keys=("x",),
|
| 205 |
+
toolbar_text="Extend",
|
| 206 |
+
toolbar_role="button",
|
| 207 |
+
confidence="high",
|
| 208 |
+
notes="Extend curve to boundary ('x').",
|
| 209 |
+
)
|
| 210 |
+
SKETCH_SPLIT = Binding(
|
| 211 |
+
toolbar_text="Split",
|
| 212 |
+
toolbar_role="button",
|
| 213 |
+
confidence="medium",
|
| 214 |
+
notes="Split curve at point.",
|
| 215 |
+
)
|
| 216 |
+
SKETCH_OFFSET = Binding(
|
| 217 |
+
keys=("o",),
|
| 218 |
+
toolbar_text="Offset",
|
| 219 |
+
toolbar_role="button",
|
| 220 |
+
confidence="high",
|
| 221 |
+
notes="Offset selected sketch curve ('o').",
|
| 222 |
+
)
|
| 223 |
+
SKETCH_SLOT = Binding(
|
| 224 |
+
toolbar_text="Slot",
|
| 225 |
+
toolbar_role="button",
|
| 226 |
+
confidence="medium",
|
| 227 |
+
)
|
| 228 |
+
SKETCH_MIRROR = Binding(
|
| 229 |
+
toolbar_text="Mirror",
|
| 230 |
+
toolbar_role="button",
|
| 231 |
+
confidence="high",
|
| 232 |
+
notes="Mirror entities across a centerline.",
|
| 233 |
+
)
|
| 234 |
+
SKETCH_PATTERN_LINEAR = Binding(
|
| 235 |
+
toolbar_text="Linear pattern",
|
| 236 |
+
toolbar_role="button",
|
| 237 |
+
confidence="medium",
|
| 238 |
+
)
|
| 239 |
+
SKETCH_PATTERN_CIRCULAR = Binding(
|
| 240 |
+
toolbar_text="Circular pattern",
|
| 241 |
+
toolbar_role="button",
|
| 242 |
+
confidence="medium",
|
| 243 |
+
)
|
| 244 |
+
SKETCH_TRANSFORM = Binding(
|
| 245 |
+
toolbar_text="Transform",
|
| 246 |
+
toolbar_role="button",
|
| 247 |
+
confidence="medium",
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
# Dimension & Constraints
|
| 251 |
+
SKETCH_DIMENSION = Binding(
|
| 252 |
+
keys=("d",),
|
| 253 |
+
toolbar_text="Dimension",
|
| 254 |
+
toolbar_role="button",
|
| 255 |
+
confidence="high",
|
| 256 |
+
notes="Drive linear/angular/radial dimension ('d').",
|
| 257 |
+
)
|
| 258 |
+
CONSTRAINT_COINCIDENT = Binding(
|
| 259 |
+
keys=("i",),
|
| 260 |
+
toolbar_text="Coincident",
|
| 261 |
+
toolbar_role="button",
|
| 262 |
+
confidence="high",
|
| 263 |
+
notes="Coincident constraint ('i').",
|
| 264 |
+
)
|
| 265 |
+
CONSTRAINT_CONCENTRIC = Binding(
|
| 266 |
+
toolbar_text="Concentric",
|
| 267 |
+
toolbar_role="button",
|
| 268 |
+
confidence="high",
|
| 269 |
+
notes="Concentric constraint between two circles/arcs.",
|
| 270 |
+
)
|
| 271 |
+
CONSTRAINT_PARALLEL = Binding(
|
| 272 |
+
keys=("b",),
|
| 273 |
+
toolbar_text="Parallel",
|
| 274 |
+
toolbar_role="button",
|
| 275 |
+
confidence="high",
|
| 276 |
+
notes="Parallel constraint between two lines ('b').",
|
| 277 |
+
)
|
| 278 |
+
CONSTRAINT_TANGENT = Binding(
|
| 279 |
+
keys=("t",),
|
| 280 |
+
toolbar_text="Tangent",
|
| 281 |
+
toolbar_role="button",
|
| 282 |
+
confidence="high",
|
| 283 |
+
notes="Tangent constraint ('t').",
|
| 284 |
+
)
|
| 285 |
+
CONSTRAINT_HORIZONTAL = Binding(
|
| 286 |
+
keys=("h",),
|
| 287 |
+
toolbar_text="Horizontal",
|
| 288 |
+
toolbar_role="button",
|
| 289 |
+
confidence="high",
|
| 290 |
+
notes="Horizontal constraint ('h').",
|
| 291 |
+
)
|
| 292 |
+
CONSTRAINT_VERTICAL = Binding(
|
| 293 |
+
keys=("v",),
|
| 294 |
+
toolbar_text="Vertical",
|
| 295 |
+
toolbar_role="button",
|
| 296 |
+
confidence="high",
|
| 297 |
+
notes="Vertical constraint ('v').",
|
| 298 |
+
)
|
| 299 |
+
CONSTRAINT_PERPENDICULAR = Binding(
|
| 300 |
+
toolbar_text="Perpendicular",
|
| 301 |
+
toolbar_role="button",
|
| 302 |
+
confidence="high",
|
| 303 |
+
notes="Perpendicular constraint between two lines.",
|
| 304 |
+
)
|
| 305 |
+
CONSTRAINT_EQUAL = Binding(
|
| 306 |
+
keys=("e",),
|
| 307 |
+
toolbar_text="Equal",
|
| 308 |
+
toolbar_role="button",
|
| 309 |
+
confidence="high",
|
| 310 |
+
notes="Equal length/radius constraint ('e').",
|
| 311 |
+
)
|
| 312 |
+
CONSTRAINT_MIDPOINT = Binding(
|
| 313 |
+
toolbar_text="Midpoint",
|
| 314 |
+
toolbar_role="button",
|
| 315 |
+
confidence="high",
|
| 316 |
+
notes="Midpoint constraint between point and line.",
|
| 317 |
+
)
|
| 318 |
+
CONSTRAINT_NORMAL = Binding(
|
| 319 |
+
toolbar_text="Normal",
|
| 320 |
+
toolbar_role="button",
|
| 321 |
+
confidence="medium",
|
| 322 |
+
notes="Normal constraint to plane or surface.",
|
| 323 |
+
)
|
| 324 |
+
CONSTRAINT_PIERCE = Binding(
|
| 325 |
+
toolbar_text="Pierce",
|
| 326 |
+
toolbar_role="button",
|
| 327 |
+
confidence="medium",
|
| 328 |
+
notes="Pierce constraint between point and curve intersecting plane.",
|
| 329 |
+
)
|
| 330 |
+
CONSTRAINT_SYMMETRIC = Binding(
|
| 331 |
+
toolbar_text="Symmetric",
|
| 332 |
+
toolbar_role="button",
|
| 333 |
+
confidence="medium",
|
| 334 |
+
notes="Symmetric constraint across a centerline.",
|
| 335 |
+
)
|
| 336 |
+
CONSTRAINT_FIX = Binding(
|
| 337 |
+
toolbar_text="Fix",
|
| 338 |
+
toolbar_role="button",
|
| 339 |
+
confidence="high",
|
| 340 |
+
notes="Fix entity in place.",
|
| 341 |
+
)
|
| 342 |
+
CONSTRAINT_CURVATURE = Binding(
|
| 343 |
+
toolbar_text="Curvature",
|
| 344 |
+
toolbar_role="button",
|
| 345 |
+
confidence="medium",
|
| 346 |
+
notes="Curvature continuous constraint.",
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
# Feature tools
|
| 350 |
+
FEATURE_EXTRUDE = Binding(
|
| 351 |
+
keys=("Shift", "e"),
|
| 352 |
+
toolbar_text="Extrude",
|
| 353 |
+
toolbar_role="button",
|
| 354 |
+
confidence="high",
|
| 355 |
+
notes="With a closed sketch region selected, press Shift+E or click Extrude.",
|
| 356 |
+
)
|
| 357 |
+
FEATURE_REVOLVE = Binding(
|
| 358 |
+
keys=("r",),
|
| 359 |
+
toolbar_text="Revolve",
|
| 360 |
+
toolbar_role="button",
|
| 361 |
+
confidence="medium",
|
| 362 |
+
)
|
| 363 |
+
FEATURE_FILLET = Binding(
|
| 364 |
+
keys=("f",),
|
| 365 |
+
toolbar_text="Fillet",
|
| 366 |
+
toolbar_role="button",
|
| 367 |
+
confidence="medium",
|
| 368 |
+
notes="'F' collides with view.fit when nothing is selected. Prefer toolbar click.",
|
| 369 |
+
)
|
| 370 |
+
FEATURE_CHAMFER = Binding(toolbar_text="Chamfer", toolbar_role="button", confidence="high")
|
| 371 |
+
FEATURE_SHELL = Binding(toolbar_text="Shell", toolbar_role="button", confidence="high")
|
| 372 |
+
FEATURE_PATTERN = Binding(toolbar_text="Pattern", toolbar_role="button", confidence="medium")
|
| 373 |
+
FEATURE_MIRROR_BODY = Binding(toolbar_text="Mirror", toolbar_role="button", confidence="medium")
|
| 374 |
+
|
| 375 |
+
# Assembly and meta
|
| 376 |
+
ASSEMBLY_MATE = Binding(toolbar_text="Mate", toolbar_role="button", confidence="high")
|
| 377 |
+
ASSEMBLY_PATTERN = Binding(toolbar_text="Pattern", toolbar_role="button", confidence="medium")
|
| 378 |
+
|
| 379 |
+
# Global bindings
|
| 380 |
+
UNDO = Binding(keys=("Control", "z"), confidence="high")
|
| 381 |
+
REDO = Binding(keys=("Control", "shift", "z"), confidence="high")
|
| 382 |
+
SAVE = Binding(
|
| 383 |
+
keys=("Control", "s"),
|
| 384 |
+
confidence="high",
|
| 385 |
+
notes="Onshape auto-saves; this is a no-op but the shortcut still works.",
|
| 386 |
+
)
|
| 387 |
+
DESELECT = Binding(keys=("Escape",), confidence="high")
|
| 388 |
+
CONFIRM = Binding(keys=("Enter",), confidence="high")
|
| 389 |
+
DELETE = Binding(keys=("Delete",), confidence="high")
|
| 390 |
+
|
| 391 |
+
# Map datasheet tool name -> binding.
|
| 392 |
+
BINDINGS: dict[str, Binding] = {
|
| 393 |
+
"view.fit": VIEW_FIT,
|
| 394 |
+
"view.top": VIEW_TOP,
|
| 395 |
+
"view.front": VIEW_FRONT,
|
| 396 |
+
"view.iso": VIEW_ISO,
|
| 397 |
+
"view.isometric": VIEW_ISO,
|
| 398 |
+
"view.rotate": Binding(
|
| 399 |
+
notes="Middle-mouse drag (no fixed coords). The LLM passes start/end via the call.",
|
| 400 |
+
confidence="high",
|
| 401 |
+
),
|
| 402 |
+
# Sketch lifecycle
|
| 403 |
+
"sketch.start": SKETCH_START,
|
| 404 |
+
"sketch.exit": SKETCH_EXIT,
|
| 405 |
+
# Sketch Primitives
|
| 406 |
+
"sketch.line": SKETCH_LINE,
|
| 407 |
+
"sketch.line_midpoint": SKETCH_LINE_MIDPOINT,
|
| 408 |
+
"sketch.rectangle": SKETCH_RECTANGLE,
|
| 409 |
+
"sketch.rectangle_center": SKETCH_RECTANGLE_CENTER,
|
| 410 |
+
"sketch.rectangle_aligned": SKETCH_RECTANGLE_ALIGNED,
|
| 411 |
+
"sketch.circle": SKETCH_CIRCLE,
|
| 412 |
+
"sketch.circle_3point": SKETCH_CIRCLE_3POINT,
|
| 413 |
+
"sketch.ellipse": SKETCH_ELLIPSE,
|
| 414 |
+
"sketch.arc": SKETCH_ARC,
|
| 415 |
+
"sketch.arc_3point": SKETCH_ARC_3POINT,
|
| 416 |
+
"sketch.arc_tangent": SKETCH_ARC_TANGENT,
|
| 417 |
+
"sketch.arc_center": SKETCH_ARC_CENTER,
|
| 418 |
+
"sketch.arc_elliptical": SKETCH_ARC_ELLIPTICAL,
|
| 419 |
+
"sketch.conic": SKETCH_CONIC,
|
| 420 |
+
"sketch.polygon": SKETCH_POLYGON,
|
| 421 |
+
"sketch.polygon_inscribed": SKETCH_POLYGON_INSCRIBED,
|
| 422 |
+
"sketch.polygon_circumscribed": SKETCH_POLYGON_CIRCUMSCRIBED,
|
| 423 |
+
"sketch.spline": SKETCH_SPLINE,
|
| 424 |
+
"sketch.bezier": SKETCH_BEZIER,
|
| 425 |
+
"sketch.point": SKETCH_POINT,
|
| 426 |
+
"sketch.text": SKETCH_TEXT,
|
| 427 |
+
"sketch.use": SKETCH_USE,
|
| 428 |
+
"sketch.intersection": SKETCH_INTERSECTION,
|
| 429 |
+
# Operations
|
| 430 |
+
"sketch.construction": SKETCH_CONSTRUCTION,
|
| 431 |
+
"sketch.fillet": SKETCH_FILLET,
|
| 432 |
+
"sketch.chamfer": SKETCH_CHAMFER,
|
| 433 |
+
"sketch.trim": SKETCH_TRIM,
|
| 434 |
+
"sketch.extend": SKETCH_EXTEND,
|
| 435 |
+
"sketch.split": SKETCH_SPLIT,
|
| 436 |
+
"sketch.offset": SKETCH_OFFSET,
|
| 437 |
+
"sketch.slot": SKETCH_SLOT,
|
| 438 |
+
"sketch.mirror": SKETCH_MIRROR,
|
| 439 |
+
"sketch.pattern_linear": SKETCH_PATTERN_LINEAR,
|
| 440 |
+
"sketch.pattern_circular": SKETCH_PATTERN_CIRCULAR,
|
| 441 |
+
"sketch.transform": SKETCH_TRANSFORM,
|
| 442 |
+
# Dimensions & Constraints
|
| 443 |
+
"sketch.dimension": SKETCH_DIMENSION,
|
| 444 |
+
"sketch.equal": CONSTRAINT_EQUAL,
|
| 445 |
+
"sketch.constrain": CONSTRAINT_COINCIDENT,
|
| 446 |
+
"constraint.coincident": CONSTRAINT_COINCIDENT,
|
| 447 |
+
"constraint.concentric": CONSTRAINT_CONCENTRIC,
|
| 448 |
+
"constraint.parallel": CONSTRAINT_PARALLEL,
|
| 449 |
+
"constraint.tangent": CONSTRAINT_TANGENT,
|
| 450 |
+
"constraint.horizontal": CONSTRAINT_HORIZONTAL,
|
| 451 |
+
"constraint.vertical": CONSTRAINT_VERTICAL,
|
| 452 |
+
"constraint.perpendicular": CONSTRAINT_PERPENDICULAR,
|
| 453 |
+
"constraint.equal": CONSTRAINT_EQUAL,
|
| 454 |
+
"constraint.midpoint": CONSTRAINT_MIDPOINT,
|
| 455 |
+
"constraint.normal": CONSTRAINT_NORMAL,
|
| 456 |
+
"constraint.pierce": CONSTRAINT_PIERCE,
|
| 457 |
+
"constraint.symmetric": CONSTRAINT_SYMMETRIC,
|
| 458 |
+
"constraint.fix": CONSTRAINT_FIX,
|
| 459 |
+
"constraint.curvature": CONSTRAINT_CURVATURE,
|
| 460 |
+
# Features
|
| 461 |
+
"feature.extrude": FEATURE_EXTRUDE,
|
| 462 |
+
"feature.revolve": FEATURE_REVOLVE,
|
| 463 |
+
"feature.fillet": FEATURE_FILLET,
|
| 464 |
+
"feature.chamfer": FEATURE_CHAMFER,
|
| 465 |
+
"feature.shell": FEATURE_SHELL,
|
| 466 |
+
"feature.pattern": FEATURE_PATTERN,
|
| 467 |
+
"feature.mirror_body": FEATURE_MIRROR_BODY,
|
| 468 |
+
"assembly.mate": ASSEMBLY_MATE,
|
| 469 |
+
"assembly.pattern": ASSEMBLY_PATTERN,
|
| 470 |
+
"select.face": Binding(
|
| 471 |
+
notes="Click in viewport at (x,y); ui_actions handles the click.", confidence="high"
|
| 472 |
+
),
|
| 473 |
+
"select.edge": Binding(notes="Click in viewport at (x,y).", confidence="high"),
|
| 474 |
+
"select.body": Binding(notes="Click in viewport or in the feature tree.", confidence="high"),
|
| 475 |
+
"ui.undo": UNDO,
|
| 476 |
+
"ui.redo": REDO,
|
| 477 |
+
"ui.wait": Binding(
|
| 478 |
+
notes="No-op. Pauses for N seconds and screenshots. Use when the LLM wants to re-observe the viewport.",
|
| 479 |
+
confidence="high",
|
| 480 |
+
),
|
| 481 |
+
"doc.save": SAVE,
|
| 482 |
+
"doc.new": Binding(
|
| 483 |
+
toolbar_text="Create",
|
| 484 |
+
toolbar_role="button",
|
| 485 |
+
notes="Create a new document. Or just navigate to /documents/new.",
|
| 486 |
+
confidence="medium",
|
| 487 |
+
),
|
| 488 |
+
"ui.deselect": DESELECT,
|
| 489 |
+
"ui.confirm": CONFIRM,
|
| 490 |
+
"ui.delete": DELETE,
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
|
| 494 |
+
def get(tool_name: str) -> Binding:
|
| 495 |
+
try:
|
| 496 |
+
return BINDINGS[tool_name]
|
| 497 |
+
except KeyError as e:
|
| 498 |
+
raise KeyError(f"No UI binding for {tool_name!r} in shortcuts.py") from e
|
src/onshape_mcp/tools.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Onshape tool datasheet. One entry per semantic op the agent can call.
|
| 2 |
+
The actual clicks live in ui_actions.py / driver.py. This file is the
|
| 3 |
+
vocabulary the LLM sees via the `tool_datasheet` MCP tool.
|
| 4 |
+
|
| 5 |
+
Status flags: planned, stub, working.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class ToolSpec:
|
| 15 |
+
name: str
|
| 16 |
+
purpose: str
|
| 17 |
+
requires: list[str] = field(default_factory=list) # preconditions
|
| 18 |
+
produces: list[str] = field(default_factory=list) # postconditions
|
| 19 |
+
next_steps: list[str] = field(default_factory=list) # likely follow-ups
|
| 20 |
+
status: str = "working"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# Sketch lifecycle
|
| 24 |
+
SKETCH_START = ToolSpec(
|
| 25 |
+
name="sketch.start",
|
| 26 |
+
purpose="Open a new sketch on a chosen plane (Top/Front/Right or a face).",
|
| 27 |
+
produces=["sketch.active=true"],
|
| 28 |
+
next_steps=["sketch.rectangle", "sketch.circle", "sketch.line", "sketch.spline"],
|
| 29 |
+
status="working",
|
| 30 |
+
)
|
| 31 |
+
SKETCH_EXIT = ToolSpec(
|
| 32 |
+
name="sketch.exit",
|
| 33 |
+
purpose="Close the active sketch and return to part studio.",
|
| 34 |
+
requires=["sketch.active=true"],
|
| 35 |
+
produces=["sketch.active=false"],
|
| 36 |
+
next_steps=["feature.extrude", "feature.revolve", "feature.fillet", "feature.chamfer"],
|
| 37 |
+
status="working",
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Sketch Primitives
|
| 41 |
+
SKETCH_LINE = ToolSpec(
|
| 42 |
+
name="sketch.line",
|
| 43 |
+
purpose="Single line segment from p1=(x1,y1) to p2=(x2,y2).",
|
| 44 |
+
requires=["sketch.active=true"],
|
| 45 |
+
next_steps=["sketch.dimension", "sketch.constrain", "sketch.exit"],
|
| 46 |
+
status="working",
|
| 47 |
+
)
|
| 48 |
+
SKETCH_LINE_MIDPOINT = ToolSpec(
|
| 49 |
+
name="sketch.line_midpoint",
|
| 50 |
+
purpose="Line segment symmetric around a midpoint coordinate.",
|
| 51 |
+
requires=["sketch.active=true"],
|
| 52 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 53 |
+
status="working",
|
| 54 |
+
)
|
| 55 |
+
SKETCH_RECTANGLE = ToolSpec(
|
| 56 |
+
name="sketch.rectangle",
|
| 57 |
+
purpose="Two-corner rectangle. Pass quadrant='1'|'2'|'3'|'4', centered=true, or corner1=(x,y), corner2=(x,y), with width/height (e.g. '10 cm' or 50).",
|
| 58 |
+
requires=["sketch.active=true"],
|
| 59 |
+
next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
|
| 60 |
+
status="working",
|
| 61 |
+
)
|
| 62 |
+
SKETCH_RECTANGLE_CENTER = ToolSpec(
|
| 63 |
+
name="sketch.rectangle_center",
|
| 64 |
+
purpose="Center-point rectangle defined by center=(x,y) and corner=(x,y) or width/height.",
|
| 65 |
+
requires=["sketch.active=true"],
|
| 66 |
+
next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
|
| 67 |
+
status="working",
|
| 68 |
+
)
|
| 69 |
+
SKETCH_RECTANGLE_ALIGNED = ToolSpec(
|
| 70 |
+
name="sketch.rectangle_aligned",
|
| 71 |
+
purpose="3-point aligned rectangle defined by baseline (p1, p2) and height point p3.",
|
| 72 |
+
requires=["sketch.active=true"],
|
| 73 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 74 |
+
status="working",
|
| 75 |
+
)
|
| 76 |
+
SKETCH_CIRCLE = ToolSpec(
|
| 77 |
+
name="sketch.circle",
|
| 78 |
+
purpose="Center point circle. Pass centered=true (for origin) or center=(x,y), radius=mm or radius_px.",
|
| 79 |
+
requires=["sketch.active=true"],
|
| 80 |
+
next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
|
| 81 |
+
status="working",
|
| 82 |
+
)
|
| 83 |
+
SKETCH_CIRCLE_3POINT = ToolSpec(
|
| 84 |
+
name="sketch.circle_3point",
|
| 85 |
+
purpose="Circle passing through three points p1, p2, p3.",
|
| 86 |
+
requires=["sketch.active=true"],
|
| 87 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 88 |
+
status="working",
|
| 89 |
+
)
|
| 90 |
+
SKETCH_ELLIPSE = ToolSpec(
|
| 91 |
+
name="sketch.ellipse",
|
| 92 |
+
purpose="Ellipse defined by center=(x,y), major axis point, and minor axis point.",
|
| 93 |
+
requires=["sketch.active=true"],
|
| 94 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 95 |
+
status="working",
|
| 96 |
+
)
|
| 97 |
+
SKETCH_ARC = ToolSpec(
|
| 98 |
+
name="sketch.arc",
|
| 99 |
+
purpose="3-point arc from p1 to p2 passing through radius/point p3.",
|
| 100 |
+
requires=["sketch.active=true"],
|
| 101 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 102 |
+
status="working",
|
| 103 |
+
)
|
| 104 |
+
SKETCH_ARC_TANGENT = ToolSpec(
|
| 105 |
+
name="sketch.arc_tangent",
|
| 106 |
+
purpose="Arc tangent to an existing line/curve starting at vertex p1 ending at p2.",
|
| 107 |
+
requires=["sketch.active=true"],
|
| 108 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 109 |
+
status="working",
|
| 110 |
+
)
|
| 111 |
+
SKETCH_ARC_CENTER = ToolSpec(
|
| 112 |
+
name="sketch.arc_center",
|
| 113 |
+
purpose="Center point arc defined by center=(x,y), start angle/point p1, end angle/point p2.",
|
| 114 |
+
requires=["sketch.active=true"],
|
| 115 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 116 |
+
status="working",
|
| 117 |
+
)
|
| 118 |
+
SKETCH_POLYGON = ToolSpec(
|
| 119 |
+
name="sketch.polygon",
|
| 120 |
+
purpose="Inscribed or circumscribed polygon with center=(x,y), radius=r, and sides=n (e.g. 6 for hexagon).",
|
| 121 |
+
requires=["sketch.active=true"],
|
| 122 |
+
next_steps=["sketch.dimension", "feature.extrude", "sketch.exit"],
|
| 123 |
+
status="working",
|
| 124 |
+
)
|
| 125 |
+
SKETCH_SPLINE = ToolSpec(
|
| 126 |
+
name="sketch.spline",
|
| 127 |
+
purpose="Spline through control points [(x1,y1), (x2,y2), ...].",
|
| 128 |
+
requires=["sketch.active=true"],
|
| 129 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 130 |
+
status="working",
|
| 131 |
+
)
|
| 132 |
+
SKETCH_POINT = ToolSpec(
|
| 133 |
+
name="sketch.point",
|
| 134 |
+
purpose="Single point at (x,y).",
|
| 135 |
+
requires=["sketch.active=true"],
|
| 136 |
+
next_steps=["sketch.constrain", "sketch.exit"],
|
| 137 |
+
status="working",
|
| 138 |
+
)
|
| 139 |
+
SKETCH_TEXT = ToolSpec(
|
| 140 |
+
name="sketch.text",
|
| 141 |
+
purpose="Text box defined by corner1=(x,y), corner2=(x,y), and text string.",
|
| 142 |
+
requires=["sketch.active=true"],
|
| 143 |
+
next_steps=["feature.extrude", "sketch.exit"],
|
| 144 |
+
status="working",
|
| 145 |
+
)
|
| 146 |
+
SKETCH_USE = ToolSpec(
|
| 147 |
+
name="sketch.use",
|
| 148 |
+
purpose="Project 3D edge or face onto active sketch plane.",
|
| 149 |
+
requires=["sketch.active=true"],
|
| 150 |
+
next_steps=["sketch.offset", "sketch.trim", "sketch.exit"],
|
| 151 |
+
status="working",
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# Modifications & Operations
|
| 155 |
+
SKETCH_CONSTRUCTION = ToolSpec(
|
| 156 |
+
name="sketch.construction",
|
| 157 |
+
purpose="Toggle construction mode or convert entity at (x,y) to/from construction line ('q').",
|
| 158 |
+
requires=["sketch.active=true"],
|
| 159 |
+
next_steps=["sketch.line", "sketch.circle", "sketch.exit"],
|
| 160 |
+
status="working",
|
| 161 |
+
)
|
| 162 |
+
SKETCH_FILLET = ToolSpec(
|
| 163 |
+
name="sketch.fillet",
|
| 164 |
+
purpose="Create a 2D rounded fillet at vertex (x,y) or between two curves with radius_mm.",
|
| 165 |
+
requires=["sketch.active=true"],
|
| 166 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 167 |
+
status="working",
|
| 168 |
+
)
|
| 169 |
+
SKETCH_CHAMFER = ToolSpec(
|
| 170 |
+
name="sketch.chamfer",
|
| 171 |
+
purpose="Create a 2D chamfer at vertex (x,y) with distance_mm.",
|
| 172 |
+
requires=["sketch.active=true"],
|
| 173 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 174 |
+
status="working",
|
| 175 |
+
)
|
| 176 |
+
SKETCH_TRIM = ToolSpec(
|
| 177 |
+
name="sketch.trim",
|
| 178 |
+
purpose="Trim a curve segment at (x,y) back to the nearest intersections ('m').",
|
| 179 |
+
requires=["sketch.active=true"],
|
| 180 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 181 |
+
status="working",
|
| 182 |
+
)
|
| 183 |
+
SKETCH_EXTEND = ToolSpec(
|
| 184 |
+
name="sketch.extend",
|
| 185 |
+
purpose="Extend an endpoint at (x,y) forward to the next curve boundary ('x').",
|
| 186 |
+
requires=["sketch.active=true"],
|
| 187 |
+
next_steps=["sketch.trim", "sketch.exit"],
|
| 188 |
+
status="working",
|
| 189 |
+
)
|
| 190 |
+
SKETCH_SPLIT = ToolSpec(
|
| 191 |
+
name="sketch.split",
|
| 192 |
+
purpose="Split curve into two segments at coordinate (x,y).",
|
| 193 |
+
requires=["sketch.active=true"],
|
| 194 |
+
next_steps=["sketch.exit"],
|
| 195 |
+
status="working",
|
| 196 |
+
)
|
| 197 |
+
SKETCH_OFFSET = ToolSpec(
|
| 198 |
+
name="sketch.offset",
|
| 199 |
+
purpose="Offset entity at (x,y) by distance_mm to target side.",
|
| 200 |
+
requires=["sketch.active=true"],
|
| 201 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 202 |
+
status="working",
|
| 203 |
+
)
|
| 204 |
+
SKETCH_SLOT = ToolSpec(
|
| 205 |
+
name="sketch.slot",
|
| 206 |
+
purpose="Create an elongated slot along a line segment with a given width/radius.",
|
| 207 |
+
requires=["sketch.active=true"],
|
| 208 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 209 |
+
status="working",
|
| 210 |
+
)
|
| 211 |
+
SKETCH_MIRROR = ToolSpec(
|
| 212 |
+
name="sketch.mirror",
|
| 213 |
+
purpose="Mirror entities across a centerline (line_xy, entities=[(x,y), ...]).",
|
| 214 |
+
requires=["sketch.active=true"],
|
| 215 |
+
next_steps=["sketch.exit", "feature.extrude"],
|
| 216 |
+
status="working",
|
| 217 |
+
)
|
| 218 |
+
SKETCH_PATTERN_LINEAR = ToolSpec(
|
| 219 |
+
name="sketch.pattern_linear",
|
| 220 |
+
purpose="Linear pattern of entities along X and Y axes with counts and spacings.",
|
| 221 |
+
requires=["sketch.active=true"],
|
| 222 |
+
next_steps=["sketch.exit"],
|
| 223 |
+
status="working",
|
| 224 |
+
)
|
| 225 |
+
SKETCH_PATTERN_CIRCULAR = ToolSpec(
|
| 226 |
+
name="sketch.pattern_circular",
|
| 227 |
+
purpose="Circular pattern of entities around center=(x,y) with count and angle.",
|
| 228 |
+
requires=["sketch.active=true"],
|
| 229 |
+
next_steps=["sketch.exit"],
|
| 230 |
+
status="working",
|
| 231 |
+
)
|
| 232 |
+
SKETCH_TRANSFORM = ToolSpec(
|
| 233 |
+
name="sketch.transform",
|
| 234 |
+
purpose="Translate, scale, or rotate selected entities.",
|
| 235 |
+
requires=["sketch.active=true"],
|
| 236 |
+
next_steps=["sketch.exit"],
|
| 237 |
+
status="working",
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Dimensions & Constraints
|
| 241 |
+
SKETCH_DIMENSION = ToolSpec(
|
| 242 |
+
name="sketch.dimension",
|
| 243 |
+
purpose="Apply a driven linear, radial, or angular dimension (entity_xy, label_xy, value).",
|
| 244 |
+
requires=["sketch.active=true"],
|
| 245 |
+
next_steps=["sketch.exit", "feature.extrude"],
|
| 246 |
+
status="working",
|
| 247 |
+
)
|
| 248 |
+
CONSTRAINT_COINCIDENT = ToolSpec(
|
| 249 |
+
name="constraint.coincident",
|
| 250 |
+
purpose="Make two points coincident, or point coincident to a curve.",
|
| 251 |
+
requires=["sketch.active=true"],
|
| 252 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 253 |
+
status="working",
|
| 254 |
+
)
|
| 255 |
+
CONSTRAINT_CONCENTRIC = ToolSpec(
|
| 256 |
+
name="constraint.concentric",
|
| 257 |
+
purpose="Make two arcs or circles share the same center point.",
|
| 258 |
+
requires=["sketch.active=true"],
|
| 259 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 260 |
+
status="working",
|
| 261 |
+
)
|
| 262 |
+
CONSTRAINT_PARALLEL = ToolSpec(
|
| 263 |
+
name="constraint.parallel",
|
| 264 |
+
purpose="Make two lines parallel ('b').",
|
| 265 |
+
requires=["sketch.active=true"],
|
| 266 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 267 |
+
status="working",
|
| 268 |
+
)
|
| 269 |
+
CONSTRAINT_TANGENT = ToolSpec(
|
| 270 |
+
name="constraint.tangent",
|
| 271 |
+
purpose="Make an arc/circle tangent to a line or another arc ('t').",
|
| 272 |
+
requires=["sketch.active=true"],
|
| 273 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 274 |
+
status="working",
|
| 275 |
+
)
|
| 276 |
+
CONSTRAINT_HORIZONTAL = ToolSpec(
|
| 277 |
+
name="constraint.horizontal",
|
| 278 |
+
purpose="Make a line horizontal or align two points horizontally ('h').",
|
| 279 |
+
requires=["sketch.active=true"],
|
| 280 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 281 |
+
status="working",
|
| 282 |
+
)
|
| 283 |
+
CONSTRAINT_VERTICAL = ToolSpec(
|
| 284 |
+
name="constraint.vertical",
|
| 285 |
+
purpose="Make a line vertical or align two points vertically ('v').",
|
| 286 |
+
requires=["sketch.active=true"],
|
| 287 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 288 |
+
status="working",
|
| 289 |
+
)
|
| 290 |
+
CONSTRAINT_PERPENDICULAR = ToolSpec(
|
| 291 |
+
name="constraint.perpendicular",
|
| 292 |
+
purpose="Make two lines perpendicular (90 degrees).",
|
| 293 |
+
requires=["sketch.active=true"],
|
| 294 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 295 |
+
status="working",
|
| 296 |
+
)
|
| 297 |
+
CONSTRAINT_EQUAL = ToolSpec(
|
| 298 |
+
name="constraint.equal",
|
| 299 |
+
purpose="Constrain two lines to equal length, or two circles/arcs to equal radius ('e').",
|
| 300 |
+
requires=["sketch.active=true"],
|
| 301 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 302 |
+
status="working",
|
| 303 |
+
)
|
| 304 |
+
CONSTRAINT_MIDPOINT = ToolSpec(
|
| 305 |
+
name="constraint.midpoint",
|
| 306 |
+
purpose="Constrain a point to the midpoint of a line segment.",
|
| 307 |
+
requires=["sketch.active=true"],
|
| 308 |
+
next_steps=["sketch.dimension", "sketch.exit"],
|
| 309 |
+
status="working",
|
| 310 |
+
)
|
| 311 |
+
CONSTRAINT_NORMAL = ToolSpec(
|
| 312 |
+
name="constraint.normal",
|
| 313 |
+
purpose="Constrain a curve normal to a plane.",
|
| 314 |
+
requires=["sketch.active=true"],
|
| 315 |
+
next_steps=["sketch.exit"],
|
| 316 |
+
status="working",
|
| 317 |
+
)
|
| 318 |
+
CONSTRAINT_PIERCE = ToolSpec(
|
| 319 |
+
name="constraint.pierce",
|
| 320 |
+
purpose="Constrain a point to the pierce point of an intersecting 3D curve.",
|
| 321 |
+
requires=["sketch.active=true"],
|
| 322 |
+
next_steps=["sketch.exit"],
|
| 323 |
+
status="working",
|
| 324 |
+
)
|
| 325 |
+
CONSTRAINT_SYMMETRIC = ToolSpec(
|
| 326 |
+
name="constraint.symmetric",
|
| 327 |
+
purpose="Constrain two entities symmetrically across a centerline.",
|
| 328 |
+
requires=["sketch.active=true"],
|
| 329 |
+
next_steps=["sketch.exit"],
|
| 330 |
+
status="working",
|
| 331 |
+
)
|
| 332 |
+
CONSTRAINT_FIX = ToolSpec(
|
| 333 |
+
name="constraint.fix",
|
| 334 |
+
purpose="Fix an entity in place preventing solver movement.",
|
| 335 |
+
requires=["sketch.active=true"],
|
| 336 |
+
next_steps=["sketch.exit"],
|
| 337 |
+
status="working",
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
# Feature tools
|
| 341 |
+
FEATURE_EXTRUDE = ToolSpec(
|
| 342 |
+
name="feature.extrude",
|
| 343 |
+
purpose="Extrude the active sketch region by depth, optionally with draft/taper.",
|
| 344 |
+
requires=["sketch.active=false"],
|
| 345 |
+
next_steps=["feature.fillet", "feature.chamfer", "feature.shell", "feature.pattern"],
|
| 346 |
+
status="working",
|
| 347 |
+
)
|
| 348 |
+
FEATURE_REVOLVE = ToolSpec(
|
| 349 |
+
name="feature.revolve",
|
| 350 |
+
purpose="Revolve a sketch region around an axis by an angle.",
|
| 351 |
+
requires=["sketch.active=false"],
|
| 352 |
+
next_steps=["feature.fillet", "feature.chamfer"],
|
| 353 |
+
status="working",
|
| 354 |
+
)
|
| 355 |
+
FEATURE_FILLET = ToolSpec(
|
| 356 |
+
name="feature.fillet",
|
| 357 |
+
purpose="Round selected 3D edges with a constant radius.",
|
| 358 |
+
next_steps=["feature.chamfer", "feature.shell", "feature.pattern"],
|
| 359 |
+
status="working",
|
| 360 |
+
)
|
| 361 |
+
FEATURE_CHAMFER = ToolSpec(
|
| 362 |
+
name="feature.chamfer",
|
| 363 |
+
purpose="Bevel selected 3D edges with a distance.",
|
| 364 |
+
next_steps=["feature.fillet", "feature.shell", "feature.pattern"],
|
| 365 |
+
status="working",
|
| 366 |
+
)
|
| 367 |
+
FEATURE_SHELL = ToolSpec(
|
| 368 |
+
name="feature.shell",
|
| 369 |
+
purpose="Hollow a body, removing selected faces, with a wall thickness.",
|
| 370 |
+
next_steps=["feature.fillet", "feature.pattern"],
|
| 371 |
+
status="working",
|
| 372 |
+
)
|
| 373 |
+
FEATURE_PATTERN = ToolSpec(
|
| 374 |
+
name="feature.pattern",
|
| 375 |
+
purpose="Linear or circular pattern of features / faces / bodies.",
|
| 376 |
+
next_steps=["feature.mirror_body", "assembly.mate"],
|
| 377 |
+
status="working",
|
| 378 |
+
)
|
| 379 |
+
FEATURE_MIRROR_BODY = ToolSpec(
|
| 380 |
+
name="feature.mirror_body",
|
| 381 |
+
purpose="Mirror bodies across a plane in the part studio.",
|
| 382 |
+
next_steps=["feature.pattern", "assembly.mate"],
|
| 383 |
+
status="working",
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
# Selection and view
|
| 387 |
+
SELECT_FACE = ToolSpec(
|
| 388 |
+
name="select.face",
|
| 389 |
+
purpose="Click a face in the viewport at (x,y).",
|
| 390 |
+
next_steps=["sketch.start", "feature.extrude", "feature.fillet"],
|
| 391 |
+
status="working",
|
| 392 |
+
)
|
| 393 |
+
SELECT_EDGE = ToolSpec(
|
| 394 |
+
name="select.edge",
|
| 395 |
+
purpose="Click an edge in the viewport at (x,y).",
|
| 396 |
+
next_steps=["feature.fillet", "feature.chamfer"],
|
| 397 |
+
status="working",
|
| 398 |
+
)
|
| 399 |
+
SELECT_BODY = ToolSpec(
|
| 400 |
+
name="select.body",
|
| 401 |
+
purpose="Click a body in the viewport or feature tree.",
|
| 402 |
+
next_steps=["feature.mirror_body"],
|
| 403 |
+
status="working",
|
| 404 |
+
)
|
| 405 |
+
VIEW_FIT = ToolSpec(
|
| 406 |
+
name="view.fit",
|
| 407 |
+
purpose="Zoom to fit all visible geometry in viewport ('f').",
|
| 408 |
+
status="working",
|
| 409 |
+
)
|
| 410 |
+
VIEW_ROTATE = ToolSpec(
|
| 411 |
+
name="view.rotate",
|
| 412 |
+
purpose="Right-mouse drag from start to end to orbit the camera.",
|
| 413 |
+
status="working",
|
| 414 |
+
)
|
| 415 |
+
ASSEMBLY_MATE = ToolSpec(
|
| 416 |
+
name="assembly.mate",
|
| 417 |
+
purpose="Create a mate between two mate connectors.",
|
| 418 |
+
status="planned",
|
| 419 |
+
)
|
| 420 |
+
ASSEMBLY_PATTERN = ToolSpec(
|
| 421 |
+
name="assembly.pattern",
|
| 422 |
+
purpose="Pattern an instance in an assembly.",
|
| 423 |
+
status="planned",
|
| 424 |
+
)
|
| 425 |
+
DOC_OPEN = ToolSpec(
|
| 426 |
+
name="doc.open",
|
| 427 |
+
purpose="Navigate to an Onshape document by URL.",
|
| 428 |
+
status="working",
|
| 429 |
+
)
|
| 430 |
+
DOC_SAVE = ToolSpec(
|
| 431 |
+
name="doc.save",
|
| 432 |
+
purpose="No-op; Onshape auto-saves continuously.",
|
| 433 |
+
status="working",
|
| 434 |
+
)
|
| 435 |
+
UNDO = ToolSpec(
|
| 436 |
+
name="ui.undo",
|
| 437 |
+
purpose="Send the standard Undo shortcut (Ctrl/Cmd+Z).",
|
| 438 |
+
next_steps=["ui.redo", "view.fit"],
|
| 439 |
+
status="working",
|
| 440 |
+
)
|
| 441 |
+
REDO = ToolSpec(
|
| 442 |
+
name="ui.redo",
|
| 443 |
+
purpose="Send Redo (Ctrl/Cmd+Shift+Z).",
|
| 444 |
+
next_steps=["ui.undo", "view.fit"],
|
| 445 |
+
status="working",
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
ALL_TOOLS: list[ToolSpec] = [
|
| 449 |
+
# Lifecycle
|
| 450 |
+
SKETCH_START,
|
| 451 |
+
SKETCH_EXIT,
|
| 452 |
+
# Primitives
|
| 453 |
+
SKETCH_LINE,
|
| 454 |
+
SKETCH_LINE_MIDPOINT,
|
| 455 |
+
SKETCH_RECTANGLE,
|
| 456 |
+
SKETCH_RECTANGLE_CENTER,
|
| 457 |
+
SKETCH_RECTANGLE_ALIGNED,
|
| 458 |
+
SKETCH_CIRCLE,
|
| 459 |
+
SKETCH_CIRCLE_3POINT,
|
| 460 |
+
SKETCH_ELLIPSE,
|
| 461 |
+
SKETCH_ARC,
|
| 462 |
+
SKETCH_ARC_TANGENT,
|
| 463 |
+
SKETCH_ARC_CENTER,
|
| 464 |
+
SKETCH_POLYGON,
|
| 465 |
+
SKETCH_SPLINE,
|
| 466 |
+
SKETCH_POINT,
|
| 467 |
+
SKETCH_TEXT,
|
| 468 |
+
SKETCH_USE,
|
| 469 |
+
# Operations
|
| 470 |
+
SKETCH_CONSTRUCTION,
|
| 471 |
+
SKETCH_FILLET,
|
| 472 |
+
SKETCH_CHAMFER,
|
| 473 |
+
SKETCH_TRIM,
|
| 474 |
+
SKETCH_EXTEND,
|
| 475 |
+
SKETCH_SPLIT,
|
| 476 |
+
SKETCH_OFFSET,
|
| 477 |
+
SKETCH_SLOT,
|
| 478 |
+
SKETCH_MIRROR,
|
| 479 |
+
SKETCH_PATTERN_LINEAR,
|
| 480 |
+
SKETCH_PATTERN_CIRCULAR,
|
| 481 |
+
SKETCH_TRANSFORM,
|
| 482 |
+
# Dimensions & Constraints
|
| 483 |
+
SKETCH_DIMENSION,
|
| 484 |
+
CONSTRAINT_COINCIDENT,
|
| 485 |
+
CONSTRAINT_CONCENTRIC,
|
| 486 |
+
CONSTRAINT_PARALLEL,
|
| 487 |
+
CONSTRAINT_TANGENT,
|
| 488 |
+
CONSTRAINT_HORIZONTAL,
|
| 489 |
+
CONSTRAINT_VERTICAL,
|
| 490 |
+
CONSTRAINT_PERPENDICULAR,
|
| 491 |
+
CONSTRAINT_EQUAL,
|
| 492 |
+
CONSTRAINT_MIDPOINT,
|
| 493 |
+
CONSTRAINT_NORMAL,
|
| 494 |
+
CONSTRAINT_PIERCE,
|
| 495 |
+
CONSTRAINT_SYMMETRIC,
|
| 496 |
+
CONSTRAINT_FIX,
|
| 497 |
+
# Features
|
| 498 |
+
FEATURE_EXTRUDE,
|
| 499 |
+
FEATURE_REVOLVE,
|
| 500 |
+
FEATURE_FILLET,
|
| 501 |
+
FEATURE_CHAMFER,
|
| 502 |
+
FEATURE_SHELL,
|
| 503 |
+
FEATURE_PATTERN,
|
| 504 |
+
FEATURE_MIRROR_BODY,
|
| 505 |
+
SELECT_FACE,
|
| 506 |
+
SELECT_EDGE,
|
| 507 |
+
SELECT_BODY,
|
| 508 |
+
VIEW_FIT,
|
| 509 |
+
VIEW_ROTATE,
|
| 510 |
+
ASSEMBLY_MATE,
|
| 511 |
+
ASSEMBLY_PATTERN,
|
| 512 |
+
DOC_OPEN,
|
| 513 |
+
DOC_SAVE,
|
| 514 |
+
UNDO,
|
| 515 |
+
REDO,
|
| 516 |
+
]
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def as_prompt_block() -> str:
|
| 520 |
+
"""Render the datasheet as a compact prompt block for the LLM."""
|
| 521 |
+
lines = ["# Onshape tool vocabulary", ""]
|
| 522 |
+
for t in ALL_TOOLS:
|
| 523 |
+
bits = [f"**{t.name}** {t.purpose}"]
|
| 524 |
+
if t.requires:
|
| 525 |
+
bits.append(f" requires: {', '.join(t.requires)}")
|
| 526 |
+
if t.produces:
|
| 527 |
+
bits.append(f" produces: {', '.join(t.produces)}")
|
| 528 |
+
if t.next_steps:
|
| 529 |
+
bits.append(f" next: {', '.join(t.next_steps)}")
|
| 530 |
+
bits.append(f" status: {t.status}")
|
| 531 |
+
lines.extend(bits)
|
| 532 |
+
return "\n".join(lines)
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
def lookup(name: str) -> ToolSpec:
|
| 536 |
+
for t in ALL_TOOLS:
|
| 537 |
+
if t.name == name:
|
| 538 |
+
return t
|
| 539 |
+
raise KeyError(f"Unknown tool: {name}")
|
src/onshape_mcp/tunnel.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Automatic web tunnel manager for Onshape MCP.
|
| 2 |
+
Spawns the local SSE server and exposes a secure public HTTPS endpoint
|
| 3 |
+
(via Cloudflare Quick Tunnels or Localtunnel) with zero configuration.
|
| 4 |
+
|
| 5 |
+
Allows web AIs (ChatGPT Web, LibreChat, Open WebUI) to connect instantly.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import shutil
|
| 13 |
+
import subprocess
|
| 14 |
+
import sys
|
| 15 |
+
import threading
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from .config import settings
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def find_tunnel_binary() -> tuple[str, list[str]] | None:
|
| 23 |
+
"""Find available tunnel tool (cloudflared or localtunnel)."""
|
| 24 |
+
# 1. Cloudflare tunnel (no sign-up, fast, reliable)
|
| 25 |
+
cf = shutil.which("cloudflared")
|
| 26 |
+
if cf:
|
| 27 |
+
return ("cloudflared", [cf, "tunnel", "--url"])
|
| 28 |
+
|
| 29 |
+
# 2. Localtunnel via npx
|
| 30 |
+
npx = shutil.which("npx")
|
| 31 |
+
if npx:
|
| 32 |
+
return ("localtunnel", [npx, "-y", "localtunnel", "--port"])
|
| 33 |
+
|
| 34 |
+
return None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def run_tunnel_and_server(port: int = 8000, host: str = "127.0.0.1") -> None:
|
| 38 |
+
"""Start the FastMCP SSE server and attach an auto-tunnel for web AIs."""
|
| 39 |
+
tunnel_info = find_tunnel_binary()
|
| 40 |
+
if not tunnel_info:
|
| 41 |
+
print("=" * 68)
|
| 42 |
+
print("⚠ No tunneling tool (cloudflared or npx) found on your system.")
|
| 43 |
+
print("To enable instant web sharing, install cloudflared:")
|
| 44 |
+
print(" • Mac: brew install cloudflared")
|
| 45 |
+
print(" • Linux: sudo apt install cloudflared (or download binary)")
|
| 46 |
+
print(" • Windows: winget install Cloudflare.cloudflared")
|
| 47 |
+
print("=" * 68)
|
| 48 |
+
# Fallback to local SSE only
|
| 49 |
+
from .server import mcp
|
| 50 |
+
mcp.settings.host = host
|
| 51 |
+
mcp.settings.port = port
|
| 52 |
+
print(f"Starting local SSE server at http://{host}:{port}/sse ...")
|
| 53 |
+
mcp.run(transport="sse")
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
tunnel_type, tunnel_cmd = tunnel_info
|
| 57 |
+
local_url = f"http://{host}:{port}"
|
| 58 |
+
|
| 59 |
+
print("=" * 68)
|
| 60 |
+
print(" 🌐 Onshape MCP — Instant Web Sharing & ChatGPT Connector")
|
| 61 |
+
print("=" * 68)
|
| 62 |
+
print(f"Starting local SSE engine on port {port}...")
|
| 63 |
+
|
| 64 |
+
# Start the tunnel process in the background
|
| 65 |
+
if tunnel_type == "cloudflared":
|
| 66 |
+
cmd = [*tunnel_cmd, local_url]
|
| 67 |
+
else:
|
| 68 |
+
cmd = [*tunnel_cmd, str(port)]
|
| 69 |
+
|
| 70 |
+
tunnel_proc = subprocess.Popen(
|
| 71 |
+
cmd,
|
| 72 |
+
stdout=subprocess.PIPE,
|
| 73 |
+
stderr=subprocess.PIPE,
|
| 74 |
+
text=True,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
public_url: str | None = None
|
| 78 |
+
start_time = time.time()
|
| 79 |
+
|
| 80 |
+
# Read output to capture the public URL
|
| 81 |
+
def _read_stream(stream: Any) -> None:
|
| 82 |
+
nonlocal public_url
|
| 83 |
+
if not stream:
|
| 84 |
+
return
|
| 85 |
+
for line in iter(stream.readline, ""):
|
| 86 |
+
if not line:
|
| 87 |
+
break
|
| 88 |
+
# Cloudflare pattern
|
| 89 |
+
m_cf = re.search(r"https://[a-zA-Z0-9-]+\.trycloudflare\.com", line)
|
| 90 |
+
if m_cf:
|
| 91 |
+
public_url = m_cf.group(0)
|
| 92 |
+
break
|
| 93 |
+
# Localtunnel pattern
|
| 94 |
+
m_lt = re.search(r"https://[a-zA-Z0-9-]+\.loca\.lt", line)
|
| 95 |
+
if m_lt:
|
| 96 |
+
public_url = m_lt.group(0)
|
| 97 |
+
break
|
| 98 |
+
|
| 99 |
+
t_err = threading.Thread(target=_read_stream, args=(tunnel_proc.stderr,), daemon=True)
|
| 100 |
+
t_out = threading.Thread(target=_read_stream, args=(tunnel_proc.stdout,), daemon=True)
|
| 101 |
+
t_err.start()
|
| 102 |
+
t_out.start()
|
| 103 |
+
|
| 104 |
+
print("Requesting secure HTTPS tunnel from Cloudflare...")
|
| 105 |
+
while public_url is None and (time.time() - start_time < 15):
|
| 106 |
+
time.sleep(0.5)
|
| 107 |
+
|
| 108 |
+
if public_url:
|
| 109 |
+
sse_url = f"{public_url.rstrip('/')}/sse"
|
| 110 |
+
print("\n" + "=" * 68)
|
| 111 |
+
print("🎉 SUCCESS! Your Onshape MCP is live on the internet:")
|
| 112 |
+
print(f"\n👉 MCP SSE URL: \033[1;32m{sse_url}\033[0m\n")
|
| 113 |
+
print("How to use this link:")
|
| 114 |
+
print("1. In ChatGPT Web: Add this link to your MCP / Custom GPT connector.")
|
| 115 |
+
print("2. In LibreChat / Open WebUI: Add this link under MCP servers.")
|
| 116 |
+
print("3. Press Ctrl+C in this terminal when you want to disconnect.")
|
| 117 |
+
print("=" * 68 + "\n")
|
| 118 |
+
else:
|
| 119 |
+
print("⚠ Tunnel did not return a public URL in 15s. Running locally.")
|
| 120 |
+
print(f"Local endpoint: http://{host}:{port}/sse\n")
|
| 121 |
+
|
| 122 |
+
# Now run the SSE server in the main thread
|
| 123 |
+
from .server import mcp
|
| 124 |
+
mcp.settings.host = host
|
| 125 |
+
mcp.settings.port = port
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
mcp.run(transport="sse")
|
| 129 |
+
finally:
|
| 130 |
+
tunnel_proc.terminate()
|
| 131 |
+
tunnel_proc.wait()
|
src/onshape_mcp/ui_actions.py
ADDED
|
@@ -0,0 +1,1216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""High-level Onshape operations. Each public function corresponds to a
|
| 2 |
+
datasheet tool. Uses driver.py primitives + shortcuts.py bindings, journals
|
| 3 |
+
to journal.py. Raises before touching the UI if a precondition isn't met.
|
| 4 |
+
|
| 5 |
+
Conventions: every function takes the driver explicitly (no globals), returns
|
| 6 |
+
a `Result` dict, and uses viewport pixel coords. Call `driver.viewport_box()`
|
| 7 |
+
to get bounds.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import asyncio
|
| 13 |
+
from dataclasses import dataclass
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from .driver import OnshapeDriver
|
| 18 |
+
from .journal import JournalEntry, journal
|
| 19 |
+
from .shortcuts import get as binding_for
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass
|
| 23 |
+
class Result:
|
| 24 |
+
ok: bool
|
| 25 |
+
note: str = ""
|
| 26 |
+
screenshot: Path | None = None
|
| 27 |
+
extra: dict[str, Any] | None = None
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def summary(self) -> str:
|
| 31 |
+
return self.note
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def meta(self) -> dict[str, Any]:
|
| 35 |
+
return self.extra or {}
|
| 36 |
+
|
| 37 |
+
def to_dict(self) -> dict[str, Any]:
|
| 38 |
+
d: dict[str, Any] = {"ok": self.ok, "note": self.note}
|
| 39 |
+
if self.screenshot is not None:
|
| 40 |
+
d["screenshot"] = str(self.screenshot)
|
| 41 |
+
if self.extra:
|
| 42 |
+
d.update(self.extra)
|
| 43 |
+
return d
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _record(tool: str, args: dict[str, Any], result: Result) -> None:
|
| 47 |
+
e = JournalEntry.new(tool, args)
|
| 48 |
+
e.result = "ok" if result.ok else "fail"
|
| 49 |
+
e.note = result.note
|
| 50 |
+
if result.screenshot is not None:
|
| 51 |
+
try:
|
| 52 |
+
e.screenshot = str(result.screenshot.relative_to(result.screenshot.parent.parent))
|
| 53 |
+
except Exception:
|
| 54 |
+
e.screenshot = str(result.screenshot)
|
| 55 |
+
journal.append(e)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# View and camera
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
async def view_fit(d: OnshapeDriver) -> Result:
|
| 62 |
+
b = binding_for("view.fit")
|
| 63 |
+
if b.keys:
|
| 64 |
+
await d.press_chord(*b.keys)
|
| 65 |
+
elif b.toolbar_text:
|
| 66 |
+
ok = await d.click_text(b.toolbar_text)
|
| 67 |
+
if not ok:
|
| 68 |
+
return Result(False, f"view.fit: toolbar button {b.toolbar_text!r} not found")
|
| 69 |
+
shot = await d.screenshot("view_fit.png")
|
| 70 |
+
r = Result(True, "fit to view", shot)
|
| 71 |
+
_record("view.fit", {}, r)
|
| 72 |
+
return r
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
async def view_top(d: OnshapeDriver) -> Result:
|
| 76 |
+
b = binding_for("view.top")
|
| 77 |
+
if b.keys:
|
| 78 |
+
await d.press_chord(*b.keys)
|
| 79 |
+
shot = await d.screenshot("view_top.png")
|
| 80 |
+
r = Result(True, "top view", shot)
|
| 81 |
+
_record("view.top", {}, r)
|
| 82 |
+
return r
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
async def view_front(d: OnshapeDriver) -> Result:
|
| 86 |
+
b = binding_for("view.front")
|
| 87 |
+
if b.keys:
|
| 88 |
+
await d.press_chord(*b.keys)
|
| 89 |
+
shot = await d.screenshot("view_front.png")
|
| 90 |
+
r = Result(True, "front view", shot)
|
| 91 |
+
_record("view.front", {}, r)
|
| 92 |
+
return r
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
async def view_iso(d: OnshapeDriver) -> Result:
|
| 96 |
+
b = binding_for("view.iso")
|
| 97 |
+
if b.keys:
|
| 98 |
+
await d.press_chord(*b.keys)
|
| 99 |
+
shot = await d.screenshot("view_isometric.png")
|
| 100 |
+
r = Result(True, "isometric view", shot)
|
| 101 |
+
_record("view.iso", {}, r)
|
| 102 |
+
return r
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
view_isometric = view_iso
|
| 106 |
+
|
| 107 |
+
# Sketching
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
async def sketch_start(
|
| 111 |
+
d: OnshapeDriver,
|
| 112 |
+
plane_xy: tuple[float, float] | None = None,
|
| 113 |
+
plane_name: str | None = None,
|
| 114 |
+
) -> Result:
|
| 115 |
+
"""Click the Sketch button, then click a plane (or pass a coordinate/name).
|
| 116 |
+
|
| 117 |
+
If `plane_xy` is None, we select the desired plane in the feature tree
|
| 118 |
+
using DOM locator (default: Top plane) and press 'n' to orient normal to the sketch plane.
|
| 119 |
+
"""
|
| 120 |
+
sketch_btn = d.page.locator("[command-id='newSketch']")
|
| 121 |
+
if await sketch_btn.count() > 0:
|
| 122 |
+
await sketch_btn.first.click()
|
| 123 |
+
else:
|
| 124 |
+
b = binding_for("sketch.start")
|
| 125 |
+
clicked = False
|
| 126 |
+
if b.toolbar_text:
|
| 127 |
+
clicked = await d.click_text(b.toolbar_text, timeout_ms=500)
|
| 128 |
+
if not clicked:
|
| 129 |
+
if b.keys:
|
| 130 |
+
await d.press_chord(*b.keys)
|
| 131 |
+
else:
|
| 132 |
+
await d.click(155.0, 58.0)
|
| 133 |
+
# small settle delay for the UI to switch into plane-pick mode
|
| 134 |
+
await asyncio.sleep(0.3)
|
| 135 |
+
|
| 136 |
+
target_name = (plane_name or "Top").capitalize()
|
| 137 |
+
if plane_xy is not None:
|
| 138 |
+
await d.click(*plane_xy)
|
| 139 |
+
await asyncio.sleep(0.4)
|
| 140 |
+
try:
|
| 141 |
+
await d.page.locator("canvas").first.hover()
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
await d.press_key("n")
|
| 145 |
+
else:
|
| 146 |
+
# Use exact DOM locator for feature tree plane
|
| 147 |
+
loc = d.page.locator("span.os-list-item-name", has_text=target_name)
|
| 148 |
+
if await loc.count() > 0:
|
| 149 |
+
await loc.first.click()
|
| 150 |
+
await asyncio.sleep(0.3)
|
| 151 |
+
# Deterministically orient normal to the selected plane via context menu
|
| 152 |
+
try:
|
| 153 |
+
await loc.first.click(button="right")
|
| 154 |
+
await asyncio.sleep(0.3)
|
| 155 |
+
normal_item = d.page.locator(
|
| 156 |
+
"div.context-menu-item-text", has_text="View normal to"
|
| 157 |
+
)
|
| 158 |
+
if await normal_item.count() > 0:
|
| 159 |
+
await normal_item.first.click()
|
| 160 |
+
else:
|
| 161 |
+
await d.press_key("n")
|
| 162 |
+
except Exception:
|
| 163 |
+
await d.press_key("n")
|
| 164 |
+
else:
|
| 165 |
+
await d.click(65.0, 232.0)
|
| 166 |
+
await asyncio.sleep(0.4)
|
| 167 |
+
await d.press_key("n")
|
| 168 |
+
await asyncio.sleep(1.2)
|
| 169 |
+
shot = await d.screenshot("sketch_start.png")
|
| 170 |
+
r = Result(True, f"sketch started on {target_name}", shot, {"plane": target_name})
|
| 171 |
+
_record("sketch.start", {"plane": target_name}, r)
|
| 172 |
+
return r
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
SKETCH_COMMAND_IDS: dict[str, str] = {
|
| 176 |
+
"sketch.start": "newSketch",
|
| 177 |
+
"sketch.line": "LINESEGMENT",
|
| 178 |
+
"sketch.line_midpoint": "LINESEGMENT_MIDPOINT",
|
| 179 |
+
"sketch.rectangle": "RECTANGLE_TWO_CORNERS",
|
| 180 |
+
"sketch.rectangle_center": "RECTANGLE_CENTER_CORNER",
|
| 181 |
+
"sketch.rectangle_aligned": "ALIGNED_RECTANGLE",
|
| 182 |
+
"sketch.circle": "CIRCLE_CENTER_RADIUS",
|
| 183 |
+
"sketch.circle_3point": "CIRCLE_THREE_POINTS",
|
| 184 |
+
"sketch.ellipse": "ELLIPSE",
|
| 185 |
+
"sketch.arc": "ARC_START_END_RADIUS",
|
| 186 |
+
"sketch.arc_3point": "ARC_START_END_RADIUS",
|
| 187 |
+
"sketch.arc_tangent": "ARC_TANGENT",
|
| 188 |
+
"sketch.arc_center": "ARC_CENTER_START_END",
|
| 189 |
+
"sketch.polygon": "INSCRIBED_POLYGON",
|
| 190 |
+
"sketch.polygon_inscribed": "INSCRIBED_POLYGON",
|
| 191 |
+
"sketch.polygon_circumscribed": "CIRCUMSCRIBED_POLYGON",
|
| 192 |
+
"sketch.spline": "SPLINE",
|
| 193 |
+
"sketch.bezier": "BEZIER",
|
| 194 |
+
"sketch.point": "POINT",
|
| 195 |
+
"sketch.text": "TEXT_RECTANGLE_TWO_CORNERS",
|
| 196 |
+
"sketch.use": "USE",
|
| 197 |
+
"sketch.intersection": "INTERSECTION",
|
| 198 |
+
"sketch.construction": "TOGGLE_CONSTRUCTION",
|
| 199 |
+
"sketch.fillet": "FILLET",
|
| 200 |
+
"sketch.chamfer": "SKETCH_CHAMFER",
|
| 201 |
+
"sketch.trim": "TRIM",
|
| 202 |
+
"sketch.extend": "EXTEND",
|
| 203 |
+
"sketch.split": "SPLIT",
|
| 204 |
+
"sketch.offset": "OFFSET",
|
| 205 |
+
"sketch.slot": "SLOT",
|
| 206 |
+
"sketch.mirror": "SKETCHMIRROR",
|
| 207 |
+
"sketch.pattern_linear": "SKETCHLPATTERN",
|
| 208 |
+
"sketch.pattern_circular": "SKETCHCPATTERN",
|
| 209 |
+
"sketch.transform": "SKETCH_TRANSFORM",
|
| 210 |
+
"sketch.dimension": "DIMENSION",
|
| 211 |
+
# Constraints
|
| 212 |
+
"sketch.equal": "EQUAL",
|
| 213 |
+
"constraint.coincident": "COINCIDENT",
|
| 214 |
+
"constraint.concentric": "CONCENTRIC",
|
| 215 |
+
"constraint.parallel": "PARALLEL",
|
| 216 |
+
"constraint.tangent": "TANGENT",
|
| 217 |
+
"constraint.horizontal": "HORIZONTAL",
|
| 218 |
+
"constraint.vertical": "VERTICAL",
|
| 219 |
+
"constraint.perpendicular": "PERPENDICULAR",
|
| 220 |
+
"constraint.equal": "EQUAL",
|
| 221 |
+
"constraint.midpoint": "MIDPOINT",
|
| 222 |
+
"constraint.normal": "NORMAL",
|
| 223 |
+
"constraint.pierce": "PIERCE",
|
| 224 |
+
"constraint.symmetric": "MIRROR",
|
| 225 |
+
"constraint.fix": "FIX",
|
| 226 |
+
"constraint.curvature": "CURVATURE",
|
| 227 |
+
# 3D Features
|
| 228 |
+
"feature.extrude": "extrude",
|
| 229 |
+
"feature.revolve": "revolve",
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
async def _activate_sketch_tool(d: OnshapeDriver, name: str) -> Result:
|
| 234 |
+
"""Activate a sketch tool via DOM command-id, keyboard shortcut, or text locator."""
|
| 235 |
+
# 1. Try keyboard shortcut first if available (fastest, most reliable)
|
| 236 |
+
b = binding_for(name)
|
| 237 |
+
if b.keys:
|
| 238 |
+
await d.press_chord(*b.keys)
|
| 239 |
+
await asyncio.sleep(0.2)
|
| 240 |
+
return Result(True, f"{name} tool active (keys: {b.keys})")
|
| 241 |
+
|
| 242 |
+
# 2. Try direct DOM command-id locator
|
| 243 |
+
cmd_id = SKETCH_COMMAND_IDS.get(name)
|
| 244 |
+
if cmd_id:
|
| 245 |
+
loc = d.page.locator(f"[command-id='{cmd_id}']")
|
| 246 |
+
if await loc.count() > 0 and await loc.first.is_visible():
|
| 247 |
+
classes = await loc.first.get_attribute("class") or ""
|
| 248 |
+
if "is-active" in classes:
|
| 249 |
+
return Result(True, f"{name} already active")
|
| 250 |
+
await loc.first.click()
|
| 251 |
+
await asyncio.sleep(0.25)
|
| 252 |
+
return Result(True, f"{name} tool active (command-id: {cmd_id})")
|
| 253 |
+
|
| 254 |
+
# 3. Fall back to visible toolbar text
|
| 255 |
+
if b.toolbar_text:
|
| 256 |
+
clicked = await d.click_text(b.toolbar_text, timeout_ms=1000)
|
| 257 |
+
if clicked:
|
| 258 |
+
await asyncio.sleep(0.2)
|
| 259 |
+
return Result(True, f"{name} tool active (text: {b.toolbar_text})")
|
| 260 |
+
|
| 261 |
+
return Result(False, f"{name}: no binding or visible tool button available")
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def _parse_dim_px(val: float | str | None, default_px: float = 120.0) -> float:
|
| 265 |
+
if val is None:
|
| 266 |
+
return default_px
|
| 267 |
+
try:
|
| 268 |
+
s = str(val).strip().lower()
|
| 269 |
+
num = float("".join(c for c in s if c.isdigit() or c == "."))
|
| 270 |
+
if "cm" in s:
|
| 271 |
+
px = num * 32.583
|
| 272 |
+
elif "mm" in s:
|
| 273 |
+
px = num * 3.2583
|
| 274 |
+
elif "in" in s or "inch" in s:
|
| 275 |
+
px = num * 82.75
|
| 276 |
+
elif num <= 25:
|
| 277 |
+
px = num * 32.583
|
| 278 |
+
else:
|
| 279 |
+
px = num * 3.2583
|
| 280 |
+
return max(40.0, min(550.0, px))
|
| 281 |
+
except Exception:
|
| 282 |
+
return default_px
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
async def get_canvas_origin(d: OnshapeDriver) -> tuple[float, float]:
|
| 286 |
+
"""Calculate the exact center point (origin) of the WebGL canvas.
|
| 287 |
+
Takes into account the left feature panel (246px) and toolbar (76px).
|
| 288 |
+
"""
|
| 289 |
+
try:
|
| 290 |
+
box = await d.page.evaluate("""() => {
|
| 291 |
+
const c = document.querySelector('canvas.os-main-canvas') || document.querySelector('canvas');
|
| 292 |
+
if (!c) return {cx: 843.0, cy: 473.0};
|
| 293 |
+
const r = c.getBoundingClientRect();
|
| 294 |
+
return {cx: r.x + r.width / 2.0, cy: r.y + r.height / 2.0};
|
| 295 |
+
}""")
|
| 296 |
+
return (float(box.get("cx", 843.0)), float(box.get("cy", 473.0)))
|
| 297 |
+
except Exception:
|
| 298 |
+
return (843.0, 473.0)
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
async def _ensure_viewport_coords(d: OnshapeDriver, pt: tuple[float, float]) -> tuple[float, float]:
|
| 302 |
+
x, y = pt
|
| 303 |
+
cx, cy = await get_canvas_origin(d)
|
| 304 |
+
# If pt is (0, 0), return exact origin
|
| 305 |
+
if x == 0 and y == 0:
|
| 306 |
+
return (cx, cy)
|
| 307 |
+
# If small coordinates (CAD units relative to origin), convert to screen pixels:
|
| 308 |
+
# In CAD space, +X is right, +Y is up (so screen Y is cy - y*scale)
|
| 309 |
+
if abs(x) <= 200 and abs(y) <= 200:
|
| 310 |
+
scale = 10.0 if (abs(x) <= 25 and abs(y) <= 25) else 1.0
|
| 311 |
+
return (cx + x * scale, cy - y * scale)
|
| 312 |
+
return (x, y)
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
async def sketch_rectangle(
|
| 316 |
+
d: OnshapeDriver,
|
| 317 |
+
corner1: tuple[float, float] | None = None,
|
| 318 |
+
corner2: tuple[float, float] | None = None,
|
| 319 |
+
width: float | str | None = None,
|
| 320 |
+
height: float | str | None = None,
|
| 321 |
+
quadrant: str | int | None = None,
|
| 322 |
+
centered: bool | None = None,
|
| 323 |
+
) -> Result:
|
| 324 |
+
cx, cy = await get_canvas_origin(d)
|
| 325 |
+
span_x = _parse_dim_px(width, default_px=120.0)
|
| 326 |
+
span_y = _parse_dim_px(height, default_px=120.0)
|
| 327 |
+
|
| 328 |
+
if centered:
|
| 329 |
+
corner1 = (cx - span_x / 2.0, cy - span_y / 2.0)
|
| 330 |
+
corner2 = (cx + span_x / 2.0, cy + span_y / 2.0)
|
| 331 |
+
elif quadrant is not None:
|
| 332 |
+
q = str(quadrant).upper().strip()
|
| 333 |
+
if q in ("1", "I", "TOP-RIGHT", "NE"):
|
| 334 |
+
corner1 = (cx, cy)
|
| 335 |
+
corner2 = (cx + span_x, cy - span_y)
|
| 336 |
+
elif q in ("2", "II", "TOP-LEFT", "NW"):
|
| 337 |
+
corner1 = (cx, cy)
|
| 338 |
+
corner2 = (cx - span_x, cy - span_y)
|
| 339 |
+
elif q in ("3", "III", "BOTTOM-LEFT", "SW"):
|
| 340 |
+
corner1 = (cx, cy)
|
| 341 |
+
corner2 = (cx - span_x, cy + span_y)
|
| 342 |
+
elif q in ("4", "IV", "BOTTOM-RIGHT", "SE"):
|
| 343 |
+
corner1 = (cx, cy)
|
| 344 |
+
corner2 = (cx + span_x, cy + span_y)
|
| 345 |
+
elif corner1 is None and corner2 is None:
|
| 346 |
+
corner1 = (cx - span_x / 2.0, cy - span_y / 2.0)
|
| 347 |
+
corner2 = (cx + span_x / 2.0, cy + span_y / 2.0)
|
| 348 |
+
|
| 349 |
+
c1 = await _ensure_viewport_coords(
|
| 350 |
+
d, corner1 if corner1 is not None else (cx - 70.0, cy - 70.0)
|
| 351 |
+
)
|
| 352 |
+
c2 = await _ensure_viewport_coords(
|
| 353 |
+
d, corner2 if corner2 is not None else (cx + 70.0, cy + 70.0)
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
t = await _activate_sketch_tool(d, "sketch.rectangle")
|
| 357 |
+
if not t.ok:
|
| 358 |
+
_record("sketch.rectangle", {"corner1": list(c1), "corner2": list(c2)}, t)
|
| 359 |
+
return t
|
| 360 |
+
await d.click(*c1)
|
| 361 |
+
await asyncio.sleep(0.3)
|
| 362 |
+
await d.click(*c2)
|
| 363 |
+
await asyncio.sleep(0.3)
|
| 364 |
+
await d.press_key("Escape")
|
| 365 |
+
await asyncio.sleep(0.3)
|
| 366 |
+
|
| 367 |
+
min_x, max_x = min(c1[0], c2[0]), max(c1[0], c2[0])
|
| 368 |
+
min_y, max_y = min(c1[1], c2[1]), max(c1[1], c2[1])
|
| 369 |
+
# Pick points at 30% along edges to safely avoid datum axis lines and origin glyphs
|
| 370 |
+
dx = max_x - min_x
|
| 371 |
+
dy = max_y - min_y
|
| 372 |
+
top_pick = (min_x + dx * 0.3, min_y)
|
| 373 |
+
left_pick = (min_x, min_y + dy * 0.3)
|
| 374 |
+
top_label = (top_pick[0], top_pick[1] - 45.0)
|
| 375 |
+
left_label = (left_pick[0] - 55.0, left_pick[1])
|
| 376 |
+
|
| 377 |
+
# If width or height are specified, apply constraints and dimensions
|
| 378 |
+
if width is not None and height is not None and str(width).strip() == str(height).strip():
|
| 379 |
+
# Square: apply Equal constraint first so geometry is strictly equilateral
|
| 380 |
+
await sketch_equal(d, top_pick, left_pick)
|
| 381 |
+
await asyncio.sleep(0.5)
|
| 382 |
+
# Dimension top edge
|
| 383 |
+
await sketch_dimension(d, top_pick, top_label, width)
|
| 384 |
+
elif width is not None:
|
| 385 |
+
await sketch_dimension(d, top_pick, top_label, width)
|
| 386 |
+
if height is not None:
|
| 387 |
+
await asyncio.sleep(0.6)
|
| 388 |
+
await sketch_dimension(d, left_pick, left_label, height)
|
| 389 |
+
elif height is not None:
|
| 390 |
+
await sketch_dimension(d, left_pick, left_label, height)
|
| 391 |
+
|
| 392 |
+
shot = await d.screenshot("sketch_rectangle.png")
|
| 393 |
+
meta = {
|
| 394 |
+
"corner1": list(c1),
|
| 395 |
+
"corner2": list(c2),
|
| 396 |
+
"last_vertex": list(c2),
|
| 397 |
+
"width": width,
|
| 398 |
+
"height": height,
|
| 399 |
+
"quadrant": quadrant,
|
| 400 |
+
"centered": centered,
|
| 401 |
+
}
|
| 402 |
+
r = Result(True, f"rectangle {c1} -> {c2} (dim={width}x{height})", shot, meta)
|
| 403 |
+
_record("sketch.rectangle", meta, r)
|
| 404 |
+
return r
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
async def sketch_circle(
|
| 408 |
+
d: OnshapeDriver,
|
| 409 |
+
center: tuple[float, float] | None = None,
|
| 410 |
+
radius_px: float = 50.0,
|
| 411 |
+
centered: bool | None = None,
|
| 412 |
+
) -> Result:
|
| 413 |
+
t = await _activate_sketch_tool(d, "sketch.circle")
|
| 414 |
+
if not t.ok:
|
| 415 |
+
_record(
|
| 416 |
+
"sketch.circle", {"center": list(center) if center else None, "radius_px": radius_px}, t
|
| 417 |
+
)
|
| 418 |
+
return t
|
| 419 |
+
cx, cy = await get_canvas_origin(d)
|
| 420 |
+
if centered or center is None or center == (0, 0):
|
| 421 |
+
c = (cx, cy)
|
| 422 |
+
else:
|
| 423 |
+
c = await _ensure_viewport_coords(d, center)
|
| 424 |
+
r_px = radius_px if radius_px > 10 else radius_px * 15.0
|
| 425 |
+
await d.click(*c)
|
| 426 |
+
await asyncio.sleep(0.1)
|
| 427 |
+
await d.click(c[0] + r_px, c[1])
|
| 428 |
+
await asyncio.sleep(0.1)
|
| 429 |
+
await d.press_key("Escape")
|
| 430 |
+
shot = await d.screenshot("sketch_circle.png")
|
| 431 |
+
r = Result(True, f"circle center={c} r={r_px}", shot)
|
| 432 |
+
_record("sketch.circle", {"center": list(c), "radius_px": r_px}, r)
|
| 433 |
+
return r
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
async def sketch_line(
|
| 437 |
+
d: OnshapeDriver,
|
| 438 |
+
p1: tuple[float, float],
|
| 439 |
+
p2: tuple[float, float],
|
| 440 |
+
) -> Result:
|
| 441 |
+
t = await _activate_sketch_tool(d, "sketch.line")
|
| 442 |
+
if not t.ok:
|
| 443 |
+
_record("sketch.line", {"p1": list(p1), "p2": list(p2)}, t)
|
| 444 |
+
return t
|
| 445 |
+
pt1 = await _ensure_viewport_coords(d, p1)
|
| 446 |
+
pt2 = await _ensure_viewport_coords(d, p2)
|
| 447 |
+
await d.click(*pt1)
|
| 448 |
+
await asyncio.sleep(0.05)
|
| 449 |
+
await d.click(*pt2)
|
| 450 |
+
await asyncio.sleep(0.05)
|
| 451 |
+
await d.press_key("Escape")
|
| 452 |
+
shot = await d.screenshot("sketch_line.png")
|
| 453 |
+
r = Result(True, f"line {pt1} -> {pt2}", shot)
|
| 454 |
+
_record("sketch.line", {"p1": list(pt1), "p2": list(pt2)}, r)
|
| 455 |
+
return r
|
| 456 |
+
|
| 457 |
+
|
| 458 |
+
async def sketch_dimension(
|
| 459 |
+
d: OnshapeDriver,
|
| 460 |
+
entity_xy: tuple[float, float],
|
| 461 |
+
label_xy: tuple[float, float],
|
| 462 |
+
value_mm: float | str,
|
| 463 |
+
) -> Result:
|
| 464 |
+
"""Add or set a dimension. Click the entity at `entity_xy`, place the
|
| 465 |
+
dimension label at `label_xy`, then type the new value + Enter.
|
| 466 |
+
|
| 467 |
+
This is the workhorse of the "make it 5x5" workflow. Draw any
|
| 468 |
+
rectangle, then call this twice (once for each side) with the
|
| 469 |
+
target mm value. The LLM doesn't need to know the px-to-mm ratio
|
| 470 |
+
because Onshape's solver does the conversion.
|
| 471 |
+
"""
|
| 472 |
+
t = await _activate_sketch_tool(d, "sketch.dimension")
|
| 473 |
+
if not t.ok:
|
| 474 |
+
return t
|
| 475 |
+
await asyncio.sleep(0.4)
|
| 476 |
+
e_xy = await _ensure_viewport_coords(d, entity_xy)
|
| 477 |
+
l_xy = await _ensure_viewport_coords(d, label_xy)
|
| 478 |
+
# Click the entity (line/edge/circle) to dimension
|
| 479 |
+
await d.page.mouse.move(*e_xy)
|
| 480 |
+
await asyncio.sleep(0.15)
|
| 481 |
+
await d.page.mouse.click(*e_xy)
|
| 482 |
+
await asyncio.sleep(0.4)
|
| 483 |
+
# Click where to place the dimension label
|
| 484 |
+
await d.page.mouse.move(*l_xy)
|
| 485 |
+
await asyncio.sleep(0.15)
|
| 486 |
+
await d.page.mouse.click(*l_xy)
|
| 487 |
+
await asyncio.sleep(0.4)
|
| 488 |
+
# Type the new value into input.os-canvas-text-edit if present or directly
|
| 489 |
+
val_str = f"{value_mm} mm" if isinstance(value_mm, (int, float)) else str(value_mm)
|
| 490 |
+
dim_input = d.page.locator("input.os-canvas-text-edit")
|
| 491 |
+
if await dim_input.count() > 0:
|
| 492 |
+
await dim_input.first.fill(val_str)
|
| 493 |
+
await asyncio.sleep(0.1)
|
| 494 |
+
await d.page.keyboard.press("Enter")
|
| 495 |
+
await asyncio.sleep(0.4)
|
| 496 |
+
else:
|
| 497 |
+
await d.type_text(val_str)
|
| 498 |
+
await asyncio.sleep(0.1)
|
| 499 |
+
await d.press_key("Enter")
|
| 500 |
+
await asyncio.sleep(0.4)
|
| 501 |
+
# Esc to drop the dimension tool, stay in sketch
|
| 502 |
+
await d.press_key("Escape")
|
| 503 |
+
shot = await d.screenshot("sketch_dimension.png")
|
| 504 |
+
r = Result(
|
| 505 |
+
True,
|
| 506 |
+
f"dimensioned {val_str} at {e_xy}",
|
| 507 |
+
shot,
|
| 508 |
+
{"value": val_str},
|
| 509 |
+
)
|
| 510 |
+
_record(
|
| 511 |
+
"sketch.dimension",
|
| 512 |
+
{"entity_xy": list(e_xy), "label_xy": list(l_xy), "value": val_str},
|
| 513 |
+
r,
|
| 514 |
+
)
|
| 515 |
+
return r
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
async def sketch_equal(d: OnshapeDriver, *entity_xys: tuple[float, float]) -> Result:
|
| 519 |
+
"""Apply the Equal constraint to make two or more entities the same size.
|
| 520 |
+
|
| 521 |
+
Workflow: activate Equal tool, click each entity in turn, press Esc.
|
| 522 |
+
The LLM passes N coordinates (each on an entity to be made equal).
|
| 523 |
+
"""
|
| 524 |
+
if len(entity_xys) < 2:
|
| 525 |
+
return Result(False, "sketch.equal needs at least 2 entities")
|
| 526 |
+
t = await _activate_sketch_tool(d, "sketch.equal")
|
| 527 |
+
if not t.ok:
|
| 528 |
+
return t
|
| 529 |
+
await asyncio.sleep(0.4)
|
| 530 |
+
for xy in entity_xys:
|
| 531 |
+
pt = await _ensure_viewport_coords(d, xy)
|
| 532 |
+
await d.page.mouse.move(*pt)
|
| 533 |
+
await asyncio.sleep(0.15)
|
| 534 |
+
await d.page.mouse.click(*pt)
|
| 535 |
+
await asyncio.sleep(0.3)
|
| 536 |
+
await d.press_key("Escape")
|
| 537 |
+
await asyncio.sleep(0.4)
|
| 538 |
+
shot = await d.screenshot("sketch_equal.png")
|
| 539 |
+
r = Result(True, f"equal across {len(entity_xys)} entities", shot)
|
| 540 |
+
_record("sketch.equal", {"entities": [list(xy) for xy in entity_xys]}, r)
|
| 541 |
+
return r
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
async def sketch_arc(
|
| 545 |
+
d: OnshapeDriver,
|
| 546 |
+
p1: tuple[float, float],
|
| 547 |
+
p2: tuple[float, float],
|
| 548 |
+
radius_pt: tuple[float, float] | None = None,
|
| 549 |
+
) -> Result:
|
| 550 |
+
"""Create a 3-point arc from p1 to p2 passing through radius_pt."""
|
| 551 |
+
t = await _activate_sketch_tool(d, "sketch.arc")
|
| 552 |
+
if not t.ok:
|
| 553 |
+
return t
|
| 554 |
+
pt1 = await _ensure_viewport_coords(d, p1)
|
| 555 |
+
pt2 = await _ensure_viewport_coords(d, p2)
|
| 556 |
+
if radius_pt is not None:
|
| 557 |
+
pt3 = await _ensure_viewport_coords(d, radius_pt)
|
| 558 |
+
else:
|
| 559 |
+
# Default arc bulge perpendicular to chord
|
| 560 |
+
mx, my = (pt1[0] + pt2[0]) / 2.0, (pt1[1] + pt2[1]) / 2.0
|
| 561 |
+
pt3 = (mx, my - 30.0)
|
| 562 |
+
await d.click(*pt1)
|
| 563 |
+
await asyncio.sleep(0.1)
|
| 564 |
+
await d.click(*pt2)
|
| 565 |
+
await asyncio.sleep(0.1)
|
| 566 |
+
await d.click(*pt3)
|
| 567 |
+
await asyncio.sleep(0.1)
|
| 568 |
+
await d.press_key("Escape")
|
| 569 |
+
shot = await d.screenshot("sketch_arc.png")
|
| 570 |
+
r = Result(True, f"arc {pt1} -> {pt2} through {pt3}", shot)
|
| 571 |
+
_record("sketch.arc", {"p1": list(pt1), "p2": list(pt2), "radius_pt": list(pt3)}, r)
|
| 572 |
+
return r
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
async def sketch_polygon(
|
| 576 |
+
d: OnshapeDriver,
|
| 577 |
+
center: tuple[float, float] | None = None,
|
| 578 |
+
radius: float = 60.0,
|
| 579 |
+
sides: int = 6,
|
| 580 |
+
circumscribed: bool = False,
|
| 581 |
+
) -> Result:
|
| 582 |
+
"""Draw an inscribed or circumscribed polygon."""
|
| 583 |
+
tool_name = "sketch.polygon_circumscribed" if circumscribed else "sketch.polygon_inscribed"
|
| 584 |
+
t = await _activate_sketch_tool(d, tool_name)
|
| 585 |
+
if not t.ok:
|
| 586 |
+
t = await _activate_sketch_tool(d, "sketch.polygon")
|
| 587 |
+
if not t.ok:
|
| 588 |
+
return t
|
| 589 |
+
cx, cy = await get_canvas_origin(d)
|
| 590 |
+
c = await _ensure_viewport_coords(d, center) if center is not None else (cx, cy)
|
| 591 |
+
r_px = _parse_dim_px(radius, default_px=60.0)
|
| 592 |
+
await d.click(*c)
|
| 593 |
+
await asyncio.sleep(0.15)
|
| 594 |
+
await d.click(c[0] + r_px, c[1])
|
| 595 |
+
await asyncio.sleep(0.2)
|
| 596 |
+
# Type number of sides
|
| 597 |
+
await d.type_text(str(int(sides)))
|
| 598 |
+
await asyncio.sleep(0.1)
|
| 599 |
+
await d.press_key("Enter")
|
| 600 |
+
await asyncio.sleep(0.2)
|
| 601 |
+
await d.press_key("Escape")
|
| 602 |
+
shot = await d.screenshot("sketch_polygon.png")
|
| 603 |
+
r = Result(True, f"{sides}-sided polygon at {c} r={r_px}", shot)
|
| 604 |
+
_record("sketch.polygon", {"center": list(c), "radius": r_px, "sides": sides}, r)
|
| 605 |
+
return r
|
| 606 |
+
|
| 607 |
+
|
| 608 |
+
async def sketch_spline(d: OnshapeDriver, points: list[tuple[float, float]]) -> Result:
|
| 609 |
+
"""Draw a spline passing through a sequence of points."""
|
| 610 |
+
if len(points) < 2:
|
| 611 |
+
return Result(False, "sketch.spline requires at least 2 points")
|
| 612 |
+
t = await _activate_sketch_tool(d, "sketch.spline")
|
| 613 |
+
if not t.ok:
|
| 614 |
+
return t
|
| 615 |
+
pts = [await _ensure_viewport_coords(d, p) for p in points]
|
| 616 |
+
for pt in pts:
|
| 617 |
+
await d.click(*pt)
|
| 618 |
+
await asyncio.sleep(0.15)
|
| 619 |
+
await d.double_click(*pts[-1])
|
| 620 |
+
await asyncio.sleep(0.2)
|
| 621 |
+
await d.press_key("Escape")
|
| 622 |
+
shot = await d.screenshot("sketch_spline.png")
|
| 623 |
+
r = Result(True, f"spline through {len(pts)} points", shot)
|
| 624 |
+
_record("sketch.spline", {"points": [list(p) for p in pts]}, r)
|
| 625 |
+
return r
|
| 626 |
+
|
| 627 |
+
|
| 628 |
+
async def sketch_point(d: OnshapeDriver, pt: tuple[float, float]) -> Result:
|
| 629 |
+
"""Place a single sketch point at coordinate pt."""
|
| 630 |
+
t = await _activate_sketch_tool(d, "sketch.point")
|
| 631 |
+
if not t.ok:
|
| 632 |
+
return t
|
| 633 |
+
p = await _ensure_viewport_coords(d, pt)
|
| 634 |
+
await d.click(*p)
|
| 635 |
+
await asyncio.sleep(0.1)
|
| 636 |
+
await d.press_key("Escape")
|
| 637 |
+
shot = await d.screenshot("sketch_point.png")
|
| 638 |
+
r = Result(True, f"point at {p}", shot)
|
| 639 |
+
_record("sketch.point", {"pt": list(p)}, r)
|
| 640 |
+
return r
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
async def sketch_text(
|
| 644 |
+
d: OnshapeDriver,
|
| 645 |
+
corner1: tuple[float, float],
|
| 646 |
+
corner2: tuple[float, float],
|
| 647 |
+
text: str,
|
| 648 |
+
) -> Result:
|
| 649 |
+
"""Draw a text box from corner1 to corner2 and enter text."""
|
| 650 |
+
t = await _activate_sketch_tool(d, "sketch.text")
|
| 651 |
+
if not t.ok:
|
| 652 |
+
return t
|
| 653 |
+
c1 = await _ensure_viewport_coords(d, corner1)
|
| 654 |
+
c2 = await _ensure_viewport_coords(d, corner2)
|
| 655 |
+
await d.click(*c1)
|
| 656 |
+
await asyncio.sleep(0.1)
|
| 657 |
+
await d.click(*c2)
|
| 658 |
+
await asyncio.sleep(0.3)
|
| 659 |
+
# Fill in the text in the dialog
|
| 660 |
+
textarea = d.page.locator("textarea.os-text-editor-input, textarea")
|
| 661 |
+
if await textarea.count() > 0:
|
| 662 |
+
await textarea.first.fill(text)
|
| 663 |
+
await asyncio.sleep(0.1)
|
| 664 |
+
ok_btn = d.page.locator(".ns-dialog-button-ok, .button-ok").first
|
| 665 |
+
if await ok_btn.count() > 0:
|
| 666 |
+
await ok_btn.click()
|
| 667 |
+
else:
|
| 668 |
+
await d.press_key("Enter")
|
| 669 |
+
else:
|
| 670 |
+
await d.type_text(text)
|
| 671 |
+
await d.press_key("Enter")
|
| 672 |
+
await asyncio.sleep(0.3)
|
| 673 |
+
await d.press_key("Escape")
|
| 674 |
+
shot = await d.screenshot("sketch_text.png")
|
| 675 |
+
r = Result(True, f"text '{text}' at {c1}->{c2}", shot)
|
| 676 |
+
_record("sketch.text", {"corner1": list(c1), "corner2": list(c2), "text": text}, r)
|
| 677 |
+
return r
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
async def sketch_use(d: OnshapeDriver, pt: tuple[float, float]) -> Result:
|
| 681 |
+
"""Project existing 3D model geometry or sketch curve at pt onto sketch plane."""
|
| 682 |
+
t = await _activate_sketch_tool(d, "sketch.use")
|
| 683 |
+
if not t.ok:
|
| 684 |
+
return t
|
| 685 |
+
p = await _ensure_viewport_coords(d, pt)
|
| 686 |
+
await d.click(*p)
|
| 687 |
+
await asyncio.sleep(0.2)
|
| 688 |
+
await d.press_key("Escape")
|
| 689 |
+
shot = await d.screenshot("sketch_use.png")
|
| 690 |
+
r = Result(True, f"projected geometry at {p}", shot)
|
| 691 |
+
_record("sketch.use", {"pt": list(p)}, r)
|
| 692 |
+
return r
|
| 693 |
+
|
| 694 |
+
|
| 695 |
+
async def sketch_construction(d: OnshapeDriver, pt: tuple[float, float] | None = None) -> Result:
|
| 696 |
+
"""Toggle construction mode or convert entity at pt to construction geometry ('q')."""
|
| 697 |
+
if pt is not None:
|
| 698 |
+
p = await _ensure_viewport_coords(d, pt)
|
| 699 |
+
await d.click(*p)
|
| 700 |
+
await asyncio.sleep(0.15)
|
| 701 |
+
await d.press_chord("q")
|
| 702 |
+
await asyncio.sleep(0.15)
|
| 703 |
+
await d.press_key("Escape")
|
| 704 |
+
shot = await d.screenshot("sketch_construction.png")
|
| 705 |
+
r = Result(True, f"converted entity at {p} to construction", shot)
|
| 706 |
+
_record("sketch.construction", {"pt": list(p)}, r)
|
| 707 |
+
return r
|
| 708 |
+
await d.press_chord("q")
|
| 709 |
+
await asyncio.sleep(0.15)
|
| 710 |
+
shot = await d.screenshot("sketch_construction.png")
|
| 711 |
+
r = Result(True, "toggled construction mode", shot)
|
| 712 |
+
_record("sketch.construction", {}, r)
|
| 713 |
+
return r
|
| 714 |
+
|
| 715 |
+
|
| 716 |
+
async def sketch_fillet(
|
| 717 |
+
d: OnshapeDriver,
|
| 718 |
+
vertex_xy: tuple[float, float],
|
| 719 |
+
radius_mm: float | str = 5.0,
|
| 720 |
+
) -> Result:
|
| 721 |
+
"""Create a 2D sketch fillet at vertex_xy with radius_mm."""
|
| 722 |
+
t = await _activate_sketch_tool(d, "sketch.fillet")
|
| 723 |
+
if not t.ok:
|
| 724 |
+
return t
|
| 725 |
+
v = await _ensure_viewport_coords(d, vertex_xy)
|
| 726 |
+
await d.click(*v)
|
| 727 |
+
await asyncio.sleep(0.3)
|
| 728 |
+
val_str = f"{radius_mm} mm" if isinstance(radius_mm, (int, float)) else str(radius_mm)
|
| 729 |
+
dim_input = d.page.locator("input.os-canvas-text-edit")
|
| 730 |
+
if await dim_input.count() > 0:
|
| 731 |
+
await dim_input.first.fill(val_str)
|
| 732 |
+
await asyncio.sleep(0.1)
|
| 733 |
+
await d.press_key("Enter")
|
| 734 |
+
else:
|
| 735 |
+
await d.type_text(val_str)
|
| 736 |
+
await d.press_key("Enter")
|
| 737 |
+
await asyncio.sleep(0.3)
|
| 738 |
+
await d.press_key("Escape")
|
| 739 |
+
shot = await d.screenshot("sketch_fillet.png")
|
| 740 |
+
r = Result(True, f"fillet {val_str} at {v}", shot)
|
| 741 |
+
_record("sketch.fillet", {"vertex": list(v), "radius": val_str}, r)
|
| 742 |
+
return r
|
| 743 |
+
|
| 744 |
+
|
| 745 |
+
async def sketch_chamfer(
|
| 746 |
+
d: OnshapeDriver,
|
| 747 |
+
vertex_xy: tuple[float, float],
|
| 748 |
+
distance_mm: float | str = 5.0,
|
| 749 |
+
) -> Result:
|
| 750 |
+
"""Create a 2D sketch chamfer at vertex_xy with distance_mm."""
|
| 751 |
+
t = await _activate_sketch_tool(d, "sketch.chamfer")
|
| 752 |
+
if not t.ok:
|
| 753 |
+
return t
|
| 754 |
+
v = await _ensure_viewport_coords(d, vertex_xy)
|
| 755 |
+
await d.click(*v)
|
| 756 |
+
await asyncio.sleep(0.3)
|
| 757 |
+
val_str = f"{distance_mm} mm" if isinstance(distance_mm, (int, float)) else str(distance_mm)
|
| 758 |
+
dim_input = d.page.locator("input.os-canvas-text-edit")
|
| 759 |
+
if await dim_input.count() > 0:
|
| 760 |
+
await dim_input.first.fill(val_str)
|
| 761 |
+
await asyncio.sleep(0.1)
|
| 762 |
+
await d.press_key("Enter")
|
| 763 |
+
else:
|
| 764 |
+
await d.type_text(val_str)
|
| 765 |
+
await d.press_key("Enter")
|
| 766 |
+
await asyncio.sleep(0.3)
|
| 767 |
+
await d.press_key("Escape")
|
| 768 |
+
shot = await d.screenshot("sketch_chamfer.png")
|
| 769 |
+
r = Result(True, f"chamfer {val_str} at {v}", shot)
|
| 770 |
+
_record("sketch.chamfer", {"vertex": list(v), "distance": val_str}, r)
|
| 771 |
+
return r
|
| 772 |
+
|
| 773 |
+
|
| 774 |
+
async def sketch_trim(d: OnshapeDriver, entity_xy: tuple[float, float]) -> Result:
|
| 775 |
+
"""Trim a sketch curve back to intersections by clicking it."""
|
| 776 |
+
t = await _activate_sketch_tool(d, "sketch.trim")
|
| 777 |
+
if not t.ok:
|
| 778 |
+
return t
|
| 779 |
+
p = await _ensure_viewport_coords(d, entity_xy)
|
| 780 |
+
await d.click(*p)
|
| 781 |
+
await asyncio.sleep(0.2)
|
| 782 |
+
await d.press_key("Escape")
|
| 783 |
+
shot = await d.screenshot("sketch_trim.png")
|
| 784 |
+
r = Result(True, f"trimmed at {p}", shot)
|
| 785 |
+
_record("sketch.trim", {"entity": list(p)}, r)
|
| 786 |
+
return r
|
| 787 |
+
|
| 788 |
+
|
| 789 |
+
async def sketch_extend(d: OnshapeDriver, endpoint_xy: tuple[float, float]) -> Result:
|
| 790 |
+
"""Extend a sketch curve to boundary by clicking its endpoint."""
|
| 791 |
+
t = await _activate_sketch_tool(d, "sketch.extend")
|
| 792 |
+
if not t.ok:
|
| 793 |
+
return t
|
| 794 |
+
p = await _ensure_viewport_coords(d, endpoint_xy)
|
| 795 |
+
await d.click(*p)
|
| 796 |
+
await asyncio.sleep(0.2)
|
| 797 |
+
await d.press_key("Escape")
|
| 798 |
+
shot = await d.screenshot("sketch_extend.png")
|
| 799 |
+
r = Result(True, f"extended at {p}", shot)
|
| 800 |
+
_record("sketch.extend", {"endpoint": list(p)}, r)
|
| 801 |
+
return r
|
| 802 |
+
|
| 803 |
+
|
| 804 |
+
async def sketch_split(d: OnshapeDriver, entity_xy: tuple[float, float]) -> Result:
|
| 805 |
+
"""Split a sketch curve at coordinate."""
|
| 806 |
+
t = await _activate_sketch_tool(d, "sketch.split")
|
| 807 |
+
if not t.ok:
|
| 808 |
+
return t
|
| 809 |
+
p = await _ensure_viewport_coords(d, entity_xy)
|
| 810 |
+
await d.click(*p)
|
| 811 |
+
await asyncio.sleep(0.2)
|
| 812 |
+
await d.press_key("Escape")
|
| 813 |
+
shot = await d.screenshot("sketch_split.png")
|
| 814 |
+
r = Result(True, f"split at {p}", shot)
|
| 815 |
+
_record("sketch.split", {"entity": list(p)}, r)
|
| 816 |
+
return r
|
| 817 |
+
|
| 818 |
+
|
| 819 |
+
async def sketch_offset(
|
| 820 |
+
d: OnshapeDriver,
|
| 821 |
+
entity_xy: tuple[float, float],
|
| 822 |
+
distance_mm: float | str = 5.0,
|
| 823 |
+
side_xy: tuple[float, float] | None = None,
|
| 824 |
+
) -> Result:
|
| 825 |
+
"""Offset selected curve by distance_mm."""
|
| 826 |
+
t = await _activate_sketch_tool(d, "sketch.offset")
|
| 827 |
+
if not t.ok:
|
| 828 |
+
return t
|
| 829 |
+
p = await _ensure_viewport_coords(d, entity_xy)
|
| 830 |
+
await d.click(*p)
|
| 831 |
+
await asyncio.sleep(0.2)
|
| 832 |
+
if side_xy is not None:
|
| 833 |
+
s = await _ensure_viewport_coords(d, side_xy)
|
| 834 |
+
await d.click(*s)
|
| 835 |
+
else:
|
| 836 |
+
await d.click(p[0] + 20.0, p[1] + 20.0)
|
| 837 |
+
await asyncio.sleep(0.2)
|
| 838 |
+
val_str = f"{distance_mm} mm" if isinstance(distance_mm, (int, float)) else str(distance_mm)
|
| 839 |
+
dim_input = d.page.locator("input.os-canvas-text-edit")
|
| 840 |
+
if await dim_input.count() > 0:
|
| 841 |
+
await dim_input.first.fill(val_str)
|
| 842 |
+
await asyncio.sleep(0.1)
|
| 843 |
+
await d.press_key("Enter")
|
| 844 |
+
else:
|
| 845 |
+
await d.type_text(val_str)
|
| 846 |
+
await d.press_key("Enter")
|
| 847 |
+
await asyncio.sleep(0.3)
|
| 848 |
+
await d.press_key("Escape")
|
| 849 |
+
shot = await d.screenshot("sketch_offset.png")
|
| 850 |
+
r = Result(True, f"offset {val_str} at {p}", shot)
|
| 851 |
+
_record("sketch.offset", {"entity": list(p), "distance": val_str}, r)
|
| 852 |
+
return r
|
| 853 |
+
|
| 854 |
+
|
| 855 |
+
async def sketch_slot(
|
| 856 |
+
d: OnshapeDriver,
|
| 857 |
+
p1: tuple[float, float],
|
| 858 |
+
p2: tuple[float, float],
|
| 859 |
+
radius_px: float = 20.0,
|
| 860 |
+
) -> Result:
|
| 861 |
+
"""Create a sketch slot along line from p1 to p2."""
|
| 862 |
+
t = await _activate_sketch_tool(d, "sketch.slot")
|
| 863 |
+
if not t.ok:
|
| 864 |
+
return t
|
| 865 |
+
pt1 = await _ensure_viewport_coords(d, p1)
|
| 866 |
+
pt2 = await _ensure_viewport_coords(d, p2)
|
| 867 |
+
await d.click(*pt1)
|
| 868 |
+
await asyncio.sleep(0.1)
|
| 869 |
+
await d.click(*pt2)
|
| 870 |
+
await asyncio.sleep(0.1)
|
| 871 |
+
await d.click(pt2[0] + radius_px, pt2[1])
|
| 872 |
+
await asyncio.sleep(0.2)
|
| 873 |
+
await d.press_key("Escape")
|
| 874 |
+
shot = await d.screenshot("sketch_slot.png")
|
| 875 |
+
r = Result(True, f"slot {pt1} -> {pt2}", shot)
|
| 876 |
+
_record("sketch.slot", {"p1": list(pt1), "p2": list(pt2)}, r)
|
| 877 |
+
return r
|
| 878 |
+
|
| 879 |
+
|
| 880 |
+
async def sketch_mirror(
|
| 881 |
+
d: OnshapeDriver,
|
| 882 |
+
centerline_xy: tuple[float, float],
|
| 883 |
+
*entity_xys: tuple[float, float],
|
| 884 |
+
) -> Result:
|
| 885 |
+
"""Mirror entities across a centerline."""
|
| 886 |
+
if not entity_xys:
|
| 887 |
+
return Result(False, "sketch.mirror requires at least 1 entity to mirror")
|
| 888 |
+
t = await _activate_sketch_tool(d, "sketch.mirror")
|
| 889 |
+
if not t.ok:
|
| 890 |
+
return t
|
| 891 |
+
cl = await _ensure_viewport_coords(d, centerline_xy)
|
| 892 |
+
await d.click(*cl)
|
| 893 |
+
await asyncio.sleep(0.3)
|
| 894 |
+
for xy in entity_xys:
|
| 895 |
+
e = await _ensure_viewport_coords(d, xy)
|
| 896 |
+
await d.click(*e)
|
| 897 |
+
await asyncio.sleep(0.2)
|
| 898 |
+
await d.press_key("Escape")
|
| 899 |
+
shot = await d.screenshot("sketch_mirror.png")
|
| 900 |
+
r = Result(True, f"mirrored {len(entity_xys)} entities across {cl}", shot)
|
| 901 |
+
_record("sketch.mirror", {"centerline": list(cl), "entities": [list(e) for e in entity_xys]}, r)
|
| 902 |
+
return r
|
| 903 |
+
|
| 904 |
+
|
| 905 |
+
async def sketch_constrain(
|
| 906 |
+
d: OnshapeDriver,
|
| 907 |
+
constraint_type: str,
|
| 908 |
+
*entity_xys: tuple[float, float],
|
| 909 |
+
) -> Result:
|
| 910 |
+
"""Apply any geometric constraint across selected entities."""
|
| 911 |
+
c_type = constraint_type.lower().replace("constraint.", "").strip()
|
| 912 |
+
tool_key = f"constraint.{c_type}"
|
| 913 |
+
t = await _activate_sketch_tool(d, tool_key)
|
| 914 |
+
if not t.ok:
|
| 915 |
+
return t
|
| 916 |
+
await asyncio.sleep(0.2)
|
| 917 |
+
for xy in entity_xys:
|
| 918 |
+
pt = await _ensure_viewport_coords(d, xy)
|
| 919 |
+
await d.page.mouse.move(*pt)
|
| 920 |
+
await asyncio.sleep(0.1)
|
| 921 |
+
await d.page.mouse.click(*pt)
|
| 922 |
+
await asyncio.sleep(0.2)
|
| 923 |
+
await d.press_key("Escape")
|
| 924 |
+
shot = await d.screenshot(f"constraint_{c_type}.png")
|
| 925 |
+
r = Result(True, f"constraint {c_type} on {len(entity_xys)} entities", shot)
|
| 926 |
+
_record(tool_key, {"entities": [list(e) for e in entity_xys]}, r)
|
| 927 |
+
return r
|
| 928 |
+
|
| 929 |
+
|
| 930 |
+
async def sketch_exit(d: OnshapeDriver, commit: bool = True, **_kw) -> Result:
|
| 931 |
+
"""Exit and accept (or cancel) the active sketch."""
|
| 932 |
+
before = await d.screenshot("sketch_exit_before.png")
|
| 933 |
+
if commit:
|
| 934 |
+
# Click green checkmark button on dialog
|
| 935 |
+
ok_btn = d.page.locator(".ns-dialog-button-ok, .button-ok").first
|
| 936 |
+
if await ok_btn.count() > 0:
|
| 937 |
+
await ok_btn.click()
|
| 938 |
+
else:
|
| 939 |
+
await d.click(424.0, 93.0)
|
| 940 |
+
await asyncio.sleep(0.5)
|
| 941 |
+
await d.press_chord("Shift", "Enter")
|
| 942 |
+
else:
|
| 943 |
+
cancel_btn = d.page.locator(".ns-dialog-button-cancel, .button-cancel").first
|
| 944 |
+
if await cancel_btn.count() > 0:
|
| 945 |
+
await cancel_btn.click()
|
| 946 |
+
else:
|
| 947 |
+
await d.press_key("Escape")
|
| 948 |
+
await asyncio.sleep(0.5)
|
| 949 |
+
await d.press_key("Escape")
|
| 950 |
+
await asyncio.sleep(0.5)
|
| 951 |
+
after = await d.screenshot("sketch_exit_after.png")
|
| 952 |
+
r = Result(
|
| 953 |
+
True, "exit sketch", after, {"before": str(before), "after": str(after), "commit": commit}
|
| 954 |
+
)
|
| 955 |
+
_record("sketch.exit", {"commit": commit}, r)
|
| 956 |
+
return r
|
| 957 |
+
|
| 958 |
+
|
| 959 |
+
# Feature tools
|
| 960 |
+
|
| 961 |
+
|
| 962 |
+
async def _select_latest_sketch(d: OnshapeDriver) -> bool:
|
| 963 |
+
"""Click the most recent sketch in Onshape's feature tree so the
|
| 964 |
+
extrude tool knows what region to use. Returns True if a sketch was
|
| 965 |
+
selected, False otherwise.
|
| 966 |
+
|
| 967 |
+
When the doc has multiple sketches, pressing E with nothing selected
|
| 968 |
+
picks nothing and Onshape shows "Select face or sketch region to
|
| 969 |
+
extrude". Clicking the latest sketch in the tree pre-selects it.
|
| 970 |
+
"""
|
| 971 |
+
try:
|
| 972 |
+
clicked = await d.page.evaluate(
|
| 973 |
+
"""() => {
|
| 974 |
+
// Feature tree rows. Onshape uses a few different class
|
| 975 |
+
// names across versions; grab anything that looks like
|
| 976 |
+
// a tree node containing a Sketch label.
|
| 977 |
+
const candidates = document.querySelectorAll(
|
| 978 |
+
'[class*="feature-tree"] [class*="item"], ' +
|
| 979 |
+
'[class*="FeatureTree"] [class*="item"], ' +
|
| 980 |
+
'[data-test*="feature-tree"] [class*="row"]'
|
| 981 |
+
);
|
| 982 |
+
let best = null;
|
| 983 |
+
let bestNum = -1;
|
| 984 |
+
for (const el of candidates) {
|
| 985 |
+
const t = (el.textContent || '').trim();
|
| 986 |
+
const m = t.match(/^Sketch\\s*(\\d+)/i);
|
| 987 |
+
if (m) {
|
| 988 |
+
const n = parseInt(m[1], 10);
|
| 989 |
+
if (n > bestNum) { bestNum = n; best = el; }
|
| 990 |
+
}
|
| 991 |
+
}
|
| 992 |
+
if (best) { best.click(); return true; }
|
| 993 |
+
return false;
|
| 994 |
+
}"""
|
| 995 |
+
)
|
| 996 |
+
if clicked:
|
| 997 |
+
await asyncio.sleep(0.4)
|
| 998 |
+
return bool(clicked)
|
| 999 |
+
except Exception:
|
| 1000 |
+
return False
|
| 1001 |
+
|
| 1002 |
+
|
| 1003 |
+
async def feature_extrude(d: OnshapeDriver, depth_mm: float | None = None) -> Result:
|
| 1004 |
+
"""Extrude a sketch region. depth_mm is a number in millimetres.
|
| 1005 |
+
|
| 1006 |
+
Workflow:
|
| 1007 |
+
1. Click the most recent sketch in the feature tree so the
|
| 1008 |
+
extrude tool knows which region to use. Required when the
|
| 1009 |
+
doc has more than one sketch.
|
| 1010 |
+
2. Activate the extrude tool (E or toolbar).
|
| 1011 |
+
3. Wait for the dialog. Find the depth input, clear it, type the
|
| 1012 |
+
value with "mm" unit, press Enter.
|
| 1013 |
+
4. Click the green checkmark to commit the feature.
|
| 1014 |
+
"""
|
| 1015 |
+
# Step 1: pre-select the most recent sketch
|
| 1016 |
+
await _select_latest_sketch(d)
|
| 1017 |
+
|
| 1018 |
+
# Step 2: activate extrude
|
| 1019 |
+
b = binding_for("feature.extrude")
|
| 1020 |
+
if b.keys:
|
| 1021 |
+
await d.press_chord(*b.keys)
|
| 1022 |
+
elif b.toolbar_text:
|
| 1023 |
+
clicked = await d.click_text(b.toolbar_text, timeout_ms=3000)
|
| 1024 |
+
if not clicked:
|
| 1025 |
+
return Result(False, f"feature.extrude: toolbar {b.toolbar_text!r} not found")
|
| 1026 |
+
else:
|
| 1027 |
+
return Result(False, "feature.extrude: no binding")
|
| 1028 |
+
await asyncio.sleep(0.5) # dialog open animation
|
| 1029 |
+
|
| 1030 |
+
if depth_mm is not None:
|
| 1031 |
+
# Step 3: fill the depth input. Onshape renders the depth field
|
| 1032 |
+
# as an input.os-canvas-text-edit. .fill() clears the field
|
| 1033 |
+
# first, then types the value with the unit suffix so Onshape
|
| 1034 |
+
# doesn't reinterpret it as the default unit (cm).
|
| 1035 |
+
depth_str = f"{depth_mm:g} mm"
|
| 1036 |
+
try:
|
| 1037 |
+
depth_input = d.page.locator("input.os-canvas-text-edit").first
|
| 1038 |
+
if await depth_input.count() > 0:
|
| 1039 |
+
await depth_input.fill(depth_str)
|
| 1040 |
+
else:
|
| 1041 |
+
# Fallback: select all and type.
|
| 1042 |
+
await d.press_chord("Control", "a")
|
| 1043 |
+
await d.type_text(depth_str)
|
| 1044 |
+
except Exception:
|
| 1045 |
+
await d.press_chord("Control", "a")
|
| 1046 |
+
await d.type_text(depth_str)
|
| 1047 |
+
await asyncio.sleep(0.2)
|
| 1048 |
+
await d.press_key("Enter")
|
| 1049 |
+
await asyncio.sleep(0.4)
|
| 1050 |
+
|
| 1051 |
+
# Step 4: commit with the green checkmark. Try aria-label first,
|
| 1052 |
+
# fall back to the hardcoded toolbar position.
|
| 1053 |
+
try:
|
| 1054 |
+
check = d.page.locator(
|
| 1055 |
+
'button[aria-label*="check" i], button[title*="check" i]'
|
| 1056 |
+
).first
|
| 1057 |
+
if await check.count() > 0:
|
| 1058 |
+
await check.click()
|
| 1059 |
+
else:
|
| 1060 |
+
await d.click(294.0, 105.0)
|
| 1061 |
+
except Exception:
|
| 1062 |
+
await d.click(294.0, 105.0)
|
| 1063 |
+
await asyncio.sleep(0.3)
|
| 1064 |
+
# Some extrude paths need a second confirm. Shift+Enter
|
| 1065 |
+
# dismisses any tooltip without committing the wrong action.
|
| 1066 |
+
await d.press_chord("Shift", "Enter")
|
| 1067 |
+
await asyncio.sleep(0.5)
|
| 1068 |
+
|
| 1069 |
+
shot = await d.screenshot("feature_extrude.png")
|
| 1070 |
+
r = Result(True, f"extrude depth={depth_mm}mm", shot)
|
| 1071 |
+
_record("feature.extrude", {"depth_mm": depth_mm}, r)
|
| 1072 |
+
return r
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
async def feature_fillet(d: OnshapeDriver, radius_mm: float | None = None) -> Result:
|
| 1076 |
+
b = binding_for("feature.fillet")
|
| 1077 |
+
if b.toolbar_text:
|
| 1078 |
+
clicked = await d.click_text(b.toolbar_text, timeout_ms=3000)
|
| 1079 |
+
if not clicked and b.keys:
|
| 1080 |
+
await d.press_chord(*b.keys)
|
| 1081 |
+
elif b.keys:
|
| 1082 |
+
await d.press_chord(*b.keys)
|
| 1083 |
+
else:
|
| 1084 |
+
return Result(False, "feature.fillet: no binding")
|
| 1085 |
+
await asyncio.sleep(0.3)
|
| 1086 |
+
if radius_mm is not None:
|
| 1087 |
+
await d.type_text(str(radius_mm))
|
| 1088 |
+
await d.press_key("Enter")
|
| 1089 |
+
await asyncio.sleep(0.3)
|
| 1090 |
+
shot = await d.screenshot("feature_fillet.png")
|
| 1091 |
+
r = Result(True, f"fillet r={radius_mm}", shot)
|
| 1092 |
+
_record("feature.fillet", {"radius_mm": radius_mm}, r)
|
| 1093 |
+
return r
|
| 1094 |
+
|
| 1095 |
+
|
| 1096 |
+
async def feature_chamfer(d: OnshapeDriver, distance_mm: float | None = None) -> Result:
|
| 1097 |
+
b = binding_for("feature.chamfer")
|
| 1098 |
+
if b.toolbar_text is None:
|
| 1099 |
+
return Result(False, "feature.chamfer: no binding")
|
| 1100 |
+
clicked = await d.click_text(b.toolbar_text, timeout_ms=3000)
|
| 1101 |
+
if not clicked:
|
| 1102 |
+
return Result(False, f"feature.chamfer: toolbar {b.toolbar_text!r} not found")
|
| 1103 |
+
await asyncio.sleep(0.3)
|
| 1104 |
+
if distance_mm is not None:
|
| 1105 |
+
await d.type_text(str(distance_mm))
|
| 1106 |
+
await d.press_key("Enter")
|
| 1107 |
+
await asyncio.sleep(0.3)
|
| 1108 |
+
shot = await d.screenshot("feature_chamfer.png")
|
| 1109 |
+
r = Result(True, f"chamfer d={distance_mm}", shot)
|
| 1110 |
+
_record("feature.chamfer", {"distance_mm": distance_mm}, r)
|
| 1111 |
+
return r
|
| 1112 |
+
|
| 1113 |
+
|
| 1114 |
+
# Selection
|
| 1115 |
+
|
| 1116 |
+
|
| 1117 |
+
async def select_face(d: OnshapeDriver, x: float, y: float) -> Result:
|
| 1118 |
+
"""Click in the viewport to select a face. Best-effort: the face at
|
| 1119 |
+
that pixel may not be what was intended, so the LLM loop verifies via
|
| 1120 |
+
screenshot."""
|
| 1121 |
+
await d.click(x, y)
|
| 1122 |
+
await asyncio.sleep(0.2)
|
| 1123 |
+
shot = await d.screenshot("select_face.png")
|
| 1124 |
+
r = Result(True, f"clicked ({x},{y})", shot, {"click": [x, y]})
|
| 1125 |
+
_record("select.face", {"x": x, "y": y}, r)
|
| 1126 |
+
return r
|
| 1127 |
+
|
| 1128 |
+
|
| 1129 |
+
async def select_edge(d: OnshapeDriver, x: float, y: float) -> Result:
|
| 1130 |
+
await d.click(x, y)
|
| 1131 |
+
await asyncio.sleep(0.2)
|
| 1132 |
+
shot = await d.screenshot("select_edge.png")
|
| 1133 |
+
r = Result(True, f"clicked edge ({x},{y})", shot, {"click": [x, y]})
|
| 1134 |
+
_record("select.edge", {"x": x, "y": y}, r)
|
| 1135 |
+
return r
|
| 1136 |
+
|
| 1137 |
+
|
| 1138 |
+
# Global undo and redo
|
| 1139 |
+
|
| 1140 |
+
|
| 1141 |
+
async def undo(d: OnshapeDriver) -> Result:
|
| 1142 |
+
b = binding_for("ui.undo")
|
| 1143 |
+
await d.press_chord(*b.keys) # type: ignore[arg-type]
|
| 1144 |
+
await asyncio.sleep(0.2)
|
| 1145 |
+
shot = await d.screenshot("undo.png")
|
| 1146 |
+
r = Result(True, "undo", shot)
|
| 1147 |
+
_record("ui.undo", {}, r)
|
| 1148 |
+
return r
|
| 1149 |
+
|
| 1150 |
+
|
| 1151 |
+
async def redo(d: OnshapeDriver) -> Result:
|
| 1152 |
+
b = binding_for("ui.redo")
|
| 1153 |
+
await d.press_chord(*b.keys) # type: ignore[arg-type]
|
| 1154 |
+
await asyncio.sleep(0.2)
|
| 1155 |
+
shot = await d.screenshot("redo.png")
|
| 1156 |
+
r = Result(True, "redo", shot)
|
| 1157 |
+
_record("ui.redo", {}, r)
|
| 1158 |
+
return r
|
| 1159 |
+
|
| 1160 |
+
|
| 1161 |
+
async def wait(d: OnshapeDriver, seconds: float = 1.0) -> Result:
|
| 1162 |
+
"""No-op pause. Useful when the LLM wants to let a dialog animation
|
| 1163 |
+
finish, or just look at the current state again on the next step."""
|
| 1164 |
+
await asyncio.sleep(max(0.0, seconds))
|
| 1165 |
+
shot = await d.screenshot("wait.png")
|
| 1166 |
+
r = Result(True, f"waited {seconds}s", shot)
|
| 1167 |
+
_record("ui.wait", {"seconds": seconds}, r)
|
| 1168 |
+
return r
|
| 1169 |
+
|
| 1170 |
+
|
| 1171 |
+
async def screenshot_only(d: OnshapeDriver, name: str = "agent.png") -> Result:
|
| 1172 |
+
"""Bare screenshot without any other action. Lets the LLM re-observe
|
| 1173 |
+
the viewport without doing anything else."""
|
| 1174 |
+
shot = await d.screenshot(name)
|
| 1175 |
+
r = Result(True, f"screenshot {name}", shot)
|
| 1176 |
+
_record("screenshot", {"name": name}, r)
|
| 1177 |
+
return r
|
| 1178 |
+
|
| 1179 |
+
|
| 1180 |
+
async def doc_open(d: OnshapeDriver, url: str) -> Result:
|
| 1181 |
+
"""Navigate to a specific Onshape document URL. Pass either a full
|
| 1182 |
+
URL or a `d/<docId>/e/<elementId>` path. Usually elementId is a
|
| 1183 |
+
Part Studio — if the document has one, deep-link to it directly.
|
| 1184 |
+
"""
|
| 1185 |
+
if not url:
|
| 1186 |
+
return Result(False, "doc.open: missing url")
|
| 1187 |
+
full = url if url.startswith("http") else f"https://cad.onshape.com/{url.lstrip('/')}"
|
| 1188 |
+
await d.open(full)
|
| 1189 |
+
shot = await d.screenshot("doc_open.png")
|
| 1190 |
+
r = Result(True, f"opened {full}", shot, {"url": full})
|
| 1191 |
+
_record("doc.open", {"url": full}, r)
|
| 1192 |
+
return r
|
| 1193 |
+
|
| 1194 |
+
|
| 1195 |
+
async def doc_new(d: OnshapeDriver) -> Result:
|
| 1196 |
+
"""Click the Onshape "Create" / new document button. Lands in a fresh
|
| 1197 |
+
Part Studio, which is where sketch + feature tools work.
|
| 1198 |
+
"""
|
| 1199 |
+
try:
|
| 1200 |
+
# Click Create dropdown button at top-left
|
| 1201 |
+
await d.click(75, 70)
|
| 1202 |
+
await asyncio.sleep(0.5)
|
| 1203 |
+
# Click "Document..." item
|
| 1204 |
+
await d.click(75, 110)
|
| 1205 |
+
await asyncio.sleep(1.0)
|
| 1206 |
+
await d.type_text("PartStudio")
|
| 1207 |
+
await asyncio.sleep(0.2)
|
| 1208 |
+
await d.press_key("Enter")
|
| 1209 |
+
await d.page.wait_for_url("**/documents/**/e/**", timeout=30000)
|
| 1210 |
+
await d.wait_for_app()
|
| 1211 |
+
except Exception as e:
|
| 1212 |
+
return Result(False, f"doc.new failed: {e}")
|
| 1213 |
+
shot = await d.screenshot("doc_new.png")
|
| 1214 |
+
r = Result(True, "new document", shot)
|
| 1215 |
+
_record("doc.new", {}, r)
|
| 1216 |
+
return r
|
src/onshape_mcp/vision.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini web client. Cookie-auth, no API key. Cookie file is gitignored
|
| 2 |
+
(see SECURITY.md). Cookie file format: {"secure_1psid": ..., "secure_1psidts": ...}.
|
| 3 |
+
|
| 4 |
+
I tried to keep the wrapper thin. The `gemini-webapi` package handles the
|
| 5 |
+
auth dance but its surface shifts often, so pin and verify on every
|
| 6 |
+
install.
|
| 7 |
+
|
| 8 |
+
The library supports two cookie paths: pass them explicitly, or let it
|
| 9 |
+
use browser-cookie3 to read straight from Chrome. The latter is more
|
| 10 |
+
robust because it grabs the full session (PSID, PSIDTS, PSIDCC, plus
|
| 11 |
+
the third-party cookies) and uses the keyring to decrypt. Explicit
|
| 12 |
+
cookies can fail for the multimodal endpoint even when text chat works,
|
| 13 |
+
because the image-upload path validates against more cookies.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import asyncio
|
| 19 |
+
import json
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from .config import settings
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GeminiWeb:
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
cookie_file: Path | None = None,
|
| 30 |
+
model: str | None = None,
|
| 31 |
+
temporary: bool | None = None,
|
| 32 |
+
auto_cleanup: bool | None = None,
|
| 33 |
+
) -> None:
|
| 34 |
+
self.cookie_file = (cookie_file or settings.gemini_cookie_file).resolve()
|
| 35 |
+
self.model = model or settings.gemini_model
|
| 36 |
+
self.temporary = settings.gemini_temporary_chat if temporary is None else temporary
|
| 37 |
+
self.auto_cleanup = settings.gemini_auto_cleanup if auto_cleanup is None else auto_cleanup
|
| 38 |
+
self._client: Any = None
|
| 39 |
+
self._active_session: Any = None
|
| 40 |
+
self.created_chat_ids: set[str] = set()
|
| 41 |
+
|
| 42 |
+
def _has_cookie_file(self) -> bool:
|
| 43 |
+
"""True if the cookie file exists and has the two required values."""
|
| 44 |
+
if not self.cookie_file.exists():
|
| 45 |
+
return False
|
| 46 |
+
try:
|
| 47 |
+
data = json.loads(self.cookie_file.read_text(encoding="utf-8"))
|
| 48 |
+
except (json.JSONDecodeError, OSError):
|
| 49 |
+
return False
|
| 50 |
+
psid = data.get("secure_1psid") or data.get("__Secure-1PSID")
|
| 51 |
+
psidts = data.get("secure_1psidts") or data.get("__Secure-1PSIDTS")
|
| 52 |
+
return bool(psid and psidts)
|
| 53 |
+
|
| 54 |
+
async def _ensure_client(self) -> None:
|
| 55 |
+
if self._client is not None:
|
| 56 |
+
return
|
| 57 |
+
try:
|
| 58 |
+
from gemini_webapi import GeminiClient # type: ignore
|
| 59 |
+
except ImportError as e: # pragma: no cover
|
| 60 |
+
raise RuntimeError("Install gemini-webapi: `pip install gemini-webapi`") from e
|
| 61 |
+
|
| 62 |
+
# Prefer letting the library use browser-cookie3 directly. It
|
| 63 |
+
# grabs the full Chrome session (PSID, PSIDTS, PSIDCC, third-party
|
| 64 |
+
# cookies) and decrypts via the keyring, which is the path that
|
| 65 |
+
# works reliably for the multimodal (image upload) endpoint.
|
| 66 |
+
try:
|
| 67 |
+
client = GeminiClient(None, None, model=self.model)
|
| 68 |
+
await client.init(timeout=15)
|
| 69 |
+
self._client = client
|
| 70 |
+
return
|
| 71 |
+
except Exception as e:
|
| 72 |
+
print(f"[vision] live browser session init note ({e}); trying cookie file")
|
| 73 |
+
|
| 74 |
+
# Fallback to explicit cookie file if browser-cookie3 could not read Chrome
|
| 75 |
+
if self._has_cookie_file():
|
| 76 |
+
data = json.loads(self.cookie_file.read_text(encoding="utf-8"))
|
| 77 |
+
psid = data.get("secure_1psid") or data.get("__Secure-1PSID")
|
| 78 |
+
psidts = data.get("secure_1psidts") or data.get("__Secure-1PSIDTS")
|
| 79 |
+
self._client = GeminiClient(psid, psidts, model=self.model)
|
| 80 |
+
await self._client.init(timeout=30)
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
raise RuntimeError("No valid Gemini credentials found (neither live Chrome nor cookie file).")
|
| 84 |
+
|
| 85 |
+
async def _refresh_cookies_from_chrome(self) -> bool:
|
| 86 |
+
"""Re-extract Gemini cookies from the user's real Chrome. The
|
| 87 |
+
gemini-webapi library doesn't refresh its session automatically
|
| 88 |
+
and the cookies can go stale between calls. This pulls fresh
|
| 89 |
+
ones and overwrites the cookie file.
|
| 90 |
+
"""
|
| 91 |
+
try:
|
| 92 |
+
import browser_cookie3
|
| 93 |
+
except ImportError:
|
| 94 |
+
return False
|
| 95 |
+
try:
|
| 96 |
+
cj = browser_cookie3.chrome(domain_name="google.com")
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"[vision] could not read Chrome cookies: {e}")
|
| 99 |
+
return False
|
| 100 |
+
psid = psidts = None
|
| 101 |
+
for c in cj:
|
| 102 |
+
if c.name == "__Secure-1PSID":
|
| 103 |
+
psid = c.value
|
| 104 |
+
elif c.name == "__Secure-1PSIDTS":
|
| 105 |
+
psidts = c.value
|
| 106 |
+
if not (psid and psidts):
|
| 107 |
+
return False
|
| 108 |
+
self.cookie_file.parent.mkdir(parents=True, exist_ok=True)
|
| 109 |
+
self.cookie_file.write_text(
|
| 110 |
+
json.dumps(
|
| 111 |
+
{
|
| 112 |
+
"secure_1psid": psid,
|
| 113 |
+
"secure_1psidts": psidts,
|
| 114 |
+
"_note": "refreshed by vision.py",
|
| 115 |
+
},
|
| 116 |
+
indent=2,
|
| 117 |
+
),
|
| 118 |
+
encoding="utf-8",
|
| 119 |
+
)
|
| 120 |
+
return True
|
| 121 |
+
|
| 122 |
+
async def new_session(self, temporary: bool | None = None) -> Any:
|
| 123 |
+
"""Create and track a new ChatSession attached to this client."""
|
| 124 |
+
await self._ensure_client()
|
| 125 |
+
session = self._client.start_chat()
|
| 126 |
+
self._session_temporary = self.temporary if temporary is None else temporary
|
| 127 |
+
self._active_session = session
|
| 128 |
+
return session
|
| 129 |
+
|
| 130 |
+
def get_session(self) -> Any:
|
| 131 |
+
"""Return the active ChatSession, if any."""
|
| 132 |
+
return self._active_session
|
| 133 |
+
|
| 134 |
+
def reset_session(self) -> None:
|
| 135 |
+
"""Clear the active ChatSession."""
|
| 136 |
+
self._active_session = None
|
| 137 |
+
|
| 138 |
+
async def delete_chat(self, cid: str) -> bool:
|
| 139 |
+
"""Delete a specific conversation by chat ID. Returns True if successful."""
|
| 140 |
+
await self._ensure_client()
|
| 141 |
+
try:
|
| 142 |
+
await self._client.delete_chat(cid)
|
| 143 |
+
self.created_chat_ids.discard(cid)
|
| 144 |
+
print(f"[vision] deleted chat {cid}")
|
| 145 |
+
return True
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"[vision] failed to delete chat {cid}: {e}")
|
| 148 |
+
return False
|
| 149 |
+
|
| 150 |
+
async def list_chats(self) -> list[Any]:
|
| 151 |
+
"""List all conversations in the user's Gemini account."""
|
| 152 |
+
await self._ensure_client()
|
| 153 |
+
try:
|
| 154 |
+
res = self._client.list_chats()
|
| 155 |
+
return res or []
|
| 156 |
+
except Exception as e:
|
| 157 |
+
print(f"[vision] failed to list chats: {e}")
|
| 158 |
+
return []
|
| 159 |
+
|
| 160 |
+
async def cleanup_created_chats(self) -> int:
|
| 161 |
+
"""Delete all non-temporary chats created and tracked by this instance."""
|
| 162 |
+
if not self.created_chat_ids or self._client is None:
|
| 163 |
+
return 0
|
| 164 |
+
to_delete = list(self.created_chat_ids)
|
| 165 |
+
deleted = 0
|
| 166 |
+
for cid in to_delete:
|
| 167 |
+
if await self.delete_chat(cid):
|
| 168 |
+
deleted += 1
|
| 169 |
+
return deleted
|
| 170 |
+
|
| 171 |
+
async def ask_with_image(
|
| 172 |
+
self,
|
| 173 |
+
prompt: str,
|
| 174 |
+
image_path: Path,
|
| 175 |
+
session: Any = None,
|
| 176 |
+
temporary: bool | None = None,
|
| 177 |
+
timeout: int = 120,
|
| 178 |
+
) -> str:
|
| 179 |
+
# Try up to 2 times. If the first call hits an "UNAUTHENTICATED"
|
| 180 |
+
# error, re-extract cookies from Chrome (they go stale) and
|
| 181 |
+
# re-initialise the client.
|
| 182 |
+
last_err: Exception | None = None
|
| 183 |
+
use_temp = (
|
| 184 |
+
getattr(self, "_session_temporary", self.temporary) if temporary is None else temporary
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
for attempt in range(2):
|
| 188 |
+
await self._ensure_client()
|
| 189 |
+
if attempt > 0:
|
| 190 |
+
chat = self._client.start_chat()
|
| 191 |
+
self._active_session = chat
|
| 192 |
+
else:
|
| 193 |
+
chat = session or self._active_session
|
| 194 |
+
if chat is None:
|
| 195 |
+
chat = self._client.start_chat()
|
| 196 |
+
self._active_session = chat
|
| 197 |
+
|
| 198 |
+
temp_flag = use_temp
|
| 199 |
+
try:
|
| 200 |
+
response = await asyncio.wait_for(
|
| 201 |
+
chat.send_message(prompt, files=[str(image_path)], temporary=temp_flag),
|
| 202 |
+
timeout=timeout,
|
| 203 |
+
)
|
| 204 |
+
cid = getattr(chat, "cid", None)
|
| 205 |
+
if cid and not temp_flag:
|
| 206 |
+
self.created_chat_ids.add(cid)
|
| 207 |
+
return getattr(response, "text", str(response))
|
| 208 |
+
except Exception as e:
|
| 209 |
+
last_err = e
|
| 210 |
+
if "UNAUTHENTICATED" in str(e) or "Permission" in str(e):
|
| 211 |
+
print(f"[vision] auth error on attempt {attempt + 1}: {e}")
|
| 212 |
+
try:
|
| 213 |
+
await self._client.close()
|
| 214 |
+
except Exception:
|
| 215 |
+
pass
|
| 216 |
+
self._client = None
|
| 217 |
+
self._active_session = None
|
| 218 |
+
if attempt == 0:
|
| 219 |
+
print("[vision] re-extracting cookies from Chrome")
|
| 220 |
+
if not await self._refresh_cookies_from_chrome():
|
| 221 |
+
print(
|
| 222 |
+
"[vision] cookie refresh failed; will retry with whatever we have"
|
| 223 |
+
)
|
| 224 |
+
continue
|
| 225 |
+
raise
|
| 226 |
+
raise last_err if last_err else RuntimeError("vision: unknown failure")
|
| 227 |
+
|
| 228 |
+
async def close(self) -> None:
|
| 229 |
+
if self.auto_cleanup and self.created_chat_ids:
|
| 230 |
+
try:
|
| 231 |
+
await self.cleanup_created_chats()
|
| 232 |
+
except Exception as e:
|
| 233 |
+
print(f"[vision] error during auto-cleanup: {e}")
|
| 234 |
+
self._active_session = None
|
| 235 |
+
if self._client is not None:
|
| 236 |
+
try:
|
| 237 |
+
await self._client.close()
|
| 238 |
+
except Exception:
|
| 239 |
+
pass
|
| 240 |
+
self._client = None
|