# 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:
--------------------------------------------------------------------------------
```
3.10
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.env
.venv
env/
venv/
ENV/
# Ruby
*.gem
*.rbc
/.config
/coverage/
/pkg/
/tmp/
.ruby-version
.ruby-gemset
.rvmrc
# Sketchup specific
*.rbz
# OS specific
.DS_Store
.DS_Store?
._*
Thumbs.db
*.tar.gz
```
--------------------------------------------------------------------------------
/examples/README.md:
--------------------------------------------------------------------------------
```markdown
# SketchUp MCP Examples
This directory contains example scripts demonstrating how to use the SketchUp MCP (Model Context Protocol) integration.
## Ruby Code Evaluation
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.
### Requirements
- SketchUp with the MCP extension installed (version 1.6.0 or later)
- Python 3.10 or later
- sketchup-mcp Python package (version 0.1.17 or later)
### Examples
#### Simple Ruby Eval Example
The `simple_ruby_eval.py` script demonstrates basic usage of the `eval_ruby` feature with several simple examples:
- Creating a line
- Creating a cube
- Getting model information
To run the example:
```bash
python examples/simple_ruby_eval.py
```
#### Arts and Crafts Cabinet Example
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.
To run the example:
```bash
python examples/arts_and_crafts_cabinet.py
```
### Using the eval_ruby Feature in Your Own Code
To use the `eval_ruby` feature in your own code:
```python
from mcp.client import Client
import json
# Connect to the SketchUp MCP server
client = Client("sketchup")
# Define your Ruby code
ruby_code = """
model = Sketchup.active_model
entities = model.active_entities
line = entities.add_line([0,0,0], [100,100,100])
line.entityID
"""
# Evaluate the Ruby code
response = client.eval_ruby(code=ruby_code)
# Parse the response
result = json.loads(response)
if result.get("success"):
print(f"Success! Result: {result.get('result')}")
else:
print(f"Error: {result.get('error')}")
```
### Tips for Using eval_ruby
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.
2. **Error Handling**: Ruby errors will be caught and returned in the response. Check the `success` field to determine if the code executed successfully.
3. **Model Operations**: For operations that modify the model, consider wrapping them in `model.start_operation` and `model.commit_operation` to make them undoable.
4. **Performance**: For complex operations, it's more efficient to send a single large Ruby script than many small ones.
5. **Security**: Be careful when evaluating user-provided Ruby code, as it has full access to the SketchUp API.
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# SketchupMCP - Sketchup Model Context Protocol Integration
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.
Big Shoutout to [Blender MCP](https://github.com/ahujasid/blender-mcp) for the inspiration and structure.
## Features
* **Two-way communication**: Connect Claude AI to Sketchup through a TCP socket connection
* **Component manipulation**: Create, modify, delete, and transform components in Sketchup
* **Material control**: Apply and modify materials and colors
* **Scene inspection**: Get detailed information about the current Sketchup scene
* **Selection handling**: Get and manipulate selected components
* **Ruby code evaluation**: Execute arbitrary Ruby code directly in SketchUp for advanced operations
## Components
The system consists of two main components:
1. **Sketchup Extension**: A Sketchup extension that creates a TCP server within Sketchup to receive and execute commands
2. **MCP Server (`sketchup_mcp/server.py`)**: A Python server that implements the Model Context Protocol and connects to the Sketchup extension
## Installation
### Python Packaging
We're using uv so you'll need to ```brew install uv```
### Sketchup Extension
1. Download or build the latest `.rbz` file
2. In Sketchup, go to Window > Extension Manager
3. Click "Install Extension" and select the downloaded `.rbz` file
4. Restart Sketchup
## Usage
### Starting the Connection
1. In Sketchup, go to Extensions > SketchupMCP > Start Server
2. The server will start on the default port (9876)
3. Make sure the MCP server is running in your terminal
### Using with Claude
Configure Claude to use the MCP server by adding the following to your Claude configuration:
```json
"mcpServers": {
"sketchup": {
"command": "uvx",
"args": [
"sketchup-mcp"
]
}
}
```
This will pull the [latest from PyPI](https://pypi.org/project/sketchup-mcp/)
Once connected, Claude can interact with Sketchup using the following capabilities:
#### Tools
* `get_scene_info` - Gets information about the current Sketchup scene
* `get_selected_components` - Gets information about currently selected components
* `create_component` - Create a new component with specified parameters
* `delete_component` - Remove a component from the scene
* `transform_component` - Move, rotate, or scale a component
* `set_material` - Apply materials to components
* `export_scene` - Export the current scene to various formats
* `eval_ruby` - Execute arbitrary Ruby code in SketchUp for advanced operations
### Example Commands
Here are some examples of what you can ask Claude to do:
* "Create a simple house model with a roof and windows"
* "Select all components and get their information"
* "Make the selected component red"
* "Move the selected component 10 units up"
* "Export the current scene as a 3D model"
* "Create a complex arts and crafts cabinet using Ruby code"
## Troubleshooting
* **Connection issues**: Make sure both the Sketchup extension server and the MCP server are running
* **Command failures**: Check the Ruby Console in Sketchup for error messages
* **Timeout errors**: Try simplifying your requests or breaking them into smaller steps
## Technical Details
### Communication Protocol
The system uses a simple JSON-based protocol over TCP sockets:
* **Commands** are sent as JSON objects with a `type` and optional `params`
* **Responses** are JSON objects with a `status` and `result` or `message`
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
MIT
```
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
```
mcp[cli]>=1.3.0
websockets>=12.0
aiohttp>=3.9.0
```
--------------------------------------------------------------------------------
/src/sketchup_mcp/__main__.py:
--------------------------------------------------------------------------------
```python
from .server import main
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/src/sketchup_mcp/__init__.py:
--------------------------------------------------------------------------------
```python
"""Sketchup integration through Model Context Protocol"""
__version__ = "0.1.17"
# Expose key classes and functions for easier imports
from .server import mcp
```
--------------------------------------------------------------------------------
/su_mcp/extension.json:
--------------------------------------------------------------------------------
```json
{
"name": "Sketchup MCP Server",
"description": "Model Context Protocol server for Sketchup",
"creator": "MCP Team",
"copyright": "2024",
"license": "MIT",
"product_id": "SU_MCP_SERVER",
"version": "1.6.0",
"build": "1"
}
```
--------------------------------------------------------------------------------
/update_and_restart.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/bash
# Kill any existing sketchup-mcp processes
pkill -f "python -m sketchup_mcp"
# Update the package
pip install sketchup-mcp==0.1.15
# Start the server in the background
python -m sketchup_mcp &
# Wait a moment for the server to start
sleep 1
echo "Updated to sketchup-mcp 0.1.15 and restarted the server"
```
--------------------------------------------------------------------------------
/su_mcp/su_mcp.rb:
--------------------------------------------------------------------------------
```ruby
require 'sketchup'
require 'extensions'
module SU_MCP
unless file_loaded?(__FILE__)
ext = SketchupExtension.new('Sketchup MCP Server', 'su_mcp/main')
ext.description = 'Model Context Protocol server for Sketchup'
ext.version = '1.5.0'
ext.copyright = '2024'
ext.creator = 'MCP Team'
Sketchup.register_extension(ext, true)
file_loaded(__FILE__)
end
end
```
--------------------------------------------------------------------------------
/su_mcp.rb:
--------------------------------------------------------------------------------
```ruby
require 'sketchup.rb'
require 'extensions.rb'
require 'json'
require 'socket'
module SU_MCP
unless file_loaded?(__FILE__)
ex = SketchupExtension.new('Sketchup MCP', 'su_mcp/main')
ex.description = 'MCP server for Sketchup that allows AI agents to control and manipulate scenes'
ex.version = '0.1.0'
ex.copyright = '2024'
Sketchup.register_extension(ex, true)
file_loaded(__FILE__)
end
end
```
--------------------------------------------------------------------------------
/test_eval_ruby.py:
--------------------------------------------------------------------------------
```python
import json
from dataclasses import dataclass
@dataclass
class MockContext:
request_id: int = 1
# Import the function we want to test
from sketchup_mcp.server import eval_ruby
# Test with a simple Ruby script
test_code = '''
model = Sketchup.active_model
entities = model.active_entities
line = entities.add_line([0,0,0], [100,100,100])
puts "Created line with ID: #{line.entityID}"
line.entityID
'''
# Call the function
result = eval_ruby(MockContext(), test_code)
print(f"Result: {result}")
# Parse the result
parsed = json.loads(result)
print(f"Parsed: {json.dumps(parsed, indent=2)}")
```
--------------------------------------------------------------------------------
/su_mcp/package.rb:
--------------------------------------------------------------------------------
```ruby
#!/usr/bin/env ruby
require 'zip'
require 'fileutils'
# Configuration
EXTENSION_NAME = 'su_mcp'
VERSION = '1.6.0'
OUTPUT_NAME = "#{EXTENSION_NAME}_v#{VERSION}.rbz"
# Create temp directory
temp_dir = "#{EXTENSION_NAME}_temp"
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
FileUtils.mkdir_p(temp_dir)
# Copy files to temp directory
FileUtils.cp_r('su_mcp', temp_dir)
FileUtils.cp('su_mcp.rb', temp_dir)
FileUtils.cp('extension.json', temp_dir)
# Create zip file
FileUtils.rm(OUTPUT_NAME) if File.exist?(OUTPUT_NAME)
Zip::File.open(OUTPUT_NAME, create: true) do |zipfile|
Dir["#{temp_dir}/**/**"].each do |file|
next if File.directory?(file)
puts "Adding: #{file}"
zipfile.add(file.sub("#{temp_dir}/", ''), file)
end
end
# Clean up
FileUtils.rm_rf(temp_dir)
puts "Created #{OUTPUT_NAME}"
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
[project]
name = "sketchup-mcp"
version = "0.1.17"
description = "Sketchup integration through Model Context Protocol"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
authors = [
{name = "Your Name", email = "[email protected]"}
]
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
dependencies = [
"mcp[cli]>=1.3.0"
]
[project.urls]
Homepage = "https://github.com/yourusername/sketchup-mcp"
Issues = "https://github.com/yourusername/sketchup-mcp/issues"
[project.scripts]
sketchup-mcp = "sketchup_mcp.server:main"
[project.entry-points.mcp]
sketchup = "sketchup_mcp.server:mcp"
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-dir = {"" = "src"}
```
--------------------------------------------------------------------------------
/examples/simple_test.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Simple Test for eval_ruby
This is a minimal test to verify that the eval_ruby feature works correctly.
"""
import json
import logging
from mcp.client import Client
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("SimpleRubyTest")
# Simple Ruby code to create a cube
CUBE_CODE = """
model = Sketchup.active_model
entities = model.active_entities
# Start an operation for undo
model.start_operation("Create Test Cube", true)
# Create a group for the cube
group = entities.add_group
# Create the bottom face
face = group.entities.add_face(
[0, 0, 0],
[10, 0, 0],
[10, 10, 0],
[0, 10, 0]
)
# Push/pull to create the cube
face.pushpull(10)
# End the operation
model.commit_operation
# Return the group ID
group.entityID.to_s
"""
def main():
"""Main function to test the eval_ruby feature."""
# Connect to the MCP server
client = Client("sketchup")
# Check if the connection is successful
if not client.is_connected:
logger.error("Failed to connect to the SketchUp MCP server.")
return
logger.info("Connected to SketchUp MCP server.")
# Evaluate the Ruby code
logger.info("Creating a simple cube...")
response = client.eval_ruby(code=CUBE_CODE)
# Parse the response
try:
result = json.loads(response)
if result.get("success"):
logger.info(f"Cube created successfully! Group ID: {result.get('result')}")
else:
logger.error(f"Failed to create cube: {result.get('error')}")
except json.JSONDecodeError:
logger.error(f"Failed to parse response: {response}")
logger.info("Test completed.")
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/examples/simple_ruby_eval.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Simple Ruby Eval Example
This example demonstrates the basic usage of the eval_ruby feature
to execute Ruby code in SketchUp.
"""
import json
import logging
from mcp.client import Client
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("SimpleRubyEvalExample")
# Simple Ruby code examples
EXAMPLES = [
{
"name": "Create a line",
"code": """
model = Sketchup.active_model
entities = model.active_entities
line = entities.add_line([0,0,0], [100,100,100])
line.entityID
"""
},
{
"name": "Create a cube",
"code": """
model = Sketchup.active_model
entities = model.active_entities
group = entities.add_group
face = group.entities.add_face(
[0, 0, 0],
[10, 0, 0],
[10, 10, 0],
[0, 10, 0]
)
face.pushpull(10)
group.entityID
"""
},
{
"name": "Get model information",
"code": """
model = Sketchup.active_model
info = {
"filename": model.path,
"title": model.title,
"description": model.description,
"entity_count": model.entities.size,
"selection_count": model.selection.size
}
info.to_json
"""
}
]
def main():
"""Main function to demonstrate the eval_ruby feature."""
# Connect to the MCP server
client = Client("sketchup")
# Check if the connection is successful
if not client.is_connected:
logger.error("Failed to connect to the SketchUp MCP server.")
return
logger.info("Connected to SketchUp MCP server.")
# Run each example
for example in EXAMPLES:
logger.info(f"Running example: {example['name']}")
# Evaluate the Ruby code
response = client.eval_ruby(code=example["code"])
# Parse the response
try:
result = json.loads(response)
if result.get("success"):
logger.info(f"Result: {result.get('result')}")
else:
logger.error(f"Error: {result.get('error')}")
except json.JSONDecodeError:
logger.error(f"Failed to parse response: {response}")
logger.info("-" * 40)
logger.info("All examples completed.")
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/sketchup.json:
--------------------------------------------------------------------------------
```json
{
"name": "sketchup",
"description": "Sketchup integration through Model Context Protocol",
"package": "sketchup-mcp",
"module": "sketchup_mcp.server",
"object": "mcp",
"tools": [
{
"name": "create_component",
"description": "Create a new component in Sketchup",
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Type of component to create",
"default": "cube"
},
"position": {
"type": "array",
"items": {
"type": "number"
},
"description": "Position [x,y,z] of the component",
"default": [0,0,0]
},
"dimensions": {
"type": "array",
"items": {
"type": "number"
},
"description": "Dimensions [width,height,depth] of the component",
"default": [1,1,1]
}
}
}
},
{
"name": "delete_component",
"description": "Delete a component by ID",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "ID of the component to delete"
}
},
"required": ["id"]
}
},
{
"name": "transform_component",
"description": "Transform a component's position, rotation, or scale",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "ID of the component to transform"
},
"position": {
"type": "array",
"items": {
"type": "number"
},
"description": "New position [x,y,z]"
},
"rotation": {
"type": "array",
"items": {
"type": "number"
},
"description": "New rotation [x,y,z] in degrees"
},
"scale": {
"type": "array",
"items": {
"type": "number"
},
"description": "New scale [x,y,z]"
}
},
"required": ["id"]
}
},
{
"name": "get_selection",
"description": "Get currently selected components",
"parameters": {
"type": "object",
"properties": {}
}
},
{
"name": "set_material",
"description": "Set material for a component",
"parameters": {
"type": "object",
"properties": {
"id": {
"type": "string",
"description": "ID of the component"
},
"material": {
"type": "string",
"description": "Name of the material to apply"
}
},
"required": ["id", "material"]
}
},
{
"name": "export_scene",
"description": "Export the current scene",
"parameters": {
"type": "object",
"properties": {
"format": {
"type": "string",
"description": "Export format (e.g. skp, obj, etc)",
"default": "skp"
}
}
}
}
],
"mcpServers": {
"sketchup": {
"command": "uvx",
"args": ["sketchup_mcp"]
}
}
}
```
--------------------------------------------------------------------------------
/examples/ruby_tester.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Ruby Code Tester
This script tests Ruby code in smaller chunks to identify compatibility issues with SketchUp.
"""
import json
import logging
from mcp.client import Client
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("RubyTester")
# Test cases - each with a name and Ruby code to test
TEST_CASES = [
{
"name": "Basic Model Access",
"code": """
model = Sketchup.active_model
entities = model.active_entities
"Success: Basic model access works"
"""
},
{
"name": "Create Group",
"code": """
model = Sketchup.active_model
entities = model.active_entities
group = entities.add_group
group.entityID.to_s
"""
},
{
"name": "Create Face and Pushpull",
"code": """
model = Sketchup.active_model
entities = model.active_entities
group = entities.add_group
face = group.entities.add_face(
[0, 0, 0],
[10, 0, 0],
[10, 10, 0],
[0, 10, 0]
)
face.pushpull(10)
"Success: Created face and pushpull"
"""
},
{
"name": "Component Definition",
"code": """
model = Sketchup.active_model
definition = model.definitions.add("Test Component")
definition.name
"""
},
{
"name": "Component Behavior",
"code": """
model = Sketchup.active_model
definition = model.definitions.add("Test Component")
# Get behavior properties
behavior = definition.behavior
# Test available methods
methods = behavior.methods - Object.methods
# Return the available methods
methods.sort.join(", ")
"""
},
{
"name": "Component Instance",
"code": """
model = Sketchup.active_model
entities = model.active_entities
definition = model.definitions.add("Test Component")
# Create a point and transformation
point = Geom::Point3d.new(0, 0, 0)
transform = Geom::Transformation.new(point)
# Add instance
instance = entities.add_instance(definition, transform)
# Set behavior properties
behavior = instance.definition.behavior
behavior.snapto = 0
"Success: Component instance created with behavior set"
"""
}
]
def test_ruby_code(client, test_case):
"""Test a single Ruby code snippet."""
logger.info(f"Testing: {test_case['name']}")
response = client.eval_ruby(code=test_case["code"])
try:
result = json.loads(response)
if result.get("success"):
logger.info(f"✅ SUCCESS: {result.get('result')}")
return True
else:
logger.error(f"❌ ERROR: {result.get('error')}")
return False
except json.JSONDecodeError:
logger.error(f"Failed to parse response: {response}")
return False
def main():
"""Main function to test Ruby code snippets."""
# Connect to the MCP server
client = Client("sketchup")
# Check if the connection is successful
if not client.is_connected:
logger.error("Failed to connect to the SketchUp MCP server.")
return
logger.info("Connected to SketchUp MCP server.")
logger.info("=" * 50)
# Run each test case
success_count = 0
for test_case in TEST_CASES:
if test_ruby_code(client, test_case):
success_count += 1
logger.info("-" * 50)
# Summary
logger.info(f"Testing complete: {success_count}/{len(TEST_CASES)} tests passed")
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/examples/behavior_tester.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Component Behavior Tester
This script specifically tests the component behavior methods in SketchUp 25.0.574.
"""
import json
import logging
from mcp.client import Client
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("BehaviorTester")
# Ruby code to test component behavior methods
BEHAVIOR_TEST_CODE = """
# Create a new model context
model = Sketchup.active_model
model.start_operation("Test Component Behavior", true)
# Create a new component definition
definition = model.definitions.add("Test Component")
# Get the behavior object
behavior = definition.behavior
# Get all methods available on the behavior object
all_methods = behavior.methods - Object.methods
# Test setting various behavior properties
results = {}
# Test common behavior properties
properties_to_test = [
"snapto",
"cuts_opening",
"always_face_camera",
"no_scale_tool",
"shadows_face_sun",
"is_component",
"component?"
]
# Test each property
property_results = {}
for prop in properties_to_test
begin
# Try to get the property
if behavior.respond_to?(prop)
property_results[prop] = {
"exists": true,
"readable": true
}
# Try to set the property (for boolean properties, try setting to true)
setter_method = prop + "="
if behavior.respond_to?(setter_method)
if prop == "snapto"
behavior.send(setter_method, 0)
else
behavior.send(setter_method, true)
end
property_results[prop]["writable"] = true
else
property_results[prop]["writable"] = false
end
else
property_results[prop] = {
"exists": false
}
end
rescue => e
property_results[prop] = {
"exists": true,
"error": e.message
}
end
end
# End the operation
model.commit_operation
# Return the results
{
"all_methods": all_methods.sort,
"property_results": property_results
}.to_json
"""
def main():
"""Main function to test component behavior methods."""
# Connect to the MCP server
client = Client("sketchup")
# Check if the connection is successful
if not client.is_connected:
logger.error("Failed to connect to the SketchUp MCP server.")
return
logger.info("Connected to SketchUp MCP server.")
# Run the behavior test
logger.info("Testing component behavior methods...")
response = client.eval_ruby(code=BEHAVIOR_TEST_CODE)
# Parse the response
try:
result = json.loads(response)
if result.get("success"):
# Parse the JSON result
behavior_data = json.loads(result.get("result"))
# Display all available methods
logger.info("Available methods on Behavior object:")
for method in behavior_data["all_methods"]:
logger.info(f" - {method}")
# Display property test results
logger.info("\nProperty test results:")
for prop, prop_result in behavior_data["property_results"].items():
if prop_result.get("exists"):
readable = prop_result.get("readable", False)
writable = prop_result.get("writable", False)
error = prop_result.get("error")
status = []
if readable:
status.append("readable")
if writable:
status.append("writable")
if error:
logger.info(f" - {prop}: EXISTS but ERROR: {error}")
else:
logger.info(f" - {prop}: EXISTS ({', '.join(status)})")
else:
logger.info(f" - {prop}: DOES NOT EXIST")
else:
logger.error(f"Error: {result.get('error')}")
except json.JSONDecodeError as e:
logger.error(f"Failed to parse response: {e}")
logger.info("Testing completed.")
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/plan.md:
--------------------------------------------------------------------------------
```markdown
Comprehensive Woodworking API Plan
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.
1. Core Geometry Operations
1.1 Advanced Shape Creation
Implement:
Rectangular Prism: Already implemented as "cube"
Cylinder: Already implemented
Cone: Already implemented
Sphere: Already implemented
Torus/Donut: For creating rings and circular moldings
Wedge: For angled cuts and joinery
Pyramid: For decorative elements
Custom Polygon Extrusion: For arbitrary base shapes
1.2 Boolean Operations
Implement:
Union: Combine multiple shapes
Difference: Cut one shape from another (crucial for joinery)
Intersection: Keep only the overlapping portion of shapes
Split: Divide a shape along a plane
1.3 Modification Operations
Implement:
Chamfer: Create beveled edges
Fillet: Create rounded edges
Shell: Hollow out a solid with a specified wall thickness
Offset: Create parallel faces at a specified distance
Taper: Create a gradual narrowing
Twist: Rotate progressively along an axis
2. Woodworking-Specific Features
2.1 Joinery
Implement:
Mortise and Tenon: Create matching mortise and tenon joints
Dovetail: Create interlocking dovetail joints
Finger Joint: Create interlocking box joints
Lap Joint: Create overlapping joints
Miter Joint: Create angled joints
Dowel Joint: Create holes for dowels
Pocket Hole: Create angled holes for pocket screws
2.2 Wood-Specific Operations
Implement:
Grain Direction: Specify and visualize wood grain
Wood Species: Library of common wood species with appropriate textures and colors
Board Dimensioning: Convert between nominal and actual lumber dimensions
Plywood Sheet Optimization: Calculate optimal cutting patterns
2.3 Hardware
Implement:
Screws: Add various types of screws
Nails: Add various types of nails
Hinges: Add hinges with proper movement constraints
Drawer Slides: Add drawer slides with proper movement
Handles/Knobs: Add decorative hardware
3. Advanced Geometry Manipulation
3.1 Curves and Surfaces
Implement:
Bezier Curves: Create smooth curves
Splines: Create complex curves through multiple points
Loft: Create a surface between multiple profiles
Sweep: Create a surface by moving a profile along a path
Revolve: Create a surface by rotating a profile around an axis
3.2 Pattern Operations
Implement:
Linear Pattern: Create multiple copies along a line
Circular Pattern: Create multiple copies around a center
Mirror: Create a mirrored copy
Symmetry: Enforce symmetry constraints
4. Material and Appearance
4.1 Materials
Implement:
Basic Colors: Already implemented
Wood Textures: Add realistic wood grain textures
Finish Types: Stain, paint, varnish, etc.
Material Properties: Reflectivity, transparency, etc.
4.2 Rendering
Implement:
Realistic Rendering: High-quality visualization
Exploded Views: Show assembly steps
Section Views: Show internal details
5. Measurement and Analysis
5.1 Dimensioning
Implement:
Linear Dimensions: Measure distances
Angular Dimensions: Measure angles
Radius/Diameter Dimensions: Measure curves
Automatic Dimensioning: Add dimensions to all features
5.2 Analysis
Implement:
Volume Calculation: Calculate wood volume
Cost Estimation: Calculate material costs
Weight Calculation: Estimate weight based on wood species
Structural Analysis: Basic strength calculations
6. Project Management
6.1 Organization
Implement:
Component Hierarchy: Organize parts into assemblies
Layers: Organize by function or stage
Tags: Add metadata to components
6.2 Documentation
Implement:
Cut Lists: Generate cutting diagrams
Assembly Instructions: Generate step-by-step guides
Bill of Materials: List all required parts and hardware
Implementation Plan
Phase 1: Core Functionality (Current)
✅ Basic shapes (cube, cylinder, sphere, cone)
✅ Basic transformations (move, rotate, scale)
✅ Basic materials
✅ Export functionality
Phase 2: Advanced Geometry (Next)
Boolean operations (union, difference, intersection)
Additional shapes (torus, wedge, pyramid)
Chamfer and fillet operations
Curve creation and manipulation
Phase 3: Woodworking Specifics
Joinery tools (mortise and tenon, dovetail, etc.)
Wood species and grain direction
Hardware components
Phase 4: Project Management
Component organization
Dimensioning and measurement
Cut lists and bill of materials
Phase 5: Advanced Visualization
Realistic materials and textures
Enhanced rendering
Animation and assembly visualization
```
--------------------------------------------------------------------------------
/examples/arts_and_crafts_cabinet.py:
--------------------------------------------------------------------------------
```python
#!/usr/bin/env python3
"""
Arts and Crafts Cabinet Example
This example demonstrates how to use the eval_ruby feature to create
a complex arts and crafts style cabinet in SketchUp using Ruby code.
"""
import json
import logging
from mcp.client import Client
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("ArtsAndCraftsCabinetExample")
# Ruby code to create an arts and crafts cabinet
CABINET_RUBY_CODE = """
# Arts and Crafts Cabinet with Working Doors
# This script creates a stylish arts and crafts style cabinet with working doors
# that can be opened and closed using SketchUp's component functionality
def create_arts_and_crafts_cabinet
# Get the active model and start an operation for undo purposes
model = Sketchup.active_model
model.start_operation("Create Arts and Crafts Cabinet", true)
# Define cabinet dimensions (in inches)
width = 36
depth = 18
height = 72
thickness = 0.75
# Create a new component definition for the cabinet
cabinet_def = model.definitions.add("Arts and Crafts Cabinet")
entities = cabinet_def.entities
# Create the main cabinet box
create_cabinet_box(entities, width, depth, height, thickness)
# Add shelves
shelf_positions = [height/3, 2*height/3]
create_shelves(entities, width, depth, thickness, shelf_positions)
# Create doors (as nested components that can swing open)
create_doors(entities, width, depth, height, thickness)
# Add decorative elements typical of arts and crafts style
add_decorative_elements(entities, width, depth, height, thickness)
# Place the component in the model
point = Geom::Point3d.new(0, 0, 0)
transform = Geom::Transformation.new(point)
instance = model.active_entities.add_instance(cabinet_def, transform)
# End the operation
model.commit_operation
# Return the component instance ID
return instance.entityID
end
def create_cabinet_box(entities, width, depth, height, thickness)
# Bottom
bottom_points = [
[0, 0, 0],
[width, 0, 0],
[width, depth, 0],
[0, depth, 0]
]
bottom_face = entities.add_face(bottom_points)
bottom_face.pushpull(-thickness)
# Back
back_points = [
[0, depth, 0],
[width, depth, 0],
[width, depth, height],
[0, depth, height]
]
back_face = entities.add_face(back_points)
back_face.pushpull(-thickness)
# Left side
left_points = [
[0, 0, 0],
[0, depth, 0],
[0, depth, height],
[0, 0, height]
]
left_face = entities.add_face(left_points)
left_face.pushpull(-thickness)
# Right side
right_points = [
[width, 0, 0],
[width, depth, 0],
[width, depth, height],
[width, 0, height]
]
right_face = entities.add_face(right_points)
right_face.pushpull(thickness)
# Top
top_points = [
[0, 0, height],
[width, 0, height],
[width, depth, height],
[0, depth, height]
]
top_face = entities.add_face(top_points)
top_face.pushpull(thickness)
end
def create_shelves(entities, width, depth, thickness, positions)
positions.each do |z_pos|
shelf_points = [
[thickness, thickness, z_pos],
[width - thickness, thickness, z_pos],
[width - thickness, depth - thickness, z_pos],
[thickness, depth - thickness, z_pos]
]
shelf_face = entities.add_face(shelf_points)
shelf_face.pushpull(-thickness)
end
end
def create_doors(entities, width, depth, height, thickness)
# Define door dimensions
door_width = (width - thickness) / 2
door_height = height - 2 * thickness
# Create left door as a component (so it can be animated)
left_door_def = Sketchup.active_model.definitions.add("Left Cabinet Door")
# Create the door geometry in the component
door_entities = left_door_def.entities
left_door_points = [
[0, 0, 0],
[door_width, 0, 0],
[door_width, thickness, 0],
[0, thickness, 0]
]
left_door_face = door_entities.add_face(left_door_points)
left_door_face.pushpull(door_height)
# Add door details
add_door_details(door_entities, door_width, thickness, door_height)
# Place the left door component
left_hinge_point = Geom::Point3d.new(thickness, thickness, thickness)
left_transform = Geom::Transformation.new(left_hinge_point)
left_door_instance = entities.add_instance(left_door_def, left_transform)
# Set the hinge axis for animation - using correct method for SketchUp 2025
# The component behavior is already set by default
left_door_instance.definition.behavior.snapto = 0 # No automatic snapping
# Create right door (similar process)
right_door_def = Sketchup.active_model.definitions.add("Right Cabinet Door")
door_entities = right_door_def.entities
right_door_points = [
[0, 0, 0],
[door_width, 0, 0],
[door_width, thickness, 0],
[0, thickness, 0]
]
right_door_face = door_entities.add_face(right_door_points)
right_door_face.pushpull(door_height)
# Add door details
add_door_details(door_entities, door_width, thickness, door_height)
# Place the right door component
right_hinge_point = Geom::Point3d.new(width - thickness, thickness, thickness)
right_transform = Geom::Transformation.new(right_hinge_point)
right_door_instance = entities.add_instance(right_door_def, right_transform)
# Set the hinge axis for animation (flipped compared to left door)
# The component behavior is already set by default
right_door_instance.definition.behavior.snapto = 0
end
def add_door_details(entities, width, thickness, height)
# Add a decorative panel that's inset
inset = thickness / 2
panel_points = [
[inset, -thickness/2, inset],
[width - inset, -thickness/2, inset],
[width - inset, -thickness/2, height - inset],
[inset, -thickness/2, height - inset]
]
panel = entities.add_face(panel_points)
panel.pushpull(-thickness/4)
# Add a small handle
handle_position = [width - 2 * inset, -thickness * 1.5, height / 2]
handle_size = height / 20
# Create a cylinder for the handle
handle_circle = entities.add_circle(handle_position, [0, 1, 0], handle_size, 12)
handle_face = entities.add_face(handle_circle)
handle_face.pushpull(-thickness)
end
def add_decorative_elements(entities, width, depth, height, thickness)
# Add characteristic arts and crafts style base
base_height = 4
# Create a slightly wider base
base_extension = 1
base_points = [
[-base_extension, -base_extension, 0],
[width + base_extension, -base_extension, 0],
[width + base_extension, depth + base_extension, 0],
[-base_extension, depth + base_extension, 0]
]
base_face = entities.add_face(base_points)
base_face.pushpull(-base_height)
# Add corbels in the arts and crafts style
add_corbels(entities, width, depth, height, thickness)
# Add crown detail at the top
add_crown(entities, width, depth, height, thickness)
end
def add_corbels(entities, width, depth, height, thickness)
# Add decorative corbels under the top
corbel_height = 3
corbel_depth = 2
# Left front corbel
left_corbel_points = [
[thickness * 2, thickness, height - thickness - corbel_height],
[thickness * 2 + corbel_depth, thickness, height - thickness - corbel_height],
[thickness * 2 + corbel_depth, thickness, height - thickness],
[thickness * 2, thickness, height - thickness]
]
left_corbel = entities.add_face(left_corbel_points)
left_corbel.pushpull(-thickness)
# Right front corbel
right_corbel_points = [
[width - thickness * 2 - corbel_depth, thickness, height - thickness - corbel_height],
[width - thickness * 2, thickness, height - thickness - corbel_height],
[width - thickness * 2, thickness, height - thickness],
[width - thickness * 2 - corbel_depth, thickness, height - thickness]
]
right_corbel = entities.add_face(right_corbel_points)
right_corbel.pushpull(-thickness)
end
def add_crown(entities, width, depth, height, thickness)
# Add a simple crown molding at the top
crown_height = 2
crown_extension = 1.5
crown_points = [
[-crown_extension, -crown_extension, height + thickness],
[width + crown_extension, -crown_extension, height + thickness],
[width + crown_extension, depth + crown_extension, height + thickness],
[-crown_extension, depth + crown_extension, height + thickness]
]
crown_face = entities.add_face(crown_points)
crown_face.pushpull(crown_height)
# Add a slight taper to the crown
taper_points = [
[-crown_extension/2, -crown_extension/2, height + thickness + crown_height],
[width + crown_extension/2, -crown_extension/2, height + thickness + crown_height],
[width + crown_extension/2, depth + crown_extension/2, height + thickness + crown_height],
[-crown_extension/2, depth + crown_extension/2, height + thickness + crown_height]
]
taper_face = entities.add_face(taper_points)
taper_face.pushpull(crown_height/2)
end
# Execute the function to create the cabinet
create_arts_and_crafts_cabinet
"""
def main():
"""Main function to create the arts and crafts cabinet in SketchUp."""
# Connect to the MCP server
client = Client("sketchup")
# Check if the connection is successful
if not client.is_connected:
logger.error("Failed to connect to the SketchUp MCP server.")
return
logger.info("Connected to SketchUp MCP server.")
# Evaluate the Ruby code to create the cabinet
logger.info("Creating arts and crafts cabinet...")
response = client.eval_ruby(code=CABINET_RUBY_CODE)
# Parse the response
try:
result = json.loads(response)
if result.get("success"):
logger.info(f"Cabinet created successfully! Result: {result.get('result')}")
else:
logger.error(f"Failed to create cabinet: {result.get('error')}")
except json.JSONDecodeError:
logger.error(f"Failed to parse response: {response}")
logger.info("Example completed.")
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/src/sketchup_mcp/server.py:
--------------------------------------------------------------------------------
```python
from mcp.server.fastmcp import FastMCP, Context
import socket
import json
import asyncio
import logging
from dataclasses import dataclass
from contextlib import asynccontextmanager
from typing import AsyncIterator, Dict, Any, List
# Configure logging
logging.basicConfig(level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger("SketchupMCPServer")
# Define version directly to avoid pkg_resources dependency
__version__ = "0.1.17"
logger.info(f"SketchupMCP Server version {__version__} starting up")
@dataclass
class SketchupConnection:
host: str
port: int
sock: socket.socket = None
def connect(self) -> bool:
"""Connect to the Sketchup extension socket server"""
if self.sock:
try:
# Test if connection is still alive
self.sock.settimeout(0.1)
self.sock.send(b'')
return True
except (socket.error, BrokenPipeError, ConnectionResetError):
# Connection is dead, close it and reconnect
logger.info("Connection test failed, reconnecting...")
self.disconnect()
try:
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.connect((self.host, self.port))
logger.info(f"Connected to Sketchup at {self.host}:{self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Sketchup: {str(e)}")
self.sock = None
return False
def disconnect(self):
"""Disconnect from the Sketchup extension"""
if self.sock:
try:
self.sock.close()
except Exception as e:
logger.error(f"Error disconnecting from Sketchup: {str(e)}")
finally:
self.sock = None
def receive_full_response(self, sock, buffer_size=8192):
"""Receive the complete response, potentially in multiple chunks"""
chunks = []
sock.settimeout(15.0)
try:
while True:
try:
chunk = sock.recv(buffer_size)
if not chunk:
if not chunks:
raise Exception("Connection closed before receiving any data")
break
chunks.append(chunk)
try:
data = b''.join(chunks)
json.loads(data.decode('utf-8'))
logger.info(f"Received complete response ({len(data)} bytes)")
return data
except json.JSONDecodeError:
continue
except socket.timeout:
logger.warning("Socket timeout during chunked receive")
break
except (ConnectionError, BrokenPipeError, ConnectionResetError) as e:
logger.error(f"Socket connection error during receive: {str(e)}")
raise
except socket.timeout:
logger.warning("Socket timeout during chunked receive")
except Exception as e:
logger.error(f"Error during receive: {str(e)}")
raise
if chunks:
data = b''.join(chunks)
logger.info(f"Returning data after receive completion ({len(data)} bytes)")
try:
json.loads(data.decode('utf-8'))
return data
except json.JSONDecodeError:
raise Exception("Incomplete JSON response received")
else:
raise Exception("No data received")
def send_command(self, method: str, params: Dict[str, Any] = None, request_id: Any = None) -> Dict[str, Any]:
"""Send a JSON-RPC request to Sketchup and return the response"""
# Try to connect if not connected
if not self.connect():
raise ConnectionError("Not connected to Sketchup")
# Ensure we're sending a proper JSON-RPC request
if method == "tools/call" and params and "name" in params and "arguments" in params:
# This is already in the correct format
request = {
"jsonrpc": "2.0",
"method": method,
"params": params,
"id": request_id
}
else:
# This is a direct command - convert to JSON-RPC
command_name = method
command_params = params or {}
# Log the conversion
logger.info(f"Converting direct command '{command_name}' to JSON-RPC format")
request = {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": command_name,
"arguments": command_params
},
"id": request_id
}
# Maximum number of retries
max_retries = 2
retry_count = 0
while retry_count <= max_retries:
try:
logger.info(f"Sending JSON-RPC request: {request}")
# Log the exact bytes being sent
request_bytes = json.dumps(request).encode('utf-8') + b'\n'
logger.info(f"Raw bytes being sent: {request_bytes}")
self.sock.sendall(request_bytes)
logger.info(f"Request sent, waiting for response...")
self.sock.settimeout(15.0)
response_data = self.receive_full_response(self.sock)
logger.info(f"Received {len(response_data)} bytes of data")
response = json.loads(response_data.decode('utf-8'))
logger.info(f"Response parsed: {response}")
if "error" in response:
logger.error(f"Sketchup error: {response['error']}")
raise Exception(response["error"].get("message", "Unknown error from Sketchup"))
return response.get("result", {})
except (socket.timeout, ConnectionError, BrokenPipeError, ConnectionResetError) as e:
logger.warning(f"Connection error (attempt {retry_count+1}/{max_retries+1}): {str(e)}")
retry_count += 1
if retry_count <= max_retries:
logger.info(f"Retrying connection...")
self.disconnect()
if not self.connect():
logger.error("Failed to reconnect")
break
else:
logger.error(f"Max retries reached, giving up")
self.sock = None
raise Exception(f"Connection to Sketchup lost after {max_retries+1} attempts: {str(e)}")
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON response from Sketchup: {str(e)}")
if 'response_data' in locals() and response_data:
logger.error(f"Raw response (first 200 bytes): {response_data[:200]}")
raise Exception(f"Invalid response from Sketchup: {str(e)}")
except Exception as e:
logger.error(f"Error communicating with Sketchup: {str(e)}")
self.sock = None
raise Exception(f"Communication error with Sketchup: {str(e)}")
# Global connection management
_sketchup_connection = None
def get_sketchup_connection():
"""Get or create a persistent Sketchup connection"""
global _sketchup_connection
if _sketchup_connection is not None:
try:
# Test connection with a ping command
ping_request = {
"jsonrpc": "2.0",
"method": "ping",
"params": {},
"id": 0
}
_sketchup_connection.sock.sendall(json.dumps(ping_request).encode('utf-8') + b'\n')
return _sketchup_connection
except Exception as e:
logger.warning(f"Existing connection is no longer valid: {str(e)}")
try:
_sketchup_connection.disconnect()
except:
pass
_sketchup_connection = None
if _sketchup_connection is None:
_sketchup_connection = SketchupConnection(host="localhost", port=9876)
if not _sketchup_connection.connect():
logger.error("Failed to connect to Sketchup")
_sketchup_connection = None
raise Exception("Could not connect to Sketchup. Make sure the Sketchup extension is running.")
logger.info("Created new persistent connection to Sketchup")
return _sketchup_connection
@asynccontextmanager
async def server_lifespan(server: FastMCP) -> AsyncIterator[Dict[str, Any]]:
"""Manage server startup and shutdown lifecycle"""
try:
logger.info("SketchupMCP server starting up")
try:
sketchup = get_sketchup_connection()
logger.info("Successfully connected to Sketchup on startup")
except Exception as e:
logger.warning(f"Could not connect to Sketchup on startup: {str(e)}")
logger.warning("Make sure the Sketchup extension is running")
yield {}
finally:
global _sketchup_connection
if _sketchup_connection:
logger.info("Disconnecting from Sketchup")
_sketchup_connection.disconnect()
_sketchup_connection = None
logger.info("SketchupMCP server shut down")
# Create MCP server with lifespan support
mcp = FastMCP(
"SketchupMCP",
description="Sketchup integration through the Model Context Protocol",
lifespan=server_lifespan
)
# Tool endpoints
@mcp.tool()
def create_component(
ctx: Context,
type: str = "cube",
position: List[float] = None,
dimensions: List[float] = None
) -> str:
"""Create a new component in Sketchup"""
try:
logger.info(f"create_component called with type={type}, position={position}, dimensions={dimensions}, request_id={ctx.request_id}")
sketchup = get_sketchup_connection()
params = {
"name": "create_component",
"arguments": {
"type": type,
"position": position or [0,0,0],
"dimensions": dimensions or [1,1,1]
}
}
logger.info(f"Calling send_command with method='tools/call', params={params}, request_id={ctx.request_id}")
result = sketchup.send_command(
method="tools/call",
params=params,
request_id=ctx.request_id
)
logger.info(f"create_component result: {result}")
return json.dumps(result)
except Exception as e:
logger.error(f"Error in create_component: {str(e)}")
return f"Error creating component: {str(e)}"
@mcp.tool()
def delete_component(
ctx: Context,
id: str
) -> str:
"""Delete a component by ID"""
try:
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "delete_component",
"arguments": {"id": id}
},
request_id=ctx.request_id
)
return json.dumps(result)
except Exception as e:
return f"Error deleting component: {str(e)}"
@mcp.tool()
def transform_component(
ctx: Context,
id: str,
position: List[float] = None,
rotation: List[float] = None,
scale: List[float] = None
) -> str:
"""Transform a component's position, rotation, or scale"""
try:
sketchup = get_sketchup_connection()
arguments = {"id": id}
if position is not None:
arguments["position"] = position
if rotation is not None:
arguments["rotation"] = rotation
if scale is not None:
arguments["scale"] = scale
result = sketchup.send_command(
method="tools/call",
params={
"name": "transform_component",
"arguments": arguments
},
request_id=ctx.request_id
)
return json.dumps(result)
except Exception as e:
return f"Error transforming component: {str(e)}"
@mcp.tool()
def get_selection(ctx: Context) -> str:
"""Get currently selected components"""
try:
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "get_selection",
"arguments": {}
},
request_id=ctx.request_id
)
return json.dumps(result)
except Exception as e:
return f"Error getting selection: {str(e)}"
@mcp.tool()
def set_material(
ctx: Context,
id: str,
material: str
) -> str:
"""Set material for a component"""
try:
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "set_material",
"arguments": {
"id": id,
"material": material
}
},
request_id=ctx.request_id
)
return json.dumps(result)
except Exception as e:
return f"Error setting material: {str(e)}"
@mcp.tool()
def export_scene(
ctx: Context,
format: str = "skp"
) -> str:
"""Export the current scene"""
try:
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "export",
"arguments": {
"format": format
}
},
request_id=ctx.request_id
)
return json.dumps(result)
except Exception as e:
return f"Error exporting scene: {str(e)}"
@mcp.tool()
def create_mortise_tenon(
ctx: Context,
mortise_id: str,
tenon_id: str,
width: float = 1.0,
height: float = 1.0,
depth: float = 1.0,
offset_x: float = 0.0,
offset_y: float = 0.0,
offset_z: float = 0.0
) -> str:
"""Create a mortise and tenon joint between two components"""
try:
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})")
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "create_mortise_tenon",
"arguments": {
"mortise_id": mortise_id,
"tenon_id": tenon_id,
"width": width,
"height": height,
"depth": depth,
"offset_x": offset_x,
"offset_y": offset_y,
"offset_z": offset_z
}
},
request_id=ctx.request_id
)
logger.info(f"create_mortise_tenon result: {result}")
return json.dumps(result)
except Exception as e:
logger.error(f"Error in create_mortise_tenon: {str(e)}")
return f"Error creating mortise and tenon joint: {str(e)}"
@mcp.tool()
def create_dovetail(
ctx: Context,
tail_id: str,
pin_id: str,
width: float = 1.0,
height: float = 1.0,
depth: float = 1.0,
angle: float = 15.0,
num_tails: int = 3,
offset_x: float = 0.0,
offset_y: float = 0.0,
offset_z: float = 0.0
) -> str:
"""Create a dovetail joint between two components"""
try:
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}")
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "create_dovetail",
"arguments": {
"tail_id": tail_id,
"pin_id": pin_id,
"width": width,
"height": height,
"depth": depth,
"angle": angle,
"num_tails": num_tails,
"offset_x": offset_x,
"offset_y": offset_y,
"offset_z": offset_z
}
},
request_id=ctx.request_id
)
logger.info(f"create_dovetail result: {result}")
return json.dumps(result)
except Exception as e:
logger.error(f"Error in create_dovetail: {str(e)}")
return f"Error creating dovetail joint: {str(e)}"
@mcp.tool()
def create_finger_joint(
ctx: Context,
board1_id: str,
board2_id: str,
width: float = 1.0,
height: float = 1.0,
depth: float = 1.0,
num_fingers: int = 5,
offset_x: float = 0.0,
offset_y: float = 0.0,
offset_z: float = 0.0
) -> str:
"""Create a finger joint (box joint) between two components"""
try:
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}")
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "create_finger_joint",
"arguments": {
"board1_id": board1_id,
"board2_id": board2_id,
"width": width,
"height": height,
"depth": depth,
"num_fingers": num_fingers,
"offset_x": offset_x,
"offset_y": offset_y,
"offset_z": offset_z
}
},
request_id=ctx.request_id
)
logger.info(f"create_finger_joint result: {result}")
return json.dumps(result)
except Exception as e:
logger.error(f"Error in create_finger_joint: {str(e)}")
return f"Error creating finger joint: {str(e)}"
@mcp.tool()
def eval_ruby(
ctx: Context,
code: str
) -> str:
"""Evaluate arbitrary Ruby code in Sketchup"""
try:
logger.info(f"eval_ruby called with code length: {len(code)}")
sketchup = get_sketchup_connection()
result = sketchup.send_command(
method="tools/call",
params={
"name": "eval_ruby",
"arguments": {
"code": code
}
},
request_id=ctx.request_id
)
logger.info(f"eval_ruby result: {result}")
# Format the response to include the result
response = {
"success": True,
"result": result.get("content", [{"text": "Success"}])[0].get("text", "Success") if isinstance(result.get("content"), list) and len(result.get("content", [])) > 0 else "Success"
}
return json.dumps(response)
except Exception as e:
logger.error(f"Error in eval_ruby: {str(e)}")
return json.dumps({
"success": False,
"error": str(e)
})
def main():
mcp.run()
if __name__ == "__main__":
main()
```
--------------------------------------------------------------------------------
/su_mcp/su_mcp/main.rb:
--------------------------------------------------------------------------------
```ruby
require 'sketchup'
require 'json'
require 'socket'
require 'fileutils'
puts "MCP Extension loading..."
SKETCHUP_CONSOLE.show rescue nil
module SU_MCP
class Server
def initialize
@port = 9876
@server = nil
@running = false
@timer_id = nil
# Try multiple ways to show console
begin
SKETCHUP_CONSOLE.show
rescue
begin
Sketchup.send_action("showRubyPanel:")
rescue
UI.start_timer(0) { SKETCHUP_CONSOLE.show }
end
end
end
def log(msg)
begin
SKETCHUP_CONSOLE.write("MCP: #{msg}\n")
rescue
puts "MCP: #{msg}"
end
STDOUT.flush
end
def start
return if @running
begin
log "Starting server on localhost:#{@port}..."
@server = TCPServer.new('127.0.0.1', @port)
log "Server created on port #{@port}"
@running = true
@timer_id = UI.start_timer(0.1, true) {
begin
if @running
# Check for connection
ready = IO.select([@server], nil, nil, 0)
if ready
log "Connection waiting..."
client = @server.accept_nonblock
log "Client accepted"
data = client.gets
log "Raw data: #{data.inspect}"
if data
begin
# Parse the raw JSON first to check format
raw_request = JSON.parse(data)
log "Raw parsed request: #{raw_request.inspect}"
# Extract the original request ID if it exists in the raw data
original_id = nil
if data =~ /"id":\s*(\d+)/
original_id = $1.to_i
log "Found original request ID: #{original_id}"
end
# Use the raw request directly without transforming it
# Just ensure the ID is preserved if it exists
request = raw_request
if !request["id"] && original_id
request["id"] = original_id
log "Added missing ID: #{original_id}"
end
log "Processed request: #{request.inspect}"
response = handle_jsonrpc_request(request)
response_json = response.to_json + "\n"
log "Sending response: #{response_json.strip}"
client.write(response_json)
client.flush
log "Response sent"
rescue JSON::ParserError => e
log "JSON parse error: #{e.message}"
error_response = {
jsonrpc: "2.0",
error: { code: -32700, message: "Parse error" },
id: original_id
}.to_json + "\n"
client.write(error_response)
client.flush
rescue StandardError => e
log "Request error: #{e.message}"
error_response = {
jsonrpc: "2.0",
error: { code: -32603, message: e.message },
id: request ? request["id"] : original_id
}.to_json + "\n"
client.write(error_response)
client.flush
end
end
client.close
log "Client closed"
end
end
rescue IO::WaitReadable
# Normal for accept_nonblock
rescue StandardError => e
log "Timer error: #{e.message}"
log e.backtrace.join("\n")
end
}
log "Server started and listening"
rescue StandardError => e
log "Error: #{e.message}"
log e.backtrace.join("\n")
stop
end
end
def stop
log "Stopping server..."
@running = false
if @timer_id
UI.stop_timer(@timer_id)
@timer_id = nil
end
@server.close if @server
@server = nil
log "Server stopped"
end
private
def handle_jsonrpc_request(request)
log "Handling JSONRPC request: #{request.inspect}"
# Handle direct command format (for backward compatibility)
if request["command"]
tool_request = {
"method" => "tools/call",
"params" => {
"name" => request["command"],
"arguments" => request["parameters"]
},
"jsonrpc" => request["jsonrpc"] || "2.0",
"id" => request["id"]
}
log "Converting to tool request: #{tool_request.inspect}"
return handle_tool_call(tool_request)
end
# Handle jsonrpc format
case request["method"]
when "tools/call"
handle_tool_call(request)
when "resources/list"
{
jsonrpc: request["jsonrpc"] || "2.0",
result: {
resources: list_resources,
success: true
},
id: request["id"]
}
when "prompts/list"
{
jsonrpc: request["jsonrpc"] || "2.0",
result: {
prompts: [],
success: true
},
id: request["id"]
}
else
{
jsonrpc: request["jsonrpc"] || "2.0",
error: {
code: -32601,
message: "Method not found",
data: { success: false }
},
id: request["id"]
}
end
end
def list_resources
model = Sketchup.active_model
return [] unless model
model.entities.map do |entity|
{
id: entity.entityID,
type: entity.typename.downcase
}
end
end
def handle_tool_call(request)
log "Handling tool call: #{request.inspect}"
tool_name = request["params"]["name"]
args = request["params"]["arguments"]
begin
result = case tool_name
when "create_component"
create_component(args)
when "delete_component"
delete_component(args)
when "transform_component"
transform_component(args)
when "get_selection"
get_selection
when "export", "export_scene"
export_scene(args)
when "set_material"
set_material(args)
when "boolean_operation"
boolean_operation(args)
when "chamfer_edges"
chamfer_edges(args)
when "fillet_edges"
fillet_edges(args)
when "create_mortise_tenon"
create_mortise_tenon(args)
when "create_dovetail"
create_dovetail(args)
when "create_finger_joint"
create_finger_joint(args)
when "eval_ruby"
eval_ruby(args)
else
raise "Unknown tool: #{tool_name}"
end
log "Tool call result: #{result.inspect}"
if result[:success]
response = {
jsonrpc: request["jsonrpc"] || "2.0",
result: {
content: [{ type: "text", text: result[:result] || "Success" }],
isError: false,
success: true,
resourceId: result[:id]
},
id: request["id"]
}
log "Sending success response: #{response.inspect}"
response
else
response = {
jsonrpc: request["jsonrpc"] || "2.0",
error: {
code: -32603,
message: "Operation failed",
data: { success: false }
},
id: request["id"]
}
log "Sending error response: #{response.inspect}"
response
end
rescue StandardError => e
log "Tool call error: #{e.message}"
response = {
jsonrpc: request["jsonrpc"] || "2.0",
error: {
code: -32603,
message: e.message,
data: { success: false }
},
id: request["id"]
}
log "Sending error response: #{response.inspect}"
response
end
end
def create_component(params)
log "Creating component with params: #{params.inspect}"
model = Sketchup.active_model
log "Got active model: #{model.inspect}"
entities = model.active_entities
log "Got active entities: #{entities.inspect}"
pos = params["position"] || [0,0,0]
dims = params["dimensions"] || [1,1,1]
case params["type"]
when "cube"
log "Creating cube at position #{pos.inspect} with dimensions #{dims.inspect}"
begin
group = entities.add_group
log "Created group: #{group.inspect}"
face = group.entities.add_face(
[pos[0], pos[1], pos[2]],
[pos[0] + dims[0], pos[1], pos[2]],
[pos[0] + dims[0], pos[1] + dims[1], pos[2]],
[pos[0], pos[1] + dims[1], pos[2]]
)
log "Created face: #{face.inspect}"
face.pushpull(dims[2])
log "Pushed/pulled face by #{dims[2]}"
result = {
id: group.entityID,
success: true
}
log "Returning result: #{result.inspect}"
result
rescue StandardError => e
log "Error in create_component: #{e.message}"
log e.backtrace.join("\n")
raise
end
when "cylinder"
log "Creating cylinder at position #{pos.inspect} with dimensions #{dims.inspect}"
begin
# Create a group to contain the cylinder
group = entities.add_group
# Extract dimensions
radius = dims[0] / 2.0
height = dims[2]
# Create a circle at the base
center = [pos[0] + radius, pos[1] + radius, pos[2]]
# Create points for a circle
num_segments = 24 # Number of segments for the circle
circle_points = []
num_segments.times do |i|
angle = Math::PI * 2 * i / num_segments
x = center[0] + radius * Math.cos(angle)
y = center[1] + radius * Math.sin(angle)
z = center[2]
circle_points << [x, y, z]
end
# Create the circular face
face = group.entities.add_face(circle_points)
# Extrude the face to create the cylinder
face.pushpull(height)
result = {
id: group.entityID,
success: true
}
log "Created cylinder, returning result: #{result.inspect}"
result
rescue StandardError => e
log "Error creating cylinder: #{e.message}"
log e.backtrace.join("\n")
raise
end
when "sphere"
log "Creating sphere at position #{pos.inspect} with dimensions #{dims.inspect}"
begin
# Create a group to contain the sphere
group = entities.add_group
# Extract dimensions
radius = dims[0] / 2.0
center = [pos[0] + radius, pos[1] + radius, pos[2] + radius]
# Use SketchUp's built-in sphere method if available
if Sketchup::Tools.respond_to?(:create_sphere)
Sketchup::Tools.create_sphere(center, radius, 24, group.entities)
else
# Fallback implementation using polygons
# Create a UV sphere with latitude and longitude segments
segments = 16
# Create points for the sphere
points = []
for lat_i in 0..segments
lat = Math::PI * lat_i / segments
for lon_i in 0..segments
lon = 2 * Math::PI * lon_i / segments
x = center[0] + radius * Math.sin(lat) * Math.cos(lon)
y = center[1] + radius * Math.sin(lat) * Math.sin(lon)
z = center[2] + radius * Math.cos(lat)
points << [x, y, z]
end
end
# Create faces for the sphere (simplified approach)
for lat_i in 0...segments
for lon_i in 0...segments
i1 = lat_i * (segments + 1) + lon_i
i2 = i1 + 1
i3 = i1 + segments + 1
i4 = i3 + 1
# Create a quad face
begin
group.entities.add_face(points[i1], points[i2], points[i4], points[i3])
rescue StandardError => e
# Skip faces that can't be created (may happen at poles)
log "Skipping face: #{e.message}"
end
end
end
end
result = {
id: group.entityID,
success: true
}
log "Created sphere, returning result: #{result.inspect}"
result
rescue StandardError => e
log "Error creating sphere: #{e.message}"
log e.backtrace.join("\n")
raise
end
when "cone"
log "Creating cone at position #{pos.inspect} with dimensions #{dims.inspect}"
begin
# Create a group to contain the cone
group = entities.add_group
# Extract dimensions
radius = dims[0] / 2.0
height = dims[2]
# Create a circle at the base
center = [pos[0] + radius, pos[1] + radius, pos[2]]
apex = [center[0], center[1], center[2] + height]
# Create points for a circle
num_segments = 24 # Number of segments for the circle
circle_points = []
num_segments.times do |i|
angle = Math::PI * 2 * i / num_segments
x = center[0] + radius * Math.cos(angle)
y = center[1] + radius * Math.sin(angle)
z = center[2]
circle_points << [x, y, z]
end
# Create the circular face for the base
base = group.entities.add_face(circle_points)
# Create the cone sides
(0...num_segments).each do |i|
j = (i + 1) % num_segments
# Create a triangular face from two adjacent points on the circle to the apex
group.entities.add_face(circle_points[i], circle_points[j], apex)
end
result = {
id: group.entityID,
success: true
}
log "Created cone, returning result: #{result.inspect}"
result
rescue StandardError => e
log "Error creating cone: #{e.message}"
log e.backtrace.join("\n")
raise
end
else
raise "Unknown component type: #{params["type"]}"
end
end
def delete_component(params)
model = Sketchup.active_model
# Handle ID format - strip quotes if present
id_str = params["id"].to_s.gsub('"', '')
log "Looking for entity with ID: #{id_str}"
entity = model.find_entity_by_id(id_str.to_i)
if entity
log "Found entity: #{entity.inspect}"
entity.erase!
{ success: true }
else
raise "Entity not found"
end
end
def transform_component(params)
model = Sketchup.active_model
# Handle ID format - strip quotes if present
id_str = params["id"].to_s.gsub('"', '')
log "Looking for entity with ID: #{id_str}"
entity = model.find_entity_by_id(id_str.to_i)
if entity
log "Found entity: #{entity.inspect}"
# Handle position
if params["position"]
pos = params["position"]
log "Transforming position to #{pos.inspect}"
# Create a transformation to move the entity
translation = Geom::Transformation.translation(Geom::Point3d.new(pos[0], pos[1], pos[2]))
entity.transform!(translation)
end
# Handle rotation (in degrees)
if params["rotation"]
rot = params["rotation"]
log "Rotating by #{rot.inspect} degrees"
# Convert to radians
x_rot = rot[0] * Math::PI / 180
y_rot = rot[1] * Math::PI / 180
z_rot = rot[2] * Math::PI / 180
# Apply rotations
if rot[0] != 0
rotation = Geom::Transformation.rotation(entity.bounds.center, Geom::Vector3d.new(1, 0, 0), x_rot)
entity.transform!(rotation)
end
if rot[1] != 0
rotation = Geom::Transformation.rotation(entity.bounds.center, Geom::Vector3d.new(0, 1, 0), y_rot)
entity.transform!(rotation)
end
if rot[2] != 0
rotation = Geom::Transformation.rotation(entity.bounds.center, Geom::Vector3d.new(0, 0, 1), z_rot)
entity.transform!(rotation)
end
end
# Handle scale
if params["scale"]
scale = params["scale"]
log "Scaling by #{scale.inspect}"
# Create a transformation to scale the entity
center = entity.bounds.center
scaling = Geom::Transformation.scaling(center, scale[0], scale[1], scale[2])
entity.transform!(scaling)
end
{ success: true, id: entity.entityID }
else
raise "Entity not found"
end
end
def get_selection
model = Sketchup.active_model
selection = model.selection
log "Getting selection, count: #{selection.length}"
selected_entities = selection.map do |entity|
{
id: entity.entityID,
type: entity.typename.downcase
}
end
{ success: true, entities: selected_entities }
end
def export_scene(params)
log "Exporting scene with params: #{params.inspect}"
model = Sketchup.active_model
format = params["format"] || "skp"
begin
# Create a temporary directory for exports
temp_dir = File.join(ENV['TEMP'] || ENV['TMP'] || Dir.tmpdir, "sketchup_exports")
FileUtils.mkdir_p(temp_dir) unless Dir.exist?(temp_dir)
# Generate a unique filename
timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
filename = "sketchup_export_#{timestamp}"
case format.downcase
when "skp"
# Export as SketchUp file
export_path = File.join(temp_dir, "#{filename}.skp")
log "Exporting to SketchUp file: #{export_path}"
model.save(export_path)
when "obj"
# Export as OBJ file
export_path = File.join(temp_dir, "#{filename}.obj")
log "Exporting to OBJ file: #{export_path}"
# Check if OBJ exporter is available
if Sketchup.require("sketchup.rb")
options = {
:triangulated_faces => true,
:double_sided_faces => true,
:edges => false,
:texture_maps => true
}
model.export(export_path, options)
else
raise "OBJ exporter not available"
end
when "dae"
# Export as COLLADA file
export_path = File.join(temp_dir, "#{filename}.dae")
log "Exporting to COLLADA file: #{export_path}"
# Check if COLLADA exporter is available
if Sketchup.require("sketchup.rb")
options = { :triangulated_faces => true }
model.export(export_path, options)
else
raise "COLLADA exporter not available"
end
when "stl"
# Export as STL file
export_path = File.join(temp_dir, "#{filename}.stl")
log "Exporting to STL file: #{export_path}"
# Check if STL exporter is available
if Sketchup.require("sketchup.rb")
options = { :units => "model" }
model.export(export_path, options)
else
raise "STL exporter not available"
end
when "png", "jpg", "jpeg"
# Export as image
ext = format.downcase == "jpg" ? "jpeg" : format.downcase
export_path = File.join(temp_dir, "#{filename}.#{ext}")
log "Exporting to image file: #{export_path}"
# Get the current view
view = model.active_view
# Set up options for the export
options = {
:filename => export_path,
:width => params["width"] || 1920,
:height => params["height"] || 1080,
:antialias => true,
:transparent => (ext == "png")
}
# Export the image
view.write_image(options)
else
raise "Unsupported export format: #{format}"
end
log "Export completed successfully to: #{export_path}"
{
success: true,
path: export_path,
format: format
}
rescue StandardError => e
log "Error in export_scene: #{e.message}"
log e.backtrace.join("\n")
raise
end
end
def set_material(params)
log "Setting material with params: #{params.inspect}"
model = Sketchup.active_model
# Handle ID format - strip quotes if present
id_str = params["id"].to_s.gsub('"', '')
log "Looking for entity with ID: #{id_str}"
entity = model.find_entity_by_id(id_str.to_i)
if entity
log "Found entity: #{entity.inspect}"
material_name = params["material"]
log "Setting material to: #{material_name}"
# Get or create the material
material = model.materials[material_name]
if !material
# Create a new material if it doesn't exist
material = model.materials.add(material_name)
# Handle color specification
case material_name.downcase
when "red"
material.color = Sketchup::Color.new(255, 0, 0)
when "green"
material.color = Sketchup::Color.new(0, 255, 0)
when "blue"
material.color = Sketchup::Color.new(0, 0, 255)
when "yellow"
material.color = Sketchup::Color.new(255, 255, 0)
when "cyan", "turquoise"
material.color = Sketchup::Color.new(0, 255, 255)
when "magenta", "purple"
material.color = Sketchup::Color.new(255, 0, 255)
when "white"
material.color = Sketchup::Color.new(255, 255, 255)
when "black"
material.color = Sketchup::Color.new(0, 0, 0)
when "brown"
material.color = Sketchup::Color.new(139, 69, 19)
when "orange"
material.color = Sketchup::Color.new(255, 165, 0)
when "gray", "grey"
material.color = Sketchup::Color.new(128, 128, 128)
else
# If it's a hex color code like "#FF0000"
if material_name.start_with?("#") && material_name.length == 7
begin
r = material_name[1..2].to_i(16)
g = material_name[3..4].to_i(16)
b = material_name[5..6].to_i(16)
material.color = Sketchup::Color.new(r, g, b)
rescue
# Default to a wood color if parsing fails
material.color = Sketchup::Color.new(184, 134, 72)
end
else
# Default to a wood color
material.color = Sketchup::Color.new(184, 134, 72)
end
end
end
# Apply the material to the entity
if entity.respond_to?(:material=)
entity.material = material
elsif entity.is_a?(Sketchup::Group) || entity.is_a?(Sketchup::ComponentInstance)
# For groups and components, we need to apply to all faces
entities = entity.is_a?(Sketchup::Group) ? entity.entities : entity.definition.entities
entities.grep(Sketchup::Face).each { |face| face.material = material }
end
{ success: true, id: entity.entityID }
else
raise "Entity not found"
end
end
def boolean_operation(params)
log "Performing boolean operation with params: #{params.inspect}"
model = Sketchup.active_model
# Get operation type
operation_type = params["operation"]
unless ["union", "difference", "intersection"].include?(operation_type)
raise "Invalid boolean operation: #{operation_type}. Must be 'union', 'difference', or 'intersection'."
end
# Get target and tool entities
target_id = params["target_id"].to_s.gsub('"', '')
tool_id = params["tool_id"].to_s.gsub('"', '')
log "Looking for target entity with ID: #{target_id}"
target_entity = model.find_entity_by_id(target_id.to_i)
log "Looking for tool entity with ID: #{tool_id}"
tool_entity = model.find_entity_by_id(tool_id.to_i)
unless target_entity && tool_entity
missing = []
missing << "target" unless target_entity
missing << "tool" unless tool_entity
raise "Entity not found: #{missing.join(', ')}"
end
# Ensure both entities are groups or component instances
unless (target_entity.is_a?(Sketchup::Group) || target_entity.is_a?(Sketchup::ComponentInstance)) &&
(tool_entity.is_a?(Sketchup::Group) || tool_entity.is_a?(Sketchup::ComponentInstance))
raise "Boolean operations require groups or component instances"
end
# Create a new group to hold the result
result_group = model.active_entities.add_group
# Perform the boolean operation
case operation_type
when "union"
log "Performing union operation"
perform_union(target_entity, tool_entity, result_group)
when "difference"
log "Performing difference operation"
perform_difference(target_entity, tool_entity, result_group)
when "intersection"
log "Performing intersection operation"
perform_intersection(target_entity, tool_entity, result_group)
end
# Clean up original entities if requested
if params["delete_originals"]
target_entity.erase! if target_entity.valid?
tool_entity.erase! if tool_entity.valid?
end
# Return the result
{
success: true,
id: result_group.entityID
}
end
def perform_union(target, tool, result_group)
model = Sketchup.active_model
# Create temporary copies of the target and tool
target_copy = target.copy
tool_copy = tool.copy
# Get the transformation of each entity
target_transform = target.transformation
tool_transform = tool.transformation
# Apply the transformations to the copies
target_copy.transform!(target_transform)
tool_copy.transform!(tool_transform)
# Get the entities from the copies
target_entities = target_copy.is_a?(Sketchup::Group) ? target_copy.entities : target_copy.definition.entities
tool_entities = tool_copy.is_a?(Sketchup::Group) ? tool_copy.entities : tool_copy.definition.entities
# Copy all entities from target to result
target_entities.each do |entity|
entity.copy(result_group.entities)
end
# Copy all entities from tool to result
tool_entities.each do |entity|
entity.copy(result_group.entities)
end
# Clean up temporary copies
target_copy.erase!
tool_copy.erase!
# Outer shell - this will merge overlapping geometry
result_group.entities.outer_shell
end
def perform_difference(target, tool, result_group)
model = Sketchup.active_model
# Create temporary copies of the target and tool
target_copy = target.copy
tool_copy = tool.copy
# Get the transformation of each entity
target_transform = target.transformation
tool_transform = tool.transformation
# Apply the transformations to the copies
target_copy.transform!(target_transform)
tool_copy.transform!(tool_transform)
# Get the entities from the copies
target_entities = target_copy.is_a?(Sketchup::Group) ? target_copy.entities : target_copy.definition.entities
tool_entities = tool_copy.is_a?(Sketchup::Group) ? tool_copy.entities : tool_copy.definition.entities
# Copy all entities from target to result
target_entities.each do |entity|
entity.copy(result_group.entities)
end
# Create a temporary group for the tool
temp_tool_group = model.active_entities.add_group
# Copy all entities from tool to temp group
tool_entities.each do |entity|
entity.copy(temp_tool_group.entities)
end
# Subtract the tool from the result
result_group.entities.subtract(temp_tool_group.entities)
# Clean up temporary copies and groups
target_copy.erase!
tool_copy.erase!
temp_tool_group.erase!
end
def perform_intersection(target, tool, result_group)
model = Sketchup.active_model
# Create temporary copies of the target and tool
target_copy = target.copy
tool_copy = tool.copy
# Get the transformation of each entity
target_transform = target.transformation
tool_transform = tool.transformation
# Apply the transformations to the copies
target_copy.transform!(target_transform)
tool_copy.transform!(tool_transform)
# Get the entities from the copies
target_entities = target_copy.is_a?(Sketchup::Group) ? target_copy.entities : target_copy.definition.entities
tool_entities = tool_copy.is_a?(Sketchup::Group) ? tool_copy.entities : tool_copy.definition.entities
# Create temporary groups for target and tool
temp_target_group = model.active_entities.add_group
temp_tool_group = model.active_entities.add_group
# Copy all entities from target and tool to temp groups
target_entities.each do |entity|
entity.copy(temp_target_group.entities)
end
tool_entities.each do |entity|
entity.copy(temp_tool_group.entities)
end
# Perform the intersection
result_group.entities.intersect_with(temp_target_group.entities, temp_tool_group.entities)
# Clean up temporary copies and groups
target_copy.erase!
tool_copy.erase!
temp_target_group.erase!
temp_tool_group.erase!
end
def chamfer_edges(params)
log "Chamfering edges with params: #{params.inspect}"
model = Sketchup.active_model
# Get entity ID
entity_id = params["entity_id"].to_s.gsub('"', '')
log "Looking for entity with ID: #{entity_id}"
entity = model.find_entity_by_id(entity_id.to_i)
unless entity
raise "Entity not found: #{entity_id}"
end
# Ensure entity is a group or component instance
unless entity.is_a?(Sketchup::Group) || entity.is_a?(Sketchup::ComponentInstance)
raise "Chamfer operation requires a group or component instance"
end
# Get the distance parameter
distance = params["distance"] || 0.5
# Get the entities collection
entities = entity.is_a?(Sketchup::Group) ? entity.entities : entity.definition.entities
# Find all edges in the entity
edges = entities.grep(Sketchup::Edge)
# If specific edges are provided, filter the edges
if params["edge_indices"] && params["edge_indices"].is_a?(Array)
edge_indices = params["edge_indices"]
edges = edges.select.with_index { |_, i| edge_indices.include?(i) }
end
# Create a new group to hold the result
result_group = model.active_entities.add_group
# Copy all entities from the original to the result
entities.each do |e|
e.copy(result_group.entities)
end
# Get the edges in the result group
result_edges = result_group.entities.grep(Sketchup::Edge)
# If specific edges were provided, filter the result edges
if params["edge_indices"] && params["edge_indices"].is_a?(Array)
edge_indices = params["edge_indices"]
result_edges = result_edges.select.with_index { |_, i| edge_indices.include?(i) }
end
# Perform the chamfer operation
begin
# Create a transformation for the chamfer
chamfer_transform = Geom::Transformation.scaling(1.0 - distance)
# For each edge, create a chamfer
result_edges.each do |edge|
# Get the faces connected to this edge
faces = edge.faces
next if faces.length < 2
# Get the start and end points of the edge
start_point = edge.start.position
end_point = edge.end.position
# Calculate the midpoint of the edge
midpoint = Geom::Point3d.new(
(start_point.x + end_point.x) / 2.0,
(start_point.y + end_point.y) / 2.0,
(start_point.z + end_point.z) / 2.0
)
# Create a chamfer by creating a new face
# This is a simplified approach - in a real implementation,
# you would need to handle various edge cases
new_points = []
# For each vertex of the edge
[edge.start, edge.end].each do |vertex|
# Get all edges connected to this vertex
connected_edges = vertex.edges - [edge]
# For each connected edge
connected_edges.each do |connected_edge|
# Get the other vertex of the connected edge
other_vertex = (connected_edge.vertices - [vertex])[0]
# Calculate a point along the connected edge
direction = other_vertex.position - vertex.position
new_point = vertex.position.offset(direction, distance)
new_points << new_point
end
end
# Create a new face using the new points
if new_points.length >= 3
result_group.entities.add_face(new_points)
end
end
# Clean up the original entity if requested
if params["delete_original"]
entity.erase! if entity.valid?
end
# Return the result
{
success: true,
id: result_group.entityID
}
rescue StandardError => e
log "Error in chamfer_edges: #{e.message}"
log e.backtrace.join("\n")
# Clean up the result group if there was an error
result_group.erase! if result_group.valid?
raise
end
end
def fillet_edges(params)
log "Filleting edges with params: #{params.inspect}"
model = Sketchup.active_model
# Get entity ID
entity_id = params["entity_id"].to_s.gsub('"', '')
log "Looking for entity with ID: #{entity_id}"
entity = model.find_entity_by_id(entity_id.to_i)
unless entity
raise "Entity not found: #{entity_id}"
end
# Ensure entity is a group or component instance
unless entity.is_a?(Sketchup::Group) || entity.is_a?(Sketchup::ComponentInstance)
raise "Fillet operation requires a group or component instance"
end
# Get the radius parameter
radius = params["radius"] || 0.5
# Get the number of segments for the fillet
segments = params["segments"] || 8
# Get the entities collection
entities = entity.is_a?(Sketchup::Group) ? entity.entities : entity.definition.entities
# Find all edges in the entity
edges = entities.grep(Sketchup::Edge)
# If specific edges are provided, filter the edges
if params["edge_indices"] && params["edge_indices"].is_a?(Array)
edge_indices = params["edge_indices"]
edges = edges.select.with_index { |_, i| edge_indices.include?(i) }
end
# Create a new group to hold the result
result_group = model.active_entities.add_group
# Copy all entities from the original to the result
entities.each do |e|
e.copy(result_group.entities)
end
# Get the edges in the result group
result_edges = result_group.entities.grep(Sketchup::Edge)
# If specific edges were provided, filter the result edges
if params["edge_indices"] && params["edge_indices"].is_a?(Array)
edge_indices = params["edge_indices"]
result_edges = result_edges.select.with_index { |_, i| edge_indices.include?(i) }
end
# Perform the fillet operation
begin
# For each edge, create a fillet
result_edges.each do |edge|
# Get the faces connected to this edge
faces = edge.faces
next if faces.length < 2
# Get the start and end points of the edge
start_point = edge.start.position
end_point = edge.end.position
# Calculate the midpoint of the edge
midpoint = Geom::Point3d.new(
(start_point.x + end_point.x) / 2.0,
(start_point.y + end_point.y) / 2.0,
(start_point.z + end_point.z) / 2.0
)
# Calculate the edge vector
edge_vector = end_point - start_point
edge_length = edge_vector.length
# Create points for the fillet curve
fillet_points = []
# Create a series of points along a circular arc
(0..segments).each do |i|
angle = Math::PI * i / segments
# Calculate the point on the arc
x = midpoint.x + radius * Math.cos(angle)
y = midpoint.y + radius * Math.sin(angle)
z = midpoint.z
fillet_points << Geom::Point3d.new(x, y, z)
end
# Create edges connecting the fillet points
(0...fillet_points.length - 1).each do |i|
result_group.entities.add_line(fillet_points[i], fillet_points[i+1])
end
# Create a face from the fillet points
if fillet_points.length >= 3
result_group.entities.add_face(fillet_points)
end
end
# Clean up the original entity if requested
if params["delete_original"]
entity.erase! if entity.valid?
end
# Return the result
{
success: true,
id: result_group.entityID
}
rescue StandardError => e
log "Error in fillet_edges: #{e.message}"
log e.backtrace.join("\n")
# Clean up the result group if there was an error
result_group.erase! if result_group.valid?
raise
end
end
def create_mortise_tenon(params)
log "Creating mortise and tenon joint with params: #{params.inspect}"
model = Sketchup.active_model
# Get the mortise and tenon board IDs
mortise_id = params["mortise_id"].to_s.gsub('"', '')
tenon_id = params["tenon_id"].to_s.gsub('"', '')
log "Looking for mortise board with ID: #{mortise_id}"
mortise_board = model.find_entity_by_id(mortise_id.to_i)
log "Looking for tenon board with ID: #{tenon_id}"
tenon_board = model.find_entity_by_id(tenon_id.to_i)
unless mortise_board && tenon_board
missing = []
missing << "mortise board" unless mortise_board
missing << "tenon board" unless tenon_board
raise "Entity not found: #{missing.join(', ')}"
end
# Ensure both entities are groups or component instances
unless (mortise_board.is_a?(Sketchup::Group) || mortise_board.is_a?(Sketchup::ComponentInstance)) &&
(tenon_board.is_a?(Sketchup::Group) || tenon_board.is_a?(Sketchup::ComponentInstance))
raise "Mortise and tenon operation requires groups or component instances"
end
# Get joint parameters
width = params["width"] || 1.0
height = params["height"] || 1.0
depth = params["depth"] || 1.0
offset_x = params["offset_x"] || 0.0
offset_y = params["offset_y"] || 0.0
offset_z = params["offset_z"] || 0.0
# Get the bounds of both boards
mortise_bounds = mortise_board.bounds
tenon_bounds = tenon_board.bounds
# Determine the face to place the joint on based on the relative positions of the boards
mortise_center = mortise_bounds.center
tenon_center = tenon_bounds.center
# Calculate the direction vector from mortise to tenon
direction_vector = tenon_center - mortise_center
# Determine which face of the mortise board is closest to the tenon board
mortise_face_direction = determine_closest_face(direction_vector)
# Create the mortise (hole) in the mortise board
mortise_result = create_mortise(
mortise_board,
width,
height,
depth,
mortise_face_direction,
mortise_bounds,
offset_x,
offset_y,
offset_z
)
# Determine which face of the tenon board is closest to the mortise board
tenon_face_direction = determine_closest_face(direction_vector.reverse)
# Create the tenon (projection) on the tenon board
tenon_result = create_tenon(
tenon_board,
width,
height,
depth,
tenon_face_direction,
tenon_bounds,
offset_x,
offset_y,
offset_z
)
# Return the result
{
success: true,
mortise_id: mortise_result[:id],
tenon_id: tenon_result[:id]
}
end
def determine_closest_face(direction_vector)
# Normalize the direction vector
direction_vector.normalize!
# Determine which axis has the largest component
x_abs = direction_vector.x.abs
y_abs = direction_vector.y.abs
z_abs = direction_vector.z.abs
if x_abs >= y_abs && x_abs >= z_abs
# X-axis is dominant
return direction_vector.x > 0 ? :east : :west
elsif y_abs >= x_abs && y_abs >= z_abs
# Y-axis is dominant
return direction_vector.y > 0 ? :north : :south
else
# Z-axis is dominant
return direction_vector.z > 0 ? :top : :bottom
end
end
def create_mortise(board, width, height, depth, face_direction, bounds, offset_x, offset_y, offset_z)
model = Sketchup.active_model
# Get the board's entities
entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
# Calculate the position of the mortise based on the face direction
mortise_position = calculate_position_on_face(face_direction, bounds, width, height, depth, offset_x, offset_y, offset_z)
log "Creating mortise at position: #{mortise_position.inspect} with dimensions: #{[width, height, depth].inspect}"
# Create a box for the mortise
mortise_group = entities.add_group
# Create the mortise box with the correct orientation
case face_direction
when :east, :west
# Mortise on east or west face (YZ plane)
mortise_face = mortise_group.entities.add_face(
[mortise_position[0], mortise_position[1], mortise_position[2]],
[mortise_position[0], mortise_position[1] + width, mortise_position[2]],
[mortise_position[0], mortise_position[1] + width, mortise_position[2] + height],
[mortise_position[0], mortise_position[1], mortise_position[2] + height]
)
mortise_face.pushpull(face_direction == :east ? -depth : depth)
when :north, :south
# Mortise on north or south face (XZ plane)
mortise_face = mortise_group.entities.add_face(
[mortise_position[0], mortise_position[1], mortise_position[2]],
[mortise_position[0] + width, mortise_position[1], mortise_position[2]],
[mortise_position[0] + width, mortise_position[1], mortise_position[2] + height],
[mortise_position[0], mortise_position[1], mortise_position[2] + height]
)
mortise_face.pushpull(face_direction == :north ? -depth : depth)
when :top, :bottom
# Mortise on top or bottom face (XY plane)
mortise_face = mortise_group.entities.add_face(
[mortise_position[0], mortise_position[1], mortise_position[2]],
[mortise_position[0] + width, mortise_position[1], mortise_position[2]],
[mortise_position[0] + width, mortise_position[1] + height, mortise_position[2]],
[mortise_position[0], mortise_position[1] + height, mortise_position[2]]
)
mortise_face.pushpull(face_direction == :top ? -depth : depth)
end
# Subtract the mortise from the board
entities.subtract(mortise_group.entities)
# Clean up the temporary group
mortise_group.erase!
# Return the result
{
success: true,
id: board.entityID
}
end
def create_tenon(board, width, height, depth, face_direction, bounds, offset_x, offset_y, offset_z)
model = Sketchup.active_model
# Get the board's entities
entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
# Calculate the position of the tenon based on the face direction
tenon_position = calculate_position_on_face(face_direction, bounds, width, height, depth, offset_x, offset_y, offset_z)
log "Creating tenon at position: #{tenon_position.inspect} with dimensions: #{[width, height, depth].inspect}"
# Create a box for the tenon
tenon_group = model.active_entities.add_group
# Create the tenon box with the correct orientation
case face_direction
when :east, :west
# Tenon on east or west face (YZ plane)
tenon_face = tenon_group.entities.add_face(
[tenon_position[0], tenon_position[1], tenon_position[2]],
[tenon_position[0], tenon_position[1] + width, tenon_position[2]],
[tenon_position[0], tenon_position[1] + width, tenon_position[2] + height],
[tenon_position[0], tenon_position[1], tenon_position[2] + height]
)
tenon_face.pushpull(face_direction == :east ? depth : -depth)
when :north, :south
# Tenon on north or south face (XZ plane)
tenon_face = tenon_group.entities.add_face(
[tenon_position[0], tenon_position[1], tenon_position[2]],
[tenon_position[0] + width, tenon_position[1], tenon_position[2]],
[tenon_position[0] + width, tenon_position[1], tenon_position[2] + height],
[tenon_position[0], tenon_position[1], tenon_position[2] + height]
)
tenon_face.pushpull(face_direction == :north ? depth : -depth)
when :top, :bottom
# Tenon on top or bottom face (XY plane)
tenon_face = tenon_group.entities.add_face(
[tenon_position[0], tenon_position[1], tenon_position[2]],
[tenon_position[0] + width, tenon_position[1], tenon_position[2]],
[tenon_position[0] + width, tenon_position[1] + height, tenon_position[2]],
[tenon_position[0], tenon_position[1] + height, tenon_position[2]]
)
tenon_face.pushpull(face_direction == :top ? depth : -depth)
end
# Get the transformation of the board
board_transform = board.transformation
# Apply the inverse transformation to the tenon group
tenon_group.transform!(board_transform.inverse)
# Union the tenon with the board
board_entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
board_entities.add_instance(tenon_group.entities.parent, Geom::Transformation.new)
# Clean up the temporary group
tenon_group.erase!
# Return the result
{
success: true,
id: board.entityID
}
end
def calculate_position_on_face(face_direction, bounds, width, height, depth, offset_x, offset_y, offset_z)
# Calculate the position on the specified face with offsets
case face_direction
when :east
# Position on the east face (max X)
[
bounds.max.x,
bounds.center.y - width/2 + offset_y,
bounds.center.z - height/2 + offset_z
]
when :west
# Position on the west face (min X)
[
bounds.min.x,
bounds.center.y - width/2 + offset_y,
bounds.center.z - height/2 + offset_z
]
when :north
# Position on the north face (max Y)
[
bounds.center.x - width/2 + offset_x,
bounds.max.y,
bounds.center.z - height/2 + offset_z
]
when :south
# Position on the south face (min Y)
[
bounds.center.x - width/2 + offset_x,
bounds.min.y,
bounds.center.z - height/2 + offset_z
]
when :top
# Position on the top face (max Z)
[
bounds.center.x - width/2 + offset_x,
bounds.center.y - height/2 + offset_y,
bounds.max.z
]
when :bottom
# Position on the bottom face (min Z)
[
bounds.center.x - width/2 + offset_x,
bounds.center.y - height/2 + offset_y,
bounds.min.z
]
end
end
def create_dovetail(params)
log "Creating dovetail joint with params: #{params.inspect}"
model = Sketchup.active_model
# Get the tail and pin board IDs
tail_id = params["tail_id"].to_s.gsub('"', '')
pin_id = params["pin_id"].to_s.gsub('"', '')
log "Looking for tail board with ID: #{tail_id}"
tail_board = model.find_entity_by_id(tail_id.to_i)
log "Looking for pin board with ID: #{pin_id}"
pin_board = model.find_entity_by_id(pin_id.to_i)
unless tail_board && pin_board
missing = []
missing << "tail board" unless tail_board
missing << "pin board" unless pin_board
raise "Entity not found: #{missing.join(', ')}"
end
# Ensure both entities are groups or component instances
unless (tail_board.is_a?(Sketchup::Group) || tail_board.is_a?(Sketchup::ComponentInstance)) &&
(pin_board.is_a?(Sketchup::Group) || pin_board.is_a?(Sketchup::ComponentInstance))
raise "Dovetail operation requires groups or component instances"
end
# Get joint parameters
width = params["width"] || 1.0
height = params["height"] || 2.0
depth = params["depth"] || 1.0
angle = params["angle"] || 15.0 # Dovetail angle in degrees
num_tails = params["num_tails"] || 3
offset_x = params["offset_x"] || 0.0
offset_y = params["offset_y"] || 0.0
offset_z = params["offset_z"] || 0.0
# Create the tails on the tail board
tail_result = create_tails(tail_board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
# Create the pins on the pin board
pin_result = create_pins(pin_board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
# Return the result
{
success: true,
tail_id: tail_result[:id],
pin_id: pin_result[:id]
}
end
def create_tails(board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
model = Sketchup.active_model
# Get the board's entities
entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
# Get the board's bounds
bounds = board.bounds
# Calculate the position of the dovetail joint
center_x = bounds.center.x + offset_x
center_y = bounds.center.y + offset_y
center_z = bounds.center.z + offset_z
# Calculate the width of each tail and space
total_width = width
tail_width = total_width / (2 * num_tails - 1)
# Create a group for the tails
tails_group = entities.add_group
# Create each tail
num_tails.times do |i|
# Calculate the position of this tail
tail_center_x = center_x - width/2 + tail_width * (2 * i)
# Calculate the dovetail shape
angle_rad = angle * Math::PI / 180.0
tail_top_width = tail_width
tail_bottom_width = tail_width + 2 * depth * Math.tan(angle_rad)
# Create the tail shape
tail_points = [
[tail_center_x - tail_top_width/2, center_y - height/2, center_z],
[tail_center_x + tail_top_width/2, center_y - height/2, center_z],
[tail_center_x + tail_bottom_width/2, center_y - height/2, center_z - depth],
[tail_center_x - tail_bottom_width/2, center_y - height/2, center_z - depth]
]
# Create the tail face
tail_face = tails_group.entities.add_face(tail_points)
# Extrude the tail
tail_face.pushpull(height)
end
# Return the result
{
success: true,
id: board.entityID
}
end
def create_pins(board, width, height, depth, angle, num_tails, offset_x, offset_y, offset_z)
model = Sketchup.active_model
# Get the board's entities
entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
# Get the board's bounds
bounds = board.bounds
# Calculate the position of the dovetail joint
center_x = bounds.center.x + offset_x
center_y = bounds.center.y + offset_y
center_z = bounds.center.z + offset_z
# Calculate the width of each tail and space
total_width = width
tail_width = total_width / (2 * num_tails - 1)
# Create a group for the pins
pins_group = entities.add_group
# Create a box for the entire pin area
pin_area_face = pins_group.entities.add_face(
[center_x - width/2, center_y - height/2, center_z],
[center_x + width/2, center_y - height/2, center_z],
[center_x + width/2, center_y + height/2, center_z],
[center_x - width/2, center_y + height/2, center_z]
)
# Extrude the pin area
pin_area_face.pushpull(depth)
# Create each tail cutout
num_tails.times do |i|
# Calculate the position of this tail
tail_center_x = center_x - width/2 + tail_width * (2 * i)
# Calculate the dovetail shape
angle_rad = angle * Math::PI / 180.0
tail_top_width = tail_width
tail_bottom_width = tail_width + 2 * depth * Math.tan(angle_rad)
# Create a group for the tail cutout
tail_cutout_group = entities.add_group
# Create the tail cutout shape
tail_points = [
[tail_center_x - tail_top_width/2, center_y - height/2, center_z],
[tail_center_x + tail_top_width/2, center_y - height/2, center_z],
[tail_center_x + tail_bottom_width/2, center_y - height/2, center_z - depth],
[tail_center_x - tail_bottom_width/2, center_y - height/2, center_z - depth]
]
# Create the tail cutout face
tail_face = tail_cutout_group.entities.add_face(tail_points)
# Extrude the tail cutout
tail_face.pushpull(height)
# Subtract the tail cutout from the pin area
pins_group.entities.subtract(tail_cutout_group.entities)
# Clean up the temporary group
tail_cutout_group.erase!
end
# Return the result
{
success: true,
id: board.entityID
}
end
def create_finger_joint(params)
log "Creating finger joint with params: #{params.inspect}"
model = Sketchup.active_model
# Get the two board IDs
board1_id = params["board1_id"].to_s.gsub('"', '')
board2_id = params["board2_id"].to_s.gsub('"', '')
log "Looking for board 1 with ID: #{board1_id}"
board1 = model.find_entity_by_id(board1_id.to_i)
log "Looking for board 2 with ID: #{board2_id}"
board2 = model.find_entity_by_id(board2_id.to_i)
unless board1 && board2
missing = []
missing << "board 1" unless board1
missing << "board 2" unless board2
raise "Entity not found: #{missing.join(', ')}"
end
# Ensure both entities are groups or component instances
unless (board1.is_a?(Sketchup::Group) || board1.is_a?(Sketchup::ComponentInstance)) &&
(board2.is_a?(Sketchup::Group) || board2.is_a?(Sketchup::ComponentInstance))
raise "Finger joint operation requires groups or component instances"
end
# Get joint parameters
width = params["width"] || 1.0
height = params["height"] || 2.0
depth = params["depth"] || 1.0
num_fingers = params["num_fingers"] || 5
offset_x = params["offset_x"] || 0.0
offset_y = params["offset_y"] || 0.0
offset_z = params["offset_z"] || 0.0
# Create the fingers on board 1
board1_result = create_board1_fingers(board1, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
# Create the matching slots on board 2
board2_result = create_board2_slots(board2, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
# Return the result
{
success: true,
board1_id: board1_result[:id],
board2_id: board2_result[:id]
}
end
def create_board1_fingers(board, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
model = Sketchup.active_model
# Get the board's entities
entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
# Get the board's bounds
bounds = board.bounds
# Calculate the position of the joint
center_x = bounds.center.x + offset_x
center_y = bounds.center.y + offset_y
center_z = bounds.center.z + offset_z
# Calculate the width of each finger
finger_width = width / num_fingers
# Create a group for the fingers
fingers_group = entities.add_group
# Create a base rectangle for the joint area
base_face = fingers_group.entities.add_face(
[center_x - width/2, center_y - height/2, center_z],
[center_x + width/2, center_y - height/2, center_z],
[center_x + width/2, center_y + height/2, center_z],
[center_x - width/2, center_y + height/2, center_z]
)
# Create cutouts for the spaces between fingers
(num_fingers / 2).times do |i|
# Calculate the position of this cutout
cutout_center_x = center_x - width/2 + finger_width * (2 * i + 1)
# Create a group for the cutout
cutout_group = entities.add_group
# Create the cutout shape
cutout_face = cutout_group.entities.add_face(
[cutout_center_x - finger_width/2, center_y - height/2, center_z],
[cutout_center_x + finger_width/2, center_y - height/2, center_z],
[cutout_center_x + finger_width/2, center_y + height/2, center_z],
[cutout_center_x - finger_width/2, center_y + height/2, center_z]
)
# Extrude the cutout
cutout_face.pushpull(depth)
# Subtract the cutout from the fingers
fingers_group.entities.subtract(cutout_group.entities)
# Clean up the temporary group
cutout_group.erase!
end
# Extrude the fingers
base_face.pushpull(depth)
# Return the result
{
success: true,
id: board.entityID
}
end
def create_board2_slots(board, width, height, depth, num_fingers, offset_x, offset_y, offset_z)
model = Sketchup.active_model
# Get the board's entities
entities = board.is_a?(Sketchup::Group) ? board.entities : board.definition.entities
# Get the board's bounds
bounds = board.bounds
# Calculate the position of the joint
center_x = bounds.center.x + offset_x
center_y = bounds.center.y + offset_y
center_z = bounds.center.z + offset_z
# Calculate the width of each finger
finger_width = width / num_fingers
# Create a group for the slots
slots_group = entities.add_group
# Create cutouts for the fingers from board 1
(num_fingers / 2 + num_fingers % 2).times do |i|
# Calculate the position of this cutout
cutout_center_x = center_x - width/2 + finger_width * (2 * i)
# Create a group for the cutout
cutout_group = entities.add_group
# Create the cutout shape
cutout_face = cutout_group.entities.add_face(
[cutout_center_x - finger_width/2, center_y - height/2, center_z],
[cutout_center_x + finger_width/2, center_y - height/2, center_z],
[cutout_center_x + finger_width/2, center_y + height/2, center_z],
[cutout_center_x - finger_width/2, center_y + height/2, center_z]
)
# Extrude the cutout
cutout_face.pushpull(depth)
# Subtract the cutout from the board
entities.subtract(cutout_group.entities)
# Clean up the temporary group
cutout_group.erase!
end
# Return the result
{
success: true,
id: board.entityID
}
end
def eval_ruby(params)
log "Evaluating Ruby code with length: #{params['code'].length}"
begin
# Create a safe binding for evaluation
binding = TOPLEVEL_BINDING.dup
# Evaluate the Ruby code
log "Starting code evaluation..."
result = eval(params["code"], binding)
log "Code evaluation completed with result: #{result.inspect}"
# Return success with the result as a string
{
success: true,
result: result.to_s
}
rescue StandardError => e
log "Error in eval_ruby: #{e.message}"
log e.backtrace.join("\n")
raise "Ruby evaluation error: #{e.message}"
end
end
end
unless file_loaded?(__FILE__)
@server = Server.new
menu = UI.menu("Plugins").add_submenu("MCP Server")
menu.add_item("Start Server") { @server.start }
menu.add_item("Stop Server") { @server.stop }
file_loaded(__FILE__)
end
end
```