#
tokens: 40074/50000 23/23 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 
```