# Directory Structure
```
├── .gitignore
├── ai
│ ├── PROMPTS.md
│ └── rhino-python-bridge-docs.md
├── claude_adapter.py
├── config
│ └── default_config.json
├── docs
│ └── ARCHITECTURE.md
├── README.md
├── requirements-dev.txt
├── requirements.txt
├── rhino-python-bridge-docs.md
├── setup.py
├── src
│ ├── __init__.py
│ ├── rhino_mcp
│ │ ├── __init__.py
│ │ ├── mcp_server.py
│ │ └── rhino_client.py
│ └── rhino_plugin
│ ├── __init__.py
│ └── rhino_server.py
├── test_create_curve.py
├── test_curve.py
├── test_mcp_client.py
├── tests
│ └── rhino_mcp
│ └── test_rhino_client.py
└── ws_adapter.py
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | *.egg-info/
24 | .installed.cfg
25 | *.egg
26 |
27 | # Virtual environments
28 | .env
29 | .venv
30 | env/
31 | venv/
32 | ENV/
33 | env.bak/
34 | venv.bak/
35 |
36 | # IDE files
37 | .idea/
38 | .vscode/
39 | *.swp
40 | *.swo
41 |
42 | # Unit test / coverage reports
43 | htmlcov/
44 | .tox/
45 | .nox/
46 | .coverage
47 | .coverage.*
48 | .cache
49 | nosetests.xml
50 | coverage.xml
51 | *.cover
52 | .hypothesis/
53 | .pytest_cache/
54 |
55 | # Jupyter Notebook
56 | .ipynb_checkpoints
57 |
58 | # Logs
59 | *.log
60 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # RhinoMCP
2 |
3 | RhinoMCP connects Rhino3D to Claude AI via the Model Context Protocol (MCP), enabling Claude to directly interact with and control Rhino3D for AI-assisted 3D modeling, analysis, and design workflows.
4 |
5 | ## Project Overview
6 |
7 | This integration consists of two main components:
8 |
9 | 1. **Rhino Plugin**: A socket server that runs inside Rhino's Python editor, providing a communication interface to Rhino's functionality.
10 | 2. **MCP Server**: An implementation of the Model Context Protocol that connects Claude AI to the Rhino plugin, enabling AI-controlled operations.
11 |
12 | ## Features
13 |
14 | - Socket-based bidirectional communication between Python and Rhino
15 | - Model Context Protocol server for Claude AI integration
16 | - Support for NURBS curve creation (initial test feature)
17 | - Python script execution within Rhino's context
18 | - Compatible with both Claude Desktop and Windsurf as clients
19 |
20 | ## Installation
21 |
22 | ### Requirements
23 |
24 | - Rhinoceros 3D (Version 7 or 8)
25 | - Python 3.10 or higher
26 | - Windows 10 or 11
27 |
28 | ### Install Using uv (Recommended)
29 |
30 | ```bash
31 | # Create and activate a virtual environment
32 | mkdir -p .venv
33 | uv venv .venv
34 | source .venv/Scripts/activate # On Windows with Git Bash
35 |
36 | # Install the package
37 | uv pip install -e .
38 | ```
39 |
40 | ### Install Using pip
41 |
42 | ```bash
43 | # Create and activate a virtual environment
44 | python -m venv .venv
45 | .venv\Scripts\activate # On Windows
46 |
47 | # Install the package
48 | pip install -e .
49 | ```
50 |
51 | ## Usage
52 |
53 | ### Step 1: Start the Rhino Bridge Server
54 |
55 | 1. Open Rhino
56 | 2. Type `EditPythonScript` in the command line to open Rhino's Python editor
57 | 3. Open the Rhino server script from `src/rhino_plugin/rhino_server.py`
58 | 4. Run the script (F5 or click the Run button)
59 | 5. Verify you see "Rhino Bridge started!" in the output panel
60 |
61 | ### Step 2: Start the MCP Server
62 |
63 | ```bash
64 | # Activate your virtual environment
65 | source .venv/Scripts/activate # On Windows with Git Bash
66 |
67 | # Start the MCP server
68 | rhinomcp
69 | ```
70 |
71 | Or run with custom settings:
72 |
73 | ```bash
74 | rhinomcp --host 127.0.0.1 --port 5000 --rhino-host 127.0.0.1 --rhino-port 8888 --debug
75 | ```
76 |
77 | ### Step 3: Connect with Claude Desktop or Windsurf
78 |
79 | Configure Claude Desktop or Windsurf to connect to the MCP server at:
80 |
81 | ```
82 | ws://127.0.0.1:5000
83 | ```
84 |
85 | ### Example: Creating a NURBS Curve
86 |
87 | When connected to Claude, you can ask it to create a NURBS curve in Rhino with a prompt like:
88 |
89 | ```
90 | Create a NURBS curve in Rhino using points at (0,0,0), (5,10,0), (10,0,0), and (15,10,0).
91 | ```
92 |
93 | ## Development
94 |
95 | ### Setup Development Environment
96 |
97 | ```bash
98 | # Clone the repository
99 | git clone https://github.com/FernandoMaytorena/RhinoMCP.git
100 | cd RhinoMCP
101 |
102 | # Create and activate virtual environment
103 | uv venv .venv
104 | source .venv/Scripts/activate # On Windows with Git Bash
105 |
106 | # Install development dependencies
107 | uv pip install -e ".[dev]"
108 | ```
109 |
110 | ### Run Tests
111 |
112 | ```bash
113 | pytest
114 | ```
115 |
116 | ### Code Style
117 |
118 | This project uses Ruff for linting and formatting:
119 |
120 | ```bash
121 | ruff check .
122 | ruff format .
123 | ```
124 |
125 | ## Project Structure
126 |
127 | ```
128 | RhinoMCP/
129 | ├── src/
130 | │ ├── rhino_plugin/ # Code that runs inside Rhino
131 | │ │ └── rhino_server.py
132 | │ └── rhino_mcp/ # MCP server implementation
133 | │ ├── rhino_client.py
134 | │ └── mcp_server.py
135 | ├── tests/ # Test modules
136 | ├── docs/ # Documentation
137 | ├── config/ # Configuration files
138 | ├── ai/ # AI documentation and prompts
139 | ├── setup.py # Package installation
140 | ├── requirements.txt # Package dependencies
141 | └── README.md # Project documentation
142 | ```
143 |
144 | ## License
145 |
146 | [MIT License](LICENSE)
147 |
148 | ## Contributing
149 |
150 | Contributions are welcome! Please feel free to submit a Pull Request.
151 |
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
1 | websockets>=11.0.0
2 | typing-extensions>=4.0.0
3 |
```
--------------------------------------------------------------------------------
/requirements-dev.txt:
--------------------------------------------------------------------------------
```
1 | -r requirements.txt
2 | pytest>=7.0.0
3 | pytest-mock>=3.10.0
4 | pytest-asyncio>=0.21.0
5 | ruff>=0.0.270
6 | mypy>=1.0.0
7 |
```
--------------------------------------------------------------------------------
/src/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """RhinoMCP package initialization.
2 |
3 | This package connects Rhino3D to Claude AI via the Model Context Protocol.
4 | """
5 |
6 | __version__ = "0.1.0"
7 |
```
--------------------------------------------------------------------------------
/src/rhino_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Rhino MCP module for RhinoMCP.
2 |
3 | This module implements the Model Context Protocol server that connects to Claude AI
4 | and communicates with the Rhino plugin via socket connection.
5 | """
6 |
```
--------------------------------------------------------------------------------
/src/rhino_plugin/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Rhino Plugin module for RhinoMCP.
2 |
3 | This module contains the components that run inside Rhino's Python environment
4 | and establishes a socket server to communicate with external Python clients.
5 | """
6 |
```
--------------------------------------------------------------------------------
/config/default_config.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "rhino_server": {
3 | "host": "127.0.0.1",
4 | "port": 8888
5 | },
6 | "mcp_server": {
7 | "host": "127.0.0.1",
8 | "port": 5000
9 | },
10 | "logging": {
11 | "level": "INFO",
12 | "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
13 | "file": null
14 | }
15 | }
16 |
```
--------------------------------------------------------------------------------
/test_curve.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | Test script to create a NURBS curve in Rhino via RhinoMCP.
4 | """
5 | from src.rhino_mcp.rhino_client import RhinoClient, Point3d
6 |
7 | def main():
8 | # Create a client and connect to Rhino
9 | client = RhinoClient()
10 | if client.connect():
11 | print("Connected to Rhino Bridge")
12 |
13 | # Create the points as dictionaries (Point3d is a TypedDict)
14 | points = [
15 | {"x": 0, "y": 0, "z": 0},
16 | {"x": 10, "y": 10, "z": 0},
17 | {"x": 20, "y": 0, "z": 0}
18 | ]
19 |
20 | # Send the curve creation command
21 | result = client.create_curve(points)
22 |
23 | # Print the result
24 | print("Curve creation result:", result)
25 |
26 | # Disconnect
27 | client.disconnect()
28 | else:
29 | print("Failed to connect to Rhino Bridge")
30 |
31 | if __name__ == "__main__":
32 | main()
33 |
```
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
```python
1 | """RhinoMCP package setup script."""
2 | from setuptools import setup, find_packages
3 |
4 | setup(
5 | name="rhinomcp",
6 | version="0.1.0",
7 | description="Connect Rhino3D to Claude AI via the Model Context Protocol",
8 | author="Fernando Maytorena",
9 | author_email="",
10 | url="https://github.com/FernandoMaytorena/RhinoMCP",
11 | package_dir={"": "src"},
12 | packages=find_packages(where="src"),
13 | python_requires=">=3.10",
14 | install_requires=[
15 | "websockets>=11.0.0",
16 | "typing-extensions>=4.0.0",
17 | ],
18 | extras_require={
19 | "dev": [
20 | "pytest>=7.0.0",
21 | "pytest-mock>=3.10.0",
22 | "pytest-asyncio>=0.21.0",
23 | "ruff>=0.0.270",
24 | "mypy>=1.0.0",
25 | ]
26 | },
27 | entry_points={
28 | "console_scripts": [
29 | "rhinomcp=rhino_mcp.mcp_server:main",
30 | ],
31 | },
32 | classifiers=[
33 | "Development Status :: 3 - Alpha",
34 | "Intended Audience :: Developers",
35 | "License :: OSI Approved :: MIT License",
36 | "Programming Language :: Python :: 3",
37 | "Programming Language :: Python :: 3.10",
38 | ],
39 | )
40 |
```
--------------------------------------------------------------------------------
/test_create_curve.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | Test script to create a curve in Rhino using the RhinoClient.
4 |
5 | This script connects directly to the Rhino Bridge and creates a NURBS curve
6 | with three points.
7 | """
8 | import sys
9 | import json
10 | from rhino_mcp.rhino_client import RhinoClient
11 |
12 | def main():
13 | """Connect to Rhino and create a curve."""
14 | # Create a client and connect to Rhino
15 | client = RhinoClient(host="127.0.0.1", port=8888)
16 | connected = client.connect()
17 |
18 | if not connected:
19 | print("Failed to connect to Rhino Bridge")
20 | return 1
21 |
22 | print("Connected to Rhino Bridge")
23 |
24 | # Create points for the curve
25 | points = [
26 | {"x": 0, "y": 0, "z": 0},
27 | {"x": 10, "y": 10, "z": 0},
28 | {"x": 20, "y": 0, "z": 0}
29 | ]
30 |
31 | # Format the command as expected by the Rhino Bridge server
32 | command = {
33 | "type": "create_curve",
34 | "data": {
35 | "points": points
36 | }
37 | }
38 |
39 | # Send the command to Rhino
40 | try:
41 | # The client.send_command method adds the type field, so we need to extract our data
42 | response = client.send_command("create_curve", {"points": points})
43 | print(f"Response from Rhino: {response}")
44 | return 0
45 | except Exception as e:
46 | print(f"Error creating curve: {e}")
47 | return 1
48 | finally:
49 | # Clean up
50 | client.disconnect()
51 |
52 | if __name__ == "__main__":
53 | sys.exit(main())
54 |
```
--------------------------------------------------------------------------------
/ws_adapter.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | WebSocket adapter for connecting Windsurf to the RhinoMCP server.
4 |
5 | This script forwards messages between Windsurf and the RhinoMCP server,
6 | acting as an adapter layer that handles the WebSocket connection.
7 | """
8 | import asyncio
9 | import json
10 | import sys
11 | import websockets
12 | from typing import Dict, Any, Optional, List
13 | import logging
14 | from websockets.exceptions import ConnectionClosed
15 |
16 | # Configure logging
17 | logging.basicConfig(
18 | level=logging.INFO,
19 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
20 | handlers=[
21 | logging.FileHandler("ws_adapter.log"),
22 | logging.StreamHandler(sys.stderr)
23 | ]
24 | )
25 | logger = logging.getLogger("ws-adapter")
26 |
27 | # Target RhinoMCP server URL
28 | TARGET_URL = "ws://127.0.0.1:5000"
29 |
30 | async def forward_messages():
31 | """Forward messages between stdin/stdout and the WebSocket server."""
32 | try:
33 | logger.info(f"Connecting to RhinoMCP server at {TARGET_URL}")
34 | async with websockets.connect(TARGET_URL) as websocket:
35 | logger.info("Connected to RhinoMCP server")
36 |
37 | # Start tasks for handling stdin->websocket and websocket->stdout
38 | stdin_task = asyncio.create_task(forward_stdin_to_websocket(websocket))
39 | ws_task = asyncio.create_task(forward_websocket_to_stdout(websocket))
40 |
41 | # Wait for either task to complete (or fail)
42 | done, pending = await asyncio.wait(
43 | [stdin_task, ws_task],
44 | return_when=asyncio.FIRST_COMPLETED
45 | )
46 |
47 | # Cancel any pending tasks
48 | for task in pending:
49 | task.cancel()
50 |
51 | # Check for exceptions
52 | for task in done:
53 | try:
54 | task.result()
55 | except Exception as e:
56 | logger.error(f"Task error: {str(e)}")
57 |
58 | except Exception as e:
59 | logger.error(f"Connection error: {str(e)}")
60 |
61 | async def forward_stdin_to_websocket(websocket):
62 | """Forward messages from stdin to the WebSocket server."""
63 | loop = asyncio.get_event_loop()
64 | while True:
65 | # Read a line from stdin (non-blocking)
66 | line = await loop.run_in_executor(None, sys.stdin.readline)
67 | if not line:
68 | logger.info("End of stdin, closing connection")
69 | break
70 |
71 | # Parse and forward the message
72 | try:
73 | message = line.strip()
74 | logger.debug(f"Sending to WS: {message}")
75 | await websocket.send(message)
76 | except Exception as e:
77 | logger.error(f"Error forwarding stdin to WebSocket: {str(e)}")
78 | break
79 |
80 | async def forward_websocket_to_stdout(websocket):
81 | """Forward messages from the WebSocket server to stdout."""
82 | try:
83 | async for message in websocket:
84 | logger.debug(f"Received from WS: {message}")
85 | # Write to stdout and flush
86 | print(message, flush=True)
87 | except ConnectionClosed:
88 | logger.info("WebSocket connection closed")
89 | except Exception as e:
90 | logger.error(f"Error forwarding WebSocket to stdout: {str(e)}")
91 |
92 | if __name__ == "__main__":
93 | try:
94 | # Run the message forwarding loop
95 | asyncio.run(forward_messages())
96 | except KeyboardInterrupt:
97 | logger.info("Adapter terminated by user")
98 | except Exception as e:
99 | logger.error(f"Unhandled exception: {str(e)}")
100 | sys.exit(1)
101 |
```
--------------------------------------------------------------------------------
/ai/PROMPTS.md:
--------------------------------------------------------------------------------
```markdown
1 | # RhinoMCP Prompt Engineering Guide
2 |
3 | This document provides guidance on effective prompt templates and strategies when using Claude AI with RhinoMCP for 3D modeling tasks.
4 |
5 | ## Overview
6 |
7 | RhinoMCP exposes Rhino3D functionality to Claude through the Model Context Protocol, allowing the AI to create and manipulate 3D geometry. Effective prompts help Claude understand the design intent and execute the appropriate commands.
8 |
9 | ## Prompt Templates
10 |
11 | ### Basic Curve Creation
12 |
13 | ```
14 | Create a NURBS curve in Rhino with the following points:
15 | - Point 1: (0, 0, 0)
16 | - Point 2: (5, 10, 0)
17 | - Point 3: (10, 0, 0)
18 | - Point 4: (15, 5, 0)
19 | ```
20 |
21 | ### Running Python Script in Rhino
22 |
23 | ```
24 | Execute the following Python script in Rhino to [describe purpose]:
25 |
26 | ```python
27 | # Your Python code here
28 | import Rhino
29 | import rhinoscriptsyntax as rs
30 |
31 | # Example: Create a sphere
32 | rs.AddSphere([0,0,0], 5)
33 | ```
34 | ```
35 |
36 | ### Checking Rhino Status
37 |
38 | ```
39 | Check if Rhino is connected and report back with the version information.
40 | ```
41 |
42 | ## Prompt Strategies
43 |
44 | ### Be Specific with Coordinates
45 |
46 | When creating geometry, provide exact coordinates rather than descriptive positions. This reduces ambiguity and ensures precise results.
47 |
48 | **Good:**
49 | ```
50 | Create a curve from (0,0,0) to (10,10,0) to (20,0,0)
51 | ```
52 |
53 | **Less Effective:**
54 | ```
55 | Create a curve that starts at the origin, goes up and to the right, then back down
56 | ```
57 |
58 | ### Use Visual References
59 |
60 | For complex shapes, provide a visual reference or description to help Claude understand the desired outcome.
61 |
62 | ```
63 | Create a curve that forms an "S" shape in the XY plane, starting at (0,0,0)
64 | and ending at (20,0,0), with control points approximately at:
65 | - (0,0,0)
66 | - (5,10,0)
67 | - (15,-10,0)
68 | - (20,0,0)
69 | ```
70 |
71 | ### Iterate and Refine
72 |
73 | Start with simple commands and gradually refine the design through conversation.
74 |
75 | ```
76 | Step 1: Create a basic curve along the X-axis from (0,0,0) to (20,0,0)
77 | Step 2: Now modify that curve to have a height of 5 units at its midpoint
78 | Step 3: Create a second curve parallel to the first, offset by 10 units in the Y direction
79 | ```
80 |
81 | ### Specify Units
82 |
83 | Always specify units when relevant to ensure the correct scale.
84 |
85 | ```
86 | Create a sphere with radius 5 millimeters at position (10,10,10)
87 | ```
88 |
89 | ## Common Patterns
90 |
91 | ### Design Iteration
92 |
93 | ```
94 | 1. Create a basic version of [design element]
95 | 2. Evaluate the result
96 | 3. Modify specific aspects: "Make this part more [attribute]"
97 | 4. Continue refining until satisfied
98 | ```
99 |
100 | ### Reference-Based Design
101 |
102 | ```
103 | 1. Begin with a reference: "Create a curve similar to [description]"
104 | 2. Provide feedback on how to adjust
105 | 3. Add features or modify to match requirements
106 | ```
107 |
108 | ## Input Constraints
109 |
110 | - Coordinate values should be numeric (avoid descriptive terms like "a little to the left")
111 | - Python scripts must be compatible with Rhino's Python environment
112 | - Avoid requesting operations on nonexistent objects
113 |
114 | ## Examples of Effective Prompts
115 |
116 | ### Example 1: Simple Curve
117 |
118 | ```
119 | Create a NURBS curve in Rhino that forms a simple arc in the XY plane.
120 | Use these points:
121 | - (0,0,0)
122 | - (5,5,0)
123 | - (10,0,0)
124 | ```
125 |
126 | ### Example 2: Script-Based Operation
127 |
128 | ```
129 | I want to create a grid of spheres in Rhino. Please execute a Python script that:
130 | 1. Creates a 3x3 grid of spheres
131 | 2. Sets the grid spacing to 10 units
132 | 3. Makes each sphere have a radius of 2 units
133 | ```
134 |
135 | ### Example 3: Multiple Operations
136 |
137 | ```
138 | Let's create a simple model of a wine glass:
139 | 1. First create a vertical curve for the profile
140 | 2. Then create a circle at the base for the foot
141 | 3. Finally, use the existing Rhino commands to revolve the profile curve around the vertical axis
142 | ```
143 |
```
--------------------------------------------------------------------------------
/docs/ARCHITECTURE.md:
--------------------------------------------------------------------------------
```markdown
1 | # RhinoMCP Architecture
2 |
3 | This document outlines the architecture and component interactions of the RhinoMCP system, which connects Rhino3D to Claude AI via the Model Context Protocol.
4 |
5 | ## System Overview
6 |
7 | RhinoMCP consists of three main components that work together to enable AI-assisted 3D modeling:
8 |
9 | 1. **Rhino Plugin**: A socket server running inside Rhino's Python environment
10 | 2. **Rhino Client**: A Python client that communicates with the Rhino plugin
11 | 3. **MCP Server**: A WebSocket server implementing the Model Context Protocol
12 |
13 | These components interact in the following way:
14 |
15 | ```
16 | Claude AI (Desktop/Windsurf) <--> MCP Server <--> Rhino Client <--> Rhino Plugin <--> Rhino3D
17 | ```
18 |
19 | ## Component Architecture
20 |
21 | ### 1. Rhino Plugin (`src/rhino_plugin/`)
22 |
23 | The Rhino Plugin is a socket server that runs inside Rhino's Python editor environment. It serves as the interface to Rhino's functionality.
24 |
25 | #### Key Files:
26 | - `rhino_server.py`: Socket server implementation that listens for commands and executes them in Rhino
27 |
28 | #### Responsibilities:
29 | - Accept socket connections from external Python processes
30 | - Receive commands in JSON format
31 | - Execute commands in Rhino's context
32 | - Return results or errors in JSON format
33 | - Manage error handling and recovery
34 |
35 | ### 2. Rhino Client (`src/rhino_mcp/rhino_client.py`)
36 |
37 | The Rhino Client is a Python module that communicates with the Rhino Plugin via a socket connection.
38 |
39 | #### Responsibilities:
40 | - Establish and maintain socket connection with the Rhino Plugin
41 | - Format commands as JSON messages
42 | - Send commands to the Rhino Plugin
43 | - Receive and parse responses
44 | - Provide a clean API for the MCP Server
45 |
46 | ### 3. MCP Server (`src/rhino_mcp/mcp_server.py`)
47 |
48 | The MCP Server implements the Model Context Protocol and exposes Rhino functionality as MCP tools.
49 |
50 | #### Responsibilities:
51 | - Implement WebSocket server for MCP communication
52 | - Register available Rhino tools
53 | - Validate tool parameters
54 | - Forward tool invocations to the Rhino Client
55 | - Format responses according to MCP specifications
56 |
57 | ## Data Flow
58 |
59 | 1. **MCP Request Flow**:
60 | - Claude AI sends a tool invocation request to the MCP Server
61 | - MCP Server validates the request and parameters
62 | - MCP Server formats the request for the Rhino Client
63 | - Rhino Client sends the request to the Rhino Plugin
64 | - Rhino Plugin executes the requested operation in Rhino
65 | - Results flow back through the same path
66 |
67 | 2. **Communication Formats**:
68 | - MCP Server <-> Claude AI: JSON-RPC over WebSockets
69 | - Rhino Client <-> Rhino Plugin: Custom JSON protocol over TCP sockets
70 |
71 | ## Error Handling
72 |
73 | The system implements multiple levels of error handling:
74 |
75 | 1. **MCP Server**: Validates requests and parameters before forwarding
76 | 2. **Rhino Client**: Handles connection issues and timeouts
77 | 3. **Rhino Plugin**: Catches exceptions during command execution in Rhino
78 | 4. **All Components**: Provide detailed error messages with stack traces for debugging
79 |
80 | ## Extensibility
81 |
82 | The architecture is designed for extensibility:
83 |
84 | 1. **Tool Registration**: New tools can be added to the MCP Server without modifying the core code
85 | 2. **Command Handlers**: The Rhino Plugin can be extended with new command handlers
86 | 3. **Protocol Versioning**: Both socket protocols include version information for compatibility
87 |
88 | ## Security Considerations
89 |
90 | 1. **Local Connections Only**: Both socket servers bind to localhost by default
91 | 2. **No Authentication**: The current implementation assumes a trusted local environment
92 | 3. **Input Validation**: All component interfaces validate input to prevent injection attacks
93 |
94 | ## Future Considerations
95 |
96 | 1. **Geometry Transfer**: Optimize large geometry data transfer between components
97 | 2. **Connection Recovery**: Improve automatic reconnection for better resilience
98 | 3. **Tool Expansion**: Add more Rhino operations as MCP tools
99 | 4. **Authentication**: Add authentication for non-local deployments
100 |
```
--------------------------------------------------------------------------------
/tests/rhino_mcp/test_rhino_client.py:
--------------------------------------------------------------------------------
```python
1 | """Tests for the Rhino client module."""
2 | from typing import Dict, Any, Optional, List, Tuple, Union, TypedDict, Generator
3 | import socket
4 | import threading
5 | import time
6 | import json
7 | import pytest
8 | from pytest.monkeypatch import MonkeyPatch
9 | from pytest.logging import LogCaptureFixture
10 |
11 | from rhino_mcp.rhino_client import RhinoClient, Point3d
12 |
13 |
14 | class MockSocket:
15 | """Mock socket for testing."""
16 |
17 | def __init__(self) -> None:
18 | """Initialize mock socket."""
19 | self.sent_data: List[bytes] = []
20 | self.responses: List[bytes] = []
21 |
22 | def connect(self, addr: Tuple[str, int]) -> None:
23 | """Mock connect method."""
24 | pass
25 |
26 | def sendall(self, data: bytes) -> None:
27 | """Mock sendall method to record sent data."""
28 | self.sent_data.append(data)
29 |
30 | def recv(self, bufsize: int) -> bytes:
31 | """Mock recv method to return pre-configured responses."""
32 | if not self.responses:
33 | return b""
34 | return self.responses.pop(0)
35 |
36 | def close(self) -> None:
37 | """Mock close method."""
38 | pass
39 |
40 | def add_response(self, response: Dict[str, Any]) -> None:
41 | """Add a response to the mock socket."""
42 | self.responses.append(json.dumps(response).encode('utf-8'))
43 |
44 |
45 | @pytest.fixture
46 | def mock_socket(monkeypatch: MonkeyPatch) -> Generator[MockSocket, None, None]:
47 | """Create a mock socket for testing."""
48 | mock = MockSocket()
49 |
50 | # Mock socket.socket to return our mock
51 | def mock_socket_constructor(*args: Any, **kwargs: Any) -> MockSocket:
52 | return mock
53 |
54 | monkeypatch.setattr(socket, "socket", mock_socket_constructor)
55 |
56 | yield mock
57 |
58 |
59 | def test_rhino_client_connect(mock_socket: MockSocket) -> None:
60 | """Test RhinoClient connect method."""
61 | client = RhinoClient()
62 |
63 | # Test successful connection
64 | assert client.connect() is True
65 | assert client.connected is True
66 |
67 |
68 | def test_rhino_client_ping(mock_socket: MockSocket) -> None:
69 | """Test RhinoClient ping method."""
70 | client = RhinoClient()
71 | client.connect()
72 |
73 | # Add mock response for ping
74 | mock_socket.add_response({
75 | 'status': 'success',
76 | 'message': 'Rhino is connected',
77 | 'data': {
78 | 'version': '8.0.0',
79 | 'has_active_doc': True,
80 | 'server_version': 'RhinoMCP-1.0',
81 | 'server_start_time': '2025-03-13 21:00:00',
82 | 'script_path': 'C:\\path\\to\\rhino_server.py'
83 | }
84 | })
85 |
86 | # Test ping
87 | response = client.ping()
88 |
89 | # Verify request was sent
90 | assert len(mock_socket.sent_data) == 1
91 | request = json.loads(mock_socket.sent_data[0].decode('utf-8'))
92 | assert request['type'] == 'ping'
93 |
94 | # Verify response was parsed correctly
95 | assert response['status'] == 'success'
96 | assert response['message'] == 'Rhino is connected'
97 | assert 'data' in response
98 | assert response['data']['version'] == '8.0.0'
99 |
100 |
101 | def test_rhino_client_create_curve(mock_socket: MockSocket) -> None:
102 | """Test RhinoClient create_curve method."""
103 | client = RhinoClient()
104 | client.connect()
105 |
106 | # Add mock response for create_curve
107 | mock_socket.add_response({
108 | 'status': 'success',
109 | 'message': 'Curve created with 3 points',
110 | 'data': {
111 | 'id': '12345-67890',
112 | 'point_count': 3
113 | }
114 | })
115 |
116 | # Test points
117 | points: List[Point3d] = [
118 | {'x': 0.0, 'y': 0.0, 'z': 0.0},
119 | {'x': 5.0, 'y': 10.0, 'z': 0.0},
120 | {'x': 10.0, 'y': 0.0, 'z': 0.0}
121 | ]
122 |
123 | # Test create_curve
124 | response = client.create_curve(points)
125 |
126 | # Verify request was sent
127 | assert len(mock_socket.sent_data) == 1
128 | request = json.loads(mock_socket.sent_data[0].decode('utf-8'))
129 | assert request['type'] == 'create_curve'
130 | assert 'data' in request
131 | assert 'points' in request['data']
132 | assert len(request['data']['points']) == 3
133 |
134 | # Verify response was parsed correctly
135 | assert response['status'] == 'success'
136 | assert response['message'] == 'Curve created with 3 points'
137 | assert 'data' in response
138 | assert response['data']['id'] == '12345-67890'
139 |
140 |
141 | def test_rhino_client_invalid_points() -> None:
142 | """Test RhinoClient create_curve with invalid points."""
143 | client = RhinoClient()
144 |
145 | # Test with empty points list
146 | with pytest.raises(ValueError):
147 | client.create_curve([])
148 |
149 | # Test with single point
150 | with pytest.raises(ValueError):
151 | client.create_curve([{'x': 0.0, 'y': 0.0, 'z': 0.0}])
152 |
```
--------------------------------------------------------------------------------
/test_mcp_client.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | Test MCP Client - Simple websocket client to test the RhinoMCP server.
4 |
5 | This script simulates a client (like Claude) making requests to the RhinoMCP server
6 | using the Model Context Protocol.
7 | """
8 | import asyncio
9 | import json
10 | import websockets
11 | from typing import Dict, Any, List, Optional
12 | import logging
13 |
14 | # Configure logging
15 | logging.basicConfig(level=logging.INFO)
16 | logger = logging.getLogger("mcp-test-client")
17 |
18 | # MCP server URI
19 | MCP_URI = "ws://127.0.0.1:5000"
20 |
21 | async def test_mcp_connection() -> bool:
22 | """Test connection to the MCP server.
23 |
24 | Returns:
25 | bool: True if the test passes, False otherwise
26 | """
27 | try:
28 | async with websockets.connect(MCP_URI) as websocket:
29 | logger.info(f"Connected to MCP server at {MCP_URI}")
30 |
31 | # Send initialize request
32 | initialize_request = {
33 | "jsonrpc": "2.0",
34 | "id": 1,
35 | "method": "initialize",
36 | "params": {}
37 | }
38 |
39 | await websocket.send(json.dumps(initialize_request))
40 | logger.info(f"Sent initialize request")
41 |
42 | # Receive response
43 | response = await websocket.recv()
44 | initialize_response = json.loads(response)
45 | logger.info(f"Received initialize response: {json.dumps(initialize_response, indent=2)}")
46 |
47 | # Send initialized notification
48 | initialized_notification = {
49 | "jsonrpc": "2.0",
50 | "method": "notifications/initialized"
51 | }
52 | await websocket.send(json.dumps(initialized_notification))
53 | logger.info(f"Sent initialized notification")
54 |
55 | # There may be a capabilities notification response, try to receive it
56 | try:
57 | response = await asyncio.wait_for(websocket.recv(), timeout=0.5)
58 | capabilities_notification = json.loads(response)
59 | logger.info(f"Received notification: {json.dumps(capabilities_notification, indent=2)}")
60 | except asyncio.TimeoutError:
61 | logger.info("No notification received (as expected)")
62 |
63 | # Request tools list
64 | tools_request = {
65 | "jsonrpc": "2.0",
66 | "id": 2,
67 | "method": "tools/list",
68 | "params": {}
69 | }
70 |
71 | await websocket.send(json.dumps(tools_request))
72 | logger.info(f"Sent tools/list request")
73 |
74 | # Receive tools list response
75 | response = await websocket.recv()
76 | tools_response = json.loads(response)
77 | logger.info(f"Received tools list: {json.dumps(tools_response, indent=2)}")
78 |
79 | # Create a curve using the MCP protocol
80 | create_curve_request = {
81 | "jsonrpc": "2.0",
82 | "id": 3,
83 | "method": "rhino_create_curve",
84 | "params": {
85 | "points": [
86 | {"x": 0, "y": 0, "z": 30},
87 | {"x": 10, "y": 10, "z": 30},
88 | {"x": 20, "y": 0, "z": 30}
89 | ]
90 | }
91 | }
92 |
93 | await websocket.send(json.dumps(create_curve_request))
94 | logger.info(f"Sent curve creation request")
95 |
96 | # Receive curve creation response
97 | response = await websocket.recv()
98 | curve_response = json.loads(response)
99 | logger.info(f"Received curve creation response: {json.dumps(curve_response, indent=2)}")
100 |
101 | # Check if curve was created successfully
102 | if "result" in curve_response and "status" in curve_response["result"]:
103 | status = curve_response["result"]["status"]
104 | logger.info(f"Curve creation status: {status}")
105 | if status == "success":
106 | logger.info("✅ Test passed: Curve created successfully!")
107 | return True
108 | else:
109 | logger.error(f"❌ Test failed: {curve_response['result']['message']}")
110 | return False
111 | else:
112 | logger.error("❌ Test failed: Invalid response format")
113 | return False
114 |
115 | except Exception as e:
116 | logger.error(f"❌ Error connecting to MCP server: {str(e)}")
117 | return False
118 |
119 | if __name__ == "__main__":
120 | asyncio.run(test_mcp_connection())
121 |
```
--------------------------------------------------------------------------------
/claude_adapter.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python
2 | """
3 | RhinoMCP Claude Desktop Adapter - Bridges between Claude Desktop and Rhino
4 |
5 | This adapter provides the stdio interface that Claude Desktop expects,
6 | while communicating with a Rhino socket server.
7 | """
8 | import sys
9 | import json
10 | import traceback
11 | import asyncio
12 | from rhino_mcp.rhino_client import RhinoClient
13 |
14 | # MCP protocol version
15 | PROTOCOL_VERSION = "2024-11-05"
16 |
17 | # Global state
18 | rhino_client = None
19 |
20 | async def main():
21 | # Initialize MCP session
22 | await send_response(0, {
23 | "protocolVersion": PROTOCOL_VERSION,
24 | "serverInfo": {"name": "rhinomcp", "version": "0.1.0"},
25 | "capabilities": {
26 | "tools": {}
27 | }
28 | })
29 |
30 | # Connect to Rhino
31 | global rhino_client
32 | rhino_client = RhinoClient()
33 |
34 | try:
35 | # Try to connect to Rhino
36 | if not rhino_client.connect():
37 | await send_log("error", "Failed to connect to Rhino server. Is it running?")
38 | return
39 |
40 | # Successfully connected
41 | await send_log("info", f"Connected to Rhino server at {rhino_client.host}:{rhino_client.port}")
42 |
43 | # Process incoming messages
44 | while True:
45 | # Read a line from stdin
46 | line = await read_line()
47 | if not line:
48 | break
49 |
50 | # Parse the message
51 | try:
52 | message = json.loads(line)
53 |
54 | # Handle the message
55 | await handle_message(message)
56 | except json.JSONDecodeError:
57 | await send_log("error", f"Invalid JSON: {line}")
58 | except Exception as e:
59 | await send_log("error", f"Error handling message: {str(e)}")
60 | traceback.print_exc(file=sys.stderr)
61 | finally:
62 | # Disconnect from Rhino
63 | if rhino_client and rhino_client.connected:
64 | rhino_client.disconnect()
65 |
66 | async def read_line():
67 | """Read a line from stdin asynchronously"""
68 | return await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline)
69 |
70 | async def send_message(message):
71 | """Send a message to stdout"""
72 | print(json.dumps(message), flush=True)
73 |
74 | async def send_response(id, result):
75 | """Send a JSON-RPC response"""
76 | await send_message({
77 | "jsonrpc": "2.0",
78 | "id": id,
79 | "result": result
80 | })
81 |
82 | async def send_error(id, code, message, data=None):
83 | """Send a JSON-RPC error response"""
84 | error = {
85 | "code": code,
86 | "message": message
87 | }
88 | if data:
89 | error["data"] = data
90 |
91 | await send_message({
92 | "jsonrpc": "2.0",
93 | "id": id,
94 | "error": error
95 | })
96 |
97 | async def send_log(level, message):
98 | """Send a log message notification"""
99 | await send_message({
100 | "jsonrpc": "2.0",
101 | "method": "logging/message",
102 | "params": {
103 | "level": level,
104 | "data": message
105 | }
106 | })
107 |
108 | async def handle_message(message):
109 | """Handle an incoming JSON-RPC message"""
110 | if "method" not in message:
111 | await send_log("error", "Invalid message: no method")
112 | return
113 |
114 | method = message.get("method")
115 | params = message.get("params", {})
116 | msg_id = message.get("id")
117 |
118 | if method == "initialize":
119 | # Already sent in main()
120 | pass
121 | elif method == "initialized":
122 | # Client is initialized, nothing to do
123 | await send_log("info", "Client initialized")
124 | elif method == "tools/list":
125 | # Return list of available tools
126 | await send_response(msg_id, {
127 | "tools": [{
128 | "name": "rhino_create_curve",
129 | "description": "Create a NURBS curve in Rhino",
130 | "inputSchema": {
131 | "type": "object",
132 | "properties": {
133 | "points": {
134 | "type": "array",
135 | "description": "Array of 3D points for the curve",
136 | "items": {
137 | "type": "object",
138 | "properties": {
139 | "x": {"type": "number"},
140 | "y": {"type": "number"},
141 | "z": {"type": "number"}
142 | },
143 | "required": ["x", "y", "z"]
144 | },
145 | "minItems": 2
146 | }
147 | },
148 | "required": ["points"]
149 | }
150 | }]
151 | })
152 | elif method == "tools/call":
153 | tool_name = params.get("name")
154 | tool_args = params.get("arguments", {})
155 |
156 | if tool_name == "rhino_create_curve":
157 | # Call the Rhino client to create a curve
158 | try:
159 | points = tool_args.get("points", [])
160 | if not points or len(points) < 2:
161 | await send_error(msg_id, -32602, "At least 2 points are required for a curve")
162 | return
163 |
164 | # Create the curve
165 | result = rhino_client.create_curve(points)
166 |
167 | if result.get("status") == "success":
168 | await send_response(msg_id, {
169 | "content": [{
170 | "type": "text",
171 | "text": f"Curve created successfully: {result.get('message', '')}"
172 | }]
173 | })
174 | else:
175 | await send_response(msg_id, {
176 | "isError": True,
177 | "content": [{
178 | "type": "text",
179 | "text": f"Failed to create curve: {result.get('message', 'Unknown error')}"
180 | }]
181 | })
182 | except Exception as e:
183 | await send_error(msg_id, -32603, f"Internal error: {str(e)}")
184 | else:
185 | await send_error(msg_id, -32601, f"Method not found: {tool_name}")
186 | elif method == "exit":
187 | # Client wants to exit
188 | await send_log("info", "Shutting down")
189 | sys.exit(0)
190 | else:
191 | await send_error(msg_id, -32601, f"Method not found: {method}")
192 |
193 | if __name__ == "__main__":
194 | try:
195 | asyncio.run(main())
196 | except KeyboardInterrupt:
197 | sys.exit(0)
198 | except Exception as e:
199 | print(f"Fatal error: {str(e)}", file=sys.stderr)
200 | traceback.print_exc(file=sys.stderr)
201 | sys.exit(1)
```
--------------------------------------------------------------------------------
/src/rhino_mcp/rhino_client.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Rhino Client - Client for communicating with Rhino via socket connection.
3 |
4 | This module implements a client that connects to the Rhino Bridge socket server
5 | and provides methods to send commands and receive responses.
6 |
7 | Version: 1.0 (2025-03-13)
8 | """
9 | from typing import Dict, Any, Optional, List, Tuple, Union, TypedDict
10 | import socket
11 | import json
12 | import time
13 | import threading
14 | from dataclasses import dataclass
15 |
16 |
17 | class Point3d(TypedDict):
18 | """Type definition for a 3D point with x, y, z coordinates."""
19 | x: float
20 | y: float
21 | z: float
22 |
23 |
24 | class RhinoClient:
25 | """Client for communicating with Rhino via socket connection.
26 |
27 | This class provides methods to connect to the Rhino Bridge socket server,
28 | send commands, and receive responses.
29 |
30 | Attributes:
31 | host: The hostname or IP address of the Rhino Bridge server
32 | port: The port number of the Rhino Bridge server
33 | socket: The socket connection to the Rhino Bridge server
34 | connected: Whether the client is currently connected to the server
35 | """
36 |
37 | def __init__(self, host: str = '127.0.0.1', port: int = 8888):
38 | """Initialize the Rhino client.
39 |
40 | Args:
41 | host: The hostname or IP address of the Rhino Bridge server
42 | port: The port number of the Rhino Bridge server
43 | """
44 | self.host = host
45 | self.port = port
46 | self.socket: Optional[socket.socket] = None
47 | self.connected = False
48 |
49 | def connect(self) -> bool:
50 | """Connect to the Rhino Bridge server.
51 |
52 | Returns:
53 | True if connected successfully, False otherwise
54 |
55 | Raises:
56 | ConnectionError: If failed to connect to the server
57 | """
58 | try:
59 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
60 | self.socket.connect((self.host, self.port))
61 | self.connected = True
62 | print(f"Connected to Rhino Bridge at {self.host}:{self.port}")
63 | return True
64 | except ConnectionRefusedError:
65 | self.connected = False
66 | raise ConnectionError(
67 | f"Failed to connect to Rhino Bridge at {self.host}:{self.port}. "
68 | "Make sure Rhino is running and the Bridge server is started."
69 | )
70 | except Exception as e:
71 | self.connected = False
72 | raise ConnectionError(f"Connection error: {str(e)}")
73 |
74 | def disconnect(self) -> None:
75 | """Disconnect from the Rhino Bridge server.
76 |
77 | Returns:
78 | None
79 | """
80 | if self.socket:
81 | self.socket.close()
82 | self.socket = None
83 | self.connected = False
84 | print("Disconnected from Rhino Bridge")
85 |
86 | def send_command(self, cmd_type: str, data: Dict[str, Any] = None) -> Dict[str, Any]:
87 | """Send a command to the Rhino Bridge server.
88 |
89 | Args:
90 | cmd_type: The type of command to send
91 | data: The data to send with the command
92 |
93 | Returns:
94 | The response from the server as a dictionary
95 |
96 | Raises:
97 | ConnectionError: If not connected to the server
98 | RuntimeError: If failed to send command or receive response
99 | """
100 | if not self.connected or not self.socket:
101 | raise ConnectionError("Not connected to Rhino Bridge")
102 |
103 | if data is None:
104 | data = {}
105 |
106 | try:
107 | # Prepare the command
108 | command = {
109 | 'type': cmd_type,
110 | 'data': data
111 | }
112 |
113 | # Send the command
114 | self.socket.sendall(json.dumps(command).encode('utf-8'))
115 |
116 | # Receive the response
117 | response_data = self.socket.recv(4096)
118 | if not response_data:
119 | raise RuntimeError("No response from server")
120 |
121 | # Parse the response
122 | response = json.loads(response_data.decode('utf-8'))
123 | return response
124 | except Exception as e:
125 | raise RuntimeError(f"Command error: {str(e)}")
126 |
127 | def ping(self) -> Dict[str, Any]:
128 | """Ping the Rhino Bridge server to check connection.
129 |
130 | Returns:
131 | Server information including version and status
132 |
133 | Raises:
134 | ConnectionError: If not connected to the server
135 | """
136 | return self.send_command('ping')
137 |
138 | def create_curve(self, points: List[Point3d]) -> Dict[str, Any]:
139 | """Create a NURBS curve in Rhino.
140 |
141 | Args:
142 | points: List of points (each a dict with x, y, z keys)
143 |
144 | Returns:
145 | Response from the server including curve ID if successful
146 |
147 | Raises:
148 | ValueError: If points list is invalid
149 | ConnectionError: If not connected to the server
150 | """
151 | if not points or len(points) < 2:
152 | raise ValueError("At least 2 points are required to create a curve")
153 |
154 | return self.send_command('create_curve', {'points': points})
155 |
156 | def refresh_view(self) -> Dict[str, Any]:
157 | """Refresh the Rhino viewport.
158 |
159 | Returns:
160 | Response from the server
161 |
162 | Raises:
163 | ConnectionError: If not connected to the server
164 | """
165 | return self.send_command('refresh_view')
166 |
167 | def run_script(self, script: str) -> Dict[str, Any]:
168 | """Run a Python script in Rhino's Python context.
169 |
170 | Args:
171 | script: The Python script to run
172 |
173 | Returns:
174 | Response from the server including script result
175 |
176 | Raises:
177 | ValueError: If script is empty
178 | ConnectionError: If not connected to the server
179 | """
180 | if not script:
181 | raise ValueError("Script cannot be empty")
182 |
183 | return self.send_command('run_script', {'script': script})
184 |
185 |
186 | def test_connection(host: str = '127.0.0.1', port: int = 8888) -> bool:
187 | """Test the connection to the Rhino Bridge server.
188 |
189 | Args:
190 | host: The hostname or IP address of the Rhino Bridge server
191 | port: The port number of the Rhino Bridge server
192 |
193 | Returns:
194 | True if connected successfully, False otherwise
195 | """
196 | client = RhinoClient(host, port)
197 | try:
198 | client.connect()
199 | response = client.ping()
200 | print(f"Connection successful! Server info:")
201 | for key, value in response.get('data', {}).items():
202 | print(f" {key}: {value}")
203 | return True
204 | except Exception as e:
205 | print(f"Connection test failed: {str(e)}")
206 | return False
207 | finally:
208 | client.disconnect()
209 |
210 |
211 | if __name__ == "__main__":
212 | # Simple test when run directly
213 | test_connection()
214 |
```
--------------------------------------------------------------------------------
/src/rhino_plugin/rhino_server.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | Rhino Bridge Server - Socket server for Rhino-Python communication.
3 |
4 | This module implements a socket server that runs inside Rhino's Python editor and
5 | allows external Python processes to communicate with and control Rhino.
6 |
7 | Version: 1.0 (2025-03-13)
8 | """
9 | from typing import Dict, Any, Optional, List, Tuple, Union
10 | import socket
11 | import json
12 | import sys
13 | import traceback
14 | import threading
15 | import time
16 |
17 | # Import Rhino-specific modules
18 | try:
19 | import Rhino
20 | import rhinoscriptsyntax as rs
21 | import scriptcontext as sc
22 | except ImportError:
23 | print("Warning: Rhino modules not found. This script must run inside Rhino's Python editor.")
24 |
25 | # Server configuration
26 | HOST = '127.0.0.1'
27 | PORT = 8888
28 | SERVER_VERSION = "RhinoMCP-1.0"
29 | SERVER_START_TIME = time.strftime("%Y-%m-%d %H:%M:%S")
30 |
31 |
32 | class RhinoEncoder(json.JSONEncoder):
33 | """Custom JSON encoder for handling Rhino and .NET objects.
34 |
35 | Args:
36 | None
37 |
38 | Returns:
39 | Encoded JSON string
40 | """
41 | def default(self, obj: Any) -> Any:
42 | # Handle .NET Version objects and other common types
43 | try:
44 | if hasattr(obj, 'ToString'):
45 | return str(obj)
46 | elif hasattr(obj, 'Count') and hasattr(obj, 'Item'):
47 | return [self.default(obj.Item[i]) for i in range(obj.Count)]
48 | else:
49 | return str(obj) # Last resort: convert anything to string
50 | except:
51 | return str(obj) # Absolute fallback
52 |
53 | # Let the base class handle other types
54 | return super(RhinoEncoder, self).default(obj)
55 |
56 |
57 | def handle_client(conn: socket.socket, addr: Tuple[str, int]) -> None:
58 | """Handle individual client connections.
59 |
60 | Args:
61 | conn: Socket connection object
62 | addr: Client address tuple (host, port)
63 |
64 | Returns:
65 | None
66 | """
67 | print(f"Connection established from {addr}")
68 |
69 | try:
70 | while True:
71 | # Receive command
72 | data = conn.recv(4096)
73 | if not data:
74 | break
75 |
76 | # Parse the command
77 | try:
78 | command_obj = json.loads(data.decode('utf-8'))
79 | cmd_type = command_obj.get('type', '')
80 | cmd_data = command_obj.get('data', {})
81 |
82 | print(f"Received command: {cmd_type}")
83 | result: Dict[str, Any] = {'status': 'error', 'message': 'Unknown command'}
84 |
85 | # Process different command types
86 | if cmd_type == 'ping':
87 | result = {
88 | 'status': 'success',
89 | 'message': 'Rhino is connected',
90 | 'data': {
91 | 'version': str(Rhino.RhinoApp.Version),
92 | 'has_active_doc': Rhino.RhinoDoc.ActiveDoc is not None,
93 | 'server_version': SERVER_VERSION,
94 | 'server_start_time': SERVER_START_TIME,
95 | 'script_path': __file__
96 | }
97 | }
98 |
99 | elif cmd_type == 'create_curve':
100 | try:
101 | # Extract points from the command data
102 | points_data = cmd_data.get('points', [])
103 |
104 | # Check if we have enough points for a curve
105 | if len(points_data) < 2:
106 | raise ValueError("At least 2 points are required to create a curve")
107 |
108 | # Convert point data to Rhino points
109 | points = []
110 | for pt in points_data:
111 | x = pt.get('x', 0.0)
112 | y = pt.get('y', 0.0)
113 | z = pt.get('z', 0.0)
114 | points.append(Rhino.Geometry.Point3d(x, y, z))
115 |
116 | # Create the curve
117 | doc = Rhino.RhinoDoc.ActiveDoc
118 | if not doc:
119 | raise Exception("No active Rhino document")
120 |
121 | # Create a NURBS curve
122 | curve = Rhino.Geometry.Curve.CreateInterpolatedCurve(points, 3)
123 |
124 | if not curve:
125 | raise Exception("Failed to create curve")
126 |
127 | # Add to document
128 | id = doc.Objects.AddCurve(curve)
129 |
130 | # Force view update
131 | doc.Views.Redraw()
132 |
133 | result = {
134 | 'status': 'success',
135 | 'message': f'Curve created with {len(points)} points',
136 | 'data': {
137 | 'id': str(id),
138 | 'point_count': len(points)
139 | }
140 | }
141 | except Exception as e:
142 | result = {
143 | 'status': 'error',
144 | 'message': f'Curve creation error: {str(e)}',
145 | 'traceback': traceback.format_exc()
146 | }
147 |
148 | elif cmd_type == 'refresh_view':
149 | try:
150 | doc = Rhino.RhinoDoc.ActiveDoc
151 | if not doc:
152 | raise Exception("No active Rhino document")
153 |
154 | doc.Views.Redraw()
155 | result = {
156 | 'status': 'success',
157 | 'message': 'View refreshed'
158 | }
159 | except Exception as e:
160 | result = {
161 | 'status': 'error',
162 | 'message': f'View refresh error: {str(e)}'
163 | }
164 |
165 | elif cmd_type == 'run_script':
166 | try:
167 | script = cmd_data.get('script', '')
168 | if not script:
169 | raise ValueError("Empty script")
170 |
171 | # Execute the script in Rhino's Python context
172 | locals_dict = {}
173 | exec(script, globals(), locals_dict)
174 |
175 | # Return the result if available
176 | script_result = locals_dict.get('result', None)
177 | result = {
178 | 'status': 'success',
179 | 'message': 'Script executed successfully',
180 | 'data': {
181 | 'result': script_result
182 | }
183 | }
184 | except Exception as e:
185 | result = {
186 | 'status': 'error',
187 | 'message': f'Script execution error: {str(e)}',
188 | 'traceback': traceback.format_exc()
189 | }
190 |
191 | # Send the result back to the client
192 | response = json.dumps(result, cls=RhinoEncoder)
193 | conn.sendall(response.encode('utf-8'))
194 |
195 | except json.JSONDecodeError:
196 | conn.sendall(json.dumps({
197 | 'status': 'error',
198 | 'message': 'Invalid JSON format'
199 | }).encode('utf-8'))
200 |
201 | except Exception as e:
202 | print(f"Connection error: {str(e)}")
203 | finally:
204 | print(f"Connection closed with {addr}")
205 | conn.close()
206 |
207 |
208 | def start_server() -> None:
209 | """Start the socket server.
210 |
211 | Args:
212 | None
213 |
214 | Returns:
215 | None
216 |
217 | Raises:
218 | OSError: If the server fails to start
219 | """
220 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
221 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
222 | try:
223 | s.bind((HOST, PORT))
224 | s.listen(5)
225 | print(f"Rhino Bridge started! Listening on {HOST}:{PORT}")
226 | print(f"Server version: {SERVER_VERSION}")
227 | print(f"Start time: {SERVER_START_TIME}")
228 |
229 | while True:
230 | conn, addr = s.accept()
231 | # Handle each client in a separate thread
232 | client_thread = threading.Thread(target=handle_client, args=(conn, addr))
233 | client_thread.daemon = True
234 | client_thread.start()
235 |
236 | except OSError as e:
237 | if e.errno == 10048: # Address already in use
238 | print("Error: Address already in use. Is the Rhino Bridge already running?")
239 | else:
240 | print(f"Socket error: {str(e)}")
241 | except Exception as e:
242 | print(f"Server error: {str(e)}")
243 | traceback.print_exc()
244 |
245 |
246 | if __name__ == "__main__":
247 | # Only start if running directly in Rhino's Python editor
248 | if 'Rhino' in sys.modules:
249 | start_server()
250 | else:
251 | print("This script should be run inside Rhino's Python editor.")
252 |
```
--------------------------------------------------------------------------------
/src/rhino_mcp/mcp_server.py:
--------------------------------------------------------------------------------
```python
1 | """
2 | MCP Server - Model Context Protocol server for RhinoMCP.
3 |
4 | This module implements the Model Context Protocol server that connects
5 | Claude AI to the Rhino client, enabling AI-assisted 3D modeling.
6 |
7 | Version: 1.0 (2025-03-13)
8 | """
9 | from typing import Dict, Any, Optional, List, Union, Callable, TypedDict
10 | import os
11 | import sys
12 | import json
13 | import logging
14 | import argparse
15 | from dataclasses import dataclass, field
16 | import traceback
17 | import asyncio
18 | import websockets
19 | from websockets.server import WebSocketServerProtocol
20 |
21 | from rhino_mcp.rhino_client import RhinoClient, Point3d
22 |
23 |
24 | # Configure logging
25 | logging.basicConfig(
26 | level=logging.INFO,
27 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
28 | handlers=[
29 | logging.StreamHandler(sys.stdout)
30 | ]
31 | )
32 | logger = logging.getLogger("rhino_mcp")
33 |
34 |
35 | class MCPRequestSchema(TypedDict):
36 | """Type definition for MCP request schema."""
37 | jsonrpc: str
38 | id: Union[str, int]
39 | method: str
40 | params: Dict[str, Any]
41 |
42 |
43 | class MCPResponseSchema(TypedDict):
44 | """Type definition for MCP response schema."""
45 | jsonrpc: str
46 | id: Union[str, int]
47 | result: Dict[str, Any]
48 |
49 |
50 | class MCPErrorSchema(TypedDict):
51 | """Type definition for MCP error response schema."""
52 | jsonrpc: str
53 | id: Union[str, int]
54 | error: Dict[str, Any]
55 |
56 |
57 | @dataclass
58 | class MCPTool:
59 | """Class representing an MCP tool.
60 |
61 | Attributes:
62 | name: The name of the tool
63 | description: The description of the tool
64 | parameters: The parameters schema of the tool
65 | handler: The function to handle tool invocation
66 | """
67 | name: str
68 | description: str
69 | parameters: Dict[str, Any]
70 | handler: Callable[[Dict[str, Any]], Dict[str, Any]]
71 |
72 |
73 | class RhinoMCPServer:
74 | """Model Context Protocol server for RhinoMCP.
75 |
76 | This class implements the MCP server that handles communication with
77 | Claude AI and forwards commands to the Rhino client.
78 |
79 | Attributes:
80 | host: The hostname to bind the server to
81 | port: The port to bind the server to
82 | rhino_client: The Rhino client to use for communication with Rhino
83 | tools: The list of available MCP tools
84 | """
85 |
86 | def __init__(
87 | self,
88 | host: str = '127.0.0.1',
89 | port: int = 5000,
90 | rhino_host: str = '127.0.0.1',
91 | rhino_port: int = 8888
92 | ):
93 | """Initialize the MCP server.
94 |
95 | Args:
96 | host: The hostname to bind the server to
97 | port: The port to bind the server to
98 | rhino_host: The hostname of the Rhino Bridge server
99 | rhino_port: The port of the Rhino Bridge server
100 | """
101 | self.host = host
102 | self.port = port
103 | self.rhino_client = RhinoClient(rhino_host, rhino_port)
104 | self.tools: List[MCPTool] = []
105 |
106 | # Register built-in tools
107 | self._register_tools()
108 |
109 | def _register_tools(self) -> None:
110 | """Register built-in MCP tools.
111 |
112 | This method registers the built-in MCP tools that will be exposed to
113 | Claude AI through the Model Context Protocol.
114 |
115 | Returns:
116 | None
117 | """
118 | # Create NURBS curve tool
119 | self.tools.append(MCPTool(
120 | name="rhino_create_curve",
121 | description="Create a NURBS curve in Rhino",
122 | parameters={
123 | "type": "object",
124 | "properties": {
125 | "points": {
126 | "type": "array",
127 | "description": "Array of 3D points for the curve",
128 | "items": {
129 | "type": "object",
130 | "properties": {
131 | "x": {"type": "number"},
132 | "y": {"type": "number"},
133 | "z": {"type": "number"}
134 | },
135 | "required": ["x", "y", "z"]
136 | },
137 | "minItems": 2
138 | }
139 | },
140 | "required": ["points"]
141 | },
142 | handler=self._handle_create_curve
143 | ))
144 |
145 | # Tool for pinging Rhino
146 | self.tools.append(MCPTool(
147 | name="rhino_ping",
148 | description="Ping Rhino to check if it's connected and get information",
149 | parameters={
150 | "type": "object",
151 | "properties": {}
152 | },
153 | handler=self._handle_ping
154 | ))
155 |
156 | # Tool for running Python script in Rhino
157 | self.tools.append(MCPTool(
158 | name="rhino_run_script",
159 | description="Run a Python script in Rhino's Python context",
160 | parameters={
161 | "type": "object",
162 | "properties": {
163 | "script": {
164 | "type": "string",
165 | "description": "Python script to run in Rhino"
166 | }
167 | },
168 | "required": ["script"]
169 | },
170 | handler=self._handle_run_script
171 | ))
172 |
173 | def _handle_create_curve(self, params: Dict[str, Any]) -> Dict[str, Any]:
174 | """Handle create_curve tool invocation.
175 |
176 | Args:
177 | params: The parameters for the tool invocation
178 |
179 | Returns:
180 | The tool invocation result
181 |
182 | Raises:
183 | ValueError: If the parameters are invalid
184 | """
185 | points_data = params.get('points', [])
186 |
187 | # Validate points
188 | if not points_data or len(points_data) < 2:
189 | raise ValueError("At least 2 points are required to create a curve")
190 |
191 | # Format points for Rhino client
192 | points: List[Point3d] = []
193 | for pt in points_data:
194 | points.append({
195 | 'x': float(pt.get('x', 0.0)),
196 | 'y': float(pt.get('y', 0.0)),
197 | 'z': float(pt.get('z', 0.0))
198 | })
199 |
200 | # Ensure Rhino client is connected
201 | if not self.rhino_client.connected:
202 | self.rhino_client.connect()
203 |
204 | # Create the curve
205 | result = self.rhino_client.create_curve(points)
206 |
207 | # Format response
208 | if result.get('status') == 'success':
209 | return {
210 | 'success': True,
211 | 'message': result.get('message', 'Curve created successfully'),
212 | 'data': result.get('data', {})
213 | }
214 | else:
215 | return {
216 | 'success': False,
217 | 'message': result.get('message', 'Failed to create curve'),
218 | 'error': result.get('traceback', '')
219 | }
220 |
221 | def _handle_ping(self, params: Dict[str, Any]) -> Dict[str, Any]:
222 | """Handle ping tool invocation.
223 |
224 | Args:
225 | params: The parameters for the tool invocation
226 |
227 | Returns:
228 | The tool invocation result
229 | """
230 | # Ensure Rhino client is connected
231 | if not self.rhino_client.connected:
232 | self.rhino_client.connect()
233 |
234 | # Ping Rhino
235 | result = self.rhino_client.ping()
236 |
237 | # Format response
238 | if result.get('status') == 'success':
239 | return {
240 | 'success': True,
241 | 'message': result.get('message', 'Rhino is connected'),
242 | 'data': result.get('data', {})
243 | }
244 | else:
245 | return {
246 | 'success': False,
247 | 'message': result.get('message', 'Failed to ping Rhino'),
248 | 'error': result.get('traceback', '')
249 | }
250 |
251 | def _handle_run_script(self, params: Dict[str, Any]) -> Dict[str, Any]:
252 | """Handle run_script tool invocation.
253 |
254 | Args:
255 | params: The parameters for the tool invocation
256 |
257 | Returns:
258 | The tool invocation result
259 |
260 | Raises:
261 | ValueError: If the script is empty
262 | """
263 | script = params.get('script', '')
264 |
265 | # Validate script
266 | if not script:
267 | raise ValueError("Script cannot be empty")
268 |
269 | # Ensure Rhino client is connected
270 | if not self.rhino_client.connected:
271 | self.rhino_client.connect()
272 |
273 | # Run the script
274 | result = self.rhino_client.run_script(script)
275 |
276 | # Format response
277 | if result.get('status') == 'success':
278 | return {
279 | 'success': True,
280 | 'message': result.get('message', 'Script executed successfully'),
281 | 'data': result.get('data', {})
282 | }
283 | else:
284 | return {
285 | 'success': False,
286 | 'message': result.get('message', 'Failed to execute script'),
287 | 'error': result.get('traceback', '')
288 | }
289 |
290 | def get_tools_schema(self) -> List[Dict[str, Any]]:
291 | """Get the schema for all registered tools.
292 |
293 | Returns:
294 | List of tool schemas in MCP format
295 | """
296 | return [
297 | {
298 | "name": tool.name,
299 | "description": tool.description,
300 | "parameters": tool.parameters
301 | }
302 | for tool in self.tools
303 | ]
304 |
305 | async def handle_jsonrpc(self, request: Dict[str, Any]) -> Dict[str, Any]:
306 | """Handle a JSON-RPC request.
307 |
308 | Args:
309 | request: The JSON-RPC request
310 |
311 | Returns:
312 | The JSON-RPC response
313 | """
314 | # Extract request data
315 | method = request.get('method', '')
316 | params = request.get('params', {})
317 | req_id = request.get('id', 0)
318 |
319 | # Handle different methods
320 | if method == 'rpc.discover':
321 | # Return MCP server information and tools
322 | return {
323 | 'jsonrpc': '2.0',
324 | 'id': req_id,
325 | 'result': {
326 | 'name': 'rhino_mcp',
327 | 'version': '1.0.0',
328 | 'functions': self.get_tools_schema()
329 | }
330 | }
331 | elif method.startswith('rhino_'):
332 | # Handle tool invocation
333 | for tool in self.tools:
334 | if tool.name == method:
335 | try:
336 | result = tool.handler(params)
337 | return {
338 | 'jsonrpc': '2.0',
339 | 'id': req_id,
340 | 'result': result
341 | }
342 | except Exception as e:
343 | logger.error(f"Tool error: {str(e)}")
344 | traceback.print_exc()
345 | return {
346 | 'jsonrpc': '2.0',
347 | 'id': req_id,
348 | 'error': {
349 | 'code': -32000,
350 | 'message': str(e),
351 | 'data': {
352 | 'traceback': traceback.format_exc()
353 | }
354 | }
355 | }
356 |
357 | # Method not found
358 | return {
359 | 'jsonrpc': '2.0',
360 | 'id': req_id,
361 | 'error': {
362 | 'code': -32601,
363 | 'message': f'Method not found: {method}'
364 | }
365 | }
366 |
367 | async def handle_websocket(self, websocket: WebSocketServerProtocol) -> None:
368 | """Handle a WebSocket connection.
369 |
370 | Args:
371 | websocket: The WebSocket connection
372 |
373 | Returns:
374 | None
375 | """
376 | logger.info(f"Client connected: {websocket.remote_address}")
377 |
378 | # Ensure Rhino client is connected
379 | if not self.rhino_client.connected:
380 | try:
381 | self.rhino_client.connect()
382 | except Exception as e:
383 | logger.error(f"Failed to connect to Rhino: {str(e)}")
384 | await websocket.close(1011, "Failed to connect to Rhino")
385 | return
386 |
387 | try:
388 | async for message in websocket:
389 | # Parse the message
390 | try:
391 | request = json.loads(message)
392 | logger.info(f"Received request: {request.get('method', 'unknown')}")
393 |
394 | # Handle the request
395 | response = await self.handle_jsonrpc(request)
396 |
397 | # Send the response
398 | await websocket.send(json.dumps(response))
399 |
400 | except json.JSONDecodeError:
401 | logger.error("Invalid JSON")
402 | await websocket.send(json.dumps({
403 | 'jsonrpc': '2.0',
404 | 'id': None,
405 | 'error': {
406 | 'code': -32700,
407 | 'message': 'Parse error'
408 | }
409 | }))
410 | except Exception as e:
411 | logger.error(f"Websocket error: {str(e)}")
412 | traceback.print_exc()
413 | await websocket.send(json.dumps({
414 | 'jsonrpc': '2.0',
415 | 'id': None,
416 | 'error': {
417 | 'code': -32603,
418 | 'message': str(e)
419 | }
420 | }))
421 | except Exception as e:
422 | logger.error(f"Connection error: {str(e)}")
423 | finally:
424 | logger.info(f"Client disconnected: {websocket.remote_address}")
425 |
426 | async def start(self) -> None:
427 | """Start the MCP server.
428 |
429 | Returns:
430 | None
431 | """
432 | # Start the WebSocket server
433 | async with websockets.serve(self.handle_websocket, self.host, self.port):
434 | logger.info(f"MCP server started at ws://{self.host}:{self.port}")
435 | await asyncio.Future() # Run forever
436 |
437 | def start_in_thread(self) -> None:
438 | """Start the MCP server in a separate thread.
439 |
440 | Returns:
441 | None
442 | """
443 | try:
444 | asyncio.run(self.start())
445 | except KeyboardInterrupt:
446 | logger.info("Server stopped by user")
447 | except Exception as e:
448 | logger.error(f"Server error: {str(e)}")
449 | traceback.print_exc()
450 | finally:
451 | if self.rhino_client.connected:
452 | self.rhino_client.disconnect()
453 |
454 |
455 | def main() -> None:
456 | """Start the MCP server from the command line.
457 |
458 | Returns:
459 | None
460 | """
461 | parser = argparse.ArgumentParser(description='Start the RhinoMCP server')
462 | parser.add_argument('--host', type=str, default='127.0.0.1',
463 | help='Hostname to bind the MCP server to')
464 | parser.add_argument('--port', type=int, default=5000,
465 | help='Port to bind the MCP server to')
466 | parser.add_argument('--rhino-host', type=str, default='127.0.0.1',
467 | help='Hostname of the Rhino Bridge server')
468 | parser.add_argument('--rhino-port', type=int, default=8888,
469 | help='Port of the Rhino Bridge server')
470 | parser.add_argument('--debug', action='store_true',
471 | help='Enable debug logging')
472 |
473 | args = parser.parse_args()
474 |
475 | # Set log level
476 | if args.debug:
477 | logger.setLevel(logging.DEBUG)
478 |
479 | # Start the server
480 | server = RhinoMCPServer(
481 | host=args.host,
482 | port=args.port,
483 | rhino_host=args.rhino_host,
484 | rhino_port=args.rhino_port
485 | )
486 |
487 | print(f"Starting RhinoMCP server at ws://{args.host}:{args.port}")
488 | print(f"Connecting to Rhino at {args.rhino_host}:{args.rhino_port}")
489 | print("Press Ctrl+C to stop")
490 |
491 | try:
492 | server.start_in_thread()
493 | except KeyboardInterrupt:
494 | print("Server stopped by user")
495 |
496 |
497 | if __name__ == "__main__":
498 | main()
499 |
```
--------------------------------------------------------------------------------
/ai/rhino-python-bridge-docs.md:
--------------------------------------------------------------------------------
```markdown
1 | # Python-Rhino Bridge: Setup & Usage Guide
2 |
3 | This guide explains how to establish a bidirectional connection between external Python scripts and Rhinoceros 3D, allowing you to control Rhino programmatically from Python running outside the Rhino environment.
4 |
5 | ## Overview
6 |
7 | The Python-Rhino Bridge consists of two main components:
8 |
9 | 1. **Server Script** - Runs inside Rhino's Python editor and listens for commands
10 | 2. **Client Script** - Runs in your external Python environment and sends commands to Rhino
11 |
12 | This connection enables:
13 | - Creating Rhino geometry from external Python
14 | - Running custom Python code within Rhino's environment
15 | - Querying information from Rhino
16 | - Building interactive tools that communicate with Rhino
17 |
18 | ## Requirements
19 |
20 | ### Software Requirements
21 |
22 | - **Rhinoceros 3D**: Version 7 or 8
23 | - **Python**: Version 3.9 or 3.10 (matching your Rhino installation)
24 | - **Operating System**: Windows 10 or 11
25 |
26 | ### Directory Structure
27 |
28 | Create a project folder with the following structure:
29 | ```
30 | rhino_windsurf/
31 | ├── rhino_bridge.py # Server script (runs in Rhino)
32 | ├── rhino_client_deluxe.py # Client script (runs in external Python)
33 | └── PythonBridgeCommand.py # Optional Rhino command script
34 | ```
35 |
36 | ## Setup Instructions
37 |
38 | ### 1. Setting Up the Rhino Server
39 |
40 | 1. **Create the server script**:
41 | - Open Rhino
42 | - Type `EditPythonScript` in the command line to open Rhino's Python editor
43 | - Create a new script named `rhino_bridge.py`
44 | - Copy the server script code (provided below) into this file
45 | - Save the file to your project directory
46 |
47 | 2. **Running the server**:
48 | - With the `rhino_bridge.py` file open in Rhino's Python editor
49 | - Click the "Run" button or press F5
50 | - Verify you see "Rhino Bridge started!" in the output panel
51 | - Keep this script running as long as you need the connection
52 |
53 | ### 2. Setting Up the Python Client
54 |
55 | 1. **Python environment setup**:
56 | - Create a virtual environment (recommended):
57 | ```
58 | python -m venv .venv_rhino
59 | .venv_rhino\Scripts\activate # On Windows
60 | ```
61 | - This isolates your project dependencies from other Python projects
62 |
63 | 2. **Create the client script**:
64 | - Create a new file named `rhino_client_deluxe.py` in your project directory
65 | - Copy the client script code (provided below) into this file
66 |
67 | 3. **Running the client**:
68 | - Ensure Rhino is running with the server script active
69 | - Open a terminal in your project directory
70 | - Activate your virtual environment if used
71 | - Run: `python rhino_client_deluxe.py`
72 | - You should see a confirmation of successful connection
73 |
74 | ### 3. Optional: Creating a Rhino Command
75 |
76 | To start the server easily within Rhino:
77 |
78 | 1. Create `PythonBridgeCommand.py` in your Rhino scripts folder:
79 | - Typically located at `%APPDATA%\McNeel\Rhinoceros\8.0\scripts\`
80 | - Copy the command script code (provided below) into this file
81 |
82 | 2. Run the command in Rhino:
83 | - Type `PythonBridge` in Rhino's command line
84 | - The server should start automatically
85 |
86 | ## Script Code
87 |
88 | ### 1. Rhino Server Script (`rhino_bridge.py`)
89 |
90 | ```python
91 | """
92 | Rhino Bridge - Simplified server for stable Python-Rhino communication.
93 | Version: 2.0 (2025-03-13)
94 |
95 | This script provides a reliable socket connection between Python and Rhino
96 | with simplified error handling and robust object creation.
97 | """
98 | import socket
99 | import json
100 | import sys
101 | import traceback
102 | import threading
103 | import Rhino
104 | import time
105 |
106 | # Server configuration
107 | HOST = '127.0.0.1'
108 | PORT = 8888
109 | SERVER_VERSION = "Bridge-2.0"
110 | SERVER_START_TIME = time.strftime("%Y-%m-%d %H:%M:%S")
111 |
112 | # Custom JSON encoder for handling .NET objects
113 | class RhinoEncoder(json.JSONEncoder):
114 | def default(self, obj):
115 | # Handle .NET Version objects and other common types
116 | try:
117 | if hasattr(obj, 'ToString'):
118 | return str(obj)
119 | elif hasattr(obj, 'Count') and hasattr(obj, 'Item'):
120 | return [self.default(obj.Item[i]) for i in range(obj.Count)]
121 | else:
122 | return str(obj) # Last resort: convert anything to string
123 | except:
124 | return str(obj) # Absolute fallback
125 |
126 | # Let the base class handle other types
127 | return super(RhinoEncoder, self).default(obj)
128 |
129 | def handle_client(conn, addr):
130 | """Handle individual client connections"""
131 | print(f"Connection established from {addr}")
132 |
133 | try:
134 | while True:
135 | # Receive command
136 | data = conn.recv(4096)
137 | if not data:
138 | break
139 |
140 | # Parse the command
141 | try:
142 | command_obj = json.loads(data.decode('utf-8'))
143 | cmd_type = command_obj.get('type', '')
144 | cmd_data = command_obj.get('data', {})
145 |
146 | print(f"Received command: {cmd_type}")
147 | result = {'status': 'error', 'message': 'Unknown command'}
148 |
149 | # Process different command types
150 | if cmd_type == 'ping':
151 | result = {
152 | 'status': 'success',
153 | 'message': 'Rhino is connected',
154 | 'data': {
155 | 'version': str(Rhino.RhinoApp.Version),
156 | 'has_active_doc': Rhino.RhinoDoc.ActiveDoc is not None,
157 | 'server_version': SERVER_VERSION,
158 | 'server_start_time': SERVER_START_TIME,
159 | 'script_path': __file__
160 | }
161 | }
162 |
163 | elif cmd_type == 'create_sphere':
164 | # SIMPLIFIED APPROACH: Just create the sphere without complex checks
165 | try:
166 | center_x = cmd_data.get('center_x', 0)
167 | center_y = cmd_data.get('center_y', 0)
168 | center_z = cmd_data.get('center_z', 0)
169 | radius = cmd_data.get('radius', 5.0)
170 |
171 | doc = Rhino.RhinoDoc.ActiveDoc
172 | if not doc:
173 | raise Exception("No active Rhino document")
174 |
175 | # Create the sphere directly
176 | center = Rhino.Geometry.Point3d(center_x, center_y, center_z)
177 | sphere = Rhino.Geometry.Sphere(center, radius)
178 |
179 | # Convert to a brep for better display
180 | brep = sphere.ToBrep()
181 |
182 | # Add to document, ignoring the return value
183 | doc.Objects.AddBrep(brep)
184 |
185 | # Force view update
186 | doc.Views.Redraw()
187 |
188 | result = {
189 | 'status': 'success',
190 | 'message': f'Sphere created at ({center_x}, {center_y}, {center_z}) with radius {radius}'
191 | }
192 | except Exception as e:
193 | result = {
194 | 'status': 'error',
195 | 'message': f'Sphere creation error: {str(e)}',
196 | 'traceback': traceback.format_exc()
197 | }
198 |
199 | elif cmd_type == 'refresh_view':
200 | try:
201 | doc = Rhino.RhinoDoc.ActiveDoc
202 | if doc:
203 | doc.Views.Redraw()
204 | result = {'status': 'success', 'message': 'Views refreshed'}
205 | else:
206 | result = {'status': 'error', 'message': 'No active document'}
207 | except Exception as e:
208 | result = {'status': 'error', 'message': f'Refresh error: {str(e)}'}
209 |
210 | elif cmd_type == 'run_script':
211 | script = cmd_data.get('script', '')
212 | if script:
213 | # Capture print output
214 | old_stdout = sys.stdout
215 | from io import StringIO
216 | captured_output = StringIO()
217 | sys.stdout = captured_output
218 |
219 | try:
220 | # Execute the script
221 | exec(script)
222 | result = {
223 | 'status': 'success',
224 | 'message': 'Script executed',
225 | 'data': {'output': captured_output.getvalue()}
226 | }
227 | except Exception as e:
228 | result = {
229 | 'status': 'error',
230 | 'message': f'Script execution error: {str(e)}',
231 | 'data': {'traceback': traceback.format_exc()}
232 | }
233 | finally:
234 | sys.stdout = old_stdout
235 | else:
236 | result = {'status': 'error', 'message': 'No script provided'}
237 |
238 | # Send the result back using the custom encoder
239 | conn.sendall(json.dumps(result, cls=RhinoEncoder).encode('utf-8'))
240 |
241 | except json.JSONDecodeError:
242 | conn.sendall(json.dumps({
243 | 'status': 'error',
244 | 'message': 'Invalid JSON format'
245 | }).encode('utf-8'))
246 | except Exception as e:
247 | print(f"Error processing command: {str(e)}")
248 | traceback.print_exc()
249 | conn.sendall(json.dumps({
250 | 'status': 'error',
251 | 'message': f'Server error: {str(e)}',
252 | 'traceback': traceback.format_exc()
253 | }, cls=RhinoEncoder).encode('utf-8'))
254 | except Exception as e:
255 | print(f"Connection error: {str(e)}")
256 | finally:
257 | print(f"Connection closed with {addr}")
258 | conn.close()
259 |
260 | def start_server():
261 | """Start the socket server"""
262 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
263 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
264 | try:
265 | s.bind((HOST, PORT))
266 | s.listen()
267 | print(f"Server started on {HOST}:{PORT}")
268 | print("Waiting for connections...")
269 |
270 | try:
271 | while True:
272 | conn, addr = s.accept()
273 | client_thread = threading.Thread(target=handle_client, args=(conn, addr))
274 | client_thread.daemon = True
275 | client_thread.start()
276 | except KeyboardInterrupt:
277 | print("Server shutting down...")
278 | except Exception as e:
279 | print(f"Server error: {str(e)}")
280 | traceback.print_exc()
281 | except Exception as e:
282 | print(f"Failed to bind to {HOST}:{PORT}. Error: {str(e)}")
283 | print("Try closing any other running instances of this script or check if another program is using this port.")
284 |
285 | # Display server information
286 | print("\n========== RHINO BRIDGE ==========")
287 | print(f"Version: {SERVER_VERSION}")
288 | print(f"Started: {SERVER_START_TIME}")
289 | print(f"File: {__file__}")
290 | print("===================================\n")
291 |
292 | # Start the server in a background thread to keep Rhino responsive
293 | server_thread = threading.Thread(target=start_server)
294 | server_thread.daemon = True
295 | server_thread.start()
296 |
297 | print("Rhino Bridge started!")
298 | print(f"Listening on {HOST}:{PORT}")
299 | print("Keep this script running in Rhino's Python editor")
300 | print("The server will run until you close this script or Rhino")
301 | ```
302 |
303 | ### 2. Python Client Script (`rhino_client_deluxe.py`)
304 |
305 | ```python
306 | """
307 | Rhino Client Deluxe - Interactive client for Rhino connection.
308 | Version: 1.0 (2025-03-13)
309 |
310 | This script provides an interactive terminal for connecting to and
311 | controlling Rhino from external Python scripts.
312 | """
313 | import os
314 | import sys
315 | import json
316 | import socket
317 | import time
318 | from typing import Dict, Any, Optional, List, Tuple
319 |
320 | class RhinoClient:
321 | """Client for maintaining an interactive connection with Rhino."""
322 |
323 | def __init__(self, host: str = '127.0.0.1', port: int = 8888):
324 | """Initialize the interactive Rhino client."""
325 | self.host = host
326 | self.port = port
327 | self.socket = None
328 | self.connected = False
329 | self.command_history: List[str] = []
330 |
331 | def connect(self) -> bool:
332 | """Establish connection to the Rhino socket server."""
333 | try:
334 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
335 | self.socket.connect((self.host, self.port))
336 | self.connected = True
337 |
338 | # Test connection with ping and verify server version
339 | response = self.send_command('ping')
340 | if response and response.get('status') == 'success':
341 | server_data = response.get('data', {})
342 | print(f"\n✅ Connected to Rhino {server_data.get('version', 'unknown')}")
343 | print(f"🔌 Server: {server_data.get('server_version', 'unknown')}")
344 | print(f"📂 Script: {server_data.get('script_path', 'unknown')}")
345 | print(f"⏰ Started: {server_data.get('server_start_time', 'unknown')}")
346 |
347 | # Ensure we're connected to the Deluxe server
348 | if 'Deluxe' not in server_data.get('server_version', ''):
349 | print("\n⚠️ WARNING: Not connected to RhinoServerDeluxe!")
350 | print("You may experience errors with spheres and other commands.")
351 | print("Please run rhino_server_deluxe.py in Rhino's Python editor.")
352 |
353 | # Add an immediate view refresh to ensure everything is visible
354 | self.refresh_view()
355 | return True
356 |
357 | print("\n❌ Connection test failed")
358 | self.disconnect()
359 | return False
360 |
361 | except Exception as e:
362 | print(f"\n❌ Connection error: {str(e)}")
363 | return False
364 |
365 | def disconnect(self) -> None:
366 | """Close the connection to Rhino."""
367 | if self.socket:
368 | try:
369 | self.socket.close()
370 | except:
371 | pass
372 | self.socket = None
373 | self.connected = False
374 | print("\nDisconnected from Rhino")
375 |
376 | def send_command(self, cmd_type: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
377 | """Send a command to the Rhino server and return the response."""
378 | if not self.connected or not self.socket:
379 | print("❌ Not connected to Rhino")
380 | return {'status': 'error', 'message': 'Not connected to Rhino'}
381 |
382 | try:
383 | # Create command
384 | command = {
385 | 'type': cmd_type,
386 | 'data': data or {}
387 | }
388 |
389 | # Send command
390 | self.socket.sendall(json.dumps(command).encode('utf-8'))
391 |
392 | # Receive response
393 | response = self.socket.recv(8192)
394 | return json.loads(response.decode('utf-8'))
395 |
396 | except Exception as e:
397 | print(f"❌ Error sending command: {str(e)}")
398 | # Don't disconnect automatically to allow for retry
399 | return {'status': 'error', 'message': f'Command error: {str(e)}'}
400 |
401 | def create_sphere(self, x: float, y: float, z: float, radius: float) -> Dict[str, Any]:
402 | """Create a sphere in Rhino."""
403 | data = {
404 | 'center_x': x,
405 | 'center_y': y,
406 | 'center_z': z,
407 | 'radius': radius
408 | }
409 | result = self.send_command('create_sphere', data)
410 |
411 | # Always refresh view after creating geometry
412 | if result.get('status') == 'success':
413 | self.refresh_view()
414 |
415 | return result
416 |
417 | def run_script(self, script: str) -> Dict[str, Any]:
418 | """Run a Python script in Rhino."""
419 | result = self.send_command('run_script', {'script': script})
420 |
421 | # Always refresh view after running a script
422 | if result.get('status') == 'success':
423 | self.refresh_view()
424 |
425 | return result
426 |
427 | def refresh_view(self) -> Dict[str, Any]:
428 | """Refresh the Rhino viewport."""
429 | return self.send_command('refresh_view')
430 |
431 | def add_to_history(self, command: str) -> None:
432 | """Add a command to the history."""
433 | if command and command not in ['', 'exit', 'help']:
434 | self.command_history.append(command)
435 | if len(self.command_history) > 100: # Limit history size
436 | self.command_history.pop(0)
437 |
438 | def print_help() -> None:
439 | """Print help information about available commands."""
440 | print("\n=== Available Commands ===")
441 | print("sphere <x> <y> <z> <radius> - Create a sphere")
442 | print("script <python_code> - Run Python code in Rhino")
443 | print("refresh - Refresh the Rhino viewport")
444 | print("history - Show command history")
445 | print("ping - Test connection to Rhino")
446 | print("help - Show this help message")
447 | print("exit - Close the connection and exit")
448 | print("\nExample: sphere 10 20 0 5")
449 | print("Example: script import Rhino; print(f\"Current doc: {Rhino.RhinoDoc.ActiveDoc.Name}\")")
450 |
451 | def main() -> None:
452 | """Run the interactive Rhino client."""
453 | print("\n========== RHINO CLIENT DELUXE ==========")
454 | print("Version: 1.0 (2025-03-13)")
455 | print("==========================================\n")
456 |
457 | print("This script provides an interactive connection to Rhino.")
458 | print("Make sure 'rhino_server_deluxe.py' is running in Rhino's Python editor.")
459 |
460 | # Create client
461 | client = RhinoClient()
462 |
463 | # Connect to Rhino
464 | if not client.connect():
465 | print("\nFailed to connect to Rhino. Make sure the server script is running.")
466 | return
467 |
468 | print_help()
469 |
470 | # Command loop
471 | try:
472 | while True:
473 | command = input("\nrhino> ").strip()
474 |
475 | if not command:
476 | continue
477 |
478 | if command.lower() == 'exit':
479 | break
480 |
481 | if command.lower() == 'help':
482 | print_help()
483 | continue
484 |
485 | if command.lower() == 'ping':
486 | response = client.send_command('ping')
487 | if response.get('status') == 'success':
488 | print(f"Connection active! Rhino version: {response.get('data', {}).get('version', 'unknown')}")
489 | else:
490 | print(f"Ping failed: {response.get('message', 'Unknown error')}")
491 | client.add_to_history(command)
492 | continue
493 |
494 | # View refresh command
495 | if command.lower() == 'refresh':
496 | response = client.refresh_view()
497 | if response.get('status') == 'success':
498 | print("✅ Viewport refreshed")
499 | else:
500 | print(f"❌ Refresh failed: {response.get('message', 'Unknown error')}")
501 | client.add_to_history(command)
502 | continue
503 |
504 | # Command history
505 | if command.lower() == 'history':
506 | if client.command_history:
507 | print("\n=== Command History ===")
508 | for i, cmd in enumerate(client.command_history):
509 | print(f"{i+1}. {cmd}")
510 | else:
511 | print("No command history yet.")
512 | continue
513 |
514 | # Parse sphere command: sphere <x> <y> <z> <radius>
515 | if command.lower().startswith('sphere '):
516 | try:
517 | parts = command.split()
518 | if len(parts) != 5:
519 | print("❌ Invalid sphere command. Format: sphere <x> <y> <z> <radius>")
520 | continue
521 |
522 | x, y, z, radius = map(float, parts[1:])
523 | response = client.create_sphere(x, y, z, radius)
524 |
525 | if response.get('status') == 'success':
526 | print(f"✅ {response.get('message', 'Sphere created successfully')}")
527 | else:
528 | print(f"❌ Sphere creation failed: {response.get('message', 'Unknown error')}")
529 | if response.get('traceback'):
530 | print("\nError details:")
531 | print(response.get('traceback'))
532 | print("\nTroubleshooting tip: Make sure you're running rhino_server_deluxe.py in Rhino")
533 |
534 | client.add_to_history(command)
535 | except ValueError:
536 | print("❌ Invalid parameters. All values must be numbers.")
537 | continue
538 |
539 | # Handle script command: script <python code>
540 | if command.lower().startswith('script '):
541 | script = command[7:] # Remove 'script ' prefix
542 | response = client.run_script(script)
543 |
544 | if response.get('status') == 'success':
545 | print("\n=== Script Output ===")
546 | output = response.get('data', {}).get('output', 'No output')
547 | if output.strip():
548 | print(output)
549 | else:
550 | print("Script executed successfully (no output)")
551 | else:
552 | print(f"❌ Script error: {response.get('message', 'Unknown error')}")
553 | if 'traceback' in response:
554 | print("\n=== Error Traceback ===")
555 | print(response['traceback'])
556 |
557 | client.add_to_history(command)
558 | continue
559 |
560 | print(f"❌ Unknown command: {command}")
561 | print("Type 'help' for available commands")
562 |
563 | except KeyboardInterrupt:
564 | print("\nInterrupted by user")
565 | except Exception as e:
566 | print(f"\n❌ Error: {str(e)}")
567 | import traceback
568 | traceback.print_exc()
569 | finally:
570 | client.disconnect()
571 |
572 | if __name__ == "__main__":
573 | main()
574 | ```
575 |
576 | ### 3. Rhino Command Script (`PythonBridgeCommand.py`)
577 |
578 | ```python
579 | """Rhino-Python Bridge Command
580 | Creates a custom Rhino command to start the Python socket server.
581 | """
582 | import Rhino
583 | import rhinoscriptsyntax as rs
584 | import scriptcontext as sc
585 | import System
586 | import os
587 |
588 | __commandname__ = "PythonBridge"
589 |
590 | def RunCommand(is_interactive):
591 | """Run the PythonBridge command, which starts the socket server for Python connections."""
592 | # Create path to the user's Documents folder (reliable location)
593 | docs_folder = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments)
594 | projects_folder = os.path.join(docs_folder, "CascadeProjects", "rhino_windsurf")
595 |
596 | # Path to the server script
597 | script_path = os.path.join(projects_folder, "rhino_bridge.py")
598 |
599 | # Check if the file exists at the default location
600 | if not os.path.exists(script_path):
601 | # If not found, ask the user for the file location
602 | filter = "Python Files (*.py)|*.py|All Files (*.*)|*.*||"
603 | script_path = rs.OpenFileName("Select the rhino_bridge.py script", filter)
604 |
605 | if not script_path:
606 | print("Operation canceled. No server script selected.")
607 | return Rhino.Commands.Result.Cancel
608 |
609 | # Run the script using Rhino's script runner
610 | Rhino.RhinoApp.RunScript("_-RunPythonScript \"" + script_path + "\"", False)
611 |
612 | print("Python Bridge server started!")
613 | print(f"Server script: {script_path}")
614 | print("You can now connect from external Python scripts")
615 |
616 | return Rhino.Commands.Result.Success
617 |
618 | # This is needed for Rhino to recognize the command
619 | if __name__ == "__main__":
620 | RunCommand(True)
621 | ```
622 |
623 | ## Using the Python-Rhino Bridge
624 |
625 | ### Basic Commands
626 |
627 | Once the bridge is set up and both the server and client are running, you can interact with Rhino using these commands:
628 |
629 | 1. **Creating Spheres**:
630 | ```
631 | rhino> sphere <x> <y> <z> <radius>
632 | ```
633 | Example: `sphere 10 20 0 5`
634 |
635 | 2. **Running Python Scripts in Rhino**:
636 | ```
637 | rhino> script <python_code>
638 | ```
639 | Example: `script import Rhino; print(f"Rhino version: {Rhino.RhinoApp.Version}")`
640 |
641 | 3. **Refreshing the Viewport**:
642 | ```
643 | rhino> refresh
644 | ```
645 |
646 | 4. **Testing Connection**:
647 | ```
648 | rhino> ping
649 | ```
650 |
651 | 5. **Viewing Command History**:
652 | ```
653 | rhino> history
654 | ```
655 |
656 | 6. **Exiting the Client**:
657 | ```
658 | rhino> exit
659 | ```
660 |
661 | ### Advanced Usage: Multi-Statement Python Commands
662 |
663 | You can run multiple Python statements in a single command by separating them with semicolons:
664 |
665 | ```
666 | rhino> script import Rhino; import random; x = random.uniform(0,10); y = random.uniform(0,10); print(f"Random point: ({x}, {y})")
667 | ```
668 |
669 | For more complex scripts, you can use Python's block syntax with proper indentation:
670 |
671 | ```
672 | rhino> script if True:
673 | import Rhino
674 | import random
675 | for i in range(3):
676 | x = random.uniform(0, 10)
677 | y = random.uniform(0, 10)
678 | z = random.uniform(0, 10)
679 | print(f"Point {i+1}: ({x}, {y}, {z})")
680 | ```
681 |
682 | ### Creating Complex Geometry
683 |
684 | To create more complex geometry, you can write scripts that leverage the full power of RhinoCommon:
685 |
686 | ```
687 | rhino> script import Rhino; doc = Rhino.RhinoDoc.ActiveDoc; curve = Rhino.Geometry.Circle(Rhino.Geometry.Plane.WorldXY, 10).ToNurbsCurve(); doc.Objects.AddCurve(curve); doc.Views.Redraw()
688 | ```
689 |
690 | ## Troubleshooting
691 |
692 | ### Common Issues and Solutions
693 |
694 | 1. **Connection Refused**:
695 | - Ensure Rhino is running with the server script active
696 | - Check if another instance of the server is already running on port 8888
697 | - Try restarting Rhino and the server script
698 |
699 | 2. **Geometry Not Appearing**:
700 | - Use the `refresh` command to force a viewport update
701 | - Make sure you have an active document open in Rhino
702 | - Check for error messages in the command response
703 |
704 | 3. **Script Execution Errors**:
705 | - Verify your Python syntax is correct
706 | - Make sure you're using RhinoCommon API methods correctly
707 | - Check the error traceback for specific issues
708 |
709 | 4. **Server Warning in Client**:
710 | - The "Not connected to RhinoServerDeluxe" warning can be ignored if using the Bridge version
711 | - Make sure you're using compatible versions of the server and client scripts
712 |
713 | ### Extending the Bridge
714 |
715 | You can extend the functionality of the bridge by:
716 |
717 | 1. Adding new command types to the server script
718 | 2. Implementing additional geometry creation methods in the client
719 | 3. Creating specialized scriptlets for common tasks
720 |
721 | ## Conclusion
722 |
723 | The Python-Rhino Bridge provides a powerful way to control Rhino programmatically from external Python scripts. Whether you're automating modeling tasks, integrating with other tools, or building custom workflows, this bridge offers a flexible and robust connection between Python and Rhino.
724 |
```
--------------------------------------------------------------------------------
/rhino-python-bridge-docs.md:
--------------------------------------------------------------------------------
```markdown
1 | # Python-Rhino Bridge: Setup & Usage Guide
2 |
3 | This guide explains how to establish a bidirectional connection between external Python scripts and Rhinoceros 3D, allowing you to control Rhino programmatically from Python running outside the Rhino environment.
4 |
5 | ## Overview
6 |
7 | The Python-Rhino Bridge consists of two main components:
8 |
9 | 1. **Server Script** - Runs inside Rhino's Python editor and listens for commands
10 | 2. **Client Script** - Runs in your external Python environment and sends commands to Rhino
11 |
12 | This connection enables:
13 | - Creating Rhino geometry from external Python
14 | - Running custom Python code within Rhino's environment
15 | - Querying information from Rhino
16 | - Building interactive tools that communicate with Rhino
17 |
18 | ## Requirements
19 |
20 | ### Software Requirements
21 |
22 | - **Rhinoceros 3D**: Version 7 or 8
23 | - **Python**: Version 3.9 or 3.10 (matching your Rhino installation)
24 | - **Operating System**: Windows 10 or 11
25 |
26 | ### Directory Structure
27 |
28 | Create a project folder with the following structure:
29 | ```
30 | rhino_windsurf/
31 | ├── rhino_bridge.py # Server script (runs in Rhino)
32 | ├── rhino_client_deluxe.py # Client script (runs in external Python)
33 | └── PythonBridgeCommand.py # Optional Rhino command script
34 | ```
35 |
36 | ## Setup Instructions
37 |
38 | ### 1. Setting Up the Rhino Server
39 |
40 | 1. **Create the server script**:
41 | - Open Rhino
42 | - Type `EditPythonScript` in the command line to open Rhino's Python editor
43 | - Create a new script named `rhino_bridge.py`
44 | - Copy the server script code (provided below) into this file
45 | - Save the file to your project directory
46 |
47 | 2. **Running the server**:
48 | - With the `rhino_bridge.py` file open in Rhino's Python editor
49 | - Click the "Run" button or press F5
50 | - Verify you see "Rhino Bridge started!" in the output panel
51 | - Keep this script running as long as you need the connection
52 |
53 | ### 2. Setting Up the Python Client
54 |
55 | 1. **Python environment setup**:
56 | - Create a virtual environment (recommended):
57 | ```
58 | python -m venv .venv_rhino
59 | .venv_rhino\Scripts\activate # On Windows
60 | ```
61 | - This isolates your project dependencies from other Python projects
62 |
63 | 2. **Create the client script**:
64 | - Create a new file named `rhino_client_deluxe.py` in your project directory
65 | - Copy the client script code (provided below) into this file
66 |
67 | 3. **Running the client**:
68 | - Ensure Rhino is running with the server script active
69 | - Open a terminal in your project directory
70 | - Activate your virtual environment if used
71 | - Run: `python rhino_client_deluxe.py`
72 | - You should see a confirmation of successful connection
73 |
74 | ### 3. Optional: Creating a Rhino Command
75 |
76 | To start the server easily within Rhino:
77 |
78 | 1. Create `PythonBridgeCommand.py` in your Rhino scripts folder:
79 | - Typically located at `%APPDATA%\McNeel\Rhinoceros\8.0\scripts\`
80 | - Copy the command script code (provided below) into this file
81 |
82 | 2. Run the command in Rhino:
83 | - Type `PythonBridge` in Rhino's command line
84 | - The server should start automatically
85 |
86 | ## Script Code
87 |
88 | ### 1. Rhino Server Script (`rhino_bridge.py`)
89 |
90 | ```python
91 | """
92 | Rhino Bridge - Simplified server for stable Python-Rhino communication.
93 | Version: 2.0 (2025-03-13)
94 |
95 | This script provides a reliable socket connection between Python and Rhino
96 | with simplified error handling and robust object creation.
97 | """
98 | import socket
99 | import json
100 | import sys
101 | import traceback
102 | import threading
103 | import Rhino
104 | import time
105 |
106 | # Server configuration
107 | HOST = '127.0.0.1'
108 | PORT = 8888
109 | SERVER_VERSION = "Bridge-2.0"
110 | SERVER_START_TIME = time.strftime("%Y-%m-%d %H:%M:%S")
111 |
112 | # Custom JSON encoder for handling .NET objects
113 | class RhinoEncoder(json.JSONEncoder):
114 | def default(self, obj):
115 | # Handle .NET Version objects and other common types
116 | try:
117 | if hasattr(obj, 'ToString'):
118 | return str(obj)
119 | elif hasattr(obj, 'Count') and hasattr(obj, 'Item'):
120 | return [self.default(obj.Item[i]) for i in range(obj.Count)]
121 | else:
122 | return str(obj) # Last resort: convert anything to string
123 | except:
124 | return str(obj) # Absolute fallback
125 |
126 | # Let the base class handle other types
127 | return super(RhinoEncoder, self).default(obj)
128 |
129 | def handle_client(conn, addr):
130 | """Handle individual client connections"""
131 | print(f"Connection established from {addr}")
132 |
133 | try:
134 | while True:
135 | # Receive command
136 | data = conn.recv(4096)
137 | if not data:
138 | break
139 |
140 | # Parse the command
141 | try:
142 | command_obj = json.loads(data.decode('utf-8'))
143 | cmd_type = command_obj.get('type', '')
144 | cmd_data = command_obj.get('data', {})
145 |
146 | print(f"Received command: {cmd_type}")
147 | result = {'status': 'error', 'message': 'Unknown command'}
148 |
149 | # Process different command types
150 | if cmd_type == 'ping':
151 | result = {
152 | 'status': 'success',
153 | 'message': 'Rhino is connected',
154 | 'data': {
155 | 'version': str(Rhino.RhinoApp.Version),
156 | 'has_active_doc': Rhino.RhinoDoc.ActiveDoc is not None,
157 | 'server_version': SERVER_VERSION,
158 | 'server_start_time': SERVER_START_TIME,
159 | 'script_path': __file__
160 | }
161 | }
162 |
163 | elif cmd_type == 'create_sphere':
164 | # SIMPLIFIED APPROACH: Just create the sphere without complex checks
165 | try:
166 | center_x = cmd_data.get('center_x', 0)
167 | center_y = cmd_data.get('center_y', 0)
168 | center_z = cmd_data.get('center_z', 0)
169 | radius = cmd_data.get('radius', 5.0)
170 |
171 | doc = Rhino.RhinoDoc.ActiveDoc
172 | if not doc:
173 | raise Exception("No active Rhino document")
174 |
175 | # Create the sphere directly
176 | center = Rhino.Geometry.Point3d(center_x, center_y, center_z)
177 | sphere = Rhino.Geometry.Sphere(center, radius)
178 |
179 | # Convert to a brep for better display
180 | brep = sphere.ToBrep()
181 |
182 | # Add to document, ignoring the return value
183 | doc.Objects.AddBrep(brep)
184 |
185 | # Force view update
186 | doc.Views.Redraw()
187 |
188 | result = {
189 | 'status': 'success',
190 | 'message': f'Sphere created at ({center_x}, {center_y}, {center_z}) with radius {radius}'
191 | }
192 | except Exception as e:
193 | result = {
194 | 'status': 'error',
195 | 'message': f'Sphere creation error: {str(e)}',
196 | 'traceback': traceback.format_exc()
197 | }
198 |
199 | elif cmd_type == 'refresh_view':
200 | try:
201 | doc = Rhino.RhinoDoc.ActiveDoc
202 | if doc:
203 | doc.Views.Redraw()
204 | result = {'status': 'success', 'message': 'Views refreshed'}
205 | else:
206 | result = {'status': 'error', 'message': 'No active document'}
207 | except Exception as e:
208 | result = {'status': 'error', 'message': f'Refresh error: {str(e)}'}
209 |
210 | elif cmd_type == 'run_script':
211 | script = cmd_data.get('script', '')
212 | if script:
213 | # Capture print output
214 | old_stdout = sys.stdout
215 | from io import StringIO
216 | captured_output = StringIO()
217 | sys.stdout = captured_output
218 |
219 | try:
220 | # Execute the script
221 | exec(script)
222 | result = {
223 | 'status': 'success',
224 | 'message': 'Script executed',
225 | 'data': {'output': captured_output.getvalue()}
226 | }
227 | except Exception as e:
228 | result = {
229 | 'status': 'error',
230 | 'message': f'Script execution error: {str(e)}',
231 | 'data': {'traceback': traceback.format_exc()}
232 | }
233 | finally:
234 | sys.stdout = old_stdout
235 | else:
236 | result = {'status': 'error', 'message': 'No script provided'}
237 |
238 | # Send the result back using the custom encoder
239 | conn.sendall(json.dumps(result, cls=RhinoEncoder).encode('utf-8'))
240 |
241 | except json.JSONDecodeError:
242 | conn.sendall(json.dumps({
243 | 'status': 'error',
244 | 'message': 'Invalid JSON format'
245 | }).encode('utf-8'))
246 | except Exception as e:
247 | print(f"Error processing command: {str(e)}")
248 | traceback.print_exc()
249 | conn.sendall(json.dumps({
250 | 'status': 'error',
251 | 'message': f'Server error: {str(e)}',
252 | 'traceback': traceback.format_exc()
253 | }, cls=RhinoEncoder).encode('utf-8'))
254 | except Exception as e:
255 | print(f"Connection error: {str(e)}")
256 | finally:
257 | print(f"Connection closed with {addr}")
258 | conn.close()
259 |
260 | def start_server():
261 | """Start the socket server"""
262 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
263 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
264 | try:
265 | s.bind((HOST, PORT))
266 | s.listen()
267 | print(f"Server started on {HOST}:{PORT}")
268 | print("Waiting for connections...")
269 |
270 | try:
271 | while True:
272 | conn, addr = s.accept()
273 | client_thread = threading.Thread(target=handle_client, args=(conn, addr))
274 | client_thread.daemon = True
275 | client_thread.start()
276 | except KeyboardInterrupt:
277 | print("Server shutting down...")
278 | except Exception as e:
279 | print(f"Server error: {str(e)}")
280 | traceback.print_exc()
281 | except Exception as e:
282 | print(f"Failed to bind to {HOST}:{PORT}. Error: {str(e)}")
283 | print("Try closing any other running instances of this script or check if another program is using this port.")
284 |
285 | # Display server information
286 | print("\n========== RHINO BRIDGE ==========")
287 | print(f"Version: {SERVER_VERSION}")
288 | print(f"Started: {SERVER_START_TIME}")
289 | print(f"File: {__file__}")
290 | print("===================================\n")
291 |
292 | # Start the server in a background thread to keep Rhino responsive
293 | server_thread = threading.Thread(target=start_server)
294 | server_thread.daemon = True
295 | server_thread.start()
296 |
297 | print("Rhino Bridge started!")
298 | print(f"Listening on {HOST}:{PORT}")
299 | print("Keep this script running in Rhino's Python editor")
300 | print("The server will run until you close this script or Rhino")
301 | ```
302 |
303 | ### 2. Python Client Script (`rhino_client_deluxe.py`)
304 |
305 | ```python
306 | """
307 | Rhino Client Deluxe - Interactive client for Rhino connection.
308 | Version: 1.0 (2025-03-13)
309 |
310 | This script provides an interactive terminal for connecting to and
311 | controlling Rhino from external Python scripts.
312 | """
313 | import os
314 | import sys
315 | import json
316 | import socket
317 | import time
318 | from typing import Dict, Any, Optional, List, Tuple
319 |
320 | class RhinoClient:
321 | """Client for maintaining an interactive connection with Rhino."""
322 |
323 | def __init__(self, host: str = '127.0.0.1', port: int = 8888):
324 | """Initialize the interactive Rhino client."""
325 | self.host = host
326 | self.port = port
327 | self.socket = None
328 | self.connected = False
329 | self.command_history: List[str] = []
330 |
331 | def connect(self) -> bool:
332 | """Establish connection to the Rhino socket server."""
333 | try:
334 | self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
335 | self.socket.connect((self.host, self.port))
336 | self.connected = True
337 |
338 | # Test connection with ping and verify server version
339 | response = self.send_command('ping')
340 | if response and response.get('status') == 'success':
341 | server_data = response.get('data', {})
342 | print(f"\n✅ Connected to Rhino {server_data.get('version', 'unknown')}")
343 | print(f"🔌 Server: {server_data.get('server_version', 'unknown')}")
344 | print(f"📂 Script: {server_data.get('script_path', 'unknown')}")
345 | print(f"⏰ Started: {server_data.get('server_start_time', 'unknown')}")
346 |
347 | # Ensure we're connected to the Deluxe server
348 | if 'Deluxe' not in server_data.get('server_version', ''):
349 | print("\n⚠️ WARNING: Not connected to RhinoServerDeluxe!")
350 | print("You may experience errors with spheres and other commands.")
351 | print("Please run rhino_server_deluxe.py in Rhino's Python editor.")
352 |
353 | # Add an immediate view refresh to ensure everything is visible
354 | self.refresh_view()
355 | return True
356 |
357 | print("\n❌ Connection test failed")
358 | self.disconnect()
359 | return False
360 |
361 | except Exception as e:
362 | print(f"\n❌ Connection error: {str(e)}")
363 | return False
364 |
365 | def disconnect(self) -> None:
366 | """Close the connection to Rhino."""
367 | if self.socket:
368 | try:
369 | self.socket.close()
370 | except:
371 | pass
372 | self.socket = None
373 | self.connected = False
374 | print("\nDisconnected from Rhino")
375 |
376 | def send_command(self, cmd_type: str, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
377 | """Send a command to the Rhino server and return the response."""
378 | if not self.connected or not self.socket:
379 | print("❌ Not connected to Rhino")
380 | return {'status': 'error', 'message': 'Not connected to Rhino'}
381 |
382 | try:
383 | # Create command
384 | command = {
385 | 'type': cmd_type,
386 | 'data': data or {}
387 | }
388 |
389 | # Send command
390 | self.socket.sendall(json.dumps(command).encode('utf-8'))
391 |
392 | # Receive response
393 | response = self.socket.recv(8192)
394 | return json.loads(response.decode('utf-8'))
395 |
396 | except Exception as e:
397 | print(f"❌ Error sending command: {str(e)}")
398 | # Don't disconnect automatically to allow for retry
399 | return {'status': 'error', 'message': f'Command error: {str(e)}'}
400 |
401 | def create_sphere(self, x: float, y: float, z: float, radius: float) -> Dict[str, Any]:
402 | """Create a sphere in Rhino."""
403 | data = {
404 | 'center_x': x,
405 | 'center_y': y,
406 | 'center_z': z,
407 | 'radius': radius
408 | }
409 | result = self.send_command('create_sphere', data)
410 |
411 | # Always refresh view after creating geometry
412 | if result.get('status') == 'success':
413 | self.refresh_view()
414 |
415 | return result
416 |
417 | def run_script(self, script: str) -> Dict[str, Any]:
418 | """Run a Python script in Rhino."""
419 | result = self.send_command('run_script', {'script': script})
420 |
421 | # Always refresh view after running a script
422 | if result.get('status') == 'success':
423 | self.refresh_view()
424 |
425 | return result
426 |
427 | def refresh_view(self) -> Dict[str, Any]:
428 | """Refresh the Rhino viewport."""
429 | return self.send_command('refresh_view')
430 |
431 | def add_to_history(self, command: str) -> None:
432 | """Add a command to the history."""
433 | if command and command not in ['', 'exit', 'help']:
434 | self.command_history.append(command)
435 | if len(self.command_history) > 100: # Limit history size
436 | self.command_history.pop(0)
437 |
438 | def print_help() -> None:
439 | """Print help information about available commands."""
440 | print("\n=== Available Commands ===")
441 | print("sphere <x> <y> <z> <radius> - Create a sphere")
442 | print("script <python_code> - Run Python code in Rhino")
443 | print("refresh - Refresh the Rhino viewport")
444 | print("history - Show command history")
445 | print("ping - Test connection to Rhino")
446 | print("help - Show this help message")
447 | print("exit - Close the connection and exit")
448 | print("\nExample: sphere 10 20 0 5")
449 | print("Example: script import Rhino; print(f\"Current doc: {Rhino.RhinoDoc.ActiveDoc.Name}\")")
450 |
451 | def main() -> None:
452 | """Run the interactive Rhino client."""
453 | print("\n========== RHINO CLIENT DELUXE ==========")
454 | print("Version: 1.0 (2025-03-13)")
455 | print("==========================================\n")
456 |
457 | print("This script provides an interactive connection to Rhino.")
458 | print("Make sure 'rhino_server_deluxe.py' is running in Rhino's Python editor.")
459 |
460 | # Create client
461 | client = RhinoClient()
462 |
463 | # Connect to Rhino
464 | if not client.connect():
465 | print("\nFailed to connect to Rhino. Make sure the server script is running.")
466 | return
467 |
468 | print_help()
469 |
470 | # Command loop
471 | try:
472 | while True:
473 | command = input("\nrhino> ").strip()
474 |
475 | if not command:
476 | continue
477 |
478 | if command.lower() == 'exit':
479 | break
480 |
481 | if command.lower() == 'help':
482 | print_help()
483 | continue
484 |
485 | if command.lower() == 'ping':
486 | response = client.send_command('ping')
487 | if response.get('status') == 'success':
488 | print(f"Connection active! Rhino version: {response.get('data', {}).get('version', 'unknown')}")
489 | else:
490 | print(f"Ping failed: {response.get('message', 'Unknown error')}")
491 | client.add_to_history(command)
492 | continue
493 |
494 | # View refresh command
495 | if command.lower() == 'refresh':
496 | response = client.refresh_view()
497 | if response.get('status') == 'success':
498 | print("✅ Viewport refreshed")
499 | else:
500 | print(f"❌ Refresh failed: {response.get('message', 'Unknown error')}")
501 | client.add_to_history(command)
502 | continue
503 |
504 | # Command history
505 | if command.lower() == 'history':
506 | if client.command_history:
507 | print("\n=== Command History ===")
508 | for i, cmd in enumerate(client.command_history):
509 | print(f"{i+1}. {cmd}")
510 | else:
511 | print("No command history yet.")
512 | continue
513 |
514 | # Parse sphere command: sphere <x> <y> <z> <radius>
515 | if command.lower().startswith('sphere '):
516 | try:
517 | parts = command.split()
518 | if len(parts) != 5:
519 | print("❌ Invalid sphere command. Format: sphere <x> <y> <z> <radius>")
520 | continue
521 |
522 | x, y, z, radius = map(float, parts[1:])
523 | response = client.create_sphere(x, y, z, radius)
524 |
525 | if response.get('status') == 'success':
526 | print(f"✅ {response.get('message', 'Sphere created successfully')}")
527 | else:
528 | print(f"❌ Sphere creation failed: {response.get('message', 'Unknown error')}")
529 | if response.get('traceback'):
530 | print("\nError details:")
531 | print(response.get('traceback'))
532 | print("\nTroubleshooting tip: Make sure you're running rhino_server_deluxe.py in Rhino")
533 |
534 | client.add_to_history(command)
535 | except ValueError:
536 | print("❌ Invalid parameters. All values must be numbers.")
537 | continue
538 |
539 | # Handle script command: script <python code>
540 | if command.lower().startswith('script '):
541 | script = command[7:] # Remove 'script ' prefix
542 | response = client.run_script(script)
543 |
544 | if response.get('status') == 'success':
545 | print("\n=== Script Output ===")
546 | output = response.get('data', {}).get('output', 'No output')
547 | if output.strip():
548 | print(output)
549 | else:
550 | print("Script executed successfully (no output)")
551 | else:
552 | print(f"❌ Script error: {response.get('message', 'Unknown error')}")
553 | if 'traceback' in response:
554 | print("\n=== Error Traceback ===")
555 | print(response['traceback'])
556 |
557 | client.add_to_history(command)
558 | continue
559 |
560 | print(f"❌ Unknown command: {command}")
561 | print("Type 'help' for available commands")
562 |
563 | except KeyboardInterrupt:
564 | print("\nInterrupted by user")
565 | except Exception as e:
566 | print(f"\n❌ Error: {str(e)}")
567 | import traceback
568 | traceback.print_exc()
569 | finally:
570 | client.disconnect()
571 |
572 | if __name__ == "__main__":
573 | main()
574 | ```
575 |
576 | ### 3. Rhino Command Script (`PythonBridgeCommand.py`)
577 |
578 | ```python
579 | """Rhino-Python Bridge Command
580 | Creates a custom Rhino command to start the Python socket server.
581 | """
582 | import Rhino
583 | import rhinoscriptsyntax as rs
584 | import scriptcontext as sc
585 | import System
586 | import os
587 |
588 | __commandname__ = "PythonBridge"
589 |
590 | def RunCommand(is_interactive):
591 | """Run the PythonBridge command, which starts the socket server for Python connections."""
592 | # Create path to the user's Documents folder (reliable location)
593 | docs_folder = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyDocuments)
594 | projects_folder = os.path.join(docs_folder, "CascadeProjects", "rhino_windsurf")
595 |
596 | # Path to the server script
597 | script_path = os.path.join(projects_folder, "rhino_bridge.py")
598 |
599 | # Check if the file exists at the default location
600 | if not os.path.exists(script_path):
601 | # If not found, ask the user for the file location
602 | filter = "Python Files (*.py)|*.py|All Files (*.*)|*.*||"
603 | script_path = rs.OpenFileName("Select the rhino_bridge.py script", filter)
604 |
605 | if not script_path:
606 | print("Operation canceled. No server script selected.")
607 | return Rhino.Commands.Result.Cancel
608 |
609 | # Run the script using Rhino's script runner
610 | Rhino.RhinoApp.RunScript("_-RunPythonScript \"" + script_path + "\"", False)
611 |
612 | print("Python Bridge server started!")
613 | print(f"Server script: {script_path}")
614 | print("You can now connect from external Python scripts")
615 |
616 | return Rhino.Commands.Result.Success
617 |
618 | # This is needed for Rhino to recognize the command
619 | if __name__ == "__main__":
620 | RunCommand(True)
621 | ```
622 |
623 | ## Using the Python-Rhino Bridge
624 |
625 | ### Basic Commands
626 |
627 | Once the bridge is set up and both the server and client are running, you can interact with Rhino using these commands:
628 |
629 | 1. **Creating Spheres**:
630 | ```
631 | rhino> sphere <x> <y> <z> <radius>
632 | ```
633 | Example: `sphere 10 20 0 5`
634 |
635 | 2. **Running Python Scripts in Rhino**:
636 | ```
637 | rhino> script <python_code>
638 | ```
639 | Example: `script import Rhino; print(f"Rhino version: {Rhino.RhinoApp.Version}")`
640 |
641 | 3. **Refreshing the Viewport**:
642 | ```
643 | rhino> refresh
644 | ```
645 |
646 | 4. **Testing Connection**:
647 | ```
648 | rhino> ping
649 | ```
650 |
651 | 5. **Viewing Command History**:
652 | ```
653 | rhino> history
654 | ```
655 |
656 | 6. **Exiting the Client**:
657 | ```
658 | rhino> exit
659 | ```
660 |
661 | ### Advanced Usage: Multi-Statement Python Commands
662 |
663 | You can run multiple Python statements in a single command by separating them with semicolons:
664 |
665 | ```
666 | rhino> script import Rhino; import random; x = random.uniform(0,10); y = random.uniform(0,10); print(f"Random point: ({x}, {y})")
667 | ```
668 |
669 | For more complex scripts, you can use Python's block syntax with proper indentation:
670 |
671 | ```
672 | rhino> script if True:
673 | import Rhino
674 | import random
675 | for i in range(3):
676 | x = random.uniform(0, 10)
677 | y = random.uniform(0, 10)
678 | z = random.uniform(0, 10)
679 | print(f"Point {i+1}: ({x}, {y}, {z})")
680 | ```
681 |
682 | ### Creating Complex Geometry
683 |
684 | To create more complex geometry, you can write scripts that leverage the full power of RhinoCommon:
685 |
686 | ```
687 | rhino> script import Rhino; doc = Rhino.RhinoDoc.ActiveDoc; curve = Rhino.Geometry.Circle(Rhino.Geometry.Plane.WorldXY, 10).ToNurbsCurve(); doc.Objects.AddCurve(curve); doc.Views.Redraw()
688 | ```
689 |
690 | ## Troubleshooting
691 |
692 | ### Common Issues and Solutions
693 |
694 | 1. **Connection Refused**:
695 | - Ensure Rhino is running with the server script active
696 | - Check if another instance of the server is already running on port 8888
697 | - Try restarting Rhino and the server script
698 |
699 | 2. **Geometry Not Appearing**:
700 | - Use the `refresh` command to force a viewport update
701 | - Make sure you have an active document open in Rhino
702 | - Check for error messages in the command response
703 |
704 | 3. **Script Execution Errors**:
705 | - Verify your Python syntax is correct
706 | - Make sure you're using RhinoCommon API methods correctly
707 | - Check the error traceback for specific issues
708 |
709 | 4. **Server Warning in Client**:
710 | - The "Not connected to RhinoServerDeluxe" warning can be ignored if using the Bridge version
711 | - Make sure you're using compatible versions of the server and client scripts
712 |
713 | ### Extending the Bridge
714 |
715 | You can extend the functionality of the bridge by:
716 |
717 | 1. Adding new command types to the server script
718 | 2. Implementing additional geometry creation methods in the client
719 | 3. Creating specialized scriptlets for common tasks
720 |
721 | ## Conclusion
722 |
723 | The Python-Rhino Bridge provides a powerful way to control Rhino programmatically from external Python scripts. Whether you're automating modeling tasks, integrating with other tools, or building custom workflows, this bridge offers a flexible and robust connection between Python and Rhino.
724 |
```