# Directory Structure
```
├── .gitignore
├── .local-index
│ └── sketchup_mcp-0.1.0-py3-none-any.whl
├── .python-version
├── examples
│ ├── arts_and_crafts_cabinet.py
│ ├── behavior_tester.py
│ ├── README.md
│ ├── ruby_tester.py
│ ├── simple_ruby_eval.py
│ └── simple_test.py
├── plan.md
├── pyproject.toml
├── README.md
├── requirements.txt
├── sketchup.json
├── src
│ └── sketchup_mcp
│ ├── __init__.py
│ ├── __main__.py
│ └── server.py
├── su_mcp
│ ├── extension.json
│ ├── package.rb
│ ├── su_mcp
│ │ └── main.rb
│ └── su_mcp.rb
├── su_mcp.rb
├── test_eval_ruby.py
└── update_and_restart.sh
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.10
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | build/
8 | dist/
9 | *.egg-info/
10 | .env
11 | .venv
12 | env/
13 | venv/
14 | ENV/
15 |
16 | # Ruby
17 | *.gem
18 | *.rbc
19 | /.config
20 | /coverage/
21 | /pkg/
22 | /tmp/
23 | .ruby-version
24 | .ruby-gemset
25 | .rvmrc
26 |
27 | # Sketchup specific
28 | *.rbz
29 |
30 | # OS specific
31 | .DS_Store
32 | .DS_Store?
33 | ._*
34 | Thumbs.db
35 | *.tar.gz
36 |
```
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # SketchUp MCP Examples
2 |
3 | This directory contains example scripts demonstrating how to use the SketchUp MCP (Model Context Protocol) integration.
4 |
5 | ## Ruby Code Evaluation
6 |
7 | The SketchUp MCP now supports evaluating arbitrary Ruby code directly in SketchUp. This powerful feature allows you to create complex models and perform advanced operations that might not be directly exposed through the MCP API.
8 |
9 | ### Requirements
10 |
11 | - SketchUp with the MCP extension installed (version 1.6.0 or later)
12 | - Python 3.10 or later
13 | - sketchup-mcp Python package (version 0.1.17 or later)
14 |
15 | ### Examples
16 |
17 | #### Simple Ruby Eval Example
18 |
19 | The `simple_ruby_eval.py` script demonstrates basic usage of the `eval_ruby` feature with several simple examples:
20 |
21 | - Creating a line
22 | - Creating a cube
23 | - Getting model information
24 |
25 | To run the example:
26 |
27 | ```bash
28 | python examples/simple_ruby_eval.py
29 | ```
30 |
31 | #### Arts and Crafts Cabinet Example
32 |
33 | The `arts_and_crafts_cabinet.py` script demonstrates a more complex example, creating a detailed arts and crafts style cabinet with working doors using Ruby code.
34 |
35 | To run the example:
36 |
37 | ```bash
38 | python examples/arts_and_crafts_cabinet.py
39 | ```
40 |
41 | ### Using the eval_ruby Feature in Your Own Code
42 |
43 | To use the `eval_ruby` feature in your own code:
44 |
45 | ```python
46 | from mcp.client import Client
47 | import json
48 |
49 | # Connect to the SketchUp MCP server
50 | client = Client("sketchup")
51 |
52 | # Define your Ruby code
53 | ruby_code = """
54 | model = Sketchup.active_model
55 | entities = model.active_entities
56 | line = entities.add_line([0,0,0], [100,100,100])
57 | line.entityID
58 | """
59 |
60 | # Evaluate the Ruby code
61 | response = client.eval_ruby(code=ruby_code)
62 |
63 | # Parse the response
64 | result = json.loads(response)
65 | if result.get("success"):
66 | print(f"Success! Result: {result.get('result')}")
67 | else:
68 | print(f"Error: {result.get('error')}")
69 | ```
70 |
71 | ### Tips for Using eval_ruby
72 |
73 | 1. **Return Values**: The last expression in your Ruby code will be returned as the result. Make sure to return something meaningful, like an entity ID or a JSON string.
74 |
75 | 2. **Error Handling**: Ruby errors will be caught and returned in the response. Check the `success` field to determine if the code executed successfully.
76 |
77 | 3. **Model Operations**: For operations that modify the model, consider wrapping them in `model.start_operation` and `model.commit_operation` to make them undoable.
78 |
79 | 4. **Performance**: For complex operations, it's more efficient to send a single large Ruby script than many small ones.
80 |
81 | 5. **Security**: Be careful when evaluating user-provided Ruby code, as it has full access to the SketchUp API.
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # SketchupMCP - Sketchup Model Context Protocol Integration
2 |
3 | SketchupMCP connects Sketchup to Claude AI through the Model Context Protocol (MCP), allowing Claude to directly interact with and control Sketchup. This integration enables prompt-assisted 3D modeling, scene creation, and manipulation in Sketchup.
4 |
5 | Big Shoutout to [Blender MCP](https://github.com/ahujasid/blender-mcp) for the inspiration and structure.
6 |
7 | ## Features
8 |
9 | * **Two-way communication**: Connect Claude AI to Sketchup through a TCP socket connection
10 | * **Component manipulation**: Create, modify, delete, and transform components in Sketchup
11 | * **Material control**: Apply and modify materials and colors
12 | * **Scene inspection**: Get detailed information about the current Sketchup scene
13 | * **Selection handling**: Get and manipulate selected components
14 | * **Ruby code evaluation**: Execute arbitrary Ruby code directly in SketchUp for advanced operations
15 |
16 | ## Components
17 |
18 | The system consists of two main components:
19 |
20 | 1. **Sketchup Extension**: A Sketchup extension that creates a TCP server within Sketchup to receive and execute commands
21 | 2. **MCP Server (`sketchup_mcp/server.py`)**: A Python server that implements the Model Context Protocol and connects to the Sketchup extension
22 |
23 | ## Installation
24 |
25 | ### Python Packaging
26 |
27 | We're using uv so you'll need to ```brew install uv```
28 |
29 | ### Sketchup Extension
30 |
31 | 1. Download or build the latest `.rbz` file
32 | 2. In Sketchup, go to Window > Extension Manager
33 | 3. Click "Install Extension" and select the downloaded `.rbz` file
34 | 4. Restart Sketchup
35 |
36 | ## Usage
37 |
38 | ### Starting the Connection
39 |
40 | 1. In Sketchup, go to Extensions > SketchupMCP > Start Server
41 | 2. The server will start on the default port (9876)
42 | 3. Make sure the MCP server is running in your terminal
43 |
44 | ### Using with Claude
45 |
46 | Configure Claude to use the MCP server by adding the following to your Claude configuration:
47 |
48 | ```json
49 | "mcpServers": {
50 | "sketchup": {
51 | "command": "uvx",
52 | "args": [
53 | "sketchup-mcp"
54 | ]
55 | }
56 | }
57 | ```
58 |
59 | This will pull the [latest from PyPI](https://pypi.org/project/sketchup-mcp/)
60 |
61 | Once connected, Claude can interact with Sketchup using the following capabilities:
62 |
63 | #### Tools
64 |
65 | * `get_scene_info` - Gets information about the current Sketchup scene
66 | * `get_selected_components` - Gets information about currently selected components
67 | * `create_component` - Create a new component with specified parameters
68 | * `delete_component` - Remove a component from the scene
69 | * `transform_component` - Move, rotate, or scale a component
70 | * `set_material` - Apply materials to components
71 | * `export_scene` - Export the current scene to various formats
72 | * `eval_ruby` - Execute arbitrary Ruby code in SketchUp for advanced operations
73 |
74 | ### Example Commands
75 |
76 | Here are some examples of what you can ask Claude to do:
77 |
78 | * "Create a simple house model with a roof and windows"
79 | * "Select all components and get their information"
80 | * "Make the selected component red"
81 | * "Move the selected component 10 units up"
82 | * "Export the current scene as a 3D model"
83 | * "Create a complex arts and crafts cabinet using Ruby code"
84 |
85 | ## Troubleshooting
86 |
87 | * **Connection issues**: Make sure both the Sketchup extension server and the MCP server are running
88 | * **Command failures**: Check the Ruby Console in Sketchup for error messages
89 | * **Timeout errors**: Try simplifying your requests or breaking them into smaller steps
90 |
91 | ## Technical Details
92 |
93 | ### Communication Protocol
94 |
95 | The system uses a simple JSON-based protocol over TCP sockets:
96 |
97 | * **Commands** are sent as JSON objects with a `type` and optional `params`
98 | * **Responses** are JSON objects with a `status` and `result` or `message`
99 |
100 | ## Contributing
101 |
102 | Contributions are welcome! Please feel free to submit a Pull Request.
103 |
104 | ## License
105 |
106 | MIT
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
1 | mcp[cli]>=1.3.0
2 | websockets>=12.0
3 | aiohttp>=3.9.0
```
--------------------------------------------------------------------------------
/src/sketchup_mcp/__main__.py:
--------------------------------------------------------------------------------
```python
1 | from .server import main
2 |
3 | if __name__ == "__main__":
4 | main()
```
--------------------------------------------------------------------------------
/src/sketchup_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
1 | """Sketchup integration through Model Context Protocol"""
2 |
3 | __version__ = "0.1.17"
4 |
5 | # Expose key classes and functions for easier imports
6 | from .server import mcp
```
--------------------------------------------------------------------------------
/su_mcp/extension.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "Sketchup MCP Server",
3 | "description": "Model Context Protocol server for Sketchup",
4 | "creator": "MCP Team",
5 | "copyright": "2024",
6 | "license": "MIT",
7 | "product_id": "SU_MCP_SERVER",
8 | "version": "1.6.0",
9 | "build": "1"
10 | }
```
--------------------------------------------------------------------------------
/update_and_restart.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/bin/bash
2 |
3 | # Kill any existing sketchup-mcp processes
4 | pkill -f "python -m sketchup_mcp"
5 |
6 | # Update the package
7 | pip install sketchup-mcp==0.1.15
8 |
9 | # Start the server in the background
10 | python -m sketchup_mcp &
11 |
12 | # Wait a moment for the server to start
13 | sleep 1
14 |
15 | echo "Updated to sketchup-mcp 0.1.15 and restarted the server"
```
--------------------------------------------------------------------------------
/su_mcp/su_mcp.rb:
--------------------------------------------------------------------------------
```ruby
1 | require 'sketchup'
2 | require 'extensions'
3 |
4 | module SU_MCP
5 | unless file_loaded?(__FILE__)
6 | ext = SketchupExtension.new('Sketchup MCP Server', 'su_mcp/main')
7 | ext.description = 'Model Context Protocol server for Sketchup'
8 | ext.version = '1.5.0'
9 | ext.copyright = '2024'
10 | ext.creator = 'MCP Team'
11 |
12 | Sketchup.register_extension(ext, true)
13 |
14 | file_loaded(__FILE__)
15 | end
16 | end
```
--------------------------------------------------------------------------------
/su_mcp.rb:
--------------------------------------------------------------------------------
```ruby
1 | require 'sketchup.rb'
2 | require 'extensions.rb'
3 | require 'json'
4 | require 'socket'
5 |
6 | module SU_MCP
7 | unless file_loaded?(__FILE__)
8 | ex = SketchupExtension.new('Sketchup MCP', 'su_mcp/main')
9 | ex.description = 'MCP server for Sketchup that allows AI agents to control and manipulate scenes'
10 | ex.version = '0.1.0'
11 | ex.copyright = '2024'
12 | Sketchup.register_extension(ex, true)
13 | file_loaded(__FILE__)
14 | end
15 | end
```
--------------------------------------------------------------------------------
/test_eval_ruby.py:
--------------------------------------------------------------------------------
```python
1 | import json
2 | from dataclasses import dataclass
3 |
4 | @dataclass
5 | class MockContext:
6 | request_id: int = 1
7 |
8 | # Import the function we want to test
9 | from sketchup_mcp.server import eval_ruby
10 |
11 | # Test with a simple Ruby script
12 | test_code = '''
13 | model = Sketchup.active_model
14 | entities = model.active_entities
15 | line = entities.add_line([0,0,0], [100,100,100])
16 | puts "Created line with ID: #{line.entityID}"
17 | line.entityID
18 | '''
19 |
20 | # Call the function
21 | result = eval_ruby(MockContext(), test_code)
22 | print(f"Result: {result}")
23 |
24 | # Parse the result
25 | parsed = json.loads(result)
26 | print(f"Parsed: {json.dumps(parsed, indent=2)}")
```
--------------------------------------------------------------------------------
/su_mcp/package.rb:
--------------------------------------------------------------------------------
```ruby
1 | #!/usr/bin/env ruby
2 |
3 | require 'zip'
4 | require 'fileutils'
5 |
6 | # Configuration
7 | EXTENSION_NAME = 'su_mcp'
8 | VERSION = '1.6.0'
9 | OUTPUT_NAME = "#{EXTENSION_NAME}_v#{VERSION}.rbz"
10 |
11 | # Create temp directory
12 | temp_dir = "#{EXTENSION_NAME}_temp"
13 | FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
14 | FileUtils.mkdir_p(temp_dir)
15 |
16 | # Copy files to temp directory
17 | FileUtils.cp_r('su_mcp', temp_dir)
18 | FileUtils.cp('su_mcp.rb', temp_dir)
19 | FileUtils.cp('extension.json', temp_dir)
20 |
21 | # Create zip file
22 | FileUtils.rm(OUTPUT_NAME) if File.exist?(OUTPUT_NAME)
23 |
24 | Zip::File.open(OUTPUT_NAME, create: true) do |zipfile|
25 | Dir["#{temp_dir}/**/**"].each do |file|
26 | next if File.directory?(file)
27 | puts "Adding: #{file}"
28 | zipfile.add(file.sub("#{temp_dir}/", ''), file)
29 | end
30 | end
31 |
32 | # Clean up
33 | FileUtils.rm_rf(temp_dir)
34 |
35 | puts "Created #{OUTPUT_NAME}"
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "sketchup-mcp"
3 | version = "0.1.17"
4 | description = "Sketchup integration through Model Context Protocol"
5 | readme = "README.md"
6 | requires-python = ">=3.10"
7 | license = {text = "MIT"}
8 | authors = [
9 | {name = "Your Name", email = "[email protected]"}
10 | ]
11 | classifiers = [
12 | "Programming Language :: Python :: 3",
13 | "License :: OSI Approved :: MIT License",
14 | "Operating System :: OS Independent",
15 | ]
16 | dependencies = [
17 | "mcp[cli]>=1.3.0"
18 | ]
19 |
20 | [project.urls]
21 | Homepage = "https://github.com/yourusername/sketchup-mcp"
22 | Issues = "https://github.com/yourusername/sketchup-mcp/issues"
23 |
24 | [project.scripts]
25 | sketchup-mcp = "sketchup_mcp.server:main"
26 |
27 | [project.entry-points.mcp]
28 | sketchup = "sketchup_mcp.server:mcp"
29 |
30 | [build-system]
31 | requires = ["setuptools>=61.0", "wheel"]
32 | build-backend = "setuptools.build_meta"
33 |
34 | [tool.setuptools]
35 | package-dir = {"" = "src"}
```
--------------------------------------------------------------------------------
/examples/simple_test.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Simple Test for eval_ruby
4 |
5 | This is a minimal test to verify that the eval_ruby feature works correctly.
6 | """
7 |
8 | import json
9 | import logging
10 | from mcp.client import Client
11 |
12 | # Configure logging
13 | logging.basicConfig(level=logging.INFO,
14 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
15 | logger = logging.getLogger("SimpleRubyTest")
16 |
17 | # Simple Ruby code to create a cube
18 | CUBE_CODE = """
19 | model = Sketchup.active_model
20 | entities = model.active_entities
21 |
22 | # Start an operation for undo
23 | model.start_operation("Create Test Cube", true)
24 |
25 | # Create a group for the cube
26 | group = entities.add_group
27 |
28 | # Create the bottom face
29 | face = group.entities.add_face(
30 | [0, 0, 0],
31 | [10, 0, 0],
32 | [10, 10, 0],
33 | [0, 10, 0]
34 | )
35 |
36 | # Push/pull to create the cube
37 | face.pushpull(10)
38 |
39 | # End the operation
40 | model.commit_operation
41 |
42 | # Return the group ID
43 | group.entityID.to_s
44 | """
45 |
46 | def main():
47 | """Main function to test the eval_ruby feature."""
48 | # Connect to the MCP server
49 | client = Client("sketchup")
50 |
51 | # Check if the connection is successful
52 | if not client.is_connected:
53 | logger.error("Failed to connect to the SketchUp MCP server.")
54 | return
55 |
56 | logger.info("Connected to SketchUp MCP server.")
57 |
58 | # Evaluate the Ruby code
59 | logger.info("Creating a simple cube...")
60 | response = client.eval_ruby(code=CUBE_CODE)
61 |
62 | # Parse the response
63 | try:
64 | result = json.loads(response)
65 | if result.get("success"):
66 | logger.info(f"Cube created successfully! Group ID: {result.get('result')}")
67 | else:
68 | logger.error(f"Failed to create cube: {result.get('error')}")
69 | except json.JSONDecodeError:
70 | logger.error(f"Failed to parse response: {response}")
71 |
72 | logger.info("Test completed.")
73 |
74 | if __name__ == "__main__":
75 | main()
```
--------------------------------------------------------------------------------
/examples/simple_ruby_eval.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Simple Ruby Eval Example
4 |
5 | This example demonstrates the basic usage of the eval_ruby feature
6 | to execute Ruby code in SketchUp.
7 | """
8 |
9 | import json
10 | import logging
11 | from mcp.client import Client
12 |
13 | # Configure logging
14 | logging.basicConfig(level=logging.INFO,
15 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
16 | logger = logging.getLogger("SimpleRubyEvalExample")
17 |
18 | # Simple Ruby code examples
19 | EXAMPLES = [
20 | {
21 | "name": "Create a line",
22 | "code": """
23 | model = Sketchup.active_model
24 | entities = model.active_entities
25 | line = entities.add_line([0,0,0], [100,100,100])
26 | line.entityID
27 | """
28 | },
29 | {
30 | "name": "Create a cube",
31 | "code": """
32 | model = Sketchup.active_model
33 | entities = model.active_entities
34 | group = entities.add_group
35 | face = group.entities.add_face(
36 | [0, 0, 0],
37 | [10, 0, 0],
38 | [10, 10, 0],
39 | [0, 10, 0]
40 | )
41 | face.pushpull(10)
42 | group.entityID
43 | """
44 | },
45 | {
46 | "name": "Get model information",
47 | "code": """
48 | model = Sketchup.active_model
49 | info = {
50 | "filename": model.path,
51 | "title": model.title,
52 | "description": model.description,
53 | "entity_count": model.entities.size,
54 | "selection_count": model.selection.size
55 | }
56 | info.to_json
57 | """
58 | }
59 | ]
60 |
61 | def main():
62 | """Main function to demonstrate the eval_ruby feature."""
63 | # Connect to the MCP server
64 | client = Client("sketchup")
65 |
66 | # Check if the connection is successful
67 | if not client.is_connected:
68 | logger.error("Failed to connect to the SketchUp MCP server.")
69 | return
70 |
71 | logger.info("Connected to SketchUp MCP server.")
72 |
73 | # Run each example
74 | for example in EXAMPLES:
75 | logger.info(f"Running example: {example['name']}")
76 |
77 | # Evaluate the Ruby code
78 | response = client.eval_ruby(code=example["code"])
79 |
80 | # Parse the response
81 | try:
82 | result = json.loads(response)
83 | if result.get("success"):
84 | logger.info(f"Result: {result.get('result')}")
85 | else:
86 | logger.error(f"Error: {result.get('error')}")
87 | except json.JSONDecodeError:
88 | logger.error(f"Failed to parse response: {response}")
89 |
90 | logger.info("-" * 40)
91 |
92 | logger.info("All examples completed.")
93 |
94 | if __name__ == "__main__":
95 | main()
```
--------------------------------------------------------------------------------
/sketchup.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "sketchup",
3 | "description": "Sketchup integration through Model Context Protocol",
4 | "package": "sketchup-mcp",
5 | "module": "sketchup_mcp.server",
6 | "object": "mcp",
7 | "tools": [
8 | {
9 | "name": "create_component",
10 | "description": "Create a new component in Sketchup",
11 | "parameters": {
12 | "type": "object",
13 | "properties": {
14 | "type": {
15 | "type": "string",
16 | "description": "Type of component to create",
17 | "default": "cube"
18 | },
19 | "position": {
20 | "type": "array",
21 | "items": {
22 | "type": "number"
23 | },
24 | "description": "Position [x,y,z] of the component",
25 | "default": [0,0,0]
26 | },
27 | "dimensions": {
28 | "type": "array",
29 | "items": {
30 | "type": "number"
31 | },
32 | "description": "Dimensions [width,height,depth] of the component",
33 | "default": [1,1,1]
34 | }
35 | }
36 | }
37 | },
38 | {
39 | "name": "delete_component",
40 | "description": "Delete a component by ID",
41 | "parameters": {
42 | "type": "object",
43 | "properties": {
44 | "id": {
45 | "type": "string",
46 | "description": "ID of the component to delete"
47 | }
48 | },
49 | "required": ["id"]
50 | }
51 | },
52 | {
53 | "name": "transform_component",
54 | "description": "Transform a component's position, rotation, or scale",
55 | "parameters": {
56 | "type": "object",
57 | "properties": {
58 | "id": {
59 | "type": "string",
60 | "description": "ID of the component to transform"
61 | },
62 | "position": {
63 | "type": "array",
64 | "items": {
65 | "type": "number"
66 | },
67 | "description": "New position [x,y,z]"
68 | },
69 | "rotation": {
70 | "type": "array",
71 | "items": {
72 | "type": "number"
73 | },
74 | "description": "New rotation [x,y,z] in degrees"
75 | },
76 | "scale": {
77 | "type": "array",
78 | "items": {
79 | "type": "number"
80 | },
81 | "description": "New scale [x,y,z]"
82 | }
83 | },
84 | "required": ["id"]
85 | }
86 | },
87 | {
88 | "name": "get_selection",
89 | "description": "Get currently selected components",
90 | "parameters": {
91 | "type": "object",
92 | "properties": {}
93 | }
94 | },
95 | {
96 | "name": "set_material",
97 | "description": "Set material for a component",
98 | "parameters": {
99 | "type": "object",
100 | "properties": {
101 | "id": {
102 | "type": "string",
103 | "description": "ID of the component"
104 | },
105 | "material": {
106 | "type": "string",
107 | "description": "Name of the material to apply"
108 | }
109 | },
110 | "required": ["id", "material"]
111 | }
112 | },
113 | {
114 | "name": "export_scene",
115 | "description": "Export the current scene",
116 | "parameters": {
117 | "type": "object",
118 | "properties": {
119 | "format": {
120 | "type": "string",
121 | "description": "Export format (e.g. skp, obj, etc)",
122 | "default": "skp"
123 | }
124 | }
125 | }
126 | }
127 | ],
128 | "mcpServers": {
129 | "sketchup": {
130 | "command": "uvx",
131 | "args": ["sketchup_mcp"]
132 | }
133 | }
134 | }
```
--------------------------------------------------------------------------------
/examples/ruby_tester.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Ruby Code Tester
4 |
5 | This script tests Ruby code in smaller chunks to identify compatibility issues with SketchUp.
6 | """
7 |
8 | import json
9 | import logging
10 | from mcp.client import Client
11 |
12 | # Configure logging
13 | logging.basicConfig(level=logging.INFO,
14 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
15 | logger = logging.getLogger("RubyTester")
16 |
17 | # Test cases - each with a name and Ruby code to test
18 | TEST_CASES = [
19 | {
20 | "name": "Basic Model Access",
21 | "code": """
22 | model = Sketchup.active_model
23 | entities = model.active_entities
24 | "Success: Basic model access works"
25 | """
26 | },
27 | {
28 | "name": "Create Group",
29 | "code": """
30 | model = Sketchup.active_model
31 | entities = model.active_entities
32 | group = entities.add_group
33 | group.entityID.to_s
34 | """
35 | },
36 | {
37 | "name": "Create Face and Pushpull",
38 | "code": """
39 | model = Sketchup.active_model
40 | entities = model.active_entities
41 | group = entities.add_group
42 | face = group.entities.add_face(
43 | [0, 0, 0],
44 | [10, 0, 0],
45 | [10, 10, 0],
46 | [0, 10, 0]
47 | )
48 | face.pushpull(10)
49 | "Success: Created face and pushpull"
50 | """
51 | },
52 | {
53 | "name": "Component Definition",
54 | "code": """
55 | model = Sketchup.active_model
56 | definition = model.definitions.add("Test Component")
57 | definition.name
58 | """
59 | },
60 | {
61 | "name": "Component Behavior",
62 | "code": """
63 | model = Sketchup.active_model
64 | definition = model.definitions.add("Test Component")
65 | # Get behavior properties
66 | behavior = definition.behavior
67 |
68 | # Test available methods
69 | methods = behavior.methods - Object.methods
70 |
71 | # Return the available methods
72 | methods.sort.join(", ")
73 | """
74 | },
75 | {
76 | "name": "Component Instance",
77 | "code": """
78 | model = Sketchup.active_model
79 | entities = model.active_entities
80 | definition = model.definitions.add("Test Component")
81 |
82 | # Create a point and transformation
83 | point = Geom::Point3d.new(0, 0, 0)
84 | transform = Geom::Transformation.new(point)
85 |
86 | # Add instance
87 | instance = entities.add_instance(definition, transform)
88 |
89 | # Set behavior properties
90 | behavior = instance.definition.behavior
91 | behavior.snapto = 0
92 |
93 | "Success: Component instance created with behavior set"
94 | """
95 | }
96 | ]
97 |
98 | def test_ruby_code(client, test_case):
99 | """Test a single Ruby code snippet."""
100 | logger.info(f"Testing: {test_case['name']}")
101 |
102 | response = client.eval_ruby(code=test_case["code"])
103 |
104 | try:
105 | result = json.loads(response)
106 | if result.get("success"):
107 | logger.info(f"✅ SUCCESS: {result.get('result')}")
108 | return True
109 | else:
110 | logger.error(f"❌ ERROR: {result.get('error')}")
111 | return False
112 | except json.JSONDecodeError:
113 | logger.error(f"Failed to parse response: {response}")
114 | return False
115 |
116 | def main():
117 | """Main function to test Ruby code snippets."""
118 | # Connect to the MCP server
119 | client = Client("sketchup")
120 |
121 | # Check if the connection is successful
122 | if not client.is_connected:
123 | logger.error("Failed to connect to the SketchUp MCP server.")
124 | return
125 |
126 | logger.info("Connected to SketchUp MCP server.")
127 | logger.info("=" * 50)
128 |
129 | # Run each test case
130 | success_count = 0
131 | for test_case in TEST_CASES:
132 | if test_ruby_code(client, test_case):
133 | success_count += 1
134 | logger.info("-" * 50)
135 |
136 | # Summary
137 | logger.info(f"Testing complete: {success_count}/{len(TEST_CASES)} tests passed")
138 |
139 | if __name__ == "__main__":
140 | main()
```
--------------------------------------------------------------------------------
/examples/behavior_tester.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Component Behavior Tester
4 |
5 | This script specifically tests the component behavior methods in SketchUp 25.0.574.
6 | """
7 |
8 | import json
9 | import logging
10 | from mcp.client import Client
11 |
12 | # Configure logging
13 | logging.basicConfig(level=logging.INFO,
14 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
15 | logger = logging.getLogger("BehaviorTester")
16 |
17 | # Ruby code to test component behavior methods
18 | BEHAVIOR_TEST_CODE = """
19 | # Create a new model context
20 | model = Sketchup.active_model
21 | model.start_operation("Test Component Behavior", true)
22 |
23 | # Create a new component definition
24 | definition = model.definitions.add("Test Component")
25 |
26 | # Get the behavior object
27 | behavior = definition.behavior
28 |
29 | # Get all methods available on the behavior object
30 | all_methods = behavior.methods - Object.methods
31 |
32 | # Test setting various behavior properties
33 | results = {}
34 |
35 | # Test common behavior properties
36 | properties_to_test = [
37 | "snapto",
38 | "cuts_opening",
39 | "always_face_camera",
40 | "no_scale_tool",
41 | "shadows_face_sun",
42 | "is_component",
43 | "component?"
44 | ]
45 |
46 | # Test each property
47 | property_results = {}
48 | for prop in properties_to_test
49 | begin
50 | # Try to get the property
51 | if behavior.respond_to?(prop)
52 | property_results[prop] = {
53 | "exists": true,
54 | "readable": true
55 | }
56 |
57 | # Try to set the property (for boolean properties, try setting to true)
58 | setter_method = prop + "="
59 | if behavior.respond_to?(setter_method)
60 | if prop == "snapto"
61 | behavior.send(setter_method, 0)
62 | else
63 | behavior.send(setter_method, true)
64 | end
65 | property_results[prop]["writable"] = true
66 | else
67 | property_results[prop]["writable"] = false
68 | end
69 | else
70 | property_results[prop] = {
71 | "exists": false
72 | }
73 | end
74 | rescue => e
75 | property_results[prop] = {
76 | "exists": true,
77 | "error": e.message
78 | }
79 | end
80 | end
81 |
82 | # End the operation
83 | model.commit_operation
84 |
85 | # Return the results
86 | {
87 | "all_methods": all_methods.sort,
88 | "property_results": property_results
89 | }.to_json
90 | """
91 |
92 | def main():
93 | """Main function to test component behavior methods."""
94 | # Connect to the MCP server
95 | client = Client("sketchup")
96 |
97 | # Check if the connection is successful
98 | if not client.is_connected:
99 | logger.error("Failed to connect to the SketchUp MCP server.")
100 | return
101 |
102 | logger.info("Connected to SketchUp MCP server.")
103 |
104 | # Run the behavior test
105 | logger.info("Testing component behavior methods...")
106 | response = client.eval_ruby(code=BEHAVIOR_TEST_CODE)
107 |
108 | # Parse the response
109 | try:
110 | result = json.loads(response)
111 | if result.get("success"):
112 | # Parse the JSON result
113 | behavior_data = json.loads(result.get("result"))
114 |
115 | # Display all available methods
116 | logger.info("Available methods on Behavior object:")
117 | for method in behavior_data["all_methods"]:
118 | logger.info(f" - {method}")
119 |
120 | # Display property test results
121 | logger.info("\nProperty test results:")
122 | for prop, prop_result in behavior_data["property_results"].items():
123 | if prop_result.get("exists"):
124 | readable = prop_result.get("readable", False)
125 | writable = prop_result.get("writable", False)
126 | error = prop_result.get("error")
127 |
128 | status = []
129 | if readable:
130 | status.append("readable")
131 | if writable:
132 | status.append("writable")
133 |
134 | if error:
135 | logger.info(f" - {prop}: EXISTS but ERROR: {error}")
136 | else:
137 | logger.info(f" - {prop}: EXISTS ({', '.join(status)})")
138 | else:
139 | logger.info(f" - {prop}: DOES NOT EXIST")
140 | else:
141 | logger.error(f"Error: {result.get('error')}")
142 | except json.JSONDecodeError as e:
143 | logger.error(f"Failed to parse response: {e}")
144 |
145 | logger.info("Testing completed.")
146 |
147 | if __name__ == "__main__":
148 | main()
```
--------------------------------------------------------------------------------
/plan.md:
--------------------------------------------------------------------------------
```markdown
1 | Comprehensive Woodworking API Plan
2 | Now, let's outline a comprehensive plan for a fully-featured woodworking API based on the SketchUp Ruby API. This will serve as a roadmap for future development.
3 | 1. Core Geometry Operations
4 | 1.1 Advanced Shape Creation
5 | Implement:
6 | Rectangular Prism: Already implemented as "cube"
7 | Cylinder: Already implemented
8 | Cone: Already implemented
9 | Sphere: Already implemented
10 | Torus/Donut: For creating rings and circular moldings
11 | Wedge: For angled cuts and joinery
12 | Pyramid: For decorative elements
13 | Custom Polygon Extrusion: For arbitrary base shapes
14 | 1.2 Boolean Operations
15 | Implement:
16 | Union: Combine multiple shapes
17 | Difference: Cut one shape from another (crucial for joinery)
18 | Intersection: Keep only the overlapping portion of shapes
19 | Split: Divide a shape along a plane
20 | 1.3 Modification Operations
21 | Implement:
22 | Chamfer: Create beveled edges
23 | Fillet: Create rounded edges
24 | Shell: Hollow out a solid with a specified wall thickness
25 | Offset: Create parallel faces at a specified distance
26 | Taper: Create a gradual narrowing
27 | Twist: Rotate progressively along an axis
28 | 2. Woodworking-Specific Features
29 | 2.1 Joinery
30 | Implement:
31 | Mortise and Tenon: Create matching mortise and tenon joints
32 | Dovetail: Create interlocking dovetail joints
33 | Finger Joint: Create interlocking box joints
34 | Lap Joint: Create overlapping joints
35 | Miter Joint: Create angled joints
36 | Dowel Joint: Create holes for dowels
37 | Pocket Hole: Create angled holes for pocket screws
38 | 2.2 Wood-Specific Operations
39 | Implement:
40 | Grain Direction: Specify and visualize wood grain
41 | Wood Species: Library of common wood species with appropriate textures and colors
42 | Board Dimensioning: Convert between nominal and actual lumber dimensions
43 | Plywood Sheet Optimization: Calculate optimal cutting patterns
44 | 2.3 Hardware
45 | Implement:
46 | Screws: Add various types of screws
47 | Nails: Add various types of nails
48 | Hinges: Add hinges with proper movement constraints
49 | Drawer Slides: Add drawer slides with proper movement
50 | Handles/Knobs: Add decorative hardware
51 | 3. Advanced Geometry Manipulation
52 | 3.1 Curves and Surfaces
53 | Implement:
54 | Bezier Curves: Create smooth curves
55 | Splines: Create complex curves through multiple points
56 | Loft: Create a surface between multiple profiles
57 | Sweep: Create a surface by moving a profile along a path
58 | Revolve: Create a surface by rotating a profile around an axis
59 | 3.2 Pattern Operations
60 | Implement:
61 | Linear Pattern: Create multiple copies along a line
62 | Circular Pattern: Create multiple copies around a center
63 | Mirror: Create a mirrored copy
64 | Symmetry: Enforce symmetry constraints
65 | 4. Material and Appearance
66 | 4.1 Materials
67 | Implement:
68 | Basic Colors: Already implemented
69 | Wood Textures: Add realistic wood grain textures
70 | Finish Types: Stain, paint, varnish, etc.
71 | Material Properties: Reflectivity, transparency, etc.
72 | 4.2 Rendering
73 | Implement:
74 | Realistic Rendering: High-quality visualization
75 | Exploded Views: Show assembly steps
76 | Section Views: Show internal details
77 | 5. Measurement and Analysis
78 | 5.1 Dimensioning
79 | Implement:
80 | Linear Dimensions: Measure distances
81 | Angular Dimensions: Measure angles
82 | Radius/Diameter Dimensions: Measure curves
83 | Automatic Dimensioning: Add dimensions to all features
84 | 5.2 Analysis
85 | Implement:
86 | Volume Calculation: Calculate wood volume
87 | Cost Estimation: Calculate material costs
88 | Weight Calculation: Estimate weight based on wood species
89 | Structural Analysis: Basic strength calculations
90 | 6. Project Management
91 | 6.1 Organization
92 | Implement:
93 | Component Hierarchy: Organize parts into assemblies
94 | Layers: Organize by function or stage
95 | Tags: Add metadata to components
96 | 6.2 Documentation
97 | Implement:
98 | Cut Lists: Generate cutting diagrams
99 | Assembly Instructions: Generate step-by-step guides
100 | Bill of Materials: List all required parts and hardware
101 | Implementation Plan
102 | Phase 1: Core Functionality (Current)
103 | ✅ Basic shapes (cube, cylinder, sphere, cone)
104 | ✅ Basic transformations (move, rotate, scale)
105 | ✅ Basic materials
106 | ✅ Export functionality
107 | Phase 2: Advanced Geometry (Next)
108 | Boolean operations (union, difference, intersection)
109 | Additional shapes (torus, wedge, pyramid)
110 | Chamfer and fillet operations
111 | Curve creation and manipulation
112 | Phase 3: Woodworking Specifics
113 | Joinery tools (mortise and tenon, dovetail, etc.)
114 | Wood species and grain direction
115 | Hardware components
116 | Phase 4: Project Management
117 | Component organization
118 | Dimensioning and measurement
119 | Cut lists and bill of materials
120 | Phase 5: Advanced Visualization
121 | Realistic materials and textures
122 | Enhanced rendering
123 | Animation and assembly visualization
```
--------------------------------------------------------------------------------
/examples/arts_and_crafts_cabinet.py:
--------------------------------------------------------------------------------
```python
1 | #!/usr/bin/env python3
2 | """
3 | Arts and Crafts Cabinet Example
4 |
5 | This example demonstrates how to use the eval_ruby feature to create
6 | a complex arts and crafts style cabinet in SketchUp using Ruby code.
7 | """
8 |
9 | import json
10 | import logging
11 | from mcp.client import Client
12 |
13 | # Configure logging
14 | logging.basicConfig(level=logging.INFO,
15 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
16 | logger = logging.getLogger("ArtsAndCraftsCabinetExample")
17 |
18 | # Ruby code to create an arts and crafts cabinet
19 | CABINET_RUBY_CODE = """
20 | # Arts and Crafts Cabinet with Working Doors
21 | # This script creates a stylish arts and crafts style cabinet with working doors
22 | # that can be opened and closed using SketchUp's component functionality
23 |
24 | def create_arts_and_crafts_cabinet
25 | # Get the active model and start an operation for undo purposes
26 | model = Sketchup.active_model
27 | model.start_operation("Create Arts and Crafts Cabinet", true)
28 |
29 | # Define cabinet dimensions (in inches)
30 | width = 36
31 | depth = 18
32 | height = 72
33 | thickness = 0.75
34 |
35 | # Create a new component definition for the cabinet
36 | cabinet_def = model.definitions.add("Arts and Crafts Cabinet")
37 | entities = cabinet_def.entities
38 |
39 | # Create the main cabinet box
40 | create_cabinet_box(entities, width, depth, height, thickness)
41 |
42 | # Add shelves
43 | shelf_positions = [height/3, 2*height/3]
44 | create_shelves(entities, width, depth, thickness, shelf_positions)
45 |
46 | # Create doors (as nested components that can swing open)
47 | create_doors(entities, width, depth, height, thickness)
48 |
49 | # Add decorative elements typical of arts and crafts style
50 | add_decorative_elements(entities, width, depth, height, thickness)
51 |
52 | # Place the component in the model
53 | point = Geom::Point3d.new(0, 0, 0)
54 | transform = Geom::Transformation.new(point)
55 | instance = model.active_entities.add_instance(cabinet_def, transform)
56 |
57 | # End the operation
58 | model.commit_operation
59 |
60 | # Return the component instance ID
61 | return instance.entityID
62 | end
63 |
64 | def create_cabinet_box(entities, width, depth, height, thickness)
65 | # Bottom
66 | bottom_points = [
67 | [0, 0, 0],
68 | [width, 0, 0],
69 | [width, depth, 0],
70 | [0, depth, 0]
71 | ]
72 | bottom_face = entities.add_face(bottom_points)
73 | bottom_face.pushpull(-thickness)
74 |
75 | # Back
76 | back_points = [
77 | [0, depth, 0],
78 | [width, depth, 0],
79 | [width, depth, height],
80 | [0, depth, height]
81 | ]
82 | back_face = entities.add_face(back_points)
83 | back_face.pushpull(-thickness)
84 |
85 | # Left side
86 | left_points = [
87 | [0, 0, 0],
88 | [0, depth, 0],
89 | [0, depth, height],
90 | [0, 0, height]
91 | ]
92 | left_face = entities.add_face(left_points)
93 | left_face.pushpull(-thickness)
94 |
95 | # Right side
96 | right_points = [
97 | [width, 0, 0],
98 | [width, depth, 0],
99 | [width, depth, height],
100 | [width, 0, height]
101 | ]
102 | right_face = entities.add_face(right_points)
103 | right_face.pushpull(thickness)
104 |
105 | # Top
106 | top_points = [
107 | [0, 0, height],
108 | [width, 0, height],
109 | [width, depth, height],
110 | [0, depth, height]
111 | ]
112 | top_face = entities.add_face(top_points)
113 | top_face.pushpull(thickness)
114 | end
115 |
116 | def create_shelves(entities, width, depth, thickness, positions)
117 | positions.each do |z_pos|
118 | shelf_points = [
119 | [thickness, thickness, z_pos],
120 | [width - thickness, thickness, z_pos],
121 | [width - thickness, depth - thickness, z_pos],
122 | [thickness, depth - thickness, z_pos]
123 | ]
124 | shelf_face = entities.add_face(shelf_points)
125 | shelf_face.pushpull(-thickness)
126 | end
127 | end
128 |
129 | def create_doors(entities, width, depth, height, thickness)
130 | # Define door dimensions
131 | door_width = (width - thickness) / 2
132 | door_height = height - 2 * thickness
133 |
134 | # Create left door as a component (so it can be animated)
135 | left_door_def = Sketchup.active_model.definitions.add("Left Cabinet Door")
136 |
137 | # Create the door geometry in the component
138 | door_entities = left_door_def.entities
139 | left_door_points = [
140 | [0, 0, 0],
141 | [door_width, 0, 0],
142 | [door_width, thickness, 0],
143 | [0, thickness, 0]
144 | ]
145 | left_door_face = door_entities.add_face(left_door_points)
146 | left_door_face.pushpull(door_height)
147 |
148 | # Add door details
149 | add_door_details(door_entities, door_width, thickness, door_height)
150 |
151 | # Place the left door component
152 | left_hinge_point = Geom::Point3d.new(thickness, thickness, thickness)
153 | left_transform = Geom::Transformation.new(left_hinge_point)
154 | left_door_instance = entities.add_instance(left_door_def, left_transform)
155 |
156 | # Set the hinge axis for animation - using correct method for SketchUp 2025
157 | # The component behavior is already set by default
158 | left_door_instance.definition.behavior.snapto = 0 # No automatic snapping
159 |
160 | # Create right door (similar process)
161 | right_door_def = Sketchup.active_model.definitions.add("Right Cabinet Door")
162 |
163 | door_entities = right_door_def.entities
164 | right_door_points = [
165 | [0, 0, 0],
166 | [door_width, 0, 0],
167 | [door_width, thickness, 0],
168 | [0, thickness, 0]
169 | ]
170 | right_door_face = door_entities.add_face(right_door_points)
171 | right_door_face.pushpull(door_height)
172 |
173 | # Add door details
174 | add_door_details(door_entities, door_width, thickness, door_height)
175 |
176 | # Place the right door component
177 | right_hinge_point = Geom::Point3d.new(width - thickness, thickness, thickness)
178 | right_transform = Geom::Transformation.new(right_hinge_point)
179 | right_door_instance = entities.add_instance(right_door_def, right_transform)
180 |
181 | # Set the hinge axis for animation (flipped compared to left door)
182 | # The component behavior is already set by default
183 | right_door_instance.definition.behavior.snapto = 0
184 | end
185 |
186 | def add_door_details(entities, width, thickness, height)
187 | # Add a decorative panel that's inset
188 | inset = thickness / 2
189 | panel_points = [
190 | [inset, -thickness/2, inset],
191 | [width - inset, -thickness/2, inset],
192 | [width - inset, -thickness/2, height - inset],
193 | [inset, -thickness/2, height - inset]
194 | ]
195 | panel = entities.add_face(panel_points)
196 | panel.pushpull(-thickness/4)
197 |
198 | # Add a small handle
199 | handle_position = [width - 2 * inset, -thickness * 1.5, height / 2]
200 | handle_size = height / 20
201 |
202 | # Create a cylinder for the handle
203 | handle_circle = entities.add_circle(handle_position, [0, 1, 0], handle_size, 12)
204 | handle_face = entities.add_face(handle_circle)
205 | handle_face.pushpull(-thickness)
206 | end
207 |
208 | def add_decorative_elements(entities, width, depth, height, thickness)
209 | # Add characteristic arts and crafts style base
210 | base_height = 4
211 |
212 | # Create a slightly wider base
213 | base_extension = 1
214 | base_points = [
215 | [-base_extension, -base_extension, 0],
216 | [width + base_extension, -base_extension, 0],
217 | [width + base_extension, depth + base_extension, 0],
218 | [-base_extension, depth + base_extension, 0]
219 | ]
220 | base_face = entities.add_face(base_points)
221 | base_face.pushpull(-base_height)
222 |
223 | # Add corbels in the arts and crafts style
224 | add_corbels(entities, width, depth, height, thickness)
225 |
226 | # Add crown detail at the top
227 | add_crown(entities, width, depth, height, thickness)
228 | end
229 |
230 | def add_corbels(entities, width, depth, height, thickness)
231 | # Add decorative corbels under the top
232 | corbel_height = 3
233 | corbel_depth = 2
234 |
235 | # Left front corbel
236 | left_corbel_points = [
237 | [thickness * 2, thickness, height - thickness - corbel_height],
238 | [thickness * 2 + corbel_depth, thickness, height - thickness - corbel_height],
239 | [thickness * 2 + corbel_depth, thickness, height - thickness],
240 | [thickness * 2, thickness, height - thickness]
241 | ]
242 | left_corbel = entities.add_face(left_corbel_points)
243 | left_corbel.pushpull(-thickness)
244 |
245 | # Right front corbel
246 | right_corbel_points = [
247 | [width - thickness * 2 - corbel_depth, thickness, height - thickness - corbel_height],
248 | [width - thickness * 2, thickness, height - thickness - corbel_height],
249 | [width - thickness * 2, thickness, height - thickness],
250 | [width - thickness * 2 - corbel_depth, thickness, height - thickness]
251 | ]
252 | right_corbel = entities.add_face(right_corbel_points)
253 | right_corbel.pushpull(-thickness)
254 | end
255 |
256 | def add_crown(entities, width, depth, height, thickness)
257 | # Add a simple crown molding at the top
258 | crown_height = 2
259 | crown_extension = 1.5
260 |
261 | crown_points = [
262 | [-crown_extension, -crown_extension, height + thickness],
263 | [width + crown_extension, -crown_extension, height + thickness],
264 | [width + crown_extension, depth + crown_extension, height + thickness],
265 | [-crown_extension, depth + crown_extension, height + thickness]
266 | ]
267 | crown_face = entities.add_face(crown_points)
268 | crown_face.pushpull(crown_height)
269 |
270 | # Add a slight taper to the crown
271 | taper_points = [
272 | [-crown_extension/2, -crown_extension/2, height + thickness + crown_height],
273 | [width + crown_extension/2, -crown_extension/2, height + thickness + crown_height],
274 | [width + crown_extension/2, depth + crown_extension/2, height + thickness + crown_height],
275 | [-crown_extension/2, depth + crown_extension/2, height + thickness + crown_height]
276 | ]
277 | taper_face = entities.add_face(taper_points)
278 | taper_face.pushpull(crown_height/2)
279 | end
280 |
281 | # Execute the function to create the cabinet
282 | create_arts_and_crafts_cabinet
283 | """
284 |
285 | def main():
286 | """Main function to create the arts and crafts cabinet in SketchUp."""
287 | # Connect to the MCP server
288 | client = Client("sketchup")
289 |
290 | # Check if the connection is successful
291 | if not client.is_connected:
292 | logger.error("Failed to connect to the SketchUp MCP server.")
293 | return
294 |
295 | logger.info("Connected to SketchUp MCP server.")
296 |
297 | # Evaluate the Ruby code to create the cabinet
298 | logger.info("Creating arts and crafts cabinet...")
299 | response = client.eval_ruby(code=CABINET_RUBY_CODE)
300 |
301 | # Parse the response
302 | try:
303 | result = json.loads(response)
304 | if result.get("success"):
305 | logger.info(f"Cabinet created successfully! Result: {result.get('result')}")
306 | else:
307 | logger.error(f"Failed to create cabinet: {result.get('error')}")
308 | except json.JSONDecodeError:
309 | logger.error(f"Failed to parse response: {response}")
310 |
311 | logger.info("Example completed.")
312 |
313 | if __name__ == "__main__":
314 | main()
```
--------------------------------------------------------------------------------
/src/sketchup_mcp/server.py:
--------------------------------------------------------------------------------
```python
1 | from mcp.server.fastmcp import FastMCP, Context
2 | import socket
3 | import json
4 | import asyncio
5 | import logging
6 | from dataclasses import dataclass
7 | from contextlib import asynccontextmanager
8 | from typing import AsyncIterator, Dict, Any, List
9 |
10 | # Configure logging
11 | logging.basicConfig(level=logging.INFO,
12 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
13 | logger = logging.getLogger("SketchupMCPServer")
14 |
15 | # Define version directly to avoid pkg_resources dependency
16 | __version__ = "0.1.17"
17 | logger.info(f"SketchupMCP Server version {__version__} starting up")
18 |
19 | @dataclass
20 | class SketchupConnection:
21 | host: str
22 | port: int
23 | sock: socket.socket = None
24 |
25 | def connect(self) -> bool:
26 | """Connect to the Sketchup extension socket server"""
27 | if self.sock:
28 | try:
29 | # Test if connection is still alive
30 | self.sock.settimeout(0.1)
31 | self.sock.send(b'')
32 | return True
33 | except (socket.error, BrokenPipeError, ConnectionResetError):
34 | # Connection is dead, close it and reconnect
35 | logger.info("Connection test failed, reconnecting...")
36 | self.disconnect()
37 |
38 | try:
39 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
40 | self.sock.connect((self.host, self.port))
41 | logger.info(f"Connected to Sketchup at {self.host}:{self.port}")
42 | return True
43 | except Exception as e:
44 | logger.error(f"Failed to connect to Sketchup: {str(e)}")
45 | self.sock = None
46 | return False
47 |
48 | def disconnect(self):
49 | """Disconnect from the Sketchup extension"""
50 | if self.sock:
51 | try:
52 | self.sock.close()
53 | except Exception as e:
54 | logger.error(f"Error disconnecting from Sketchup: {str(e)}")
55 | finally:
56 | self.sock = None
57 |
58 | def receive_full_response(self, sock, buffer_size=8192):
59 | """Receive the complete response, potentially in multiple chunks"""
60 | chunks = []
61 | sock.settimeout(15.0)
62 |
63 | try:
64 | while True:
65 | try:
66 | chunk = sock.recv(buffer_size)
67 | if not chunk:
68 | if not chunks:
69 | raise Exception("Connection closed before receiving any data")
70 | break
71 |
72 | chunks.append(chunk)
73 |
74 | try:
75 | data = b''.join(chunks)
76 | json.loads(data.decode('utf-8'))
77 | logger.info(f"Received complete response ({len(data)} bytes)")
78 | return data
79 | except json.JSONDecodeError:
80 | continue
81 | except socket.timeout:
82 | logger.warning("Socket timeout during chunked receive")
83 | break
84 | except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
85 | logger.error(f"Socket connection error during receive: {str(e)}")
86 | raise
87 | except socket.timeout:
88 | logger.warning("Socket timeout during chunked receive")
89 | except Exception as e:
90 | logger.error(f"Error during receive: {str(e)}")
91 | raise
92 |
93 | if chunks:
94 | data = b''.join(chunks)
95 | logger.info(f"Returning data after receive completion ({len(data)} bytes)")
96 | try:
97 | json.loads(data.decode('utf-8'))
98 | return data
99 | except json.JSONDecodeError:
100 | raise Exception("Incomplete JSON response received")
101 | else:
102 | raise Exception("No data received")
103 |
104 | def send_command(self, method: str, params: Dict[str, Any] = None, request_id: Any = None) -> Dict[str, Any]:
105 | """Send a JSON-RPC request to Sketchup and return the response"""
106 | # Try to connect if not connected
107 | if not self.connect():
108 | raise ConnectionError("Not connected to Sketchup")
109 |
110 | # Ensure we're sending a proper JSON-RPC request
111 | if method == "tools/call" and params and "name" in params and "arguments" in params:
112 | # This is already in the correct format
113 | request = {
114 | "jsonrpc": "2.0",
115 | "method": method,
116 | "params": params,
117 | "id": request_id
118 | }
119 | else:
120 | # This is a direct command - convert to JSON-RPC
121 | command_name = method
122 | command_params = params or {}
123 |
124 | # Log the conversion
125 | logger.info(f"Converting direct command '{command_name}' to JSON-RPC format")
126 |
127 | request = {
128 | "jsonrpc": "2.0",
129 | "method": "tools/call",
130 | "params": {
131 | "name": command_name,
132 | "arguments": command_params
133 | },
134 | "id": request_id
135 | }
136 |
137 | # Maximum number of retries
138 | max_retries = 2
139 | retry_count = 0
140 |
141 | while retry_count <= max_retries:
142 | try:
143 | logger.info(f"Sending JSON-RPC request: {request}")
144 |
145 | # Log the exact bytes being sent
146 | request_bytes = json.dumps(request).encode('utf-8') + b'\n'
147 | logger.info(f"Raw bytes being sent: {request_bytes}")
148 |
149 | self.sock.sendall(request_bytes)
150 | logger.info(f"Request sent, waiting for response...")
151 |
152 | self.sock.settimeout(15.0)
153 |
154 | response_data = self.receive_full_response(self.sock)
155 | logger.info(f"Received {len(response_data)} bytes of data")
156 |
157 | response = json.loads(response_data.decode('utf-8'))
158 | logger.info(f"Response parsed: {response}")
159 |
160 | if "error" in response:
161 | logger.error(f"Sketchup error: {response['error']}")
162 | raise Exception(response["error"].get("message", "Unknown error from Sketchup"))
163 |
164 | return response.get("result", {})
165 |
166 | except (socket.timeout, ConnectionError, BrokenPipeError, ConnectionResetError) as e:
167 | logger.warning(f"Connection error (attempt {retry_count+1}/{max_retries+1}): {str(e)}")
168 | retry_count += 1
169 |
170 | if retry_count <= max_retries:
171 | logger.info(f"Retrying connection...")
172 | self.disconnect()
173 | if not self.connect():
174 | logger.error("Failed to reconnect")
175 | break
176 | else:
177 | logger.error(f"Max retries reached, giving up")
178 | self.sock = None
179 | raise Exception(f"Connection to Sketchup lost after {max_retries+1} attempts: {str(e)}")
180 |
181 | except json.JSONDecodeError as e:
182 | logger.error(f"Invalid JSON response from Sketchup: {str(e)}")
183 | if 'response_data' in locals() and response_data:
184 | logger.error(f"Raw response (first 200 bytes): {response_data[:200]}")
185 | raise Exception(f"Invalid response from Sketchup: {str(e)}")
186 |
187 | except Exception as e:
188 | logger.error(f"Error communicating with Sketchup: {str(e)}")
189 | self.sock = None
190 | raise Exception(f"Communication error with Sketchup: {str(e)}")
191 |
192 | # Global connection management
193 | _sketchup_connection = None
194 |
195 | def get_sketchup_connection():
196 | """Get or create a persistent Sketchup connection"""
197 | global _sketchup_connection
198 |
199 | if _sketchup_connection is not None:
200 | try:
201 | # Test connection with a ping command
202 | ping_request = {
203 | "jsonrpc": "2.0",
204 | "method": "ping",
205 | "params": {},
206 | "id": 0
207 | }
208 | _sketchup_connection.sock.sendall(json.dumps(ping_request).encode('utf-8') + b'\n')
209 | return _sketchup_connection
210 | except Exception as e:
211 | logger.warning(f"Existing connection is no longer valid: {str(e)}")
212 | try:
213 | _sketchup_connection.disconnect()
214 | except:
215 | pass
216 | _sketchup_connection = None
217 |
218 | if _sketchup_connection is None:
219 | _sketchup_connection = SketchupConnection(host="localhost", port=9876)
220 | if not _sketchup_connection.connect():
221 | logger.error("Failed to connect to Sketchup")
222 | _sketchup_connection = None
223 | raise Exception("Could not connect to Sketchup. Make sure the Sketchup extension is running.")
224 | logger.info("Created new persistent connection to Sketchup")
225 |
226 | return _sketchup_connection
227 |
228 | @asynccontextmanager
229 | async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
230 | """Manage server startup and shutdown lifecycle"""
231 | try:
232 | logger.info("SketchupMCP server starting up")
233 | try:
234 | sketchup = get_sketchup_connection()
235 | logger.info("Successfully connected to Sketchup on startup")
236 | except Exception as e:
237 | logger.warning(f"Could not connect to Sketchup on startup: {str(e)}")
238 | logger.warning("Make sure the Sketchup extension is running")
239 | yield {}
240 | finally:
241 | global _sketchup_connection
242 | if _sketchup_connection:
243 | logger.info("Disconnecting from Sketchup")
244 | _sketchup_connection.disconnect()
245 | _sketchup_connection = None
246 | logger.info("SketchupMCP server shut down")
247 |
248 | # Create MCP server with lifespan support
249 | mcp = FastMCP(
250 | "SketchupMCP",
251 | description="Sketchup integration through the Model Context Protocol",
252 | lifespan=server_lifespan
253 | )
254 |
255 | # Tool endpoints
256 | @mcp.tool()
257 | def create_component(
258 | ctx: Context,
259 | type: str = "cube",
260 | position: List[float] = None,
261 | dimensions: List[float] = None
262 | ) -> str:
263 | """Create a new component in Sketchup"""
264 | try:
265 | logger.info(f"create_component called with type={type}, position={position}, dimensions={dimensions}, request_id={ctx.request_id}")
266 |
267 | sketchup = get_sketchup_connection()
268 |
269 | params = {
270 | "name": "create_component",
271 | "arguments": {
272 | "type": type,
273 | "position": position or [0,0,0],
274 | "dimensions": dimensions or [1,1,1]
275 | }
276 | }
277 |
278 | logger.info(f"Calling send_command with method='tools/call', params={params}, request_id={ctx.request_id}")
279 |
280 | result = sketchup.send_command(
281 | method="tools/call",
282 | params=params,
283 | request_id=ctx.request_id
284 | )
285 |
286 | logger.info(f"create_component result: {result}")
287 | return json.dumps(result)
288 | except Exception as e:
289 | logger.error(f"Error in create_component: {str(e)}")
290 | return f"Error creating component: {str(e)}"
291 |
292 | @mcp.tool()
293 | def delete_component(
294 | ctx: Context,
295 | id: str
296 | ) -> str:
297 | """Delete a component by ID"""
298 | try:
299 | sketchup = get_sketchup_connection()
300 | result = sketchup.send_command(
301 | method="tools/call",
302 | params={
303 | "name": "delete_component",
304 | "arguments": {"id": id}
305 | },
306 | request_id=ctx.request_id
307 | )
308 | return json.dumps(result)
309 | except Exception as e:
310 | return f"Error deleting component: {str(e)}"
311 |
312 | @mcp.tool()
313 | def transform_component(
314 | ctx: Context,
315 | id: str,
316 | position: List[float] = None,
317 | rotation: List[float] = None,
318 | scale: List[float] = None
319 | ) -> str:
320 | """Transform a component's position, rotation, or scale"""
321 | try:
322 | sketchup = get_sketchup_connection()
323 | arguments = {"id": id}
324 | if position is not None:
325 | arguments["position"] = position
326 | if rotation is not None:
327 | arguments["rotation"] = rotation
328 | if scale is not None:
329 | arguments["scale"] = scale
330 |
331 | result = sketchup.send_command(
332 | method="tools/call",
333 | params={
334 | "name": "transform_component",
335 | "arguments": arguments
336 | },
337 | request_id=ctx.request_id
338 | )
339 | return json.dumps(result)
340 | except Exception as e:
341 | return f"Error transforming component: {str(e)}"
342 |
343 | @mcp.tool()
344 | def get_selection(ctx: Context) -> str:
345 | """Get currently selected components"""
346 | try:
347 | sketchup = get_sketchup_connection()
348 | result = sketchup.send_command(
349 | method="tools/call",
350 | params={
351 | "name": "get_selection",
352 | "arguments": {}
353 | },
354 | request_id=ctx.request_id
355 | )
356 | return json.dumps(result)
357 | except Exception as e:
358 | return f"Error getting selection: {str(e)}"
359 |
360 | @mcp.tool()
361 | def set_material(
362 | ctx: Context,
363 | id: str,
364 | material: str
365 | ) -> str:
366 | """Set material for a component"""
367 | try:
368 | sketchup = get_sketchup_connection()
369 | result = sketchup.send_command(
370 | method="tools/call",
371 | params={
372 | "name": "set_material",
373 | "arguments": {
374 | "id": id,
375 | "material": material
376 | }
377 | },
378 | request_id=ctx.request_id
379 | )
380 | return json.dumps(result)
381 | except Exception as e:
382 | return f"Error setting material: {str(e)}"
383 |
384 | @mcp.tool()
385 | def export_scene(
386 | ctx: Context,
387 | format: str = "skp"
388 | ) -> str:
389 | """Export the current scene"""
390 | try:
391 | sketchup = get_sketchup_connection()
392 | result = sketchup.send_command(
393 | method="tools/call",
394 | params={
395 | "name": "export",
396 | "arguments": {
397 | "format": format
398 | }
399 | },
400 | request_id=ctx.request_id
401 | )
402 | return json.dumps(result)
403 | except Exception as e:
404 | return f"Error exporting scene: {str(e)}"
405 |
406 | @mcp.tool()
407 | def create_mortise_tenon(
408 | ctx: Context,
409 | mortise_id: str,
410 | tenon_id: str,
411 | width: float = 1.0,
412 | height: float = 1.0,
413 | depth: float = 1.0,
414 | offset_x: float = 0.0,
415 | offset_y: float = 0.0,
416 | offset_z: float = 0.0
417 | ) -> str:
418 | """Create a mortise and tenon joint between two components"""
419 | try:
420 | logger.info(f"create_mortise_tenon called with mortise_id={mortise_id}, tenon_id={tenon_id}, width={width}, height={height}, depth={depth}, offsets=({offset_x}, {offset_y}, {offset_z})")
421 |
422 | sketchup = get_sketchup_connection()
423 |
424 | result = sketchup.send_command(
425 | method="tools/call",
426 | params={
427 | "name": "create_mortise_tenon",
428 | "arguments": {
429 | "mortise_id": mortise_id,
430 | "tenon_id": tenon_id,
431 | "width": width,
432 | "height": height,
433 | "depth": depth,
434 | "offset_x": offset_x,
435 | "offset_y": offset_y,
436 | "offset_z": offset_z
437 | }
438 | },
439 | request_id=ctx.request_id
440 | )
441 |
442 | logger.info(f"create_mortise_tenon result: {result}")
443 | return json.dumps(result)
444 | except Exception as e:
445 | logger.error(f"Error in create_mortise_tenon: {str(e)}")
446 | return f"Error creating mortise and tenon joint: {str(e)}"
447 |
448 | @mcp.tool()
449 | def create_dovetail(
450 | ctx: Context,
451 | tail_id: str,
452 | pin_id: str,
453 | width: float = 1.0,
454 | height: float = 1.0,
455 | depth: float = 1.0,
456 | angle: float = 15.0,
457 | num_tails: int = 3,
458 | offset_x: float = 0.0,
459 | offset_y: float = 0.0,
460 | offset_z: float = 0.0
461 | ) -> str:
462 | """Create a dovetail joint between two components"""
463 | try:
464 | logger.info(f"create_dovetail called with tail_id={tail_id}, pin_id={pin_id}, width={width}, height={height}, depth={depth}, angle={angle}, num_tails={num_tails}")
465 |
466 | sketchup = get_sketchup_connection()
467 |
468 | result = sketchup.send_command(
469 | method="tools/call",
470 | params={
471 | "name": "create_dovetail",
472 | "arguments": {
473 | "tail_id": tail_id,
474 | "pin_id": pin_id,
475 | "width": width,
476 | "height": height,
477 | "depth": depth,
478 | "angle": angle,
479 | "num_tails": num_tails,
480 | "offset_x": offset_x,
481 | "offset_y": offset_y,
482 | "offset_z": offset_z
483 | }
484 | },
485 | request_id=ctx.request_id
486 | )
487 |
488 | logger.info(f"create_dovetail result: {result}")
489 | return json.dumps(result)
490 | except Exception as e:
491 | logger.error(f"Error in create_dovetail: {str(e)}")
492 | return f"Error creating dovetail joint: {str(e)}"
493 |
494 | @mcp.tool()
495 | def create_finger_joint(
496 | ctx: Context,
497 | board1_id: str,
498 | board2_id: str,
499 | width: float = 1.0,
500 | height: float = 1.0,
501 | depth: float = 1.0,
502 | num_fingers: int = 5,
503 | offset_x: float = 0.0,
504 | offset_y: float = 0.0,
505 | offset_z: float = 0.0
506 | ) -> str:
507 | """Create a finger joint (box joint) between two components"""
508 | try:
509 | logger.info(f"create_finger_joint called with board1_id={board1_id}, board2_id={board2_id}, width={width}, height={height}, depth={depth}, num_fingers={num_fingers}")
510 |
511 | sketchup = get_sketchup_connection()
512 |
513 | result = sketchup.send_command(
514 | method="tools/call",
515 | params={
516 | "name": "create_finger_joint",
517 | "arguments": {
518 | "board1_id": board1_id,
519 | "board2_id": board2_id,
520 | "width": width,
521 | "height": height,
522 | "depth": depth,
523 | "num_fingers": num_fingers,
524 | "offset_x": offset_x,
525 | "offset_y": offset_y,
526 | "offset_z": offset_z
527 | }
528 | },
529 | request_id=ctx.request_id
530 | )
531 |
532 | logger.info(f"create_finger_joint result: {result}")
533 | return json.dumps(result)
534 | except Exception as e:
535 | logger.error(f"Error in create_finger_joint: {str(e)}")
536 | return f"Error creating finger joint: {str(e)}"
537 |
538 | @mcp.tool()
539 | def eval_ruby(
540 | ctx: Context,
541 | code: str
542 | ) -> str:
543 | """Evaluate arbitrary Ruby code in Sketchup"""
544 | try:
545 | logger.info(f"eval_ruby called with code length: {len(code)}")
546 |
547 | sketchup = get_sketchup_connection()
548 |
549 | result = sketchup.send_command(
550 | method="tools/call",
551 | params={
552 | "name": "eval_ruby",
553 | "arguments": {
554 | "code": code
555 | }
556 | },
557 | request_id=ctx.request_id
558 | )
559 |
560 | logger.info(f"eval_ruby result: {result}")
561 |
562 | # Format the response to include the result
563 | response = {
564 | "success": True,
565 | "result": result.get("content", [{"text": "Success"}])[0].get("text", "Success") if isinstance(result.get("content"), list) and len(result.get("content", [])) > 0 else "Success"
566 | }
567 |
568 | return json.dumps(response)
569 | except Exception as e:
570 | logger.error(f"Error in eval_ruby: {str(e)}")
571 | return json.dumps({
572 | "success": False,
573 | "error": str(e)
574 | })
575 |
576 | def main():
577 | mcp.run()
578 |
579 | if __name__ == "__main__":
580 | main()
```
--------------------------------------------------------------------------------
/su_mcp/su_mcp/main.rb:
--------------------------------------------------------------------------------
```ruby
1 | require 'sketchup'
2 | require 'json'
3 | require 'socket'
4 | require 'fileutils'
5 |
6 | puts "MCP Extension loading..."
7 | SKETCHUP_CONSOLE.show rescue nil
8 |
9 | module SU_MCP
10 | class Server
11 | def initialize
12 | @port = 9876
13 | @server = nil
14 | @running = false
15 | @timer_id = nil
16 |
17 | # Try multiple ways to show console
18 | begin
19 | SKETCHUP_CONSOLE.show
20 | rescue
21 | begin
22 | Sketchup.send_action("showRubyPanel:")
23 | rescue
24 | UI.start_timer(0) { SKETCHUP_CONSOLE.show }
25 | end
26 | end
27 | end
28 |
29 | def log(msg)
30 | begin
31 | SKETCHUP_CONSOLE.write("MCP: #{msg}\n")
32 | rescue
33 | puts "MCP: #{msg}"
34 | end
35 | STDOUT.flush
36 | end
37 |
38 | def start
39 | return if @running
40 |
41 | begin
42 | log "Starting server on localhost:#{@port}..."
43 |
44 | @server = TCPServer.new('127.0.0.1', @port)
45 | log "Server created on port #{@port}"
46 |
47 | @running = true
48 |
49 | @timer_id = UI.start_timer(0.1, true) {
50 | begin
51 | if @running
52 | # Check for connection
53 | ready = IO.select([@server], nil, nil, 0)
54 | if ready
55 | log "Connection waiting..."
56 | client = @server.accept_nonblock
57 | log "Client accepted"
58 |
59 | data = client.gets
60 | log "Raw data: #{data.inspect}"
61 |
62 | if data
63 | begin
64 | # Parse the raw JSON first to check format
65 | raw_request = JSON.parse(data)
66 | log "Raw parsed request: #{raw_request.inspect}"
67 |
68 | # Extract the original request ID if it exists in the raw data
69 | original_id = nil
70 | if data =~ /"id":\s*(\d+)/
71 | original_id = $1.to_i
72 | log "Found original request ID: #{original_id}"
73 | end
74 |
75 | # Use the raw request directly without transforming it
76 | # Just ensure the ID is preserved if it exists
77 | request = raw_request
78 | if !request["id"] && original_id
79 | request["id"] = original_id
80 | log "Added missing ID: #{original_id}"
81 | end
82 |
83 | log "Processed request: #{request.inspect}"
84 | response = handle_jsonrpc_request(request)
85 | response_json = response.to_json + "\n"
86 |
87 | log "Sending response: #{response_json.strip}"
88 | client.write(response_json)
89 | client.flush
90 | log "Response sent"
91 | rescue JSON::ParserError => e
92 | log "JSON parse error: #{e.message}"
93 | error_response = {
94 | jsonrpc: "2.0",
95 | error: { code: -32700, message: "Parse error" },
96 | id: original_id
97 | }.to_json + "\n"
98 | client.write(error_response)
99 | client.flush
100 | rescue StandardError => e
101 | log "Request error: #{e.message}"
102 | error_response = {
103 | jsonrpc: "2.0",
104 | error: { code: -32603, message: e.message },
105 | id: request ? request["id"] : original_id
106 | }.to_json + "\n"
107 | client.write(error_response)
108 | client.flush
109 | end
110 | end
111 |
112 | client.close
113 | log "Client closed"
114 | end
115 | end
116 | rescue IO::WaitReadable
117 | # Normal for accept_nonblock
118 | rescue StandardError => e
119 | log "Timer error: #{e.message}"
120 | log e.backtrace.join("\n")
121 | end
122 | }
123 |
124 | log "Server started and listening"
125 |
126 | rescue StandardError => e
127 | log "Error: #{e.message}"
128 | log e.backtrace.join("\n")
129 | stop
130 | end
131 | end
132 |
133 | def stop
134 | log "Stopping server..."
135 | @running = false
136 |
137 | if @timer_id
138 | UI.stop_timer(@timer_id)
139 | @timer_id = nil
140 | end
141 |
142 | @server.close if @server
143 | @server = nil
144 | log "Server stopped"
145 | end
146 |
147 | private
148 |
149 | def handle_jsonrpc_request(request)
150 | log "Handling JSONRPC request: #{request.inspect}"
151 |
152 | # Handle direct command format (for backward compatibility)
153 | if request["command"]
154 | tool_request = {
155 | "method" => "tools/call",
156 | "params" => {
157 | "name" => request["command"],
158 | "arguments" => request["parameters"]
159 | },
160 | "jsonrpc" => request["jsonrpc"] || "2.0",
161 | "id" => request["id"]
162 | }
163 | log "Converting to tool request: #{tool_request.inspect}"
164 | return handle_tool_call(tool_request)
165 | end
166 |
167 | # Handle jsonrpc format
168 | case request["method"]
169 | when "tools/call"
170 | handle_tool_call(request)
171 | when "resources/list"
172 | {
173 | jsonrpc: request["jsonrpc"] || "2.0",
174 | result: {
175 | resources: list_resources,
176 | success: true
177 | },
178 | id: request["id"]
179 | }
180 | when "prompts/list"
181 | {
182 | jsonrpc: request["jsonrpc"] || "2.0",
183 | result: {
184 | prompts: [],
185 | success: true
186 | },
187 | id: request["id"]
188 | }
189 | else
190 | {
191 | jsonrpc: request["jsonrpc"] || "2.0",
192 | error: {
193 | code: -32601,
194 | message: "Method not found",
195 | data: { success: false }
196 | },
197 | id: request["id"]
198 | }
199 | end
200 | end
201 |
202 | def list_resources
203 | model = Sketchup.active_model
204 | return [] unless model
205 |
206 | model.entities.map do |entity|
207 | {
208 | id: entity.entityID,
209 | type: entity.typename.downcase
210 | }
211 | end
212 | end
213 |
214 | def handle_tool_call(request)
215 | log "Handling tool call: #{request.inspect}"
216 | tool_name = request["params"]["name"]
217 | args = request["params"]["arguments"]
218 |
219 | begin
220 | result = case tool_name
221 | when "create_component"
222 | create_component(args)
223 | when "delete_component"
224 | delete_component(args)
225 | when "transform_component"
226 | transform_component(args)
227 | when "get_selection"
228 | get_selection
229 | when "export", "export_scene"
230 | export_scene(args)
231 | when "set_material"
232 | set_material(args)
233 | when "boolean_operation"
234 | boolean_operation(args)
235 | when "chamfer_edges"
236 | chamfer_edges(args)
237 | when "fillet_edges"
238 | fillet_edges(args)
239 | when "create_mortise_tenon"
240 | create_mortise_tenon(args)
241 | when "create_dovetail"
242 | create_dovetail(args)
243 | when "create_finger_joint"
244 | create_finger_joint(args)
245 | when "eval_ruby"
246 | eval_ruby(args)
247 | else
248 | raise "Unknown tool: #{tool_name}"
249 | end
250 |
251 | log "Tool call result: #{result.inspect}"
252 | if result[:success]
253 | response = {
254 | jsonrpc: request["jsonrpc"] || "2.0",
255 | result: {
256 | content: [{ type: "text", text: result[:result] || "Success" }],
257 | isError: false,
258 | success: true,
259 | resourceId: result[:id]
260 | },
261 | id: request["id"]
262 | }
263 | log "Sending success response: #{response.inspect}"
264 | response
265 | else
266 | response = {
267 | jsonrpc: request["jsonrpc"] || "2.0",
268 | error: {
269 | code: -32603,
270 | message: "Operation failed",
271 | data: { success: false }
272 | },
273 | id: request["id"]
274 | }
275 | log "Sending error response: #{response.inspect}"
276 | response
277 | end
278 | rescue StandardError => e
279 | log "Tool call error: #{e.message}"
280 | response = {
281 | jsonrpc: request["jsonrpc"] || "2.0",
282 | error: {
283 | code: -32603,
284 | message: e.message,
285 | data: { success: false }
286 | },
287 | id: request["id"]
288 | }
289 | log "Sending error response: #{response.inspect}"
290 | response
291 | end
292 | end
293 |
294 | def create_component(params)
295 | log "Creating component with params: #{params.inspect}"
296 | model = Sketchup.active_model
297 | log "Got active model: #{model.inspect}"
298 | entities = model.active_entities
299 | log "Got active entities: #{entities.inspect}"
300 |
301 | pos = params["position"] || [0,0,0]
302 | dims = params["dimensions"] || [1,1,1]
303 |
304 | case params["type"]
305 | when "cube"
306 | log "Creating cube at position #{pos.inspect} with dimensions #{dims.inspect}"
307 |
308 | begin
309 | group = entities.add_group
310 | log "Created group: #{group.inspect}"
311 |
312 | face = group.entities.add_face(
313 | [pos[0], pos[1], pos[2]],
314 | [pos[0] + dims[0], pos[1], pos[2]],
315 | [pos[0] + dims[0], pos[1] + dims[1], pos[2]],
316 | [pos[0], pos[1] + dims[1], pos[2]]
317 | )
318 | log "Created face: #{face.inspect}"
319 |
320 | face.pushpull(dims[2])
321 | log "Pushed/pulled face by #{dims[2]}"
322 |
323 | result = {
324 | id: group.entityID,
325 | success: true
326 | }
327 | log "Returning result: #{result.inspect}"
328 | result
329 | rescue StandardError => e
330 | log "Error in create_component: #{e.message}"
331 | log e.backtrace.join("\n")
332 | raise
333 | end
334 | when "cylinder"
335 | log "Creating cylinder at position #{pos.inspect} with dimensions #{dims.inspect}"
336 |
337 | begin
338 | # Create a group to contain the cylinder
339 | group = entities.add_group
340 |
341 | # Extract dimensions
342 | radius = dims[0] / 2.0
343 | height = dims[2]
344 |
345 | # Create a circle at the base
346 | center = [pos[0] + radius, pos[1] + radius, pos[2]]
347 |
348 | # Create points for a circle
349 | num_segments = 24 # Number of segments for the circle
350 | circle_points = []
351 |
352 | num_segments.times do |i|
353 | angle = Math::PI * 2 * i / num_segments
354 | x = center[0] + radius * Math.cos(angle)
355 | y = center[1] + radius * Math.sin(angle)
356 | z = center[2]
357 | circle_points << [x, y, z]
358 | end
359 |
360 | # Create the circular face
361 | face = group.entities.add_face(circle_points)
362 |
363 | # Extrude the face to create the cylinder
364 | face.pushpull(height)
365 |
366 | result = {
367 | id: group.entityID,
368 | success: true
369 | }
370 | log "Created cylinder, returning result: #{result.inspect}"
371 | result
372 | rescue StandardError => e
373 | log "Error creating cylinder: #{e.message}"
374 | log e.backtrace.join("\n")
375 | raise
376 | end
377 | when "sphere"
378 | log "Creating sphere at position #{pos.inspect} with dimensions #{dims.inspect}"
379 |
380 | begin
381 | # Create a group to contain the sphere
382 | group = entities.add_group
383 |
384 | # Extract dimensions
385 | radius = dims[0] / 2.0
386 | center = [pos[0] + radius, pos[1] + radius, pos[2] + radius]
387 |
388 | # Use SketchUp's built-in sphere method if available
389 | if Sketchup::Tools.respond_to?(:create_sphere)
390 | Sketchup::Tools.create_sphere(center, radius, 24, group.entities)
391 | else
392 | # Fallback implementation using polygons
393 | # Create a UV sphere with latitude and longitude segments
394 | segments = 16
395 |
396 | # Create points for the sphere
397 | points = []
398 | for lat_i in 0..segments
399 | lat = Math::PI * lat_i / segments
400 | for lon_i in 0..segments
401 | lon = 2 * Math::PI * lon_i / segments
402 | x = center[0] + radius * Math.sin(lat) * Math.cos(lon)
403 | y = center[1] + radius * Math.sin(lat) * Math.sin(lon)
404 | z = center[2] + radius * Math.cos(lat)
405 | points << [x, y, z]
406 | end
407 | end
408 |
409 | # Create faces for the sphere (simplified approach)
410 | for lat_i in 0...segments
411 | for lon_i in 0...segments
412 | i1 = lat_i * (segments + 1) + lon_i
413 | i2 = i1 + 1
414 | i3 = i1 + segments + 1
415 | i4 = i3 + 1
416 |
417 | # Create a quad face
418 | begin
419 | group.entities.add_face(points[i1], points[i2], points[i4], points[i3])
420 | rescue StandardError => e
421 | # Skip faces that can't be created (may happen at poles)
422 | log "Skipping face: #{e.message}"
423 | end
424 | end
425 | end
426 | end
427 |
428 | result = {
429 | id: group.entityID,
430 | success: true
431 | }
432 | log "Created sphere, returning result: #{result.inspect}"
433 | result
434 | rescue StandardError => e
435 | log "Error creating sphere: #{e.message}"
436 | log e.backtrace.join("\n")
437 | raise
438 | end
439 | when "cone"
440 | log "Creating cone at position #{pos.inspect} with dimensions #{dims.inspect}"
441 |
442 | begin
443 | # Create a group to contain the cone
444 | group = entities.add_group
445 |
446 | # Extract dimensions
447 | radius = dims[0] / 2.0
448 | height = dims[2]
449 |
450 | # Create a circle at the base
451 | center = [pos[0] + radius, pos[1] + radius, pos[2]]
452 | apex = [center[0], center[1], center[2] + height]
453 |
454 | # Create points for a circle
455 | num_segments = 24 # Number of segments for the circle
456 | circle_points = []
457 |
458 | num_segments.times do |i|
459 | angle = Math::PI * 2 * i / num_segments
460 | x = center[0] + radius * Math.cos(angle)
461 | y = center[1] + radius * Math.sin(angle)
462 | z = center[2]
463 | circle_points << [x, y, z]
464 | end
465 |
466 | # Create the circular face for the base
467 | base = group.entities.add_face(circle_points)
468 |
469 | # Create the cone sides
470 | (0...num_segments).each do |i|
471 | j = (i + 1) % num_segments
472 | # Create a triangular face from two adjacent points on the circle to the apex
473 | group.entities.add_face(circle_points[i], circle_points[j], apex)
474 | end
475 |
476 | result = {
477 | id: group.entityID,
478 | success: true
479 | }
480 | log "Created cone, returning result: #{result.inspect}"
481 | result
482 | rescue StandardError => e
483 | log "Error creating cone: #{e.message}"
484 | log e.backtrace.join("\n")
485 | raise
486 | end
487 | else
488 | raise "Unknown component type: #{params["type"]}"
489 | end
490 | end
491 |
492 | def delete_component(params)
493 | model = Sketchup.active_model
494 |
495 | # Handle ID format - strip quotes if present
496 | id_str = params["id"].to_s.gsub('"', '')
497 | log "Looking for entity with ID: #{id_str}"
498 |
499 | entity = model.find_entity_by_id(id_str.to_i)
500 |
501 | if entity
502 | log "Found entity: #{entity.inspect}"
503 | entity.erase!
504 | { success: true }
505 | else
506 | raise "Entity not found"
507 | end
508 | end
509 |
510 | def transform_component(params)
511 | model = Sketchup.active_model
512 |
513 | # Handle ID format - strip quotes if present
514 | id_str = params["id"].to_s.gsub('"', '')
515 | log "Looking for entity with ID: #{id_str}"
516 |
517 | entity = model.find_entity_by_id(id_str.to_i)
518 |
519 | if entity
520 | log "Found entity: #{entity.inspect}"
521 |
522 | # Handle position
523 | if params["position"]
524 | pos = params["position"]
525 | log "Transforming position to #{pos.inspect}"
526 |
527 | # Create a transformation to move the entity
528 | translation = Geom::Transformation.translation(Geom::Point3d.new(pos[0], pos[1], pos[2]))
529 | entity.transform!(translation)
530 | end
531 |
532 | # Handle rotation (in degrees)
533 | if params["rotation"]
534 | rot = params["rotation"]
535 | log "Rotating by #{rot.inspect} degrees"
536 |
537 | # Convert to radians
538 | x_rot = rot[0] * Math::PI / 180
539 | y_rot = rot[1] * Math::PI / 180
540 | z_rot = rot[2] * Math::PI / 180
541 |
542 | # Apply rotations
543 | if rot[0] != 0
544 | rotation = Geom::Transformation.rotation(entity.bounds.center, Geom::Vector3d.new(1, 0, 0), x_rot)
545 | entity.transform!(rotation)
546 | end
547 |
548 | if rot[1] != 0
549 | rotation = Geom::Transformation.rotation(entity.bounds.center, Geom::Vector3d.new(0, 1, 0), y_rot)
550 | entity.transform!(rotation)
551 | end
552 |
553 | if rot[2] != 0
554 | rotation = Geom::Transformation.rotation(entity.bounds.center, Geom::Vector3d.new(0, 0, 1), z_rot)
555 | entity.transform!(rotation)
556 | end
557 | end
558 |
559 | # Handle scale
560 | if params["scale"]
561 | scale = params["scale"]
562 | log "Scaling by #{scale.inspect}"
563 |
564 | # Create a transformation to scale the entity
565 | center = entity.bounds.center
566 | scaling = Geom::Transformation.scaling(center, scale[0], scale[1], scale[2])
567 | entity.transform!(scaling)
568 | end
569 |
570 | { success: true, id: entity.entityID }
571 | else
572 | raise "Entity not found"
573 | end
574 | end
575 |
576 | def get_selection
577 | model = Sketchup.active_model
578 | selection = model.selection
579 |
580 | log "Getting selection, count: #{selection.length}"
581 |
582 | selected_entities = selection.map do |entity|
583 | {
584 | id: entity.entityID,
585 | type: entity.typename.downcase
586 | }
587 | end
588 |
589 | { success: true, entities: selected_entities }
590 | end
591 |
592 | def export_scene(params)
593 | log "Exporting scene with params: #{params.inspect}"
594 | model = Sketchup.active_model
595 |
596 | format = params["format"] || "skp"
597 |
598 | begin
599 | # Create a temporary directory for exports
600 | temp_dir = File.join(ENV['TEMP'] || ENV['TMP'] || Dir.tmpdir, "sketchup_exports")
601 | FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
602 |
603 | # Generate a unique filename
604 | timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
605 | filename = "sketchup_export_#{timestamp}"
606 |
607 | case format.downcase
608 | when "skp"
609 | # Export as SketchUp file
610 | export_path = File.join(temp_dir, "#{filename}.skp")
611 | log "Exporting to SketchUp file: #{export_path}"
612 | model.save(export_path)
613 |
614 | when "obj"
615 | # Export as OBJ file
616 | export_path = File.join(temp_dir, "#{filename}.obj")
617 | log "Exporting to OBJ file: #{export_path}"
618 |
619 | # Check if OBJ exporter is available
620 | if Sketchup.require("sketchup.rb")
621 | options = {
622 | :triangulated_faces => true,
623 | :double_sided_faces => true,
624 | :edges => false,
625 | :texture_maps => true
626 | }
627 | model.export(export_path, options)
628 | else
629 | raise "OBJ exporter not available"
630 | end
631 |
632 | when "dae"
633 | # Export as COLLADA file
634 | export_path = File.join(temp_dir, "#{filename}.dae")
635 | log "Exporting to COLLADA file: #{export_path}"
636 |
637 | # Check if COLLADA exporter is available
638 | if Sketchup.require("sketchup.rb")
639 | options = { :triangulated_faces => true }
640 | model.export(export_path, options)
641 | else
642 | raise "COLLADA exporter not available"
643 | end
644 |
645 | when "stl"
646 | # Export as STL file
647 | export_path = File.join(temp_dir, "#{filename}.stl")
648 | log "Exporting to STL file: #{export_path}"
649 |
650 | # Check if STL exporter is available
651 | if Sketchup.require("sketchup.rb")
652 | options = { :units => "model" }
653 | model.export(export_path, options)
654 | else
655 | raise "STL exporter not available"
656 | end
657 |
658 | when "png", "jpg", "jpeg"
659 | # Export as image
660 | ext = format.downcase == "jpg" ? "jpeg" : format.downcase
661 | export_path = File.join(temp_dir, "#{filename}.#{ext}")
662 | log "Exporting to image file: #{export_path}"
663 |
664 | # Get the current view
665 | view = model.active_view
666 |
667 | # Set up options for the export
668 | options = {
669 | :filename => export_path,
670 | :width => params["width"] || 1920,
671 | :height => params["height"] || 1080,
672 | :antialias => true,
673 | :transparent => (ext == "png")
674 | }
675 |
676 | # Export the image
677 | view.write_image(options)
678 |
679 | else
680 | raise "Unsupported export format: #{format}"
681 | end
682 |
683 | log "Export completed successfully to: #{export_path}"
684 |
685 | {
686 | success: true,
687 | path: export_path,
688 | format: format
689 | }
690 | rescue StandardError => e
691 | log "Error in export_scene: #{e.message}"
692 | log e.backtrace.join("\n")
693 | raise
694 | end
695 | end
696 |
697 | def set_material(params)
698 | log "Setting material with params: #{params.inspect}"
699 | model = Sketchup.active_model
700 |
701 | # Handle ID format - strip quotes if present
702 | id_str = params["id"].to_s.gsub('"', '')
703 | log "Looking for entity with ID: #{id_str}"
704 |
705 | entity = model.find_entity_by_id(id_str.to_i)
706 |
707 | if entity
708 | log "Found entity: #{entity.inspect}"
709 |
710 | material_name = params["material"]
711 | log "Setting material to: #{material_name}"
712 |
713 | # Get or create the material
714 | material = model.materials[material_name]
715 | if !material
716 | # Create a new material if it doesn't exist
717 | material = model.materials.add(material_name)
718 |
719 | # Handle color specification
720 | case material_name.downcase
721 | when "red"
722 | material.color = Sketchup::Color.new(255, 0, 0)
723 | when "green"
724 | material.color = Sketchup::Color.new(0, 255, 0)
725 | when "blue"
726 | material.color = Sketchup::Color.new(0, 0, 255)
727 | when "yellow"
728 | material.color = Sketchup::Color.new(255, 255, 0)
729 | when "cyan", "turquoise"
730 | material.color = Sketchup::Color.new(0, 255, 255)
731 | when "magenta", "purple"
732 | material.color = Sketchup::Color.new(255, 0, 255)
733 | when "white"
734 | material.color = Sketchup::Color.new(255, 255, 255)
735 | when "black"
736 | material.color = Sketchup::Color.new(0, 0, 0)
737 | when "brown"
738 | material.color = Sketchup::Color.new(139, 69, 19)
739 | when "orange"
740 | material.color = Sketchup::Color.new(255, 165, 0)
741 | when "gray", "grey"
742 | material.color = Sketchup::Color.new(128, 128, 128)
743 | else
744 | # If it's a hex color code like "#FF0000"
745 | if material_name.start_with?("#") && material_name.length == 7
746 | begin
747 | r = material_name[1..2].to_i(16)
748 | g = material_name[3..4].to_i(16)
749 | b = material_name[5..6].to_i(16)
750 | material.color = Sketchup::Color.new(r, g, b)
751 | rescue
752 | # Default to a wood color if parsing fails
753 | material.color = Sketchup::Color.new(184, 134, 72)
754 | end
755 | else
756 | # Default to a wood color
757 | material.color = Sketchup::Color.new(184, 134, 72)
758 | end
759 | end
760 | end
761 |
762 | # Apply the material to the entity
763 | if entity.respond_to?(:material=)
764 | entity.material = material
765 | elsif entity.is_a?(Sketchup::Group) || entity.is_a?(Sketchup::ComponentInstance)
766 | # For groups and components, we need to apply to all faces
767 | entities = entity.is_a?(Sketchup::Group) ? entity.entities : entity.definition.entities
768 | entities.grep(Sketchup::Face).each { |face| face.material = material }
769 | end
770 |
771 | { success: true, id: entity.entityID }
772 | else
773 | raise "Entity not found"
774 | end
775 | end
776 |
777 | def boolean_operation(params)
778 | log "Performing boolean operation with params: #{params.inspect}"
779 | model = Sketchup.active_model
780 |
781 | # Get operation type
782 | operation_type = params["operation"]
783 | unless ["union", "difference", "intersection"].include?(operation_type)
784 | raise "Invalid boolean operation: #{operation_type}. Must be 'union', 'difference', or 'intersection'."
785 | end
786 |
787 | # Get target and tool entities
788 | target_id = params["target_id"].to_s.gsub('"', '')
789 | tool_id = params["tool_id"].to_s.gsub('"', '')
790 |
791 | log "Looking for target entity with ID: #{target_id}"
792 | target_entity = model.find_entity_by_id(target_id.to_i)
793 |
794 | log "Looking for tool entity with ID: #{tool_id}"
795 | tool_entity = model.find_entity_by_id(tool_id.to_i)
796 |
797 | unless target_entity && tool_entity
798 | missing = []
799 | missing << "target" unless target_entity
800 | missing << "tool" unless tool_entity
801 | raise "Entity not found: #{missing.join(', ')}"
802 | end
803 |
804 | # Ensure both entities are groups or component instances
805 | unless (target_entity.is_a?(Sketchup::Group) || target_entity.is_a?(Sketchup::ComponentInstance)) &&
806 | (tool_entity.is_a?(Sketchup::Group) || tool_entity.is_a?(Sketchup::ComponentInstance))
807 | raise "Boolean operations require groups or component instances"
808 | end
809 |
810 | # Create a new group to hold the result
811 | result_group = model.active_entities.add_group
812 |
813 | # Perform the boolean operation
814 | case operation_type
815 | when "union"
816 | log "Performing union operation"
817 | perform_union(target_entity, tool_entity, result_group)
818 | when "difference"
819 | log "Performing difference operation"
820 | perform_difference(target_entity, tool_entity, result_group)
821 | when "intersection"
822 | log "Performing intersection operation"
823 | perform_intersection(target_entity, tool_entity, result_group)
824 | end
825 |
826 | # Clean up original entities if requested
827 | if params["delete_originals"]
828 | target_entity.erase! if target_entity.valid?
829 | tool_entity.erase! if tool_entity.valid?
830 | end
831 |
832 | # Return the result
833 | {
834 | success: true,
835 | id: result_group.entityID
836 | }
837 | end
838 |
839 | def perform_union(target, tool, result_group)
840 | model = Sketchup.active_model
841 |
842 | # Create temporary copies of the target and tool
843 | target_copy = target.copy
844 | tool_copy = tool.copy
845 |
846 | # Get the transformation of each entity
847 | target_transform = target.transformation
848 | tool_transform = tool.transformation
849 |
850 | # Apply the transformations to the copies
851 | target_copy.transform!(target_transform)
852 | tool_copy.transform!(tool_transform)
853 |
854 | # Get the entities from the copies
855 | target_entities = target_copy.is_a?(Sketchup::Group) ? target_copy.entities : target_copy.definition.entities
856 | tool_entities = tool_copy.is_a?(Sketchup::Group) ? tool_copy.entities : tool_copy.definition.entities
857 |
858 | # Copy all entities from target to result
859 | target_entities.each do |entity|
860 | entity.copy(result_group.entities)
861 | end
862 |
863 | # Copy all entities from tool to result
864 | tool_entities.each do |entity|
865 | entity.copy(result_group.entities)
866 | end
867 |
868 | # Clean up temporary copies
869 | target_copy.erase!
870 | tool_copy.erase!
871 |
872 | # Outer shell - this will merge overlapping geometry
873 | result_group.entities.outer_shell
874 | end
875 |
876 | def perform_difference(target, tool, result_group)
877 | model = Sketchup.active_model
878 |
879 | # Create temporary copies of the target and tool
880 | target_copy = target.copy
881 | tool_copy = tool.copy
882 |
883 | # Get the transformation of each entity
884 | target_transform = target.transformation
885 | tool_transform = tool.transformation
886 |
887 | # Apply the transformations to the copies
888 | target_copy.transform!(target_transform)
889 | tool_copy.transform!(tool_transform)
890 |
891 | # Get the entities from the copies
892 | target_entities = target_copy.is_a?(Sketchup::Group) ? target_copy.entities : target_copy.definition.entities
893 | tool_entities = tool_copy.is_a?(Sketchup::Group) ? tool_copy.entities : tool_copy.definition.entities
894 |
895 | # Copy all entities from target to result
896 | target_entities.each do |entity|
897 | entity.copy(result_group.entities)
898 | end
899 |
900 | # Create a temporary group for the tool
901 | temp_tool_group = model.active_entities.add_group
902 |
903 | # Copy all entities from tool to temp group
904 | tool_entities.each do |entity|
905 | entity.copy(temp_tool_group.entities)
906 | end
907 |
908 | # Subtract the tool from the result
909 | result_group.entities.subtract(temp_tool_group.entities)
910 |
911 | # Clean up temporary copies and groups
912 | target_copy.erase!
913 | tool_copy.erase!
914 | temp_tool_group.erase!
915 | end
916 |
917 | def perform_intersection(target, tool, result_group)
918 | model = Sketchup.active_model
919 |
920 | # Create temporary copies of the target and tool
921 | target_copy = target.copy
922 | tool_copy = tool.copy
923 |
924 | # Get the transformation of each entity
925 | target_transform = target.transformation
926 | tool_transform = tool.transformation
927 |
928 | # Apply the transformations to the copies
929 | target_copy.transform!(target_transform)
930 | tool_copy.transform!(tool_transform)
931 |
932 | # Get the entities from the copies
933 | target_entities = target_copy.is_a?(Sketchup::Group) ? target_copy.entities : target_copy.definition.entities
934 | tool_entities = tool_copy.is_a?(Sketchup::Group) ? tool_copy.entities : tool_copy.definition.entities
935 |
936 | # Create temporary groups for target and tool
937 | temp_target_group = model.active_entities.add_group
938 | temp_tool_group = model.active_entities.add_group
939 |
940 | # Copy all entities from target and tool to temp groups
941 | target_entities.each do |entity|
942 | entity.copy(temp_target_group.entities)
943 | end
944 |
945 | tool_entities.each do |entity|
946 | entity.copy(temp_tool_group.entities)
947 | end
948 |
949 | # Perform the intersection
950 | result_group.entities.intersect_with(temp_target_group.entities, temp_tool_group.entities)
951 |
952 | # Clean up temporary copies and groups
953 | target_copy.erase!
954 | tool_copy.erase!
955 | temp_target_group.erase!
956 | temp_tool_group.erase!
957 | end
958 |
959 | def chamfer_edges(params)
960 | log "Chamfering edges with params: #{params.inspect}"
961 | model = Sketchup.active_model
962 |
963 | # Get entity ID
964 | entity_id = params["entity_id"].to_s.gsub('"', '')
965 | log "Looking for entity with ID: #{entity_id}"
966 |
967 | entity = model.find_entity_by_id(entity_id.to_i)
968 | unless entity
969 | raise "Entity not found: #{entity_id}"
970 | end
971 |
972 | # Ensure entity is a group or component instance
973 | unless entity.is_a?(Sketchup::Group) || entity.is_a?(Sketchup::ComponentInstance)
974 | raise "Chamfer operation requires a group or component instance"
975 | end
976 |
977 | # Get the distance parameter
978 | distance = params["distance"] || 0.5
979 |
980 | # Get the entities collection
981 | entities = entity.is_a?(Sketchup::Group) ? entity.entities : entity.definition.entities
982 |
983 | # Find all edges in the entity
984 | edges = entities.grep(Sketchup::Edge)
985 |
986 | # If specific edges are provided, filter the edges
987 | if params["edge_indices"] && params["edge_indices"].is_a?(Array)
988 | edge_indices = params["edge_indices"]
989 | edges = edges.select.with_index { |_, i| edge_indices.include?(i) }
990 | end
991 |
992 | # Create a new group to hold the result
993 | result_group = model.active_entities.add_group
994 |
995 | # Copy all entities from the original to the result
996 | entities.each do |e|
997 | e.copy(result_group.entities)
998 | end
999 |
1000 | # Get the edges in the result group
1001 | result_edges = result_group.entities.grep(Sketchup::Edge)
1002 |
1003 | # If specific edges were provided, filter the result edges
1004 | if params["edge_indices"] && params["edge_indices"].is_a?(Array)
1005 | edge_indices = params["edge_indices"]
1006 | result_edges = result_edges.select.with_index { |_, i| edge_indices.include?(i) }
1007 | end
1008 |
1009 | # Perform the chamfer operation
1010 | begin
1011 | # Create a transformation for the chamfer
1012 | chamfer_transform = Geom::Transformation.scaling(1.0 - distance)
1013 |
1014 | # For each edge, create a chamfer
1015 | result_edges.each do |edge|
1016 | # Get the faces connected to this edge
1017 | faces = edge.faces
1018 | next if faces.length < 2
1019 |
1020 | # Get the start and end points of the edge
1021 | start_point = edge.start.position
1022 | end_point = edge.end.position
1023 |
1024 | # Calculate the midpoint of the edge
1025 | midpoint = Geom::Point3d.new(
1026 | (start_point.x + end_point.x) / 2.0,
1027 | (start_point.y + end_point.y) / 2.0,
1028 | (start_point.z + end_point.z) / 2.0
1029 | )
1030 |
1031 | # Create a chamfer by creating a new face
1032 | # This is a simplified approach - in a real implementation,
1033 | # you would need to handle various edge cases
1034 | new_points = []
1035 |
1036 | # For each vertex of the edge
1037 | [edge.start, edge.end].each do |vertex|
1038 | # Get all edges connected to this vertex
1039 | connected_edges = vertex.edges - [edge]
1040 |
1041 | # For each connected edge
1042 | connected_edges.each do |connected_edge|
1043 | # Get the other vertex of the connected edge
1044 | other_vertex = (connected_edge.vertices - [vertex])[0]
1045 |
1046 | # Calculate a point along the connected edge
1047 | direction = other_vertex.position - vertex.position
1048 | new_point = vertex.position.offset(direction, distance)
1049 |
1050 | new_points << new_point
1051 | end
1052 | end
1053 |
1054 | # Create a new face using the new points
1055 | if new_points.length >= 3
1056 | result_group.entities.add_face(new_points)
1057 | end
1058 | end
1059 |
1060 | # Clean up the original entity if requested
1061 | if params["delete_original"]
1062 | entity.erase! if entity.valid?
1063 | end
1064 |
1065 | # Return the result
1066 | {
1067 | success: true,
1068 | id: result_group.entityID
1069 | }
1070 | rescue StandardError => e
1071 | log "Error in chamfer_edges: #{e.message}"
1072 | log e.backtrace.join("\n")
1073 |
1074 | # Clean up the result group if there was an error
1075 | result_group.erase! if result_group.valid?
1076 |
1077 | raise
1078 | end
1079 | end
1080 |
1081 | def fillet_edges(params)
1082 | log "Filleting edges with params: #{params.inspect}"
1083 | model = Sketchup.active_model
1084 |
1085 | # Get entity ID
1086 | entity_id = params["entity_id"].to_s.gsub('"', '')
1087 | log "Looking for entity with ID: #{entity_id}"
1088 |
1089 | entity = model.find_entity_by_id(entity_id.to_i)
1090 | unless entity
1091 | raise "Entity not found: #{entity_id}"
1092 | end
1093 |
1094 | # Ensure entity is a group or component instance
1095 | unless entity.is_a?(Sketchup::Group) || entity.is_a?(Sketchup::ComponentInstance)
1096 | raise "Fillet operation requires a group or component instance"
1097 | end
1098 |
1099 | # Get the radius parameter
1100 | radius = params["radius"] || 0.5
1101 |
1102 | # Get the number of segments for the fillet
1103 | segments = params["segments"] || 8
1104 |
1105 | # Get the entities collection
1106 | entities = entity.is_a?(Sketchup::Group) ? entity.entities : entity.definition.entities
1107 |
1108 | # Find all edges in the entity
1109 | edges = entities.grep(Sketchup::Edge)
1110 |
1111 | # If specific edges are provided, filter the edges
1112 | if params["edge_indices"] && params["edge_indices"].is_a?(Array)
1113 | edge_indices = params["edge_indices"]
1114 | edges = edges.select.with_index { |_, i| edge_indices.include?(i) }
1115 | end
1116 |
1117 | # Create a new group to hold the result
1118 | result_group = model.active_entities.add_group
1119 |
1120 | # Copy all entities from the original to the result
1121 | entities.each do |e|
1122 | e.copy(result_group.entities)
1123 | end
1124 |
1125 | # Get the edges in the result group
1126 | result_edges = result_group.entities.grep(Sketchup::Edge)
1127 |
1128 | # If specific edges were provided, filter the result edges
1129 | if params["edge_indices"] && params["edge_indices"].is_a?(Array)
1130 | edge_indices = params["edge_indices"]
1131 | result_edges = result_edges.select.with_index { |_, i| edge_indices.include?(i) }
1132 | end
1133 |
1134 | # Perform the fillet operation
1135 | begin
1136 | # For each edge, create a fillet
1137 | result_edges.each do |edge|
1138 | # Get the faces connected to this edge
1139 | faces = edge.faces
1140 | next if faces.length < 2
1141 |
1142 | # Get the start and end points of the edge
1143 | start_point = edge.start.position
1144 | end_point = edge.end.position
1145 |
1146 | # Calculate the midpoint of the edge
1147 | midpoint = Geom::Point3d.new(
1148 | (start_point.x + end_point.x) / 2.0,
1149 | (start_point.y + end_point.y) / 2.0,
1150 | (start_point.z + end_point.z) / 2.0
1151 | )
1152 |
1153 | # Calculate the edge vector
1154 | edge_vector = end_point - start_point
1155 | edge_length = edge_vector.length
1156 |
1157 | # Create points for the fillet curve
1158 | fillet_points = []
1159 |
1160 | # Create a series of points along a circular arc
1161 | (0..segments).each do |i|
1162 | angle = Math::PI * i / segments
1163 |
1164 | # Calculate the point on the arc
1165 | x = midpoint.x + radius * Math.cos(angle)
1166 | y = midpoint.y + radius * Math.sin(angle)
1167 | z = midpoint.z
1168 |
1169 | fillet_points << Geom::Point3d.new(x, y, z)
1170 | end
1171 |
1172 | # Create edges connecting the fillet points
1173 | (0...fillet_points.length - 1).each do |i|
1174 | result_group.entities.add_line(fillet_points[i], fillet_points[i+1])
1175 | end
1176 |
1177 | # Create a face from the fillet points
1178 | if fillet_points.length >= 3
1179 | result_group.entities.add_face(fillet_points)
1180 | end
1181 | end
1182 |
1183 | # Clean up the original entity if requested
1184 | if params["delete_original"]
1185 | entity.erase! if entity.valid?
1186 | end
1187 |
1188 | # Return the result
1189 | {
1190 | success: true,
1191 | id: result_group.entityID
1192 | }
1193 | rescue StandardError => e
1194 | log "Error in fillet_edges: #{e.message}"
1195 | log e.backtrace.join("\n")
1196 |
1197 | # Clean up the result group if there was an error
1198 | result_group.erase! if result_group.valid?
1199 |
1200 | raise
1201 | end
1202 | end
1203 |
1204 | def create_mortise_tenon(params)
1205 | log "Creating mortise and tenon joint with params: #{params.inspect}"
1206 | model = Sketchup.active_model
1207 |
1208 | # Get the mortise and tenon board IDs
1209 | mortise_id = params["mortise_id"].to_s.gsub('"', '')
1210 | tenon_id = params["tenon_id"].to_s.gsub('"', '')
1211 |
1212 | log "Looking for mortise board with ID: #{mortise_id}"
1213 | mortise_board = model.find_entity_by_id(mortise_id.to_i)
1214 |
1215 | log "Looking for tenon board with ID: #{tenon_id}"
1216 | tenon_board = model.find_entity_by_id(tenon_id.to_i)
1217 |
1218 | unless mortise_board && tenon_board
1219 | missing = []
1220 | missing << "mortise board" unless mortise_board
1221 | missing << "tenon board" unless tenon_board
1222 | raise "Entity not found: #{missing.join(', ')}"
1223 | end
1224 |
1225 | # Ensure both entities are groups or component instances
1226 | unless (mortise_board.is_a?(Sketchup::Group) || mortise_board.is_a?(Sketchup::ComponentInstance)) &&
1227 | (tenon_board.is_a?(Sketchup::Group) || tenon_board.is_a?(Sketchup::ComponentInstance))
1228 | raise "Mortise and tenon operation requires groups or component instances"
1229 | end
1230 |
1231 | # Get joint parameters
1232 | width = params["width"] || 1.0
1233 | height = params["height"] || 1.0
1234 | depth = params["depth"] || 1.0
1235 | offset_x = params["offset_x"] || 0.0
1236 | offset_y = params["offset_y"] || 0.0
1237 | offset_z = params["offset_z"] || 0.0
1238 |
1239 | # Get the bounds of both boards
1240 | mortise_bounds = mortise_board.bounds
1241 | tenon_bounds = tenon_board.bounds
1242 |
1243 | # Determine the face to place the joint on based on the relative positions of the boards
1244 | mortise_center = mortise_bounds.center
1245 | tenon_center = tenon_bounds.center
1246 |
1247 | # Calculate the direction vector from mortise to tenon
1248 | direction_vector = tenon_center - mortise_center
1249 |
1250 | # Determine which face of the mortise board is closest to the tenon board
1251 | mortise_face_direction = determine_closest_face(direction_vector)
1252 |
1253 | # Create the mortise (hole) in the mortise board
1254 | mortise_result = create_mortise(
1255 | mortise_board,
1256 | width,
1257 | height,
1258 | depth,
1259 | mortise_face_direction,
1260 | mortise_bounds,
1261 | offset_x,
1262 | offset_y,
1263 | offset_z
1264 | )
1265 |
1266 | # Determine which face of the tenon board is closest to the mortise board
1267 | tenon_face_direction = determine_closest_face(direction_vector.reverse)
1268 |
1269 | # Create the tenon (projection) on the tenon board
1270 | tenon_result = create_tenon(
1271 | tenon_board,
1272 | width,
1273 | height,
1274 | depth,
1275 | tenon_face_direction,
1276 | tenon_bounds,
1277 | offset_x,
1278 | offset_y,
1279 | offset_z
1280 | )
1281 |
1282 | # Return the result
1283 | {
1284 | success: true,
1285 | mortise_id: mortise_result[:id],
1286 | tenon_id: tenon_result[:id]
1287 | }
1288 | end
1289 |
1290 | def determine_closest_face(direction_vector)
1291 | # Normalize the direction vector
1292 | direction_vector.normalize!
1293 |
1294 | # Determine which axis has the largest component
1295 | x_abs = direction_vector.x.abs
1296 | y_abs = direction_vector.y.abs
1297 | z_abs = direction_vector.z.abs
1298 |
1299 | if x_abs >= y_abs && x_abs >= z_abs
1300 | # X-axis is dominant
1301 | return direction_vector.x > 0 ? :east : :west
1302 | elsif y_abs >= x_abs && y_abs >= z_abs
1303 | # Y-axis is dominant
1304 | return direction_vector.y > 0 ? :north : :south
1305 | else
1306 | # Z-axis is dominant
1307 | return direction_vector.z > 0 ? :top : :bottom
1308 | end
1309 | end
1310 |
1311 | def create_mortise(board, width, height, depth, face_direction, bounds, offset_x, offset_y, offset_z)
1312 | model = Sketchup.active_model
1313 |
1314 | # Get the board's entities
1315 | entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1316 |
1317 | # Calculate the position of the mortise based on the face direction
1318 | mortise_position = calculate_position_on_face(face_direction, bounds, width, height, depth, offset_x, offset_y, offset_z)
1319 |
1320 | log "Creating mortise at position: #{mortise_position.inspect} with dimensions: #{[width, height, depth].inspect}"
1321 |
1322 | # Create a box for the mortise
1323 | mortise_group = entities.add_group
1324 |
1325 | # Create the mortise box with the correct orientation
1326 | case face_direction
1327 | when :east, :west
1328 | # Mortise on east or west face (YZ plane)
1329 | mortise_face = mortise_group.entities.add_face(
1330 | [mortise_position[0], mortise_position[1], mortise_position[2]],
1331 | [mortise_position[0], mortise_position[1] + width, mortise_position[2]],
1332 | [mortise_position[0], mortise_position[1] + width, mortise_position[2] + height],
1333 | [mortise_position[0], mortise_position[1], mortise_position[2] + height]
1334 | )
1335 | mortise_face.pushpull(face_direction == :east ? -depth : depth)
1336 | when :north, :south
1337 | # Mortise on north or south face (XZ plane)
1338 | mortise_face = mortise_group.entities.add_face(
1339 | [mortise_position[0], mortise_position[1], mortise_position[2]],
1340 | [mortise_position[0] + width, mortise_position[1], mortise_position[2]],
1341 | [mortise_position[0] + width, mortise_position[1], mortise_position[2] + height],
1342 | [mortise_position[0], mortise_position[1], mortise_position[2] + height]
1343 | )
1344 | mortise_face.pushpull(face_direction == :north ? -depth : depth)
1345 | when :top, :bottom
1346 | # Mortise on top or bottom face (XY plane)
1347 | mortise_face = mortise_group.entities.add_face(
1348 | [mortise_position[0], mortise_position[1], mortise_position[2]],
1349 | [mortise_position[0] + width, mortise_position[1], mortise_position[2]],
1350 | [mortise_position[0] + width, mortise_position[1] + height, mortise_position[2]],
1351 | [mortise_position[0], mortise_position[1] + height, mortise_position[2]]
1352 | )
1353 | mortise_face.pushpull(face_direction == :top ? -depth : depth)
1354 | end
1355 |
1356 | # Subtract the mortise from the board
1357 | entities.subtract(mortise_group.entities)
1358 |
1359 | # Clean up the temporary group
1360 | mortise_group.erase!
1361 |
1362 | # Return the result
1363 | {
1364 | success: true,
1365 | id: board.entityID
1366 | }
1367 | end
1368 |
1369 | def create_tenon(board, width, height, depth, face_direction, bounds, offset_x, offset_y, offset_z)
1370 | model = Sketchup.active_model
1371 |
1372 | # Get the board's entities
1373 | entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1374 |
1375 | # Calculate the position of the tenon based on the face direction
1376 | tenon_position = calculate_position_on_face(face_direction, bounds, width, height, depth, offset_x, offset_y, offset_z)
1377 |
1378 | log "Creating tenon at position: #{tenon_position.inspect} with dimensions: #{[width, height, depth].inspect}"
1379 |
1380 | # Create a box for the tenon
1381 | tenon_group = model.active_entities.add_group
1382 |
1383 | # Create the tenon box with the correct orientation
1384 | case face_direction
1385 | when :east, :west
1386 | # Tenon on east or west face (YZ plane)
1387 | tenon_face = tenon_group.entities.add_face(
1388 | [tenon_position[0], tenon_position[1], tenon_position[2]],
1389 | [tenon_position[0], tenon_position[1] + width, tenon_position[2]],
1390 | [tenon_position[0], tenon_position[1] + width, tenon_position[2] + height],
1391 | [tenon_position[0], tenon_position[1], tenon_position[2] + height]
1392 | )
1393 | tenon_face.pushpull(face_direction == :east ? depth : -depth)
1394 | when :north, :south
1395 | # Tenon on north or south face (XZ plane)
1396 | tenon_face = tenon_group.entities.add_face(
1397 | [tenon_position[0], tenon_position[1], tenon_position[2]],
1398 | [tenon_position[0] + width, tenon_position[1], tenon_position[2]],
1399 | [tenon_position[0] + width, tenon_position[1], tenon_position[2] + height],
1400 | [tenon_position[0], tenon_position[1], tenon_position[2] + height]
1401 | )
1402 | tenon_face.pushpull(face_direction == :north ? depth : -depth)
1403 | when :top, :bottom
1404 | # Tenon on top or bottom face (XY plane)
1405 | tenon_face = tenon_group.entities.add_face(
1406 | [tenon_position[0], tenon_position[1], tenon_position[2]],
1407 | [tenon_position[0] + width, tenon_position[1], tenon_position[2]],
1408 | [tenon_position[0] + width, tenon_position[1] + height, tenon_position[2]],
1409 | [tenon_position[0], tenon_position[1] + height, tenon_position[2]]
1410 | )
1411 | tenon_face.pushpull(face_direction == :top ? depth : -depth)
1412 | end
1413 |
1414 | # Get the transformation of the board
1415 | board_transform = board.transformation
1416 |
1417 | # Apply the inverse transformation to the tenon group
1418 | tenon_group.transform!(board_transform.inverse)
1419 |
1420 | # Union the tenon with the board
1421 | board_entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1422 | board_entities.add_instance(tenon_group.entities.parent, Geom::Transformation.new)
1423 |
1424 | # Clean up the temporary group
1425 | tenon_group.erase!
1426 |
1427 | # Return the result
1428 | {
1429 | success: true,
1430 | id: board.entityID
1431 | }
1432 | end
1433 |
1434 | def calculate_position_on_face(face_direction, bounds, width, height, depth, offset_x, offset_y, offset_z)
1435 | # Calculate the position on the specified face with offsets
1436 | case face_direction
1437 | when :east
1438 | # Position on the east face (max X)
1439 | [
1440 | bounds.max.x,
1441 | bounds.center.y - width/2 + offset_y,
1442 | bounds.center.z - height/2 + offset_z
1443 | ]
1444 | when :west
1445 | # Position on the west face (min X)
1446 | [
1447 | bounds.min.x,
1448 | bounds.center.y - width/2 + offset_y,
1449 | bounds.center.z - height/2 + offset_z
1450 | ]
1451 | when :north
1452 | # Position on the north face (max Y)
1453 | [
1454 | bounds.center.x - width/2 + offset_x,
1455 | bounds.max.y,
1456 | bounds.center.z - height/2 + offset_z
1457 | ]
1458 | when :south
1459 | # Position on the south face (min Y)
1460 | [
1461 | bounds.center.x - width/2 + offset_x,
1462 | bounds.min.y,
1463 | bounds.center.z - height/2 + offset_z
1464 | ]
1465 | when :top
1466 | # Position on the top face (max Z)
1467 | [
1468 | bounds.center.x - width/2 + offset_x,
1469 | bounds.center.y - height/2 + offset_y,
1470 | bounds.max.z
1471 | ]
1472 | when :bottom
1473 | # Position on the bottom face (min Z)
1474 | [
1475 | bounds.center.x - width/2 + offset_x,
1476 | bounds.center.y - height/2 + offset_y,
1477 | bounds.min.z
1478 | ]
1479 | end
1480 | end
1481 |
1482 | def create_dovetail(params)
1483 | log "Creating dovetail joint with params: #{params.inspect}"
1484 | model = Sketchup.active_model
1485 |
1486 | # Get the tail and pin board IDs
1487 | tail_id = params["tail_id"].to_s.gsub('"', '')
1488 | pin_id = params["pin_id"].to_s.gsub('"', '')
1489 |
1490 | log "Looking for tail board with ID: #{tail_id}"
1491 | tail_board = model.find_entity_by_id(tail_id.to_i)
1492 |
1493 | log "Looking for pin board with ID: #{pin_id}"
1494 | pin_board = model.find_entity_by_id(pin_id.to_i)
1495 |
1496 | unless tail_board && pin_board
1497 | missing = []
1498 | missing << "tail board" unless tail_board
1499 | missing << "pin board" unless pin_board
1500 | raise "Entity not found: #{missing.join(', ')}"
1501 | end
1502 |
1503 | # Ensure both entities are groups or component instances
1504 | unless (tail_board.is_a?(Sketchup::Group) || tail_board.is_a?(Sketchup::ComponentInstance)) &&
1505 | (pin_board.is_a?(Sketchup::Group) || pin_board.is_a?(Sketchup::ComponentInstance))
1506 | raise "Dovetail operation requires groups or component instances"
1507 | end
1508 |
1509 | # Get joint parameters
1510 | width = params["width"] || 1.0
1511 | height = params["height"] || 2.0
1512 | depth = params["depth"] || 1.0
1513 | angle = params["angle"] || 15.0 # Dovetail angle in degrees
1514 | num_tails = params["num_tails"] || 3
1515 | offset_x = params["offset_x"] || 0.0
1516 | offset_y = params["offset_y"] || 0.0
1517 | offset_z = params["offset_z"] || 0.0
1518 |
1519 | # Create the tails on the tail board
1520 | tail_result = create_tails(tail_board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
1521 |
1522 | # Create the pins on the pin board
1523 | pin_result = create_pins(pin_board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
1524 |
1525 | # Return the result
1526 | {
1527 | success: true,
1528 | tail_id: tail_result[:id],
1529 | pin_id: pin_result[:id]
1530 | }
1531 | end
1532 |
1533 | def create_tails(board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
1534 | model = Sketchup.active_model
1535 |
1536 | # Get the board's entities
1537 | entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1538 |
1539 | # Get the board's bounds
1540 | bounds = board.bounds
1541 |
1542 | # Calculate the position of the dovetail joint
1543 | center_x = bounds.center.x + offset_x
1544 | center_y = bounds.center.y + offset_y
1545 | center_z = bounds.center.z + offset_z
1546 |
1547 | # Calculate the width of each tail and space
1548 | total_width = width
1549 | tail_width = total_width / (2 * num_tails - 1)
1550 |
1551 | # Create a group for the tails
1552 | tails_group = entities.add_group
1553 |
1554 | # Create each tail
1555 | num_tails.times do |i|
1556 | # Calculate the position of this tail
1557 | tail_center_x = center_x - width/2 + tail_width * (2 * i)
1558 |
1559 | # Calculate the dovetail shape
1560 | angle_rad = angle * Math::PI / 180.0
1561 | tail_top_width = tail_width
1562 | tail_bottom_width = tail_width + 2 * depth * Math.tan(angle_rad)
1563 |
1564 | # Create the tail shape
1565 | tail_points = [
1566 | [tail_center_x - tail_top_width/2, center_y - height/2, center_z],
1567 | [tail_center_x + tail_top_width/2, center_y - height/2, center_z],
1568 | [tail_center_x + tail_bottom_width/2, center_y - height/2, center_z - depth],
1569 | [tail_center_x - tail_bottom_width/2, center_y - height/2, center_z - depth]
1570 | ]
1571 |
1572 | # Create the tail face
1573 | tail_face = tails_group.entities.add_face(tail_points)
1574 |
1575 | # Extrude the tail
1576 | tail_face.pushpull(height)
1577 | end
1578 |
1579 | # Return the result
1580 | {
1581 | success: true,
1582 | id: board.entityID
1583 | }
1584 | end
1585 |
1586 | def create_pins(board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
1587 | model = Sketchup.active_model
1588 |
1589 | # Get the board's entities
1590 | entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1591 |
1592 | # Get the board's bounds
1593 | bounds = board.bounds
1594 |
1595 | # Calculate the position of the dovetail joint
1596 | center_x = bounds.center.x + offset_x
1597 | center_y = bounds.center.y + offset_y
1598 | center_z = bounds.center.z + offset_z
1599 |
1600 | # Calculate the width of each tail and space
1601 | total_width = width
1602 | tail_width = total_width / (2 * num_tails - 1)
1603 |
1604 | # Create a group for the pins
1605 | pins_group = entities.add_group
1606 |
1607 | # Create a box for the entire pin area
1608 | pin_area_face = pins_group.entities.add_face(
1609 | [center_x - width/2, center_y - height/2, center_z],
1610 | [center_x + width/2, center_y - height/2, center_z],
1611 | [center_x + width/2, center_y + height/2, center_z],
1612 | [center_x - width/2, center_y + height/2, center_z]
1613 | )
1614 |
1615 | # Extrude the pin area
1616 | pin_area_face.pushpull(depth)
1617 |
1618 | # Create each tail cutout
1619 | num_tails.times do |i|
1620 | # Calculate the position of this tail
1621 | tail_center_x = center_x - width/2 + tail_width * (2 * i)
1622 |
1623 | # Calculate the dovetail shape
1624 | angle_rad = angle * Math::PI / 180.0
1625 | tail_top_width = tail_width
1626 | tail_bottom_width = tail_width + 2 * depth * Math.tan(angle_rad)
1627 |
1628 | # Create a group for the tail cutout
1629 | tail_cutout_group = entities.add_group
1630 |
1631 | # Create the tail cutout shape
1632 | tail_points = [
1633 | [tail_center_x - tail_top_width/2, center_y - height/2, center_z],
1634 | [tail_center_x + tail_top_width/2, center_y - height/2, center_z],
1635 | [tail_center_x + tail_bottom_width/2, center_y - height/2, center_z - depth],
1636 | [tail_center_x - tail_bottom_width/2, center_y - height/2, center_z - depth]
1637 | ]
1638 |
1639 | # Create the tail cutout face
1640 | tail_face = tail_cutout_group.entities.add_face(tail_points)
1641 |
1642 | # Extrude the tail cutout
1643 | tail_face.pushpull(height)
1644 |
1645 | # Subtract the tail cutout from the pin area
1646 | pins_group.entities.subtract(tail_cutout_group.entities)
1647 |
1648 | # Clean up the temporary group
1649 | tail_cutout_group.erase!
1650 | end
1651 |
1652 | # Return the result
1653 | {
1654 | success: true,
1655 | id: board.entityID
1656 | }
1657 | end
1658 |
1659 | def create_finger_joint(params)
1660 | log "Creating finger joint with params: #{params.inspect}"
1661 | model = Sketchup.active_model
1662 |
1663 | # Get the two board IDs
1664 | board1_id = params["board1_id"].to_s.gsub('"', '')
1665 | board2_id = params["board2_id"].to_s.gsub('"', '')
1666 |
1667 | log "Looking for board 1 with ID: #{board1_id}"
1668 | board1 = model.find_entity_by_id(board1_id.to_i)
1669 |
1670 | log "Looking for board 2 with ID: #{board2_id}"
1671 | board2 = model.find_entity_by_id(board2_id.to_i)
1672 |
1673 | unless board1 && board2
1674 | missing = []
1675 | missing << "board 1" unless board1
1676 | missing << "board 2" unless board2
1677 | raise "Entity not found: #{missing.join(', ')}"
1678 | end
1679 |
1680 | # Ensure both entities are groups or component instances
1681 | unless (board1.is_a?(Sketchup::Group) || board1.is_a?(Sketchup::ComponentInstance)) &&
1682 | (board2.is_a?(Sketchup::Group) || board2.is_a?(Sketchup::ComponentInstance))
1683 | raise "Finger joint operation requires groups or component instances"
1684 | end
1685 |
1686 | # Get joint parameters
1687 | width = params["width"] || 1.0
1688 | height = params["height"] || 2.0
1689 | depth = params["depth"] || 1.0
1690 | num_fingers = params["num_fingers"] || 5
1691 | offset_x = params["offset_x"] || 0.0
1692 | offset_y = params["offset_y"] || 0.0
1693 | offset_z = params["offset_z"] || 0.0
1694 |
1695 | # Create the fingers on board 1
1696 | board1_result = create_board1_fingers(board1, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
1697 |
1698 | # Create the matching slots on board 2
1699 | board2_result = create_board2_slots(board2, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
1700 |
1701 | # Return the result
1702 | {
1703 | success: true,
1704 | board1_id: board1_result[:id],
1705 | board2_id: board2_result[:id]
1706 | }
1707 | end
1708 |
1709 | def create_board1_fingers(board, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
1710 | model = Sketchup.active_model
1711 |
1712 | # Get the board's entities
1713 | entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1714 |
1715 | # Get the board's bounds
1716 | bounds = board.bounds
1717 |
1718 | # Calculate the position of the joint
1719 | center_x = bounds.center.x + offset_x
1720 | center_y = bounds.center.y + offset_y
1721 | center_z = bounds.center.z + offset_z
1722 |
1723 | # Calculate the width of each finger
1724 | finger_width = width / num_fingers
1725 |
1726 | # Create a group for the fingers
1727 | fingers_group = entities.add_group
1728 |
1729 | # Create a base rectangle for the joint area
1730 | base_face = fingers_group.entities.add_face(
1731 | [center_x - width/2, center_y - height/2, center_z],
1732 | [center_x + width/2, center_y - height/2, center_z],
1733 | [center_x + width/2, center_y + height/2, center_z],
1734 | [center_x - width/2, center_y + height/2, center_z]
1735 | )
1736 |
1737 | # Create cutouts for the spaces between fingers
1738 | (num_fingers / 2).times do |i|
1739 | # Calculate the position of this cutout
1740 | cutout_center_x = center_x - width/2 + finger_width * (2 * i + 1)
1741 |
1742 | # Create a group for the cutout
1743 | cutout_group = entities.add_group
1744 |
1745 | # Create the cutout shape
1746 | cutout_face = cutout_group.entities.add_face(
1747 | [cutout_center_x - finger_width/2, center_y - height/2, center_z],
1748 | [cutout_center_x + finger_width/2, center_y - height/2, center_z],
1749 | [cutout_center_x + finger_width/2, center_y + height/2, center_z],
1750 | [cutout_center_x - finger_width/2, center_y + height/2, center_z]
1751 | )
1752 |
1753 | # Extrude the cutout
1754 | cutout_face.pushpull(depth)
1755 |
1756 | # Subtract the cutout from the fingers
1757 | fingers_group.entities.subtract(cutout_group.entities)
1758 |
1759 | # Clean up the temporary group
1760 | cutout_group.erase!
1761 | end
1762 |
1763 | # Extrude the fingers
1764 | base_face.pushpull(depth)
1765 |
1766 | # Return the result
1767 | {
1768 | success: true,
1769 | id: board.entityID
1770 | }
1771 | end
1772 |
1773 | def create_board2_slots(board, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
1774 | model = Sketchup.active_model
1775 |
1776 | # Get the board's entities
1777 | entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
1778 |
1779 | # Get the board's bounds
1780 | bounds = board.bounds
1781 |
1782 | # Calculate the position of the joint
1783 | center_x = bounds.center.x + offset_x
1784 | center_y = bounds.center.y + offset_y
1785 | center_z = bounds.center.z + offset_z
1786 |
1787 | # Calculate the width of each finger
1788 | finger_width = width / num_fingers
1789 |
1790 | # Create a group for the slots
1791 | slots_group = entities.add_group
1792 |
1793 | # Create cutouts for the fingers from board 1
1794 | (num_fingers / 2 + num_fingers % 2).times do |i|
1795 | # Calculate the position of this cutout
1796 | cutout_center_x = center_x - width/2 + finger_width * (2 * i)
1797 |
1798 | # Create a group for the cutout
1799 | cutout_group = entities.add_group
1800 |
1801 | # Create the cutout shape
1802 | cutout_face = cutout_group.entities.add_face(
1803 | [cutout_center_x - finger_width/2, center_y - height/2, center_z],
1804 | [cutout_center_x + finger_width/2, center_y - height/2, center_z],
1805 | [cutout_center_x + finger_width/2, center_y + height/2, center_z],
1806 | [cutout_center_x - finger_width/2, center_y + height/2, center_z]
1807 | )
1808 |
1809 | # Extrude the cutout
1810 | cutout_face.pushpull(depth)
1811 |
1812 | # Subtract the cutout from the board
1813 | entities.subtract(cutout_group.entities)
1814 |
1815 | # Clean up the temporary group
1816 | cutout_group.erase!
1817 | end
1818 |
1819 | # Return the result
1820 | {
1821 | success: true,
1822 | id: board.entityID
1823 | }
1824 | end
1825 |
1826 | def eval_ruby(params)
1827 | log "Evaluating Ruby code with length: #{params['code'].length}"
1828 |
1829 | begin
1830 | # Create a safe binding for evaluation
1831 | binding = TOPLEVEL_BINDING.dup
1832 |
1833 | # Evaluate the Ruby code
1834 | log "Starting code evaluation..."
1835 | result = eval(params["code"], binding)
1836 | log "Code evaluation completed with result: #{result.inspect}"
1837 |
1838 | # Return success with the result as a string
1839 | {
1840 | success: true,
1841 | result: result.to_s
1842 | }
1843 | rescue StandardError => e
1844 | log "Error in eval_ruby: #{e.message}"
1845 | log e.backtrace.join("\n")
1846 | raise "Ruby evaluation error: #{e.message}"
1847 | end
1848 | end
1849 | end
1850 |
1851 | unless file_loaded?(__FILE__)
1852 | @server = Server.new
1853 |
1854 | menu = UI.menu("Plugins").add_submenu("MCP Server")
1855 | menu.add_item("Start Server") { @server.start }
1856 | menu.add_item("Stop Server") { @server.stop }
1857 |
1858 | file_loaded(__FILE__)
1859 | end
1860 | end
```