# Directory Structure ``` ├── .DS_Store ├── .env ├── .env.example ├── .gitignore ├── .python-version ├── examples │ ├── compute_line.py │ ├── rhinopython.py │ ├── test_CL_GH.py │ ├── test_CodeListener.py │ ├── test_GH.py │ ├── test_rhino3dm.3dm │ ├── test_rhino3dm.3dmbak │ ├── test_rhino3dm.py │ ├── test01.gh │ ├── test02.gh │ ├── test03.gh │ ├── zaha01.png │ └── zaha01.py ├── grasshopper_mcp │ ├── __init__.py │ ├── .DS_Store │ ├── config.py │ ├── prompts │ │ ├── __init__.py │ │ ├── grasshopper_prompts.py │ │ └── templates.py │ ├── resources │ │ ├── __init__.py │ │ └── model_data.py │ ├── rhino │ │ ├── __init__.py │ │ └── connection.py │ ├── server.py │ ├── tools │ │ ├── __init__.py │ │ ├── advanced_grasshopper.py │ │ ├── analysis.py │ │ ├── grasshopper.py │ │ ├── modeling.py │ │ └── rhino_code_gen.py │ └── utils │ ├── __init__.py │ └── request.py ├── LICENSE ├── main.py ├── pyproject.toml ├── README.md ├── run_server.py ├── scripts │ └── install.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | ``` -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- ``` 1 | # Rhino/Grasshopper configuration 2 | RHINO_PATH=C:/Program Files/Rhino 7/System 3 | COMPUTE_API_KEY=your_compute_key_here 4 | COMPUTE_URL=https://compute.rhino3d.com 5 | 6 | # MCP server configuration 7 | SERVER_NAME=Grasshopper MCP 8 | SERVER_PORT=8080 9 | ``` -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- ``` 1 | # Rhino/Grasshopper configuration 2 | USE_RHINO3DM=true # using Rhino3dm (for mac), switch to true 3 | RHINO_PATH=C:/Program Files/Rhino 7/System 4 | USE_COMPUTE_API=false 5 | COMPUTE_API_KEY=your_compute_key_here 6 | COMPUTE_URL=https://compute.rhino3d.com 7 | 8 | # MCP server configuration 9 | SERVER_NAME=Grasshopper_MCP 10 | SERVER_PORT=8080 11 | 12 | 13 | 14 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # GH_mcp_server 2 | 3 | GH_mcp_server provides an approach that allows designer to interact with Rhino and Grasshopper directly via LLMs, including to analyse .3dm file, do 3D modeling and generate GHPython automatically in Grasshopper based on user’s guidance. 4 | 5 | > This project is **still under construction** — and we’d love your help! 6 | > 7 | > - Feel free to **open an issue** if you encounter bugs or have ideas. 8 | > - Pull requests are always welcome. 9 | > - If you're interested in collaborating long-term, feel free to reach out to [email protected] — we’d love to **have you on the team**! 10 | 11 |  12 | 13 | ## Requirements 14 | 15 | - Rhino 7 or 8 16 | 17 | - Install `RhinoPython`: https://github.com/jingcheng-chen/RhinoPythonForVscode/tree/master?tab=readme-ov-file 18 | 19 | - `uv` 20 | 21 | - ``` 22 | # For MacOS and Linux 23 | curl -LsSf https://astral.sh/uv/install.sh | sh 24 | ``` 25 | 26 | - `````` 27 | # For Windows 28 | powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 29 | `````` 30 | 31 | - Claude Desktop 32 | 33 | ## Installation 34 | 35 | ### 1. Clone the repository 36 | 37 | ``` 38 | git clone [email protected]:veoery/GH_mcp_server.git 39 | cd GH_mcp_server 40 | ``` 41 | 42 | ------ 43 | 44 | ### 2. Set up the environment 45 | 46 | We recommend using `uv`: 47 | 48 | #### macOS/Linux 49 | 50 | ``` 51 | uv venv 52 | source .venv/bin/activate 53 | uv pip install -e . 54 | ``` 55 | 56 | #### Windows 57 | 58 | ``` 59 | uv venv 60 | .venv\Scripts\activate 61 | uv pip install -e . 62 | ``` 63 | 64 | > Make sure the virtual environment is activated before running or developing the project. 65 | 66 | ### 3. Configuration 67 | 68 | 1. In the Claude Desktop, Navigate to Settings->Developer. You will see ```Edit Config```. 69 | 70 | 2. Click the ```Edit Config``` and open the file ```claude_desktop_config.json``` 71 | 72 | 3. Place the following code to the json file: 73 | ```python 74 | { 75 | "mcpServers": { 76 | "grasshopper": { 77 | "command": "path_to_GH_mcp_server/.venv/bin/python", 78 | "args": [ 79 | "path_to_GH_mcp_server/run_server.py" 80 | ] 81 | } 82 | } 83 | } 84 | ``` 85 | 86 | 4. Restart the Claude Desktop. If you are able to see a hammer icon, the configuration is successful. Click the hammer icon to check all the attached MCP tools. 87 | 88 | ## Usage 89 | 90 | 1. Start Rhino 91 | 92 | 2. Type command `CodeListener`. You should see `VS Code Listener Started...`. 93 | 94 | 3. Open the Claude Desktop and type the prompts to interact with GH_mcp_server tools. Please also check the file `examples\zaha01.gh` as a reference for interacting with Grasshopper. Here are some examples: 95 | 96 | ``` 97 | Read the file "D:\test01.3dm" first and analyse the objects in this file. 98 | ``` 99 | 100 | ``` 101 | write GHpython to create a tower referring to zaha and write the ghpython code to "D:\zaha01.py" 102 | ``` 103 | 104 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/prompts/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/resources/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/rhino/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/tools/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/utils/__init__.py: -------------------------------------------------------------------------------- ```python 1 | ``` -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- ```python 1 | def main(): 2 | print("Hello from gh-mcp-server!") 3 | 4 | 5 | if __name__ == "__main__": 6 | main() 7 | ``` -------------------------------------------------------------------------------- /run_server.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python 2 | import sys 3 | import os 4 | 5 | # Set up the Python path to find the package 6 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 7 | 8 | # Import and run the server module 9 | from grasshopper_mcp.server import main 10 | 11 | if __name__ == "__main__": 12 | main() 13 | ``` -------------------------------------------------------------------------------- /examples/test_CL_GH.py: -------------------------------------------------------------------------------- ```python 1 | import Rhino 2 | import rhinoscriptsyntax as rs 3 | import scriptcontext as sc 4 | import System 5 | from Rhino.Geometry import * 6 | 7 | 8 | def main(): 9 | center = Point3d(10, 50, 0) 10 | circle = Circle(Plane.WorldXY, center, 20) 11 | return circle 12 | 13 | 14 | if __name__ == "__main__": 15 | circle = main() 16 | ``` -------------------------------------------------------------------------------- /examples/rhinopython.py: -------------------------------------------------------------------------------- ```python 1 | import rhinoscriptsyntax as rs 2 | import ghpythonlib.components as ghcomp 3 | import scriptcontext 4 | 5 | #points = rs.GetPoints(True, True) 6 | #if points: 7 | # curves = ghcomp.Voronoi(points) 8 | # for curve in curves: 9 | # scriptcontext.doc.Objects.AddCurve(curve) 10 | # for point in points: 11 | # scriptcontext.doc.Objects.AddPoint(point) 12 | # scriptcontext.doc.Views.Redraw() 13 | 14 | p=ghcomp.ConstructPoint(0,0,0) 15 | scriptcontext.doc.Objects.AddPoint(p) 16 | #scriptcontext.doc.Views.Redraw() ``` -------------------------------------------------------------------------------- /examples/test_GH.py: -------------------------------------------------------------------------------- ```python 1 | # Grasshopper Python Component: CreateCircle 2 | # Generated from prompt: Create a circle with center point at coordinates (10,20,30) and radius of 50 3 | 4 | import Rhino 5 | import rhinoscriptsyntax as rs 6 | import scriptcontext as sc 7 | import Rhino.Geometry as rg 8 | import ghpythonlib.components as ghcomp 9 | import math 10 | 11 | # Create a circle based on prompt: Create a circle with center point at coordinates (10,20,30) and radius of 50 12 | center = rg.Point3d(10, 20, 30) 13 | circle = rg.Circle(rg.Plane.WorldXY, center, 50) 14 | print("Created a circle!") 15 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [build-system] 2 | requires = ["setuptools>=42", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "grasshopper-mcp" 7 | version = "0.1.0" 8 | description = "MCP server for Grasshopper 3D modeling" 9 | readme = "README.md" 10 | requires-python = ">=3.10" 11 | license = {text = "MIT"} 12 | dependencies = [ 13 | "mcp>=1.4.0", 14 | "rhinoinside; platform_system=='Windows'", # For Windows 15 | "rhino3dm>=7.15.0", 16 | "requests", 17 | "python-dotenv", 18 | ] 19 | 20 | [project.optional-dependencies] 21 | dev = [ 22 | "pytest", 23 | "black", 24 | "isort", 25 | ] 26 | 27 | [project.scripts] 28 | grasshopper-mcp = "grasshopper_mcp.server:main" 29 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/prompts/templates.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | import mcp.types as types 3 | 4 | 5 | def register_prompts(mcp: FastMCP) -> None: 6 | """Register prompt templates with the MCP server.""" 7 | 8 | @mcp.prompt() 9 | def create_parametric_model(component_description: str) -> str: 10 | """Create a parametric model based on a description.""" 11 | return f""" 12 | Please help me create a parametric 3D model based on this description: 13 | 14 | {component_description} 15 | 16 | First, analyze what I want to build. Then generate the Python code to create this model using Rhino's geometry classes. Focus on creating a parametric design where key dimensions can be changed easily. 17 | """ 18 | ``` -------------------------------------------------------------------------------- /examples/test_rhino3dm.py: -------------------------------------------------------------------------------- ```python 1 | import rhino3dm 2 | 3 | model = rhino3dm.File3dm() 4 | 5 | # create geometry 6 | sphere1 = rhino3dm.Sphere(rhino3dm.Point3d(0, 0, 0), 10) 7 | sphere2 = rhino3dm.Sphere(rhino3dm.Point3d(10, 10, 10), 4) 8 | geometry = (sphere1.ToBrep(), sphere2.ToBrep()) 9 | 10 | # create attributes 11 | attr1 = rhino3dm.ObjectAttributes() 12 | attr1.Name = "Sphere 1" 13 | attr2 = rhino3dm.ObjectAttributes() 14 | attr2.Name = "Sphere 2" 15 | attributes = (attr1, attr2) 16 | basepoint = rhino3dm.Point3d(0, 0, 0) 17 | 18 | # create idef 19 | index = model.InstanceDefinitions.Add("name", "description", "url", "urltag", basepoint, geometry, attributes) 20 | print("Index of new idef: " + str(index)) 21 | 22 | # create iref 23 | idef = model.InstanceDefinitions.FindIndex(index) 24 | xf = rhino3dm.Transform(10.00) 25 | iref = rhino3dm.InstanceReference(idef.Id, xf) 26 | uuid = model.Objects.Add(iref, None) 27 | print("id of new iref: " + str(uuid)) 28 | 29 | # save file 30 | model.Write("./examples/test_rhino3dm.3dm", 7) 31 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/tools/rhino_code_gen.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | from typing import Dict, Optional, Any 3 | 4 | 5 | def register_rhino_code_generation_tools(mcp: FastMCP) -> None: 6 | """Register code generation tools with the MCP server.""" 7 | 8 | @mcp.tool() 9 | async def generate_rhino_code(prompt: str, parameters: Optional[Dict[str, Any]] = None) -> str: 10 | """Generate and execute Rhino Python code based on a description. 11 | 12 | Args: 13 | prompt: Description of what you want the code to do 14 | parameters: Optional parameters to use in the code generation 15 | 16 | Returns: 17 | Result of the operation 18 | """ 19 | ctx = mcp.get_context() 20 | rhino = ctx.request_context.lifespan_context.rhino 21 | 22 | result = await rhino.generate_and_execute_rhino_code(prompt, parameters) 23 | 24 | if result["result"] == "error": 25 | return f"Error generating or executing code: {result['error']}" 26 | 27 | return f"""Generated and executed code successfully: 28 | {result['code']}""" 29 | ``` -------------------------------------------------------------------------------- /examples/compute_line.py: -------------------------------------------------------------------------------- ```python 1 | import compute_rhino3d.Util 2 | import compute_rhino3d.Grasshopper as gh 3 | import rhino3dm 4 | import json 5 | 6 | compute_rhino3d.Util.url = "http://localhost:8000/" 7 | # compute_rhino3d.Util.apiKey = "" 8 | 9 | pt1 = rhino3dm.Point3d(0, 0, 0) 10 | circle = rhino3dm.Circle(pt1, 5) 11 | angle = 20 12 | 13 | # convert circle to curve and stringify 14 | curve = json.dumps(circle.ToNurbsCurve().Encode()) 15 | 16 | # create list of input trees 17 | curve_tree = gh.DataTree("curve") 18 | curve_tree.Append([0], [curve]) 19 | rotate_tree = gh.DataTree("rotate") 20 | rotate_tree.Append([0], [angle]) 21 | trees = [curve_tree, rotate_tree] 22 | 23 | output = gh.EvaluateDefinition("twisty.gh", trees) 24 | print(output) 25 | 26 | # decode results 27 | branch = output["values"][0]["InnerTree"]["{0;0}"] 28 | lines = [rhino3dm.CommonObject.Decode(json.loads(item["data"])) for item in branch] 29 | 30 | filename = "twisty.3dm" 31 | 32 | print("Writing {} lines to {}".format(len(lines), filename)) 33 | 34 | # create a 3dm file with results 35 | model = rhino3dm.File3dm() 36 | for l in lines: 37 | model.Objects.AddCurve(l) # they're actually LineCurves... 38 | 39 | model.Write(filename) 40 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/config.py: -------------------------------------------------------------------------------- ```python 1 | import os 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | 6 | @dataclass 7 | class ServerConfig: 8 | """Server configuration.""" 9 | 10 | # Rhino/Grasshopper configuration 11 | rhino_path: Optional[str] = None # Path to Rhino installation 12 | use_compute_api: bool = False # Whether to use compute.rhino3d.com 13 | use_rhino3dm: bool = False # Whether to use rhino3dm library 14 | compute_url: Optional[str] = None # Compute API URL 15 | compute_api_key: Optional[str] = None # Compute API key 16 | 17 | # Server configuration 18 | server_name: str = "Grasshopper MCP" 19 | server_port: int = 8080 20 | 21 | @classmethod 22 | def from_env(cls) -> "ServerConfig": 23 | """Create configuration from environment variables.""" 24 | use_compute = os.getenv("USE_COMPUTE_API", "false").lower() == "true" 25 | use_rhino3dm = os.getenv("USE_RHINO3DM", "true").lower() == "true" 26 | 27 | return cls( 28 | rhino_path=os.getenv("RHINO_PATH"), 29 | use_compute_api=use_compute, 30 | use_rhino3dm=use_rhino3dm, 31 | compute_url=os.getenv("COMPUTE_URL"), 32 | compute_api_key=os.getenv("COMPUTE_API_KEY"), 33 | server_name=os.getenv("SERVER_NAME", "Grasshopper MCP"), 34 | server_port=int(os.getenv("SERVER_PORT", "8080")), 35 | ) 36 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/prompts/grasshopper_prompts.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | 3 | 4 | def register_grasshopper_code_prompts(mcp: FastMCP) -> None: 5 | """Register prompt templates with the MCP server.""" 6 | 7 | @mcp.prompt() 8 | def grasshopper_GHpython_generation_prompt(task_description: str) -> str: 9 | """Creates a prompt template for generating Grasshopper Python code with proper imports and grammar.""" 10 | return """ 11 | When writing Python code for Grasshopper, please follow these guidelines: 12 | 13 | 0. Add "Used the prompts from mcp.prompt()" at the beginning of python file. 14 | 15 | 1. Always start by including the following import statements: 16 | ```python 17 | import Rhino.Geometry as rg 18 | import ghpythonlib.components as ghcomp 19 | import rhinoscriptsyntax as rs 20 | 21 | 2. Structure your code with the following sections: 22 | Import statements at the top 23 | Global variables and constants 24 | Function definitions if needed 25 | Main execution code 26 | 27 | 3. Use descriptive variable names that follow Python naming conventions 28 | Use snake_case for variables and functions 29 | Use UPPER_CASE for constants 30 | 31 | 4. Include comments explaining complex logic or non-obvious operations 32 | 5. Carefully check grammar in all comments and docstrings 33 | 6. Ensure proper indentation and consistent code style 34 | 7. Use proper error handling when appropriate 35 | 8. Optimize for Grasshopper's data tree structure when handling multiple data items 36 | 9. Save the output to "result". 37 | """ 38 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/utils/request.py: -------------------------------------------------------------------------------- ```python 1 | import socket 2 | import json 3 | import tempfile 4 | import os 5 | 6 | 7 | def test_codelistener_with_file(host="127.0.0.1", port=614): 8 | """Test CodeListener by creating a temporary file and sending its path.""" 9 | try: 10 | # Create a temporary Python file 11 | fd, temp_path = tempfile.mkstemp(suffix=".py") 12 | try: 13 | # Write Python code to the file 14 | with os.fdopen(fd, "w") as f: 15 | f.write( 16 | """ 17 | import Rhino 18 | import scriptcontext as sc 19 | 20 | # Get Rhino version 21 | version = Rhino.RhinoApp.Version 22 | print("Hello from CodeListener!") 23 | print("Rhino version: ", version) 24 | 25 | # Access the active document 26 | doc = sc.doc 27 | if doc is not None: 28 | print("Active document: ", doc.Name) 29 | else: 30 | print("No active document") 31 | """ 32 | ) 33 | 34 | # Create JSON message object 35 | msg_obj = {"filename": temp_path, "run": True, "reset": False, "temp": True} 36 | 37 | # Convert to JSON 38 | json_msg = json.dumps(msg_obj) 39 | 40 | # Connect to CodeListener 41 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 42 | sock.settimeout(10) 43 | sock.connect((host, port)) 44 | 45 | # Send the JSON message 46 | print(f"Sending request to execute file: {temp_path}") 47 | sock.sendall(json_msg.encode("utf-8")) 48 | 49 | # Receive response 50 | print("Waiting for response...") 51 | response = sock.recv(4096).decode("utf-8") 52 | print(f"Response received: {response}") 53 | 54 | sock.close() 55 | return True 56 | 57 | finally: 58 | # Clean up - remove temporary file 59 | try: 60 | os.unlink(temp_path) 61 | except: 62 | pass 63 | 64 | except Exception as e: 65 | print(f"Error: {e}") 66 | return False 67 | 68 | 69 | # Run the test 70 | if __name__ == "__main__": 71 | test_codelistener_with_file() 72 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/server.py: -------------------------------------------------------------------------------- ```python 1 | from contextlib import asynccontextmanager 2 | from dataclasses import dataclass 3 | from typing import AsyncIterator 4 | import os 5 | 6 | from mcp.server.fastmcp import Context, FastMCP 7 | from dotenv import load_dotenv 8 | 9 | from grasshopper_mcp.rhino.connection import RhinoConnection 10 | from grasshopper_mcp.config import ServerConfig 11 | 12 | 13 | import sys 14 | 15 | print("Rhino MCP Server starting up...", file=sys.stderr) 16 | 17 | load_dotenv() # Load environment variables from .env file 18 | 19 | 20 | @dataclass 21 | class AppContext: 22 | """Application context with initialized connections.""" 23 | 24 | rhino: RhinoConnection 25 | config: ServerConfig 26 | 27 | 28 | @asynccontextmanager 29 | async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: 30 | """Initialize and manage server resources.""" 31 | # Load configuration 32 | config = ServerConfig.from_env() 33 | 34 | # Initialize Rhino/Grasshopper connection 35 | rhino_connection = RhinoConnection(config) 36 | try: 37 | await rhino_connection.initialize() 38 | 39 | # Provide context to request handlers 40 | yield AppContext(rhino=rhino_connection, config=config) 41 | finally: 42 | # Cleanup on shutdown 43 | await rhino_connection.close() 44 | 45 | 46 | # Create the MCP server 47 | mcp = FastMCP("Grasshopper 3D Modeling", lifespan=app_lifespan) 48 | 49 | # Import tool definitions 50 | from grasshopper_mcp.tools.modeling import register_modeling_tools 51 | from grasshopper_mcp.tools.analysis import register_analysis_tools 52 | from grasshopper_mcp.resources.model_data import register_model_resources 53 | from grasshopper_mcp.tools.grasshopper import register_grasshopper_tools 54 | from grasshopper_mcp.tools.advanced_grasshopper import register_advanced_grasshopper_tools 55 | from grasshopper_mcp.tools.rhino_code_gen import register_rhino_code_generation_tools 56 | 57 | # Import prompt definitions 58 | from grasshopper_mcp.prompts.grasshopper_prompts import register_grasshopper_code_prompts 59 | 60 | # Register tools 61 | register_modeling_tools(mcp) 62 | register_analysis_tools(mcp) 63 | register_model_resources(mcp) 64 | 65 | register_grasshopper_tools(mcp) 66 | 67 | # register_advanced_grasshopper_tools(mcp) 68 | # register_rhino_code_generation_tools(mcp) 69 | 70 | # Register prompts 71 | register_grasshopper_code_prompts(mcp) 72 | 73 | 74 | def main(): 75 | """Run the server.""" 76 | mcp.run() 77 | 78 | 79 | if __name__ == "__main__": 80 | main() 81 | ``` -------------------------------------------------------------------------------- /scripts/install.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Helper script to install the Grasshopper MCP server in Claude Desktop. 4 | """ 5 | import os 6 | import json 7 | import platform 8 | import argparse 9 | import sys 10 | from pathlib import Path 11 | 12 | 13 | def get_config_path(): 14 | """Get the path to the Claude Desktop config file.""" 15 | if platform.system() == "Darwin": # macOS 16 | return os.path.expanduser("~/Library/Application Support/Claude/claude_desktop_config.json") 17 | elif platform.system() == "Windows": 18 | return os.path.join(os.environ.get("APPDATA", ""), "Claude", "claude_desktop_config.json") 19 | else: 20 | print("Unsupported platform. Only macOS and Windows are supported.") 21 | sys.exit(1) 22 | 23 | 24 | def main(): 25 | parser = argparse.ArgumentParser(description="Install Grasshopper MCP server in Claude Desktop") 26 | parser.add_argument("--name", default="grasshopper", help="Name for the server in Claude Desktop") 27 | args = parser.parse_args() 28 | 29 | # Get the path to this script's directory 30 | script_dir = Path(__file__).parent.absolute() 31 | project_dir = script_dir.parent 32 | 33 | # Get the path to the server script 34 | server_script = project_dir / "grasshopper_mcp" / "server.py" 35 | 36 | if not server_script.exists(): 37 | print(f"Server script not found at {server_script}") 38 | sys.exit(1) 39 | 40 | config_path = get_config_path() 41 | config_dir = os.path.dirname(config_path) 42 | 43 | # Create config directory if it doesn't exist 44 | os.makedirs(config_dir, exist_ok=True) 45 | 46 | # Load existing config or create new one 47 | if os.path.exists(config_path): 48 | with open(config_path, "r") as f: 49 | try: 50 | config = json.load(f) 51 | except json.JSONDecodeError: 52 | config = {} 53 | else: 54 | config = {} 55 | 56 | # Ensure mcpServers exists 57 | if "mcpServers" not in config: 58 | config["mcpServers"] = {} 59 | 60 | # Add our server 61 | python_path = sys.executable 62 | config["mcpServers"][args.name] = {"command": python_path, "args": [str(server_script)]} 63 | 64 | # Write updated config 65 | with open(config_path, "w") as f: 66 | json.dump(config, f, indent=2) 67 | 68 | print(f"Grasshopper MCP server installed as '{args.name}' in Claude Desktop") 69 | print(f"Configuration written to: {config_path}") 70 | 71 | 72 | if __name__ == "__main__": 73 | main() 74 | ``` -------------------------------------------------------------------------------- /examples/test_CodeListener.py: -------------------------------------------------------------------------------- ```python 1 | # import os 2 | # import sys 3 | 4 | # print(1) 5 | 6 | 7 | # scriptcontext_path = os.path.join( 8 | # os.environ["APPDATA"], 9 | # "McNeel", 10 | # "Rhinoceros", 11 | # "7.0", 12 | # "Plug-ins", 13 | # "IronPython (814d908a-e25c-493d-97e9-ee3861957f49)", 14 | # "settings", 15 | # "lib", 16 | # ) 17 | 18 | # sys.path.append(scriptcontext_path) 19 | 20 | # # import rhinoinside 21 | 22 | # # rhinoinside.load() 23 | # import rhinoscriptsyntax as rs 24 | # import scriptcontext as sc 25 | # import Rhino 26 | 27 | ################################################################################ 28 | # SampleAddNurbsCurves.py 29 | # Copyright (c) 2017 Robert McNeel & Associates. 30 | # See License.md in the root of this repository for details. 31 | ################################################################################ 32 | import Rhino 33 | import scriptcontext 34 | 35 | 36 | def SampleAddNurbsCurves(): 37 | 38 | # World 3-D, or Euclidean, locations 39 | pt0 = Rhino.Geometry.Point3d(-8.0, -3.0, 0.0) 40 | pt1 = Rhino.Geometry.Point3d(-4.0, 3.0, 2.0) 41 | pt2 = Rhino.Geometry.Point3d(4.0, 3.0, 2.0) 42 | pt3 = Rhino.Geometry.Point3d(8.0, -3.0, 0.0) 43 | 44 | # Create NURBS curve: 45 | # Dimension = 3 46 | # Rational = False 47 | # Order (Degree + 1) = 4 48 | # Control point count = 4 49 | # Knot count = Control point count + degree - 1 50 | nc0 = Rhino.Geometry.NurbsCurve(3, False, 4, 4) 51 | # World 3-D, or Euclidean, control points, 52 | nc0.Points[0] = Rhino.Geometry.ControlPoint(pt0) 53 | nc0.Points[1] = Rhino.Geometry.ControlPoint(pt1) 54 | nc0.Points[2] = Rhino.Geometry.ControlPoint(pt2) 55 | nc0.Points[3] = Rhino.Geometry.ControlPoint(pt3) 56 | # Clamped knots 57 | nc0.Knots[0] = 0 58 | nc0.Knots[1] = 0 59 | nc0.Knots[2] = 0 60 | nc0.Knots[3] = 1 61 | nc0.Knots[4] = 1 62 | nc0.Knots[5] = 1 63 | 64 | # Create NURBS curve: 65 | # Dimension = 3 66 | # Rational = True 67 | # Order (Degree + 1) = 4 68 | # Control point count = 4 69 | # Knot count = Control point count + degree - 1 70 | nc1 = Rhino.Geometry.NurbsCurve(3, True, 4, 4) 71 | # Control points from a world 3-D, or Euclidean, locations and a weight 72 | nc1.Points[0] = Rhino.Geometry.ControlPoint(pt0, 1.0) 73 | nc1.Points[1] = Rhino.Geometry.ControlPoint(pt1, 2.0) 74 | nc1.Points[2] = Rhino.Geometry.ControlPoint(pt2, 4.0) 75 | nc1.Points[3] = Rhino.Geometry.ControlPoint(pt3, 1.0) 76 | # Clamped knots 77 | nc1.Knots[0] = 0 78 | nc1.Knots[1] = 0 79 | nc1.Knots[2] = 0 80 | nc1.Knots[3] = 1 81 | nc1.Knots[4] = 1 82 | nc1.Knots[5] = 1 83 | 84 | # Create NURBS curve: 85 | # Dimension = 3 86 | # Rational = True 87 | # Order (Degree + 1) = 4 88 | # Control point count = 4 89 | # Knot count = Control point count + degree - 1 90 | nc2 = Rhino.Geometry.NurbsCurve(3, True, 4, 4) 91 | # Homogeneous control points 92 | nc2.Points[0] = Rhino.Geometry.ControlPoint(-8.0, -3.0, 0.0, 1.0) 93 | nc2.Points[1] = Rhino.Geometry.ControlPoint(-4.0, 3.0, 2.0, 2.0) 94 | nc2.Points[2] = Rhino.Geometry.ControlPoint(4.0, 3.0, 2.0, 4.0) 95 | nc2.Points[3] = Rhino.Geometry.ControlPoint(8.0, -3.0, 0.0, 1.0) 96 | # Clamped knots 97 | nc2.Knots[0] = 0 98 | nc2.Knots[1] = 0 99 | nc2.Knots[2] = 0 100 | nc2.Knots[3] = 1 101 | nc2.Knots[4] = 1 102 | nc2.Knots[5] = 1 103 | 104 | # Add to document 105 | scriptcontext.doc.Objects.Add(nc0) 106 | scriptcontext.doc.Objects.Add(nc1) 107 | scriptcontext.doc.Objects.Add(nc2) 108 | scriptcontext.doc.Views.Redraw() 109 | 110 | 111 | # Check to see if this file is being executed as the "main" python 112 | # script instead of being used as a module by some other python script 113 | # This allows us to use the module which ever way we want. 114 | if __name__ == "__main__": 115 | SampleAddNurbsCurves() 116 | 117 | 118 | # rhino_path = "C:\\Program Files\\Rhino 7\\System" 119 | # sys.path.append(rhino_path) 120 | # # print(sys.path) 121 | # # import scriptcontext as sc 122 | 123 | # # import rhinoscriptsyntax as rs 124 | # import rhinoinside 125 | 126 | # rhinoinside.load() 127 | # print("rhinoinside installed.") 128 | # # Import Rhino components 129 | # import Rhino 130 | 131 | # print("Rhino installed.") 132 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/tools/analysis.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP, Context 2 | 3 | 4 | def register_analysis_tools(mcp: FastMCP) -> None: 5 | """Register analysis tools with the MCP server.""" 6 | 7 | @mcp.tool() 8 | async def analyze_rhino_file(file_path: str) -> str: 9 | """Analyze a Rhino (.3dm) file. 10 | 11 | Args: 12 | file_path: Path to the .3dm file 13 | 14 | Returns: 15 | Analysis of the file contents 16 | """ 17 | # Get context using the FastMCP mechanism 18 | ctx = mcp.get_context() 19 | rhino = ctx.request_context.lifespan_context.rhino 20 | 21 | result = await rhino.read_3dm_file(file_path) 22 | 23 | if result["result"] == "error": 24 | return f"Error: {result['error']}" 25 | 26 | model = result["model"] 27 | 28 | # Use r3d directly for rhino3dm mode 29 | if rhino.rhino_instance.get("use_rhino3dm", False): 30 | r3d = rhino.rhino_instance["r3d"] 31 | 32 | # Collect file information 33 | info = { 34 | "unit_system": str(model.Settings.ModelUnitSystem), 35 | "object_count": len(model.Objects), 36 | "layer_count": len(model.Layers), 37 | } 38 | 39 | # Get object types 40 | object_types = {} 41 | for obj in model.Objects: 42 | geom = obj.Geometry 43 | if geom: 44 | geom_type = str(geom.ObjectType) 45 | object_types[geom_type] = object_types.get(geom_type, 0) + 1 46 | 47 | info["object_types"] = object_types 48 | 49 | # Format output 50 | output = [f"Analysis of {file_path}:"] 51 | output.append(f"- Unit System: {info['unit_system']}") 52 | output.append(f"- Total Objects: {info['object_count']}") 53 | output.append(f"- Total Layers: {info['layer_count']}") 54 | output.append("- Object Types:") 55 | for obj_type, count in info["object_types"].items(): 56 | output.append(f" - {obj_type}: {count}") 57 | 58 | return "\n".join(output) 59 | else: 60 | # RhinoInside mode (Windows) 61 | # Similar implementation using Rhino SDK 62 | return "RhinoInside implementation not provided" 63 | 64 | @mcp.tool() 65 | async def list_objects(file_path: str) -> str: 66 | """List all objects in a Rhino file. 67 | 68 | Args: 69 | file_path: Path to the .3dm file 70 | 71 | Returns: 72 | Information about objects in the file 73 | """ 74 | ctx = mcp.get_context() 75 | rhino = ctx.request_context.lifespan_context.rhino 76 | 77 | result = await rhino.read_3dm_file(file_path) 78 | 79 | if result["result"] == "error": 80 | return f"Error: {result['error']}" 81 | 82 | model = result["model"] 83 | 84 | # Use rhino3dm for cross-platform support 85 | if rhino.rhino_instance.get("use_rhino3dm", False): 86 | # Gather object information 87 | objects_info = [] 88 | for i, obj in enumerate(model.Objects): 89 | geom = obj.Geometry 90 | if geom: 91 | attrs = obj.Attributes 92 | name = attrs.Name or f"Object {i}" 93 | layer_index = attrs.LayerIndex 94 | 95 | # Get layer name if available 96 | layer_name = "Unknown" 97 | if 0 <= layer_index < len(model.Layers): 98 | layer_name = model.Layers[layer_index].Name 99 | 100 | obj_info = {"name": name, "type": str(geom.ObjectType), "layer": layer_name, "index": i} 101 | objects_info.append(obj_info) 102 | 103 | # Format output 104 | output = [f"Objects in {file_path}:"] 105 | for info in objects_info: 106 | output.append(f"{info['index']}. {info['name']} (Type: {info['type']}, Layer: {info['layer']})") 107 | 108 | return "\n".join(output) 109 | else: 110 | # RhinoInside mode 111 | return "RhinoInside implementation not provided" 112 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/resources/model_data.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | 3 | 4 | def register_model_resources(mcp: FastMCP) -> None: 5 | """Register model data resources with the MCP server.""" 6 | 7 | @mcp.resource("rhino://{file_path}") 8 | async def get_rhino_file_info(file_path: str) -> str: 9 | """Get information about a Rhino file.""" 10 | ctx = mcp.get_context() 11 | rhino = ctx.request_context.lifespan_context.rhino 12 | 13 | result = await rhino.read_3dm_file(file_path) 14 | 15 | if result["result"] == "error": 16 | return f"Error: {result['error']}" 17 | 18 | model = result["model"] 19 | 20 | # Use rhino3dm 21 | if rhino.rhino_instance.get("use_rhino3dm", False): 22 | # Basic file information 23 | info = { 24 | "file_path": file_path, 25 | "unit_system": str(model.Settings.ModelUnitSystem), 26 | "object_count": len(model.Objects), 27 | "layer_count": len(model.Layers), 28 | "material_count": len(model.Materials), 29 | "notes": model.Notes or "No notes", 30 | } 31 | 32 | # Format output 33 | output = [f"# Rhino File: {file_path}"] 34 | output.append(f"- Unit System: {info['unit_system']}") 35 | output.append(f"- Objects: {info['object_count']}") 36 | output.append(f"- Layers: {info['layer_count']}") 37 | output.append(f"- Materials: {info['material_count']}") 38 | output.append(f"- Notes: {info['notes']}") 39 | 40 | return "\n".join(output) 41 | else: 42 | # RhinoInside mode 43 | return "RhinoInside implementation not provided" 44 | 45 | @mcp.resource("rhino://{file_path}/object/{index}") 46 | async def get_object_info(file_path: str, index: int) -> str: 47 | """Get information about a specific object in a Rhino file.""" 48 | ctx = mcp.get_context() 49 | rhino = ctx.request_context.lifespan_context.rhino 50 | 51 | result = await rhino.read_3dm_file(file_path) 52 | 53 | if result["result"] == "error": 54 | return f"Error: {result['error']}" 55 | 56 | model = result["model"] 57 | index = int(index) # Convert to integer 58 | 59 | # Check if index is valid 60 | if index < 0 or index >= len(model.Objects): 61 | return f"Error: Invalid object index. File has {len(model.Objects)} objects." 62 | 63 | # Use rhino3dm 64 | if rhino.rhino_instance.get("use_rhino3dm", False): 65 | r3d = rhino.rhino_instance["r3d"] 66 | obj = model.Objects[index] 67 | geom = obj.Geometry 68 | attrs = obj.Attributes 69 | 70 | # Basic object information 71 | info = { 72 | "name": attrs.Name or f"Object {index}", 73 | "type": str(geom.ObjectType), 74 | "layer_index": attrs.LayerIndex, 75 | "material_index": attrs.MaterialIndex, 76 | "visible": not attrs.IsHidden, 77 | } 78 | 79 | # Get bounding box 80 | bbox = geom.BoundingBox if hasattr(geom, "BoundingBox") else geom.GetBoundingBox() 81 | if bbox: 82 | info["bounding_box"] = { 83 | "min": [bbox.Min.X, bbox.Min.Y, bbox.Min.Z], 84 | "max": [bbox.Max.X, bbox.Max.Y, bbox.Max.Z], 85 | } 86 | 87 | # Type-specific properties 88 | if hasattr(geom, "ObjectType"): 89 | if geom.ObjectType == r3d.ObjectType.Curve: 90 | info["length"] = geom.GetLength() if hasattr(geom, "GetLength") else "Unknown" 91 | info["is_closed"] = geom.IsClosed if hasattr(geom, "IsClosed") else "Unknown" 92 | elif geom.ObjectType == r3d.ObjectType.Brep: 93 | info["faces"] = len(geom.Faces) if hasattr(geom, "Faces") else "Unknown" 94 | info["edges"] = len(geom.Edges) if hasattr(geom, "Edges") else "Unknown" 95 | info["is_solid"] = geom.IsSolid if hasattr(geom, "IsSolid") else "Unknown" 96 | info["volume"] = geom.GetVolume() if hasattr(geom, "GetVolume") else "Unknown" 97 | elif geom.ObjectType == r3d.ObjectType.Mesh: 98 | info["vertices"] = len(geom.Vertices) if hasattr(geom, "Vertices") else "Unknown" 99 | info["faces"] = len(geom.Faces) if hasattr(geom, "Faces") else "Unknown" 100 | 101 | # Format output 102 | output = [f"# Object {index}: {info['name']}"] 103 | output.append(f"- Type: {info['type']}") 104 | output.append(f"- Layer Index: {info['layer_index']}") 105 | output.append(f"- Material Index: {info['material_index']}") 106 | output.append(f"- Visible: {info['visible']}") 107 | 108 | if "bounding_box" in info: 109 | bbox = info["bounding_box"] 110 | output.append("- Bounding Box:") 111 | output.append(f" - Min: ({bbox['min'][0]}, {bbox['min'][1]}, {bbox['min'][2]})") 112 | output.append(f" - Max: ({bbox['max'][0]}, {bbox['max'][1]}, {bbox['max'][2]})") 113 | 114 | for key, value in info.items(): 115 | if key not in ["name", "type", "layer_index", "material_index", "visible", "bounding_box"]: 116 | output.append(f"- {key.replace('_', ' ').title()}: {value}") 117 | 118 | return "\n".join(output) 119 | else: 120 | # RhinoInside mode 121 | return "RhinoInside implementation not provided" 122 | ``` -------------------------------------------------------------------------------- /examples/zaha01.py: -------------------------------------------------------------------------------- ```python 1 | import Rhino.Geometry as rg 2 | import ghpythonlib.components as ghcomp 3 | import math 4 | import random 5 | 6 | # === INPUT PARAMETERS === 7 | height = 200.0 # Total height of the tower 8 | base_radius = 30.0 # Radius at the base of the tower 9 | top_radius = 15.0 # Radius at the top of the tower 10 | twist_angle = 75.0 # Total twist angle from base to top (degrees) 11 | floors = 35 # Number of floors 12 | curvature_factor = 0.3 # Factor controlling central spine curvature (0-1) 13 | organic_factor = 0.4 # Factor controlling the organic deformation of floor plates (0-1) 14 | 15 | # === OUTPUTS === 16 | tower_surfaces = [] # Collection of surfaces forming the tower 17 | floor_curves = [] # Collection of floor plate curves 18 | central_spine = None # Central spine curve 19 | 20 | # === HELPER FUNCTIONS === 21 | def create_organic_floor_curve(center, radius, segments, organic_factor, phase): 22 | """ 23 | Creates an organic floor curve with controlled deformation. 24 | 25 | Args: 26 | center: Center point of the floor curve 27 | radius: Base radius of the floor curve 28 | segments: Number of segments for the curve (smoothness) 29 | organic_factor: Amount of organic deformation (0-1) 30 | phase: Phase shift for the organic deformation pattern 31 | 32 | Returns: 33 | A closed curve representing the floor shape 34 | """ 35 | points = [] 36 | for i in range(segments): 37 | angle = (math.pi * 2.0 * i) / segments 38 | 39 | # Create organic variation using multiple sine waves with different frequencies 40 | variation = 1.0 + organic_factor * ( 41 | 0.4 * math.sin(angle * 2 + phase) + 42 | 0.3 * math.sin(angle * 3 + phase * 1.7) + 43 | 0.2 * math.sin(angle * 5 + phase * 0.8) 44 | ) 45 | 46 | # Calculate point coordinates 47 | x = center.X + radius * variation * math.cos(angle) 48 | y = center.Y + radius * variation * math.sin(angle) 49 | point = rg.Point3d(x, y, center.Z) 50 | points.append(point) 51 | 52 | # Close the curve by adding the first point again 53 | points.append(points[0]) 54 | 55 | # Create interpolated curve through points 56 | # Degree 3 for smooth, flowing curves characteristic of Zaha Hadid's work 57 | return rg.Curve.CreateInterpolatedCurve(points, 3) 58 | 59 | def ease_in_out(t): 60 | """ 61 | Provides a smooth ease-in-out interpolation. 62 | Used for more natural transitions characteristic of Hadid's fluid forms. 63 | 64 | Args: 65 | t: Input value (0-1) 66 | 67 | Returns: 68 | Eased value (0-1) 69 | """ 70 | return 0.5 - 0.5 * math.cos(t * math.pi) 71 | 72 | # === MAIN ALGORITHM === 73 | 74 | # 1. Create the central spine with a gentle S-curve (Hadid's sinuous forms) 75 | spine_points = [] 76 | for i in range(floors + 1): 77 | # Calculate height position 78 | z = i * (height / floors) 79 | t = z / height # Normalized height (0-1) 80 | 81 | # Create an S-curve using sine function 82 | # This creates the flowing, undulating central spine typical in Hadid's work 83 | curve_x = math.sin(t * math.pi) * base_radius * curvature_factor 84 | curve_y = math.sin(t * math.pi * 0.5) * base_radius * curvature_factor * 0.7 85 | 86 | spine_points.append(rg.Point3d(curve_x, curve_y, z)) 87 | 88 | # Create a smooth interpolated curve through the spine points 89 | central_spine = rg.Curve.CreateInterpolatedCurve(spine_points, 3) 90 | 91 | # 2. Create floor curves with organic shapes and twisting 92 | for i in range(floors + 1): 93 | # Calculate height position 94 | z = i * (height / floors) 95 | t = z / height # Normalized height (0-1) 96 | 97 | # Get point on spine at this height 98 | spine_param = central_spine.Domain.ParameterAt(t) 99 | center = central_spine.PointAt(spine_param) 100 | 101 | # Calculate radius with smooth transition from base to top 102 | # Using ease_in_out for more natural, fluid transition 103 | eased_t = ease_in_out(t) 104 | radius = base_radius * (1 - eased_t) + top_radius * eased_t 105 | 106 | # Add Hadid-like bulges at strategic points 107 | if 0.3 < t < 0.7: 108 | # Create a subtle bulge in the middle section 109 | bulge_factor = math.sin((t - 0.3) * math.pi / 0.4) * 0.15 110 | radius *= (1 + bulge_factor) 111 | 112 | # Calculate twist angle based on height 113 | angle_rad = math.radians(twist_angle * t) 114 | 115 | # Create a plane for the floor curve 116 | # First get the spine's tangent at this point 117 | tangent = central_spine.TangentAt(spine_param) 118 | tangent.Unitize() 119 | 120 | # Create perpendicular vectors for the plane 121 | x_dir = rg.Vector3d.CrossProduct(tangent, rg.Vector3d.ZAxis) 122 | if x_dir.Length < 0.001: 123 | x_dir = rg.Vector3d.XAxis 124 | x_dir.Unitize() 125 | 126 | y_dir = rg.Vector3d.CrossProduct(tangent, x_dir) 127 | y_dir.Unitize() 128 | 129 | # Apply twist rotation 130 | rotated_x = x_dir * math.cos(angle_rad) - y_dir * math.sin(angle_rad) 131 | rotated_y = x_dir * math.sin(angle_rad) + y_dir * math.cos(angle_rad) 132 | 133 | floor_plane = rg.Plane(center, rotated_x, rotated_y) 134 | 135 | # Phase shift creates variation in organic patterns between floors 136 | # This creates the flowing, continuous aesthetic of Hadid's work 137 | phase_shift = t * 8.0 138 | 139 | # Create organic floor curve 140 | segments = 24 # Number of segments for smoothness 141 | curve = create_organic_floor_curve(floor_plane.Origin, radius, segments, 142 | organic_factor * (1 + 0.5 * math.sin(t * math.pi)), 143 | phase_shift) 144 | 145 | floor_curves.append(curve) 146 | 147 | # 3. Create surfaces between floor curves 148 | for i in range(len(floor_curves) - 1): 149 | # Create loft surface between consecutive floors 150 | # Using Tight loft type for more fluid transitions 151 | loft_curves = [floor_curves[i], floor_curves[i+1]] 152 | loft_type = rg.LoftType.Tight 153 | 154 | try: 155 | # Create loft surfaces 156 | loft = ghcomp.Loft(loft_curves, loft_type) 157 | if isinstance(loft, list): 158 | tower_surfaces.extend(loft) 159 | else: 160 | tower_surfaces.append(loft) 161 | except: 162 | # Skip if loft creation fails 163 | pass 164 | 165 | # === ASSIGN OUTPUTS === 166 | a = tower_surfaces # Tower surfaces 167 | b = floor_curves # Floor curves 168 | c = central_spine # Central spine curve ``` -------------------------------------------------------------------------------- /grasshopper_mcp/tools/grasshopper.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | from typing import Dict, List, Any, Optional 3 | 4 | 5 | def register_grasshopper_tools(mcp: FastMCP) -> None: 6 | """Register Grasshopper-specific tools with the MCP server.""" 7 | 8 | # @mcp.tool() 9 | # async def generate_grasshopper_code( 10 | # description: str, 11 | # file_path: str, 12 | # parameters: Dict[str, Any] = None, 13 | # component_name: Optional[str] = None, 14 | # ) -> str: 15 | # """Generate Python code for a Grasshopper component based on a description. 16 | 17 | # Args: 18 | # description: Description of what the code should do 19 | # file_path: Path where the generated code will be saved 20 | # parameters: Dictionary of parameters to use in the code generation. 21 | # Can include the following keys: 22 | # - code_override: String containing complete code to use instead of generating 23 | # - center_x, center_y, center_z: Numeric values for geometric operations 24 | # - radius: Numeric value for circles or spheres 25 | # - width, height, depth: Dimensions for rectangular forms 26 | # - [Other commonly used parameters...] 27 | # component_name: Optional name for the GH component 28 | 29 | # Returns: 30 | # Result of the operation including the file path to the generated code 31 | # """ 32 | # ctx = mcp.get_context() 33 | # rhino = ctx.request_context.lifespan_context.rhino 34 | 35 | # result = await rhino.generate_and_execute_gh_code(description, file_path, parameters, component_name) 36 | 37 | # if result["result"] == "error": 38 | # return f"Error generating Grasshopper code: {result['error']}" 39 | 40 | # return f"""Generated Grasshopper Python code successfully: 41 | # {result['code']}""" 42 | 43 | @mcp.tool() 44 | async def execute_grasshopper_code(code: str, file_path: str) -> str: 45 | """Execute given Python code. 46 | 47 | Args: 48 | code: The given code to execute 49 | file_path: Path where the generated code will be saved 50 | 51 | Returns: 52 | Result of the executing code 53 | """ 54 | ctx = mcp.get_context() 55 | rhino = ctx.request_context.lifespan_context.rhino 56 | 57 | result = await rhino.send_code_to_gh(code, file_path) 58 | 59 | @mcp.tool() 60 | async def add_grasshopper_component( 61 | component_name: str, component_type: str, parameters: Dict[str, Any] 62 | ) -> str: 63 | """Add a component from an existing Grasshopper plugin. 64 | 65 | Args: 66 | component_name: Name of the component 67 | component_type: Type/category of the component 68 | parameters: Component parameters and settings 69 | 70 | Returns: 71 | Result of the operation 72 | """ 73 | ctx = mcp.get_context() 74 | rhino = ctx.request_context.lifespan_context.rhino 75 | 76 | result = await rhino.add_gh_component( 77 | component_name=component_name, component_type=component_type, parameters=parameters 78 | ) 79 | 80 | if result["result"] == "error": 81 | return f"Error adding component: {result['error']}" 82 | 83 | return f"""Successfully added Grasshopper component: 84 | - Component: {component_name} ({component_type}) 85 | - Component ID: {result.get('component_id', 'Unknown')} 86 | """ 87 | 88 | @mcp.tool() 89 | async def connect_grasshopper_components( 90 | source_id: str, source_param: str, target_id: str, target_param: str 91 | ) -> str: 92 | """Connect parameters between Grasshopper components. 93 | 94 | Args: 95 | source_id: Source component ID 96 | source_param: Source parameter name 97 | target_id: Target component ID 98 | target_param: Target parameter name 99 | 100 | Returns: 101 | Result of the operation 102 | """ 103 | ctx = mcp.get_context() 104 | rhino = ctx.request_context.lifespan_context.rhino 105 | 106 | result = await rhino.connect_gh_components( 107 | source_id=source_id, source_param=source_param, target_id=target_id, target_param=target_param 108 | ) 109 | 110 | if result["result"] == "error": 111 | return f"Error connecting components: {result['error']}" 112 | 113 | return f"""Successfully connected Grasshopper components: 114 | - Connected: {source_id}.{source_param} → {target_id}.{target_param} 115 | """ 116 | 117 | @mcp.tool() 118 | async def run_grasshopper_definition( 119 | file_path: Optional[str] = None, save_output: bool = False, output_path: Optional[str] = None 120 | ) -> str: 121 | """Run a Grasshopper definition. 122 | 123 | Args: 124 | file_path: Path to the .gh file (or None for current definition) 125 | save_output: Whether to save the output 126 | output_path: Path to save the output (if save_output is True) 127 | 128 | Returns: 129 | Result of the operation 130 | """ 131 | ctx = mcp.get_context() 132 | rhino = ctx.request_context.lifespan_context.rhino 133 | 134 | result = await rhino.run_gh_definition( 135 | file_path=file_path, save_output=save_output, output_path=output_path 136 | ) 137 | 138 | if result["result"] == "error": 139 | return f"Error running definition: {result['error']}" 140 | 141 | return f"""Successfully ran Grasshopper definition: 142 | - Execution time: {result.get('execution_time', 'Unknown')} seconds 143 | - Outputs: {result.get('output_summary', 'No output summary available')} 144 | """ 145 | 146 | 147 | async def generate_python_code( 148 | rhino_connection, description: str, inputs: List[Dict[str, Any]], outputs: List[Dict[str, Any]] 149 | ) -> Dict[str, Any]: 150 | """Generate Python code for Grasshopper based on description and parameters. 151 | 152 | This is a simplified implementation. In a production system, 153 | this might call an LLM or use templates. 154 | """ 155 | # Build code header with imports 156 | code = "import Rhino.Geometry as rg\n" 157 | code += "import scriptcontext as sc\n" 158 | code += "import ghpythonlib.components as ghcomp\n\n" 159 | 160 | # Add description as comment 161 | code += f"# {description}\n\n" 162 | 163 | # Process inputs 164 | input_vars = [] 165 | for i, inp in enumerate(inputs): 166 | var_name = inp["name"] 167 | input_vars.append(var_name) 168 | 169 | # Add comments about input parameters 170 | code += f"# Input: {var_name} ({inp['type']}) - {inp.get('description', '')}\n" 171 | 172 | code += "\n# Processing\n" 173 | 174 | # Add basic implementation based on description 175 | # This is where you might want to call an LLM or use more sophisticated templates 176 | if "circle" in description.lower(): 177 | code += """if radius is not None: 178 | circle = rg.Circle(rg.Point3d(0, 0, 0), radius) 179 | circle = circle.ToNurbsCurve() 180 | else: 181 | circle = None 182 | """ 183 | elif "box" in description.lower(): 184 | code += """if width is not None and height is not None and depth is not None: 185 | box = rg.Box( 186 | rg.Plane.WorldXY, 187 | rg.Interval(0, width), 188 | rg.Interval(0, height), 189 | rg.Interval(0, depth) 190 | ) 191 | else: 192 | box = None 193 | """ 194 | else: 195 | # Generic code template 196 | code += "# Add your implementation here based on the description\n" 197 | code += "# Use the input parameters to generate the desired output\n\n" 198 | 199 | # Process outputs 200 | output_assignments = [] 201 | for output in outputs: 202 | var_name = output["name"] 203 | # Assign a dummy value to each output 204 | output_assignments.append(f"{var_name} = {var_name}") 205 | 206 | # Add output assignments 207 | code += "\n# Outputs\n" 208 | code += "\n".join(output_assignments) 209 | 210 | return {"result": "success", "code": code} 211 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/tools/modeling.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | 3 | 4 | def register_modeling_tools(mcp: FastMCP) -> None: 5 | """Register geometry access tools with the MCP server.""" 6 | 7 | @mcp.tool() 8 | async def extract_geometry(file_path: str, object_index: int) -> str: 9 | """Extract geometric data from an existing object. 10 | 11 | Args: 12 | file_path: Path to the .3dm file 13 | object_index: Index of the object to extract data from 14 | 15 | Returns: 16 | Geometric data in a readable format 17 | """ 18 | ctx = mcp.get_context() 19 | rhino = ctx.request_context.lifespan_context.rhino 20 | 21 | result = await rhino.read_3dm_file(file_path) 22 | 23 | if result["result"] == "error": 24 | return f"Error: {result['error']}" 25 | 26 | model = result["model"] 27 | 28 | # Check if index is valid 29 | try: 30 | index = int(object_index) 31 | if index < 0 or index >= len(model.Objects): 32 | return f"Error: Invalid object index. File has {len(model.Objects)} objects." 33 | except ValueError: 34 | return f"Error: Object index must be a number." 35 | 36 | # Extract geometry data using rhino3dm 37 | obj = model.Objects[index] 38 | geom = obj.Geometry 39 | 40 | if not geom: 41 | return f"Error: No geometry found for object at index {index}." 42 | 43 | r3d = rhino.rhino_instance["r3d"] 44 | 45 | # Extract data based on geometry type 46 | geometry_data = {"type": str(geom.ObjectType), "id": str(obj.Id) if hasattr(obj, "Id") else "Unknown"} 47 | 48 | # Get bounding box 49 | bbox = geom.GetBoundingBox() if hasattr(geom, "GetBoundingBox") else None 50 | if bbox: 51 | geometry_data["bounding_box"] = { 52 | "min": [bbox.Min.X, bbox.Min.Y, bbox.Min.Z], 53 | "max": [bbox.Max.X, bbox.Max.Y, bbox.Max.Z], 54 | "dimensions": [bbox.Max.X - bbox.Min.X, bbox.Max.Y - bbox.Min.Y, bbox.Max.Z - bbox.Min.Z], 55 | } 56 | 57 | # Type-specific data extraction 58 | if hasattr(geom, "ObjectType"): 59 | if geom.ObjectType == r3d.ObjectType.Point: 60 | point = geom.Location 61 | geometry_data["coordinates"] = [point.X, point.Y, point.Z] 62 | 63 | elif geom.ObjectType == r3d.ObjectType.Curve: 64 | # For curves, extract key points 65 | geometry_data["length"] = geom.GetLength() if hasattr(geom, "GetLength") else "Unknown" 66 | geometry_data["is_closed"] = geom.IsClosed if hasattr(geom, "IsClosed") else "Unknown" 67 | 68 | # Get start and end points if not closed 69 | if hasattr(geom, "PointAtStart") and hasattr(geom, "PointAtEnd"): 70 | start = geom.PointAtStart 71 | end = geom.PointAtEnd 72 | geometry_data["start_point"] = [start.X, start.Y, start.Z] 73 | geometry_data["end_point"] = [end.X, end.Y, end.Z] 74 | 75 | elif geom.ObjectType == r3d.ObjectType.Brep: 76 | # For solids, extract volume and surface area 77 | geometry_data["volume"] = geom.GetVolume() if hasattr(geom, "GetVolume") else "Unknown" 78 | geometry_data["area"] = geom.GetArea() if hasattr(geom, "GetArea") else "Unknown" 79 | geometry_data["is_solid"] = geom.IsSolid if hasattr(geom, "IsSolid") else "Unknown" 80 | 81 | # Count faces, edges, vertices 82 | if hasattr(geom, "Faces") and hasattr(geom, "Edges"): 83 | geometry_data["face_count"] = len(geom.Faces) 84 | geometry_data["edge_count"] = len(geom.Edges) 85 | 86 | elif geom.ObjectType == r3d.ObjectType.Mesh: 87 | # For meshes, extract vertex and face counts 88 | if hasattr(geom, "Vertices") and hasattr(geom, "Faces"): 89 | geometry_data["vertex_count"] = len(geom.Vertices) 90 | geometry_data["face_count"] = len(geom.Faces) 91 | 92 | # Format output as readable text 93 | output = [f"# Geometry Data for Object {index}"] 94 | output.append(f"- Type: {geometry_data['type']}") 95 | output.append(f"- ID: {geometry_data['id']}") 96 | 97 | if "bounding_box" in geometry_data: 98 | bbox = geometry_data["bounding_box"] 99 | output.append("- Bounding Box:") 100 | output.append(f" - Min: ({bbox['min'][0]:.2f}, {bbox['min'][1]:.2f}, {bbox['min'][2]:.2f})") 101 | output.append(f" - Max: ({bbox['max'][0]:.2f}, {bbox['max'][1]:.2f}, {bbox['max'][2]:.2f})") 102 | output.append( 103 | f" - Dimensions: {bbox['dimensions'][0]:.2f} × {bbox['dimensions'][1]:.2f} × {bbox['dimensions'][2]:.2f}" 104 | ) 105 | 106 | # Add remaining data with nice formatting 107 | for key, value in geometry_data.items(): 108 | if key not in ["type", "id", "bounding_box"]: 109 | # Format key nicely 110 | formatted_key = key.replace("_", " ").title() 111 | 112 | # Format value based on type 113 | if isinstance(value, list) and len(value) == 3: 114 | formatted_value = f"({value[0]:.2f}, {value[1]:.2f}, {value[2]:.2f})" 115 | elif isinstance(value, float): 116 | formatted_value = f"{value:.4f}" 117 | else: 118 | formatted_value = str(value) 119 | 120 | output.append(f"- {formatted_key}: {formatted_value}") 121 | 122 | return "\n".join(output) 123 | 124 | @mcp.tool() 125 | async def measure_distance(file_path: str, object_index1: int, object_index2: int) -> str: 126 | """Measure the distance between two objects in a Rhino file. 127 | 128 | Args: 129 | file_path: Path to the .3dm file 130 | object_index1: Index of the first object 131 | object_index2: Index of the second object 132 | 133 | Returns: 134 | Distance measurement information 135 | """ 136 | ctx = mcp.get_context() 137 | rhino = ctx.request_context.lifespan_context.rhino 138 | 139 | result = await rhino.read_3dm_file(file_path) 140 | 141 | if result["result"] == "error": 142 | return f"Error: {result['error']}" 143 | 144 | model = result["model"] 145 | r3d = rhino.rhino_instance["r3d"] 146 | 147 | # Validate indices 148 | try: 149 | idx1 = int(object_index1) 150 | idx2 = int(object_index2) 151 | 152 | if idx1 < 0 or idx1 >= len(model.Objects) or idx2 < 0 or idx2 >= len(model.Objects): 153 | return ( 154 | f"Error: Invalid object indices. File has {len(model.Objects)} objects (0-{len(model.Objects)-1})." 155 | ) 156 | except ValueError: 157 | return "Error: Object indices must be numbers." 158 | 159 | # Get geometries 160 | obj1 = model.Objects[idx1] 161 | obj2 = model.Objects[idx2] 162 | geom1 = obj1.Geometry 163 | geom2 = obj2.Geometry 164 | 165 | if not geom1 or not geom2: 166 | return "Error: One or both objects don't have geometry." 167 | 168 | # Calculate distances using bounding boxes (simple approach) 169 | bbox1 = geom1.GetBoundingBox() if hasattr(geom1, "GetBoundingBox") else None 170 | bbox2 = geom2.GetBoundingBox() if hasattr(geom2, "GetBoundingBox") else None 171 | 172 | if not bbox1 or not bbox2: 173 | return "Error: Couldn't get bounding boxes for the objects." 174 | 175 | # Calculate center points 176 | center1 = r3d.Point3d( 177 | (bbox1.Min.X + bbox1.Max.X) / 2, (bbox1.Min.Y + bbox1.Max.Y) / 2, (bbox1.Min.Z + bbox1.Max.Z) / 2 178 | ) 179 | 180 | center2 = r3d.Point3d( 181 | (bbox2.Min.X + bbox2.Max.X) / 2, (bbox2.Min.Y + bbox2.Max.Y) / 2, (bbox2.Min.Z + bbox2.Max.Z) / 2 182 | ) 183 | 184 | # Calculate distance between centers 185 | distance = center1.DistanceTo(center2) 186 | 187 | # Get object names 188 | name1 = obj1.Attributes.Name or f"Object {idx1}" 189 | name2 = obj2.Attributes.Name or f"Object {idx2}" 190 | 191 | return f"""Measurement between '{name1}' and '{name2}': 192 | - Center-to-center distance: {distance:.4f} units 193 | - Object 1 center: ({center1.X:.2f}, {center1.Y:.2f}, {center1.Z:.2f}) 194 | - Object 2 center: ({center2.X:.2f}, {center2.Y:.2f}, {center2.Z:.2f}) 195 | 196 | Note: This is an approximate center-to-center measurement using bounding boxes. 197 | """ 198 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/tools/advanced_grasshopper.py: -------------------------------------------------------------------------------- ```python 1 | from mcp.server.fastmcp import FastMCP 2 | from typing import Dict, List, Any, Optional 3 | 4 | 5 | def register_advanced_grasshopper_tools(mcp: FastMCP) -> None: 6 | """Register advanced Grasshopper operations with the MCP server.""" 7 | 8 | @mcp.tool() 9 | async def create_parametric_definition( 10 | description: str, parameters: Dict[str, Any], output_file: Optional[str] = None 11 | ) -> str: 12 | """Create a complete parametric definition in Grasshopper based on a description. 13 | 14 | Args: 15 | description: Detailed description of the parametric model to create 16 | parameters: Dictionary of parameter names and values 17 | output_file: Optional path to save the definition 18 | 19 | Returns: 20 | Result of the operation 21 | """ 22 | ctx = mcp.get_context() 23 | rhino = ctx.request_context.lifespan_context.rhino 24 | 25 | # First, analyze the description to determine required components 26 | # This would ideally be done with an LLM or a sophisticated parsing system 27 | # Here we're using a simplified approach 28 | 29 | # Generate a basic workflow based on the description 30 | workflow = await generate_grasshopper_workflow(rhino, description, parameters) 31 | 32 | if workflow["result"] == "error": 33 | return f"Error generating workflow: {workflow['error']}" 34 | 35 | # Create the components in the definition 36 | component_ids = {} 37 | 38 | # Parameter components (sliders, panels, etc.) 39 | for param_name, param_info in workflow["parameters"].items(): 40 | result = await rhino.add_gh_component( 41 | component_name=param_info["component"], 42 | component_type="Params", 43 | parameters={"NickName": param_name, "Value": param_info.get("value")}, 44 | ) 45 | 46 | if result["result"] == "error": 47 | return f"Error creating parameter component: {result['error']}" 48 | 49 | component_ids[param_name] = result["component_id"] 50 | 51 | # Processing components 52 | for comp_name, comp_info in workflow["components"].items(): 53 | result = await rhino.add_gh_component( 54 | component_name=comp_info["component"], 55 | component_type=comp_info["type"], 56 | parameters={"NickName": comp_name}, 57 | ) 58 | 59 | if result["result"] == "error": 60 | return f"Error creating component: {result['error']}" 61 | 62 | component_ids[comp_name] = result["component_id"] 63 | 64 | # Python script components 65 | for script_name, script_info in workflow["scripts"].items(): 66 | result = await rhino.create_gh_script_component( 67 | description=script_name, 68 | inputs=script_info["inputs"], 69 | outputs=script_info["outputs"], 70 | code=script_info["code"], 71 | ) 72 | 73 | if result["result"] == "error": 74 | return f"Error creating script component: {result['error']}" 75 | 76 | component_ids[script_name] = result["component_id"] 77 | 78 | # Connect the components 79 | for connection in workflow["connections"]: 80 | source = connection["from"].split(".") 81 | target = connection["to"].split(".") 82 | 83 | source_id = component_ids.get(source[0]) 84 | target_id = component_ids.get(target[0]) 85 | 86 | if not source_id or not target_id: 87 | continue 88 | 89 | result = await rhino.connect_gh_components( 90 | source_id=source_id, source_param=source[1], target_id=target_id, target_param=target[1] 91 | ) 92 | 93 | if result["result"] == "error": 94 | return f"Error connecting components: {result['error']}" 95 | 96 | # Run the definition to validate 97 | result = await rhino.run_gh_definition() 98 | 99 | if result["result"] == "error": 100 | return f"Error running definition: {result['error']}" 101 | 102 | # Save if output file is specified 103 | if output_file: 104 | save_result = await rhino.run_gh_definition(file_path=None, save_output=True, output_path=output_file) 105 | 106 | if save_result["result"] == "error": 107 | return f"Error saving definition: {save_result['error']}" 108 | 109 | return f"""Successfully created parametric Grasshopper definition: 110 | - Description: {description} 111 | - Components: {len(component_ids)} created 112 | - Parameters: {len(workflow['parameters'])} 113 | - Saved to: {output_file if output_file else 'Not saved to file'} 114 | """ 115 | 116 | @mcp.tool() 117 | async def call_grasshopper_plugin( 118 | plugin_name: str, component_name: str, inputs: Dict[str, Any], file_path: Optional[str] = None 119 | ) -> str: 120 | """Call a specific component from a Grasshopper plugin. 121 | 122 | Args: 123 | plugin_name: Name of the plugin (e.g., 'Kangaroo', 'Ladybug') 124 | component_name: Name of the component to use 125 | inputs: Dictionary of input parameter names and values 126 | file_path: Optional path to a GH file to append to 127 | 128 | Returns: 129 | Result of the operation 130 | """ 131 | ctx = mcp.get_context() 132 | rhino = ctx.request_context.lifespan_context.rhino 133 | 134 | # If file path provided, open that definition 135 | if file_path: 136 | open_result = await rhino.run_gh_definition(file_path=file_path) 137 | if open_result["result"] == "error": 138 | return f"Error opening file: {open_result['error']}" 139 | 140 | # Add the plugin component 141 | plugin_comp_result = await rhino.add_gh_component( 142 | component_name=component_name, component_type=plugin_name, parameters={} 143 | ) 144 | 145 | if plugin_comp_result["result"] == "error": 146 | return f"Error adding plugin component: {plugin_comp_result['error']}" 147 | 148 | plugin_comp_id = plugin_comp_result["component_id"] 149 | 150 | # Add input parameters 151 | input_comp_ids = {} 152 | for input_name, input_value in inputs.items(): 153 | # Determine the appropriate parameter component 154 | if isinstance(input_value, (int, float)): 155 | comp_type = "Number" 156 | elif isinstance(input_value, str): 157 | comp_type = "Text" 158 | elif isinstance(input_value, bool): 159 | comp_type = "Boolean" 160 | else: 161 | return f"Unsupported input type for {input_name}: {type(input_value)}" 162 | 163 | # Create the parameter component 164 | input_result = await rhino.add_gh_component( 165 | component_name=comp_type, 166 | component_type="Params", 167 | parameters={"NickName": input_name, "Value": input_value}, 168 | ) 169 | 170 | if input_result["result"] == "error": 171 | return f"Error creating input parameter {input_name}: {input_result['error']}" 172 | 173 | input_comp_ids[input_name] = input_result["component_id"] 174 | 175 | # Connect to the plugin component 176 | connect_result = await rhino.connect_gh_components( 177 | source_id=input_result["component_id"], 178 | source_param="output", 179 | target_id=plugin_comp_id, 180 | target_param=input_name, 181 | ) 182 | 183 | if connect_result["result"] == "error": 184 | return f"Error connecting {input_name}: {connect_result['error']}" 185 | 186 | # Run the definition 187 | run_result = await rhino.run_gh_definition() 188 | 189 | if run_result["result"] == "error": 190 | return f"Error running definition with plugin: {run_result['error']}" 191 | 192 | return f"""Successfully called Grasshopper plugin component: 193 | - Plugin: {plugin_name} 194 | - Component: {component_name} 195 | - Inputs: {', '.join(inputs.keys())} 196 | - Execution time: {run_result.get('execution_time', 'Unknown')} seconds 197 | """ 198 | 199 | @mcp.tool() 200 | async def edit_gh_script_component(file_path: str, component_id: str, new_code: str) -> str: 201 | """Edit the code in an existing Python script component. 202 | 203 | Args: 204 | file_path: Path to the Grasshopper file 205 | component_id: ID of the component to edit 206 | new_code: New Python script for the component 207 | 208 | Returns: 209 | Result of the operation 210 | """ 211 | ctx = mcp.get_context() 212 | rhino = ctx.request_context.lifespan_context.rhino 213 | 214 | # Open the file 215 | open_result = await rhino.run_gh_definition(file_path=file_path) 216 | if open_result["result"] == "error": 217 | return f"Error opening file: {open_result['error']}" 218 | 219 | # Edit the component 220 | execution_code = """ 221 | import Rhino 222 | import Grasshopper 223 | 224 | # Access the current Grasshopper document 225 | gh_doc = Grasshopper.Instances.ActiveCanvas.Document 226 | 227 | # Find the component by ID 228 | target_component = None 229 | for obj in gh_doc.Objects: 230 | if str(obj.ComponentGuid) == component_id: 231 | target_component = obj 232 | break 233 | 234 | if target_component is None: 235 | raise ValueError(f"Component with ID {component_id} not found") 236 | 237 | # Check if it's a Python component 238 | if not hasattr(target_component, "ScriptSource"): 239 | raise ValueError(f"Component is not a Python script component") 240 | 241 | # Update the code 242 | target_component.ScriptSource = new_code 243 | 244 | # Update the document 245 | gh_doc.NewSolution(True) 246 | 247 | result = { 248 | "component_name": target_component.NickName, 249 | "success": True 250 | } 251 | """ 252 | 253 | edit_result = await rhino._execute_rhino(execution_code, {"component_id": component_id, "new_code": new_code}) 254 | 255 | if edit_result["result"] == "error": 256 | return f"Error editing component: {edit_result['error']}" 257 | 258 | return f"""Successfully edited Python script component: 259 | - Component: {edit_result.get('data', {}).get('component_name', 'Unknown')} 260 | - Updated code length: {len(new_code)} characters 261 | """ 262 | 263 | 264 | async def generate_grasshopper_workflow( 265 | rhino_connection, description: str, parameters: Dict[str, Any] 266 | ) -> Dict[str, Any]: 267 | """Generate a Grasshopper workflow based on a description. 268 | 269 | This is a simplified implementation that parses the description to determine 270 | the necessary components, parameters, and connections. 271 | 272 | In a production system, this would likely use an LLM to generate the workflow. 273 | """ 274 | # Initialize workflow structure 275 | workflow = {"parameters": {}, "components": {}, "scripts": {}, "connections": [], "result": "success"} 276 | 277 | # Analyze description to determine what we're building 278 | description_lower = description.lower() 279 | 280 | # Default to a basic parametric box if no specific shape is mentioned 281 | if "box" in description_lower or "cube" in description_lower: 282 | # Create a parametric box 283 | workflow["parameters"] = { 284 | "Width": {"component": "Number Slider", "value": parameters.get("Width", 10)}, 285 | "Height": {"component": "Number Slider", "value": parameters.get("Height", 10)}, 286 | "Depth": {"component": "Number Slider", "value": parameters.get("Depth", 10)}, 287 | } 288 | 289 | workflow["components"] = { 290 | "BoxOrigin": {"component": "Construct Point", "type": "Vector"}, 291 | "Box": {"component": "Box", "type": "Surface"}, 292 | } 293 | 294 | workflow["connections"] = [ 295 | {"from": "Width.output", "to": "Box.X Size"}, 296 | {"from": "Height.output", "to": "Box.Y Size"}, 297 | {"from": "Depth.output", "to": "Box.Z Size"}, 298 | {"from": "BoxOrigin.Point", "to": "Box.Base Point"}, 299 | ] 300 | 301 | elif "cylinder" in description_lower: 302 | # Create a parametric cylinder 303 | workflow["parameters"] = { 304 | "Radius": {"component": "Number Slider", "value": parameters.get("Radius", 5)}, 305 | "Height": {"component": "Number Slider", "value": parameters.get("Height", 20)}, 306 | } 307 | 308 | workflow["components"] = { 309 | "BasePoint": {"component": "Construct Point", "type": "Vector"}, 310 | "Circle": {"component": "Circle", "type": "Curve"}, 311 | "Cylinder": {"component": "Extrude", "type": "Surface"}, 312 | } 313 | 314 | workflow["connections"] = [ 315 | {"from": "Radius.output", "to": "Circle.Radius"}, 316 | {"from": "BasePoint.Point", "to": "Circle.Base"}, 317 | {"from": "Circle.Circle", "to": "Cylinder.Base"}, 318 | {"from": "Height.output", "to": "Cylinder.Direction"}, 319 | ] 320 | 321 | elif "loft" in description_lower or "surface" in description_lower: 322 | # Create a lofted surface between curves 323 | workflow["parameters"] = { 324 | "Points": {"component": "Number Slider", "value": parameters.get("Points", 5)}, 325 | "Height": {"component": "Number Slider", "value": parameters.get("Height", 20)}, 326 | "RadiusBottom": {"component": "Number Slider", "value": parameters.get("RadiusBottom", 10)}, 327 | "RadiusTop": {"component": "Number Slider", "value": parameters.get("RadiusTop", 5)}, 328 | } 329 | 330 | workflow["components"] = { 331 | "BasePoint": {"component": "Construct Point", "type": "Vector"}, 332 | "TopPoint": {"component": "Construct Point", "type": "Vector"}, 333 | "CircleBottom": {"component": "Circle", "type": "Curve"}, 334 | "CircleTop": {"component": "Circle", "type": "Curve"}, 335 | "Loft": {"component": "Loft", "type": "Surface"}, 336 | } 337 | 338 | # For more complex workflows, we can use Python script components 339 | workflow["scripts"] = { 340 | "HeightVector": { 341 | "inputs": [{"name": "height", "type": "float", "description": "Height of the loft"}], 342 | "outputs": [{"name": "vector", "type": "vector", "description": "Height vector"}], 343 | "code": """ 344 | import Rhino.Geometry as rg 345 | 346 | # Create a vertical vector for the height 347 | vector = rg.Vector3d(0, 0, height) 348 | """, 349 | } 350 | } 351 | 352 | workflow["connections"] = [ 353 | {"from": "Height.output", "to": "HeightVector.height"}, 354 | {"from": "HeightVector.vector", "to": "TopPoint.Z"}, 355 | {"from": "RadiusBottom.output", "to": "CircleBottom.Radius"}, 356 | {"from": "RadiusTop.output", "to": "CircleTop.Radius"}, 357 | {"from": "BasePoint.Point", "to": "CircleBottom.Base"}, 358 | {"from": "TopPoint.Point", "to": "CircleTop.Base"}, 359 | {"from": "CircleBottom.Circle", "to": "Loft.Curves"}, 360 | {"from": "CircleTop.Circle", "to": "Loft.Curves"}, 361 | ] 362 | 363 | else: 364 | # Generic parametric object with Python script 365 | workflow["parameters"] = { 366 | "Parameter1": {"component": "Number Slider", "value": parameters.get("Parameter1", 10)}, 367 | "Parameter2": {"component": "Number Slider", "value": parameters.get("Parameter2", 20)}, 368 | } 369 | 370 | workflow["scripts"] = { 371 | "CustomGeometry": { 372 | "inputs": [ 373 | {"name": "param1", "type": "float", "description": "First parameter"}, 374 | {"name": "param2", "type": "float", "description": "Second parameter"}, 375 | ], 376 | "outputs": [{"name": "geometry", "type": "geometry", "description": "Resulting geometry"}], 377 | "code": """ 378 | import Rhino.Geometry as rg 379 | import math 380 | 381 | # Create custom geometry based on parameters 382 | point = rg.Point3d(0, 0, 0) 383 | radius = param1 384 | height = param2 385 | 386 | # Default to a simple cylinder if nothing specific is mentioned 387 | cylinder = rg.Cylinder( 388 | new rg.Circle(point, radius), 389 | height 390 | ) 391 | 392 | geometry = cylinder.ToBrep(True, True) 393 | """, 394 | } 395 | } 396 | 397 | workflow["connections"] = [ 398 | {"from": "Parameter1.output", "to": "CustomGeometry.param1"}, 399 | {"from": "Parameter2.output", "to": "CustomGeometry.param2"}, 400 | ] 401 | 402 | return workflow 403 | ``` -------------------------------------------------------------------------------- /grasshopper_mcp/rhino/connection.py: -------------------------------------------------------------------------------- ```python 1 | import platform 2 | import os 3 | import json 4 | import uuid 5 | from typing import Any, Dict, List, Optional 6 | import time 7 | import platform 8 | import sys 9 | import socket 10 | import tempfile 11 | 12 | from ..config import ServerConfig 13 | 14 | 15 | def find_scriptcontext_path(): 16 | scriptcontext_path = os.path.join( 17 | os.environ["APPDATA"], 18 | "McNeel", 19 | "Rhinoceros", 20 | "7.0", 21 | "Plug-ins", 22 | "IronPython (814d908a-e25c-493d-97e9-ee3861957f49)", 23 | "settings", 24 | "lib", 25 | ) 26 | 27 | if not os.path.exists(scriptcontext_path): 28 | # If the specific path doesn't exist, try to find it 29 | import glob 30 | 31 | appdata = os.environ["APPDATA"] 32 | potential_paths = glob.glob( 33 | os.path.join(appdata, "McNeel", "Rhinoceros", "7.0", "Plug-ins", "IronPython*", "settings", "lib") 34 | ) 35 | if potential_paths: 36 | scriptcontext_path 37 | # sys.path.append(potential_paths[0]) 38 | 39 | return scriptcontext_path 40 | 41 | 42 | def find_RhinoPython_path(rhino_path): 43 | appdata = os.environ["APPDATA"] 44 | rhino_python_paths = [ 45 | # Standard Rhino Python lib paths 46 | os.path.join(os.path.dirname(rhino_path), "Plug-ins", "IronPython"), 47 | os.path.join(os.path.dirname(rhino_path), "Plug-ins", "IronPython", "Lib"), 48 | os.path.join(os.path.dirname(rhino_path), "Plug-ins", "PythonPlugins"), 49 | os.path.join(os.path.dirname(rhino_path), "Scripts"), 50 | # Try to find RhinoPython in various locations 51 | os.path.join(os.path.dirname(rhino_path), "Plug-ins"), 52 | os.path.join(appdata, "McNeel", "Rhinoceros", "7.0", "Plug-ins"), 53 | os.path.join(appdata, "McNeel", "Rhinoceros", "7.0", "Scripts"), 54 | # Common Rhino installation paths for plugins 55 | "C:\\Program Files\\Rhino 7\\Plug-ins", 56 | "C:\\Program Files\\Rhino 7\\Plug-ins\\PythonPlugins", 57 | ] 58 | 59 | return rhino_python_paths 60 | 61 | 62 | class RhinoConnection: 63 | """Connection to Rhino/Grasshopper.""" 64 | 65 | def __init__(self, config: ServerConfig): 66 | self.config = config 67 | self.connected = False 68 | self.rhino_instance = None 69 | self.is_mac = platform.system() == "Darwin" 70 | 71 | self.codelistener_host = "127.0.0.1" 72 | self.codelistener_port = 614 # Default CodeListener port 73 | 74 | async def initialize(self) -> None: 75 | """Initialize connection to Rhino/Grasshopper.""" 76 | if self.config.use_compute_api: 77 | # Setup compute API connection 78 | self._initialize_compute() 79 | else: 80 | # Setup direct connection 81 | self._initialize_rhino() 82 | 83 | self.connected = True 84 | 85 | def _initialize_rhino(self) -> None: 86 | """Initialize Rhino geometry access.""" 87 | if platform.system() == "Windows" and not self.config.use_rhino3dm: 88 | # Windows-specific RhinoInside implementation 89 | import sys 90 | 91 | rhino_path = self.config.rhino_path 92 | 93 | if not rhino_path or not os.path.exists(rhino_path): 94 | raise ValueError(f"Invalid Rhino path: {rhino_path}") 95 | # print(rhino_path) 96 | sys.path.append(rhino_path) 97 | 98 | # Add the specific path for scriptcontext 99 | scriptcontext_path = find_scriptcontext_path() 100 | sys.path.append(scriptcontext_path) 101 | 102 | RhinoPython_path = find_RhinoPython_path(rhino_path) 103 | for path in RhinoPython_path: 104 | if os.path.exists(path): 105 | sys.path.append(path) 106 | print(path) 107 | 108 | try: 109 | import rhinoinside 110 | 111 | rhinoinside.load() 112 | print("rhinoinside installed.") 113 | # Import Rhino components 114 | import Rhino 115 | 116 | print("Rhino installed.") 117 | import Rhino.Geometry as rg 118 | 119 | print("Rhino.Geometry installed.") 120 | import scriptcontext as sc 121 | 122 | # Store references 123 | self.rhino_instance = {"Rhino": Rhino, "rg": rg, "sc": sc, "use_rhino3dm": False} 124 | except ImportError as e: 125 | raise ImportError(f"Error importing RhinoInside or Rhino components: {e}") 126 | else: 127 | # Cross-platform rhino3dm implementation 128 | try: 129 | import rhino3dm as r3d 130 | 131 | self.rhino_instance = {"r3d": r3d, "use_rhino3dm": True} 132 | except ImportError: 133 | raise ImportError("Please install rhino3dm: uv add rhino3dm") 134 | 135 | async def send_code_to_rhino(self, code: str) -> Dict[str, Any]: 136 | """Send Python code to Rhino via CodeListener. 137 | 138 | Args: 139 | code: Python code to execute in Rhino 140 | 141 | Returns: 142 | Dictionary with result and response or error 143 | """ 144 | try: 145 | # Create a temporary Python file 146 | fd, temp_path = tempfile.mkstemp(suffix=".py") 147 | try: 148 | # Write the code to the file 149 | with os.fdopen(fd, "w") as f: 150 | f.write(code) 151 | 152 | # Create message object 153 | msg_obj = {"filename": temp_path, "run": True, "reset": False, "temp": True} 154 | 155 | # Convert to JSON 156 | json_msg = json.dumps(msg_obj) 157 | 158 | # Create a TCP socket 159 | sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 160 | sock.settimeout(10) 161 | sock.connect((self.codelistener_host, self.codelistener_port)) 162 | 163 | # Send the JSON message 164 | sock.sendall(json_msg.encode("utf-8")) 165 | 166 | # Receive the response 167 | response = sock.recv(4096).decode("utf-8") 168 | 169 | # Close the socket 170 | sock.close() 171 | 172 | return {"result": "success", "response": response} 173 | 174 | finally: 175 | # Clean up - remove temporary file after execution 176 | try: 177 | os.unlink(temp_path) 178 | except Exception as cleanup_error: 179 | print(f"Error cleaning up temporary file: {cleanup_error}") 180 | 181 | except Exception as e: 182 | return {"result": "error", "error": str(e)} 183 | 184 | async def generate_and_execute_rhino_code( 185 | self, prompt: str, model_context: Optional[Dict[str, Any]] = None 186 | ) -> Dict[str, Any]: 187 | """Generate Rhino Python code from a prompt and execute it. 188 | 189 | Args: 190 | prompt: Description of what code to generate 191 | model_context: Optional context about the model (dimensions, parameters, etc.) 192 | 193 | Returns: 194 | Result dictionary with code, execution result, and any output 195 | """ 196 | # Step 1: Generate Python code based on the prompt 197 | code = await self._generate_code_from_prompt(prompt, model_context) 198 | 199 | # Step 2: Execute the generated code in Rhino 200 | result = await self.send_code_to_rhino(code) 201 | 202 | # Return both the code and the execution result 203 | return { 204 | "result": result.get("result", "error"), 205 | "code": code, 206 | "response": result.get("response", ""), 207 | "error": result.get("error", ""), 208 | } 209 | 210 | async def _generate_code_from_prompt( 211 | self, prompt: str, model_context: Optional[Dict[str, Any]] = None 212 | ) -> str: 213 | """Generate Rhino Python code from a text prompt. 214 | 215 | Args: 216 | prompt: Description of what the code should do 217 | model_context: Optional context about the model 218 | 219 | Returns: 220 | Generated Python code as a string 221 | """ 222 | # Add standard imports for Rhino Python code 223 | code = """ 224 | import Rhino 225 | import rhinoscriptsyntax as rs 226 | import scriptcontext as sc 227 | import System 228 | from Rhino.Geometry import * 229 | 230 | # Disable redraw to improve performance 231 | rs.EnableRedraw(False) 232 | """ 233 | 234 | # Add code based on the prompt 235 | prompt_lower = prompt.lower() 236 | 237 | if "circle" in prompt_lower: 238 | radius = model_context.get("radius", 10.0) if model_context else 10.0 239 | center_x = model_context.get("center_x", 0.0) if model_context else 0.0 240 | center_y = model_context.get("center_y", 0.0) if model_context else 0.0 241 | center_z = model_context.get("center_z", 0.0) if model_context else 0.0 242 | code += f""" 243 | # Create a circle based on prompt: {prompt} 244 | center = Point3d({center_x}, {center_y}, {center_z}) 245 | circle = Circle(Plane.WorldXY, center, {radius}) 246 | circle_id = sc.doc.Objects.AddCircle(circle) 247 | if circle_id: 248 | rs.ObjectName(circle_id, "GeneratedCircle") 249 | print("Created a circle!") 250 | else: 251 | print("Failed to create circle") 252 | """ 253 | return code 254 | 255 | async def send_code_to_gh(self, code: str, file_path: str) -> Dict[str, Any]: 256 | """Send Python code to a file for Grasshopper to use. 257 | 258 | Args: 259 | code: Python code to save for Grasshopper 260 | file_path: Path where the Python file should be saved 261 | 262 | Returns: 263 | Dictionary with result and file path 264 | """ 265 | try: 266 | # Write the code to the file 267 | with open(file_path, "w") as f: 268 | f.write(code) 269 | 270 | return { 271 | "result": "success", 272 | "file_path": file_path, 273 | "message": f"Grasshopper Python file created at {file_path}", 274 | } 275 | 276 | except Exception as e: 277 | return {"result": "error", "error": str(e)} 278 | 279 | async def generate_and_execute_gh_code( 280 | self, 281 | prompt: str, 282 | file_path: str, 283 | model_context: Optional[Dict[str, Any]] = None, 284 | component_name: Optional[str] = None, 285 | ) -> Dict[str, Any]: 286 | """Generate Grasshopper Python code from a prompt and save it for execution. 287 | 288 | Args: 289 | prompt: Description of what code to generate 290 | model_context: Optional context about the model (dimensions, parameters, etc.) 291 | component_name: Optional name for the GH Python component 292 | 293 | Returns: 294 | Result dictionary with code, file path, and any output 295 | """ 296 | # Step 1: Generate Python code based on the prompt 297 | code = await self._generate_gh_code_from_prompt(prompt, model_context, component_name) 298 | 299 | # Step 2: Save the generated code for Grasshopper to use 300 | result = await self.send_code_to_gh(code, file_path) 301 | 302 | # Return both the code and the result 303 | return { 304 | "result": result.get("result", "error"), 305 | "code": code, 306 | "file_path": result.get("file_path", ""), 307 | "response": result.get("response", ""), 308 | "error": result.get("error", ""), 309 | } 310 | 311 | async def _generate_gh_code_from_prompt( 312 | self, 313 | prompt: str, 314 | model_context: Optional[Dict[str, Any]] = None, 315 | component_name: Optional[str] = None, 316 | ) -> str: 317 | """Generate Grasshopper Python code from a text prompt. 318 | 319 | Args: 320 | prompt: Description of what the code should do 321 | model_context: Optional context about the model 322 | component_name: Optional name for the component 323 | 324 | Returns: 325 | Generated Python code as a string 326 | """ 327 | # Add component name as a comment 328 | if component_name: 329 | code = f"""# Grasshopper Python Component: {component_name} 330 | # Generated from prompt: {prompt} 331 | """ 332 | else: 333 | code = f"""# Grasshopper Python Component 334 | # Generated from prompt: {prompt} 335 | """ 336 | 337 | # Add standard imports for Grasshopper Python code 338 | code += """ 339 | import Rhino 340 | import rhinoscriptsyntax as rs 341 | import scriptcontext as sc 342 | import Rhino.Geometry as rg 343 | import ghpythonlib.components as ghcomp 344 | import math 345 | """ 346 | # Add code based on the prompt 347 | prompt_lower = prompt.lower() 348 | 349 | if "circle" in prompt_lower: 350 | radius = model_context.get("radius", 10.0) if model_context else 10.0 351 | center_x = model_context.get("center_x", 0.0) if model_context else 0.0 352 | center_y = model_context.get("center_y", 0.0) if model_context else 0.0 353 | center_z = model_context.get("center_z", 0.0) if model_context else 0.0 354 | code += f""" 355 | # Create a circle based on prompt: {prompt} 356 | center = rg.Point3d({center_x}, {center_y}, {center_z}) 357 | circle = rg.Circle(rg.Plane.WorldXY, center, {radius}) 358 | print("Created a circle!") 359 | """ 360 | return code 361 | 362 | def _initialize_compute(self) -> None: 363 | """Initialize connection to compute.rhino3d.com.""" 364 | if not self.config.compute_url or not self.config.compute_api_key: 365 | raise ValueError("Compute API URL and key required for compute API connection") 366 | 367 | # We'll use requests for API calls 368 | 369 | async def close(self) -> None: 370 | """Close connection to Rhino/Grasshopper.""" 371 | # Cleanup as needed 372 | self.connected = False 373 | 374 | async def execute_code(self, code: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 375 | """Execute operations on Rhino geometry.""" 376 | if not self.connected: 377 | raise RuntimeError("Not connected to Rhino geometry system") 378 | 379 | if self.config.use_compute_api: 380 | return await self._execute_compute(code, parameters) 381 | else: 382 | # Check if we're using rhino3dm 383 | if self.rhino_instance.get("use_rhino3dm", False): 384 | return await self._execute_rhino3dm(code, parameters) 385 | else: 386 | return await self._execute_rhino(code, parameters) 387 | 388 | async def _execute_rhino(self, code: str, parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: 389 | """Execute code directly in Rhino (Windows only).""" 390 | # Existing Rhino execution code 391 | globals_dict = dict(self.rhino_instance) 392 | 393 | # Add parameters to context 394 | if parameters: 395 | globals_dict.update(parameters) 396 | 397 | # Execute the code 398 | locals_dict = {} 399 | try: 400 | exec(code, globals_dict, locals_dict) 401 | return {"result": "success", "data": locals_dict.get("result", None)} 402 | except Exception as e: 403 | # More detailed error reporting for Windows 404 | import traceback 405 | 406 | error_trace = traceback.format_exc() 407 | return {"result": "error", "error": str(e), "traceback": error_trace} 408 | 409 | async def _execute_rhino3dm( 410 | self, code: str, parameters: Optional[Dict[str, Any]] = None 411 | ) -> Dict[str, Any]: 412 | """Execute code using rhino3dm library.""" 413 | # Create execution context with rhino3dm 414 | r3d = self.rhino_instance["r3d"] 415 | globals_dict = {"r3d": r3d, "parameters": parameters or {}} 416 | 417 | # Add parameters to context 418 | if parameters: 419 | globals_dict.update(parameters) 420 | 421 | # Execute the code 422 | locals_dict = {} 423 | try: 424 | exec(code, globals_dict, locals_dict) 425 | return {"result": "success", "data": locals_dict.get("result", None)} 426 | except Exception as e: 427 | return {"result": "error", "error": str(e)} 428 | 429 | async def _execute_compute( 430 | self, code: str, parameters: Optional[Dict[str, Any]] = None 431 | ) -> Dict[str, Any]: 432 | """Execute code via compute.rhino3d.com API.""" 433 | # Existing Compute API code 434 | import requests 435 | 436 | url = f"{self.config.compute_url}/grasshopper" 437 | 438 | # Prepare request payload 439 | payload = {"algo": code, "pointer": None, "values": parameters or {}} 440 | 441 | headers = { 442 | "Authorization": f"Bearer {self.config.compute_api_key}", 443 | "Content-Type": "application/json", 444 | } 445 | 446 | try: 447 | response = requests.post(url, json=payload, headers=headers) 448 | response.raise_for_status() 449 | return {"result": "success", "data": response.json()} 450 | except Exception as e: 451 | return {"result": "error", "error": str(e)} 452 | 453 | async def read_3dm_file(self, file_path: str) -> Dict[str, Any]: 454 | """Read a .3dm file and return its model.""" 455 | if not self.connected: 456 | raise RuntimeError("Not connected to Rhino geometry system") 457 | 458 | try: 459 | if self.rhino_instance.get("use_rhino3dm", False): 460 | r3d = self.rhino_instance["r3d"] 461 | model = r3d.File3dm.Read(file_path) 462 | if model: 463 | return {"result": "success", "model": model} 464 | else: 465 | return {"result": "error", "error": f"Failed to open file: {file_path}"} 466 | else: 467 | # For RhinoInside on Windows, use a different approach 468 | code = """ 469 | import Rhino 470 | result = { 471 | "model": Rhino.FileIO.File3dm.Read(file_path) 472 | } 473 | """ 474 | return await self._execute_rhino(code, {"file_path": file_path}) 475 | except Exception as e: 476 | return {"result": "error", "error": str(e)} 477 | 478 | ### For Grasshopper 479 | async def create_gh_script_component( 480 | self, description: str, inputs: List[Dict[str, Any]], outputs: List[Dict[str, Any]], code: str 481 | ) -> Dict[str, Any]: 482 | """Create a Python script component in a Grasshopper definition. 483 | 484 | Args: 485 | description: Description of the component 486 | inputs: List of input parameters 487 | outputs: List of output parameters 488 | code: Python code for the component 489 | 490 | Returns: 491 | Result dictionary with component_id on success 492 | """ 493 | if not self.connected: 494 | return {"result": "error", "error": "Not connected to Rhino/Grasshopper"} 495 | 496 | # Generate a unique component ID 497 | component_id = f"py_{str(uuid.uuid4())[:8]}" 498 | 499 | if self.config.use_compute_api: 500 | # Implementation for compute API 501 | return await self._create_gh_script_component_compute( 502 | component_id, description, inputs, outputs, code 503 | ) 504 | elif platform.system() == "Windows" and not self.rhino_instance.get("use_rhino3dm", True): 505 | # Implementation for RhinoInside (Windows) 506 | return await self._create_gh_script_component_rhinoinside( 507 | component_id, description, inputs, outputs, code 508 | ) 509 | else: 510 | # We can't directly create Grasshopper components with rhino3dm 511 | return { 512 | "result": "error", 513 | "error": "Creating Grasshopper components requires RhinoInside or Compute API", 514 | } 515 | 516 | async def _create_gh_script_component_rhinoinside( 517 | self, 518 | component_id: str, 519 | description: str, 520 | inputs: List[Dict[str, Any]], 521 | outputs: List[Dict[str, Any]], 522 | code: str, 523 | ) -> Dict[str, Any]: 524 | """Create a Python script component using RhinoInside.""" 525 | # Using the RhinoInside context 526 | execution_code = """ 527 | import Rhino 528 | import Grasshopper 529 | import GhPython 530 | from Grasshopper.Kernel import GH_Component 531 | 532 | # Access the current Grasshopper document 533 | gh_doc = Grasshopper.Instances.ActiveCanvas.Document 534 | 535 | # Create a new Python component 536 | py_comp = GhPython.Component.PythonComponent() 537 | py_comp.NickName = description 538 | py_comp.Name = description 539 | py_comp.Description = description 540 | py_comp.ComponentGuid = System.Guid(component_id) 541 | 542 | # Set up inputs 543 | for i, inp in enumerate(inputs): 544 | name = inp["name"] 545 | param_type = inp.get("type", "object") 546 | # Convert param_type to Grasshopper parameter type 547 | access_type = 0 # 0 = item access 548 | py_comp.Params.Input[i].Name = name 549 | py_comp.Params.Input[i].NickName = name 550 | py_comp.Params.Input[i].Description = inp.get("description", "") 551 | 552 | # Set up outputs 553 | for i, out in enumerate(outputs): 554 | name = out["name"] 555 | param_type = out.get("type", "object") 556 | # Convert param_type to Grasshopper parameter type 557 | py_comp.Params.Output[i].Name = name 558 | py_comp.Params.Output[i].NickName = name 559 | py_comp.Params.Output[i].Description = out.get("description", "") 560 | 561 | # Set the Python code 562 | py_comp.ScriptSource = code 563 | 564 | # Add the component to the document 565 | gh_doc.AddObject(py_comp, False) 566 | 567 | # Set the position on canvas (centered) 568 | py_comp.Attributes.Pivot = Grasshopper.Kernel.GH_Convert.ToPoint( 569 | Rhino.Geometry.Point2d(gh_doc.Bounds.Center.X, gh_doc.Bounds.Center.Y) 570 | ) 571 | 572 | # Update the document 573 | gh_doc.NewSolution(True) 574 | 575 | # Store component for reference 576 | result = { 577 | "component_id": str(component_id) 578 | } 579 | """ 580 | 581 | # Execute the code 582 | return await self._execute_rhino( 583 | execution_code, 584 | { 585 | "component_id": component_id, 586 | "description": description, 587 | "inputs": inputs, 588 | "outputs": outputs, 589 | "code": code, 590 | }, 591 | ) 592 | 593 | async def _create_gh_script_component_compute( 594 | self, 595 | component_id: str, 596 | description: str, 597 | inputs: List[Dict[str, Any]], 598 | outputs: List[Dict[str, Any]], 599 | code: str, 600 | ) -> Dict[str, Any]: 601 | """Create a Python script component using Compute API.""" 602 | import requests 603 | 604 | url = f"{self.config.compute_url}/grasshopper/scriptcomponent" 605 | 606 | # Prepare payload 607 | payload = { 608 | "id": component_id, 609 | "name": description, 610 | "description": description, 611 | "inputs": inputs, 612 | "outputs": outputs, 613 | "code": code, 614 | } 615 | 616 | headers = { 617 | "Authorization": f"Bearer {self.config.compute_api_key}", 618 | "Content-Type": "application/json", 619 | } 620 | 621 | try: 622 | response = requests.post(url, json=payload, headers=headers) 623 | response.raise_for_status() 624 | return {"result": "success", "component_id": component_id, "data": response.json()} 625 | except Exception as e: 626 | return {"result": "error", "error": str(e)} 627 | 628 | async def add_gh_component( 629 | self, component_name: str, component_type: str, parameters: Dict[str, Any] 630 | ) -> Dict[str, Any]: 631 | """Add a component from an existing Grasshopper plugin. 632 | 633 | Args: 634 | component_name: Name of the component 635 | component_type: Type/category of the component 636 | parameters: Component parameters and settings 637 | 638 | Returns: 639 | Result dictionary with component_id on success 640 | """ 641 | if not self.connected: 642 | return {"result": "error", "error": "Not connected to Rhino/Grasshopper"} 643 | 644 | # Generate a unique component ID 645 | component_id = f"comp_{str(uuid.uuid4())[:8]}" 646 | 647 | if self.config.use_compute_api: 648 | # Implementation for compute API 649 | return await self._add_gh_component_compute( 650 | component_id, component_name, component_type, parameters 651 | ) 652 | elif platform.system() == "Windows" and not self.rhino_instance.get("use_rhino3dm", True): 653 | # Implementation for RhinoInside 654 | return await self._add_gh_component_rhinoinside( 655 | component_id, component_name, component_type, parameters 656 | ) 657 | else: 658 | return { 659 | "result": "error", 660 | "error": "Adding Grasshopper components requires RhinoInside or Compute API", 661 | } 662 | 663 | async def _add_gh_component_rhinoinside( 664 | self, component_id: str, component_name: str, component_type: str, parameters: Dict[str, Any] 665 | ) -> Dict[str, Any]: 666 | """Add a Grasshopper component using RhinoInside.""" 667 | execution_code = """ 668 | import Rhino 669 | import Grasshopper 670 | from Grasshopper.Kernel import GH_ComponentServer 671 | 672 | # Access the current Grasshopper document 673 | gh_doc = Grasshopper.Instances.ActiveCanvas.Document 674 | 675 | # Find the component by name and type 676 | server = GH_ComponentServer.FindServer(component_name, component_type) 677 | if server is None: 678 | raise ValueError(f"Component '{component_name}' of type '{component_type}' not found") 679 | 680 | # Create the component instance 681 | component = server.Create() 682 | component.ComponentGuid = System.Guid(component_id) 683 | 684 | # Set parameters 685 | for param_name, param_value in parameters.items(): 686 | if hasattr(component, param_name): 687 | setattr(component, param_name, param_value) 688 | 689 | # Add the component to the document 690 | gh_doc.AddObject(component, False) 691 | 692 | # Set the position on canvas 693 | component.Attributes.Pivot = Grasshopper.Kernel.GH_Convert.ToPoint( 694 | Rhino.Geometry.Point2d(gh_doc.Bounds.Center.X, gh_doc.Bounds.Center.Y) 695 | ) 696 | 697 | # Update the document 698 | gh_doc.NewSolution(True) 699 | 700 | # Return component info 701 | result = { 702 | "component_id": str(component_id) 703 | } 704 | """ 705 | 706 | return await self._execute_rhino( 707 | execution_code, 708 | { 709 | "component_id": component_id, 710 | "component_name": component_name, 711 | "component_type": component_type, 712 | "parameters": parameters, 713 | }, 714 | ) 715 | 716 | async def _add_gh_component_compute( 717 | self, component_id: str, component_name: str, component_type: str, parameters: Dict[str, Any] 718 | ) -> Dict[str, Any]: 719 | """Add a Grasshopper component using Compute API.""" 720 | import requests 721 | 722 | url = f"{self.config.compute_url}/grasshopper/component" 723 | 724 | # Prepare payload 725 | payload = { 726 | "id": component_id, 727 | "name": component_name, 728 | "type": component_type, 729 | "parameters": parameters, 730 | } 731 | 732 | headers = { 733 | "Authorization": f"Bearer {self.config.compute_api_key}", 734 | "Content-Type": "application/json", 735 | } 736 | 737 | try: 738 | response = requests.post(url, json=payload, headers=headers) 739 | response.raise_for_status() 740 | return {"result": "success", "component_id": component_id, "data": response.json()} 741 | except Exception as e: 742 | return {"result": "error", "error": str(e)} 743 | 744 | async def connect_gh_components( 745 | self, source_id: str, source_param: str, target_id: str, target_param: str 746 | ) -> Dict[str, Any]: 747 | """Connect parameters between Grasshopper components. 748 | 749 | Args: 750 | source_id: Source component ID 751 | source_param: Source parameter name 752 | target_id: Target component ID 753 | target_param: Target parameter name 754 | 755 | Returns: 756 | Result dictionary 757 | """ 758 | if not self.connected: 759 | return {"result": "error", "error": "Not connected to Rhino/Grasshopper"} 760 | 761 | if self.config.use_compute_api: 762 | return await self._connect_gh_components_compute(source_id, source_param, target_id, target_param) 763 | elif platform.system() == "Windows" and not self.rhino_instance.get("use_rhino3dm", True): 764 | return await self._connect_gh_components_rhinoinside( 765 | source_id, source_param, target_id, target_param 766 | ) 767 | else: 768 | return { 769 | "result": "error", 770 | "error": "Connecting Grasshopper components requires RhinoInside or Compute API", 771 | } 772 | 773 | async def _connect_gh_components_rhinoinside( 774 | self, source_id: str, source_param: str, target_id: str, target_param: str 775 | ) -> Dict[str, Any]: 776 | """Connect Grasshopper components using RhinoInside.""" 777 | execution_code = """ 778 | import Rhino 779 | import Grasshopper 780 | from Grasshopper.Kernel import GH_Document 781 | 782 | # Access the current Grasshopper document 783 | gh_doc = Grasshopper.Instances.ActiveCanvas.Document 784 | 785 | # Find the source component 786 | source = None 787 | for obj in gh_doc.Objects: 788 | if str(obj.ComponentGuid) == source_id: 789 | source = obj 790 | break 791 | 792 | if source is None: 793 | raise ValueError(f"Source component with ID {source_id} not found") 794 | 795 | # Find the target component 796 | target = None 797 | for obj in gh_doc.Objects: 798 | if str(obj.ComponentGuid) == target_id: 799 | target = obj 800 | break 801 | 802 | if target is None: 803 | raise ValueError(f"Target component with ID {target_id} not found") 804 | 805 | # Find the source output parameter 806 | source_output = None 807 | for i, param in enumerate(source.Params.Output): 808 | if param.Name == source_param: 809 | source_output = param 810 | break 811 | 812 | if source_output is None: 813 | raise ValueError(f"Source parameter {source_param} not found on component {source_id}") 814 | 815 | # Find the target input parameter 816 | target_input = None 817 | for i, param in enumerate(target.Params.Input): 818 | if param.Name == target_param: 819 | target_input = param 820 | break 821 | 822 | if target_input is None: 823 | raise ValueError(f"Target parameter {target_param} not found on component {target_id}") 824 | 825 | # Connect the parameters 826 | gh_doc.GraftIO(source_output.Recipients, target_input.Sources) 827 | 828 | # Update the document 829 | gh_doc.NewSolution(True) 830 | 831 | result = { 832 | "success": True 833 | } 834 | """ 835 | 836 | return await self._execute_rhino( 837 | execution_code, 838 | { 839 | "source_id": source_id, 840 | "source_param": source_param, 841 | "target_id": target_id, 842 | "target_param": target_param, 843 | }, 844 | ) 845 | 846 | async def _connect_gh_components_compute( 847 | self, source_id: str, source_param: str, target_id: str, target_param: str 848 | ) -> Dict[str, Any]: 849 | """Connect Grasshopper components using Compute API.""" 850 | import requests 851 | 852 | url = f"{self.config.compute_url}/grasshopper/connect" 853 | 854 | # Prepare payload 855 | payload = { 856 | "source_id": source_id, 857 | "source_param": source_param, 858 | "target_id": target_id, 859 | "target_param": target_param, 860 | } 861 | 862 | headers = { 863 | "Authorization": f"Bearer {self.config.compute_api_key}", 864 | "Content-Type": "application/json", 865 | } 866 | 867 | try: 868 | response = requests.post(url, json=payload, headers=headers) 869 | response.raise_for_status() 870 | return {"result": "success", "data": response.json()} 871 | except Exception as e: 872 | return {"result": "error", "error": str(e)} 873 | 874 | async def run_gh_definition( 875 | self, file_path: Optional[str] = None, save_output: bool = False, output_path: Optional[str] = None 876 | ) -> Dict[str, Any]: 877 | """Run a Grasshopper definition. 878 | 879 | Args: 880 | file_path: Path to the .gh file (or None for current definition) 881 | save_output: Whether to save the output 882 | output_path: Path to save the output (if save_output is True) 883 | 884 | Returns: 885 | Result dictionary with execution information 886 | """ 887 | if not self.connected: 888 | return {"result": "error", "error": "Not connected to Rhino/Grasshopper"} 889 | 890 | if self.config.use_compute_api: 891 | return await self._run_gh_definition_compute(file_path, save_output, output_path) 892 | elif platform.system() == "Windows" and not self.rhino_instance.get("use_rhino3dm", True): 893 | return await self._run_gh_definition_rhinoinside(file_path, save_output, output_path) 894 | else: 895 | return { 896 | "result": "error", 897 | "error": "Running Grasshopper definitions requires RhinoInside or Compute API", 898 | } 899 | 900 | async def _run_gh_definition_rhinoinside( 901 | self, file_path: Optional[str] = None, save_output: bool = False, output_path: Optional[str] = None 902 | ) -> Dict[str, Any]: 903 | """Run a Grasshopper definition using RhinoInside.""" 904 | execution_code = """ 905 | import Rhino 906 | import Grasshopper 907 | import time 908 | 909 | start_time = time.time() 910 | 911 | if file_path: 912 | # Open the specified Grasshopper definition 913 | gh_doc = Grasshopper.Kernel.GH_Document() 914 | gh_doc.LoadDocumentObject(file_path) 915 | else: 916 | # Use the current document 917 | gh_doc = Grasshopper.Instances.ActiveCanvas.Document 918 | 919 | # Run the solution 920 | gh_doc.NewSolution(True) 921 | 922 | # Wait for solution to complete 923 | while gh_doc.SolutionState != Grasshopper.Kernel.GH_ProcessStep.Finished: 924 | time.sleep(0.1) 925 | 926 | execution_time = time.time() - start_time 927 | 928 | # Save if requested 929 | if save_output and output_path: 930 | gh_doc.SaveAs(output_path, False) 931 | 932 | # Get a summary of the outputs 933 | output_summary = [] 934 | for obj in gh_doc.Objects: 935 | if obj.Attributes.GetTopLevel.DocObject is not None: 936 | for param in obj.Params.Output: 937 | if param.VolatileDataCount > 0: 938 | output_summary.append({ 939 | "component": obj.NickName, 940 | "param": param.Name, 941 | "data_count": param.VolatileDataCount 942 | }) 943 | 944 | result = { 945 | "execution_time": execution_time, 946 | "output_summary": output_summary 947 | } 948 | """ 949 | 950 | return await self._execute_rhino( 951 | execution_code, {"file_path": file_path, "save_output": save_output, "output_path": output_path} 952 | ) 953 | 954 | async def _run_gh_definition_compute( 955 | self, file_path: Optional[str] = None, save_output: bool = False, output_path: Optional[str] = None 956 | ) -> Dict[str, Any]: 957 | """Run a Grasshopper definition using Compute API.""" 958 | import requests 959 | 960 | url = f"{self.config.compute_url}/grasshopper/run" 961 | 962 | # Prepare payload 963 | payload = {"file_path": file_path, "save_output": save_output, "output_path": output_path} 964 | 965 | headers = { 966 | "Authorization": f"Bearer {self.config.compute_api_key}", 967 | "Content-Type": "application/json", 968 | } 969 | 970 | try: 971 | response = requests.post(url, json=payload, headers=headers) 972 | response.raise_for_status() 973 | return {"result": "success", "data": response.json()} 974 | except Exception as e: 975 | return {"result": "error", "error": str(e)} 976 | ```