iflow-mcp_cwahlfeldt_blender-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blender/__init__.py +1 -0
- blender/executor.py +160 -0
- blender/uv_tools.py +323 -0
- iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/METADATA +117 -0
- iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/RECORD +13 -0
- iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/WHEEL +4 -0
- iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- scripts/__init__.py +1 -0
- scripts/manager.py +200 -0
- utils/__init__.py +1 -0
- utils/helpers.py +206 -0
- utils/uv_integration.py +264 -0
- utils/uv_manager.py +64 -0
blender/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# blender module initialization
|
blender/executor.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
class BlenderExecutor:
|
|
8
|
+
"""Class to handle script execution in Blender"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, blender_path=None, timeout=60):
|
|
11
|
+
"""
|
|
12
|
+
Initialize the BlenderExecutor
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
blender_path (str, optional): Path to the Blender executable. If None, will try to find it.
|
|
16
|
+
timeout (int, optional): Timeout for script execution in seconds
|
|
17
|
+
"""
|
|
18
|
+
self.blender_path = blender_path or self._find_blender_executable()
|
|
19
|
+
self.timeout = timeout
|
|
20
|
+
self.temp_dir = Path(tempfile.gettempdir()) / "blender_mcp"
|
|
21
|
+
self._setup()
|
|
22
|
+
|
|
23
|
+
def _setup(self):
|
|
24
|
+
"""Setup the executor, create necessary directories"""
|
|
25
|
+
os.makedirs(self.temp_dir, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
def _find_blender_executable(self):
|
|
28
|
+
"""Find Blender executable path"""
|
|
29
|
+
# Try common locations
|
|
30
|
+
common_paths = [
|
|
31
|
+
"blender", # If it's in PATH
|
|
32
|
+
"/usr/bin/blender",
|
|
33
|
+
"/usr/local/bin/blender",
|
|
34
|
+
"/Applications/Blender.app/Contents/MacOS/Blender",
|
|
35
|
+
"C:\\Program Files\\Blender Foundation\\Blender\\blender.exe",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
for path in common_paths:
|
|
39
|
+
try:
|
|
40
|
+
# Just check if we can run it with --version
|
|
41
|
+
subprocess.run([path, "--version"],
|
|
42
|
+
stdout=subprocess.PIPE,
|
|
43
|
+
stderr=subprocess.PIPE,
|
|
44
|
+
timeout=5)
|
|
45
|
+
return path
|
|
46
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
47
|
+
continue
|
|
48
|
+
|
|
49
|
+
# If we couldn't find it, default to "blender" and hope it's in PATH
|
|
50
|
+
return "blender"
|
|
51
|
+
|
|
52
|
+
def execute(self, script_name, script_content, blend_file=None):
|
|
53
|
+
"""
|
|
54
|
+
Execute a script in Blender
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
script_name (str): Name of the script
|
|
58
|
+
script_content (str): Content of the script to execute
|
|
59
|
+
blend_file (str, optional): Path to a .blend file to use
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
str: Output of the script execution
|
|
63
|
+
"""
|
|
64
|
+
# Create a temporary script file
|
|
65
|
+
script_file = self.temp_dir / f"{script_name}.py"
|
|
66
|
+
output_file = self.temp_dir / f"{script_name}.out"
|
|
67
|
+
|
|
68
|
+
# Prepare variables to avoid f-string backslash issues
|
|
69
|
+
output_file_str = str(output_file)
|
|
70
|
+
newlines = "\n\n"
|
|
71
|
+
|
|
72
|
+
# Write the script content with output capture
|
|
73
|
+
with open(script_file, "w") as f:
|
|
74
|
+
# Add code to capture stdout and stderr
|
|
75
|
+
indented_script = script_content.replace('\n', '\n ')
|
|
76
|
+
wrapped_script = f"""
|
|
77
|
+
import sys
|
|
78
|
+
import io
|
|
79
|
+
import traceback
|
|
80
|
+
|
|
81
|
+
# Redirect stdout and stderr
|
|
82
|
+
old_stdout = sys.stdout
|
|
83
|
+
old_stderr = sys.stderr
|
|
84
|
+
stdout_buffer = io.StringIO()
|
|
85
|
+
stderr_buffer = io.StringIO()
|
|
86
|
+
sys.stdout = stdout_buffer
|
|
87
|
+
sys.stderr = stderr_buffer
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
# Execute the script
|
|
91
|
+
{indented_script}
|
|
92
|
+
except Exception as e:
|
|
93
|
+
print(f"Error executing script: {{e}}")
|
|
94
|
+
traceback.print_exc()
|
|
95
|
+
finally:
|
|
96
|
+
# Restore stdout and stderr
|
|
97
|
+
sys.stdout = old_stdout
|
|
98
|
+
sys.stderr = old_stderr
|
|
99
|
+
|
|
100
|
+
# Write output to file
|
|
101
|
+
with open(r"{output_file_str}", "w") as output:
|
|
102
|
+
output.write(stdout_buffer.getvalue())
|
|
103
|
+
output.write("{newlines}")
|
|
104
|
+
output.write(stderr_buffer.getvalue())
|
|
105
|
+
"""
|
|
106
|
+
f.write(wrapped_script)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
# Run Blender with the script
|
|
110
|
+
cmd = [
|
|
111
|
+
self.blender_path,
|
|
112
|
+
"--background", # Run without GUI
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
# Add blend file if provided
|
|
116
|
+
if blend_file:
|
|
117
|
+
if os.path.exists(blend_file):
|
|
118
|
+
cmd.extend(["--file", blend_file])
|
|
119
|
+
else:
|
|
120
|
+
return f"Error: Blend file not found: {blend_file}"
|
|
121
|
+
|
|
122
|
+
# Add Python script
|
|
123
|
+
cmd.extend(["--python", str(script_file)])
|
|
124
|
+
|
|
125
|
+
process = subprocess.run(
|
|
126
|
+
cmd,
|
|
127
|
+
stdout=subprocess.PIPE,
|
|
128
|
+
stderr=subprocess.PIPE,
|
|
129
|
+
timeout=self.timeout,
|
|
130
|
+
text=True
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# Check if the output file exists
|
|
134
|
+
if output_file.exists():
|
|
135
|
+
with open(output_file, "r") as f:
|
|
136
|
+
script_output = f.read()
|
|
137
|
+
else:
|
|
138
|
+
script_output = "No output captured from script."
|
|
139
|
+
|
|
140
|
+
# Add Blender's stdout/stderr if there was an error
|
|
141
|
+
if process.returncode != 0:
|
|
142
|
+
script_output += "\n\nBlender process output:\n"
|
|
143
|
+
script_output += f"STDOUT: {process.stdout}\n"
|
|
144
|
+
script_output += f"STDERR: {process.stderr}"
|
|
145
|
+
|
|
146
|
+
return script_output
|
|
147
|
+
|
|
148
|
+
except subprocess.TimeoutExpired:
|
|
149
|
+
return f"Script execution timed out after {self.timeout} seconds"
|
|
150
|
+
except Exception as e:
|
|
151
|
+
return f"Error executing script: {str(e)}"
|
|
152
|
+
finally:
|
|
153
|
+
# Clean up temp files
|
|
154
|
+
try:
|
|
155
|
+
if script_file.exists():
|
|
156
|
+
script_file.unlink()
|
|
157
|
+
if output_file.exists():
|
|
158
|
+
output_file.unlink()
|
|
159
|
+
except Exception as e:
|
|
160
|
+
logging.warning(f"Failed to clean up temp files: {e}")
|
blender/uv_tools.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
class UVTools:
|
|
7
|
+
"""Tools for UV mapping operations in Blender"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, executor):
|
|
10
|
+
"""
|
|
11
|
+
Initialize UV tools with a BlenderExecutor
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
executor: BlenderExecutor instance for running scripts
|
|
15
|
+
"""
|
|
16
|
+
self.executor = executor
|
|
17
|
+
self.temp_dir = Path(tempfile.gettempdir()) / "blender_mcp_uv"
|
|
18
|
+
self._setup()
|
|
19
|
+
|
|
20
|
+
def _setup(self):
|
|
21
|
+
"""Setup the UV tools, create necessary directories"""
|
|
22
|
+
os.makedirs(self.temp_dir, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
def unwrap_object(self, object_name, method="ANGLE_BASED", blend_file=None):
|
|
25
|
+
"""
|
|
26
|
+
Unwrap a specific object in a blend file
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
object_name (str): Name of the object to unwrap
|
|
30
|
+
method (str): Unwrapping method (ANGLE_BASED, CONFORMAL, or SMART_PROJECT)
|
|
31
|
+
blend_file (str): Path to the blend file
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
dict: Result of the operation
|
|
35
|
+
"""
|
|
36
|
+
if blend_file is None or not os.path.exists(blend_file):
|
|
37
|
+
return {"error": f"Blend file not found: {blend_file}"}
|
|
38
|
+
|
|
39
|
+
script = f"""
|
|
40
|
+
import bpy
|
|
41
|
+
import json
|
|
42
|
+
|
|
43
|
+
result = {{"success": False, "message": ""}}
|
|
44
|
+
|
|
45
|
+
# Check if the object exists
|
|
46
|
+
if "{object_name}" not in bpy.data.objects:
|
|
47
|
+
result["message"] = f"Object '{object_name}' not found in the blend file"
|
|
48
|
+
else:
|
|
49
|
+
obj = bpy.data.objects["{object_name}"]
|
|
50
|
+
|
|
51
|
+
# Check if it's a mesh
|
|
52
|
+
if obj.type != 'MESH':
|
|
53
|
+
result["message"] = f"Object '{object_name}' is not a mesh (type: {{obj.type}})"
|
|
54
|
+
else:
|
|
55
|
+
# Select only this object and make it active
|
|
56
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
57
|
+
obj.select_set(True)
|
|
58
|
+
bpy.context.view_layer.objects.active = obj
|
|
59
|
+
|
|
60
|
+
# Go to edit mode
|
|
61
|
+
bpy.ops.object.mode_set(mode='EDIT')
|
|
62
|
+
|
|
63
|
+
# Select all faces
|
|
64
|
+
bpy.ops.mesh.select_all(action='SELECT')
|
|
65
|
+
|
|
66
|
+
# Perform the unwrap operation
|
|
67
|
+
try:
|
|
68
|
+
method = "{method}"
|
|
69
|
+
if method == "SMART_PROJECT":
|
|
70
|
+
bpy.ops.uv.smart_project(angle_limit=66, island_margin=0.02)
|
|
71
|
+
else:
|
|
72
|
+
# ANGLE_BASED or CONFORMAL
|
|
73
|
+
bpy.ops.uv.unwrap(method=method, margin=0.02)
|
|
74
|
+
|
|
75
|
+
result["success"] = True
|
|
76
|
+
result["message"] = f"Successfully unwrapped {{obj.name}} using {{method}} method"
|
|
77
|
+
|
|
78
|
+
# Get UV stats
|
|
79
|
+
me = obj.data
|
|
80
|
+
if me.uv_layers:
|
|
81
|
+
result["uv_layers"] = [layer.name for layer in me.uv_layers]
|
|
82
|
+
result["active_uv"] = me.uv_layers.active.name if me.uv_layers.active else None
|
|
83
|
+
result["uv_count"] = len(me.uv_layers)
|
|
84
|
+
else:
|
|
85
|
+
result["message"] += "\\nNo UV layers found after unwrapping. This may indicate an error."
|
|
86
|
+
except Exception as e:
|
|
87
|
+
result["message"] = f"Error during unwrapping: {{str(e)}}"
|
|
88
|
+
|
|
89
|
+
# Return to object mode
|
|
90
|
+
bpy.ops.object.mode_set(mode='OBJECT')
|
|
91
|
+
|
|
92
|
+
# Save the file
|
|
93
|
+
bpy.ops.wm.save_mainfile()
|
|
94
|
+
|
|
95
|
+
# Print the result as JSON for parsing
|
|
96
|
+
print(json.dumps(result))
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
# Execute the script
|
|
100
|
+
output = self.executor.execute("unwrap_object", script, blend_file)
|
|
101
|
+
|
|
102
|
+
# Try to parse the JSON result
|
|
103
|
+
try:
|
|
104
|
+
# Extract JSON from the output (it may contain other print statements)
|
|
105
|
+
lines = output.strip().split('\n')
|
|
106
|
+
for line in lines:
|
|
107
|
+
try:
|
|
108
|
+
result = json.loads(line)
|
|
109
|
+
if isinstance(result, dict) and "success" in result:
|
|
110
|
+
return result
|
|
111
|
+
except json.JSONDecodeError:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
# If no valid JSON found, return the raw output
|
|
115
|
+
return {"error": "Could not parse result", "output": output}
|
|
116
|
+
except Exception as e:
|
|
117
|
+
return {"error": f"Error parsing result: {str(e)}", "output": output}
|
|
118
|
+
|
|
119
|
+
def mark_seams(self, object_name, blend_file=None, seam_angle=80):
|
|
120
|
+
"""
|
|
121
|
+
Mark seams on an object based on angle
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
object_name (str): Name of the object to mark seams on
|
|
125
|
+
blend_file (str): Path to the blend file
|
|
126
|
+
seam_angle (float): Angle in degrees to use for marking seams
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
dict: Result of the operation
|
|
130
|
+
"""
|
|
131
|
+
if blend_file is None or not os.path.exists(blend_file):
|
|
132
|
+
return {"error": f"Blend file not found: {blend_file}"}
|
|
133
|
+
|
|
134
|
+
script = f"""
|
|
135
|
+
import bpy
|
|
136
|
+
import json
|
|
137
|
+
import math
|
|
138
|
+
import bmesh
|
|
139
|
+
|
|
140
|
+
result = {{"success": False, "message": ""}}
|
|
141
|
+
|
|
142
|
+
# Check if the object exists
|
|
143
|
+
if "{object_name}" not in bpy.data.objects:
|
|
144
|
+
result["message"] = f"Object '{object_name}' not found in the blend file"
|
|
145
|
+
else:
|
|
146
|
+
obj = bpy.data.objects["{object_name}"]
|
|
147
|
+
|
|
148
|
+
# Check if it's a mesh
|
|
149
|
+
if obj.type != 'MESH':
|
|
150
|
+
result["message"] = f"Object '{object_name}' is not a mesh (type: {{obj.type}})"
|
|
151
|
+
else:
|
|
152
|
+
# Select only this object and make it active
|
|
153
|
+
bpy.ops.object.select_all(action='DESELECT')
|
|
154
|
+
obj.select_set(True)
|
|
155
|
+
bpy.context.view_layer.objects.active = obj
|
|
156
|
+
|
|
157
|
+
# Go to edit mode
|
|
158
|
+
bpy.ops.object.mode_set(mode='EDIT')
|
|
159
|
+
|
|
160
|
+
# Get the bmesh
|
|
161
|
+
me = obj.data
|
|
162
|
+
bm = bmesh.from_edit_mesh(me)
|
|
163
|
+
|
|
164
|
+
# Ensure we have custom data layers for seams
|
|
165
|
+
if not bm.edges.layers.seam:
|
|
166
|
+
bm.edges.layers.seam.new()
|
|
167
|
+
|
|
168
|
+
seam_layer = bm.edges.layers.seam.active
|
|
169
|
+
|
|
170
|
+
# Mark seams by angle
|
|
171
|
+
seam_angle_rad = {seam_angle} * (math.pi / 180.0)
|
|
172
|
+
seam_count = 0
|
|
173
|
+
|
|
174
|
+
for edge in bm.edges:
|
|
175
|
+
if edge.is_boundary:
|
|
176
|
+
# Mark boundary edges as seams
|
|
177
|
+
edge[seam_layer] = True
|
|
178
|
+
seam_count += 1
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Get connected faces
|
|
182
|
+
if len(edge.link_faces) == 2:
|
|
183
|
+
f1, f2 = edge.link_faces
|
|
184
|
+
angle = f1.normal.angle(f2.normal)
|
|
185
|
+
|
|
186
|
+
# If angle between faces exceeds threshold, mark as seam
|
|
187
|
+
if angle > seam_angle_rad:
|
|
188
|
+
edge[seam_layer] = True
|
|
189
|
+
seam_count += 1
|
|
190
|
+
|
|
191
|
+
# Update the mesh and unwrap
|
|
192
|
+
bmesh.update_edit_mesh(me)
|
|
193
|
+
|
|
194
|
+
# Select all faces
|
|
195
|
+
bpy.ops.mesh.select_all(action='SELECT')
|
|
196
|
+
|
|
197
|
+
# Unwrap using the seams
|
|
198
|
+
bpy.ops.uv.unwrap(method='ANGLE_BASED', margin=0.02)
|
|
199
|
+
|
|
200
|
+
result["success"] = True
|
|
201
|
+
result["message"] = f"Marked {{seam_count}} seams on {{obj.name}} using angle threshold of {seam_angle}° and unwrapped"
|
|
202
|
+
result["seam_count"] = seam_count
|
|
203
|
+
|
|
204
|
+
# Return to object mode
|
|
205
|
+
bpy.ops.object.mode_set(mode='OBJECT')
|
|
206
|
+
|
|
207
|
+
# Save the file
|
|
208
|
+
bpy.ops.wm.save_mainfile()
|
|
209
|
+
|
|
210
|
+
# Print the result as JSON for parsing
|
|
211
|
+
print(json.dumps(result))
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
# Execute the script
|
|
215
|
+
output = self.executor.execute("mark_seams", script, blend_file)
|
|
216
|
+
|
|
217
|
+
# Try to parse the JSON result
|
|
218
|
+
try:
|
|
219
|
+
# Extract JSON from the output
|
|
220
|
+
lines = output.strip().split('\n')
|
|
221
|
+
for line in lines:
|
|
222
|
+
try:
|
|
223
|
+
result = json.loads(line)
|
|
224
|
+
if isinstance(result, dict) and "success" in result:
|
|
225
|
+
return result
|
|
226
|
+
except json.JSONDecodeError:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
# If no valid JSON found, return the raw output
|
|
230
|
+
return {"error": "Could not parse result", "output": output}
|
|
231
|
+
except Exception as e:
|
|
232
|
+
return {"error": f"Error parsing result: {str(e)}", "output": output}
|
|
233
|
+
|
|
234
|
+
def texture_object(self, object_name, blend_file=None, texture_type="checker", color1=None, color2=None):
|
|
235
|
+
"""
|
|
236
|
+
Apply a textured material to an object using its UV maps
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
object_name (str): Name of the object to texture
|
|
240
|
+
blend_file (str): Path to the blend file
|
|
241
|
+
texture_type (str): Type of texture (checker, gradient, grid)
|
|
242
|
+
color1 (list): First color as [r, g, b] (0.0-1.0 range)
|
|
243
|
+
color2 (list): Second color as [r, g, b] (0.0-1.0 range)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
dict: Result of the operation
|
|
247
|
+
"""
|
|
248
|
+
if blend_file is None or not os.path.exists(blend_file):
|
|
249
|
+
return {"error": f"Blend file not found: {blend_file}"}
|
|
250
|
+
|
|
251
|
+
# Set default colors if none provided
|
|
252
|
+
if color1 is None:
|
|
253
|
+
color1 = [0.8, 0.1, 0.1] # Red
|
|
254
|
+
if color2 is None:
|
|
255
|
+
color2 = [0.1, 0.1, 0.8] # Blue
|
|
256
|
+
|
|
257
|
+
script = f"""
|
|
258
|
+
import bpy
|
|
259
|
+
import json
|
|
260
|
+
|
|
261
|
+
result = {{"success": False, "message": ""}}
|
|
262
|
+
|
|
263
|
+
# Check if the object exists
|
|
264
|
+
if "{object_name}" not in bpy.data.objects:
|
|
265
|
+
result["message"] = f"Object '{object_name}' not found in the blend file"
|
|
266
|
+
else:
|
|
267
|
+
obj = bpy.data.objects["{object_name}"]
|
|
268
|
+
|
|
269
|
+
# Check if it's a mesh
|
|
270
|
+
if obj.type != 'MESH':
|
|
271
|
+
result["message"] = f"Object '{object_name}' is not a mesh (type: {{obj.type}})"
|
|
272
|
+
else:
|
|
273
|
+
# Check if the object has UVs
|
|
274
|
+
if not obj.data.uv_layers:
|
|
275
|
+
result["message"] = f"Object '{object_name}' has no UV layers. Unwrap it first."
|
|
276
|
+
else:
|
|
277
|
+
# Create a new material
|
|
278
|
+
mat_name = f"{{obj.name}}_{{'{texture_type}'}}_Material"
|
|
279
|
+
mat = bpy.data.materials.new(name=mat_name)
|
|
280
|
+
mat.use_nodes = True
|
|
281
|
+
|
|
282
|
+
# Clear default nodes
|
|
283
|
+
node_tree = mat.node_tree
|
|
284
|
+
for node in node_tree.nodes:
|
|
285
|
+
node_tree.nodes.remove(node)
|
|
286
|
+
|
|
287
|
+
# Create texture coordinate node
|
|
288
|
+
tex_coord = node_tree.nodes.new(type='ShaderNodeTexCoord')
|
|
289
|
+
tex_coord.location = (-600, 0)
|
|
290
|
+
|
|
291
|
+
# Create BSDF node
|
|
292
|
+
bsdf = node_tree.nodes.new(type='ShaderNodeBsdfPrincipled')
|
|
293
|
+
bsdf.location = (0, 0)
|
|
294
|
+
|
|
295
|
+
# Create output node
|
|
296
|
+
output = node_tree.nodes.new(type='ShaderNodeOutputMaterial')
|
|
297
|
+
output.location = (200, 0)
|
|
298
|
+
|
|
299
|
+
# Different texture setup based on type
|
|
300
|
+
texture_type = "{texture_type}".lower()
|
|
301
|
+
|
|
302
|
+
if texture_type == "checker":
|
|
303
|
+
# Create checker texture node
|
|
304
|
+
checker = node_tree.nodes.new(type='ShaderNodeTexChecker')
|
|
305
|
+
checker.location = (-400, 0)
|
|
306
|
+
checker.inputs['Scale'].default_value = 4.0
|
|
307
|
+
checker.inputs['Color1'].default_value = {color1 + [1.0]}
|
|
308
|
+
checker.inputs['Color2'].default_value = {color2 + [1.0]}
|
|
309
|
+
|
|
310
|
+
# Link nodes
|
|
311
|
+
node_tree.links.new(tex_coord.outputs['UV'], checker.inputs['Vector'])
|
|
312
|
+
node_tree.links.new(checker.outputs['Color'], bsdf.inputs['Base Color'])
|
|
313
|
+
|
|
314
|
+
elif texture_type == "gradient":
|
|
315
|
+
# Create gradient texture node
|
|
316
|
+
gradient = node_tree.nodes.new(type='ShaderNodeTexGradient')
|
|
317
|
+
gradient.location = (-400, 0)
|
|
318
|
+
|
|
319
|
+
# Create color ramp for the gradient
|
|
320
|
+
color_ramp = node_tree.nodes.new(type='ShaderNodeValToRGB')
|
|
321
|
+
color_ramp.location = (-200, 0)
|
|
322
|
+
# Customize the color ramp
|
|
323
|
+
color_ramp
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iflow-mcp_cwahlfeldt_blender-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for executing Blender scripts
|
|
5
|
+
Author: Blender MCP Team
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: mcp>=0.1.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: black>=23.0.0; extra == 'dev'
|
|
10
|
+
Requires-Dist: isort>=5.12.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Blender MCP Server
|
|
15
|
+
|
|
16
|
+
A Model Context Protocol (MCP) server for managing and executing Blender scripts.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- Add, edit, execute, and remove Blender Python scripts
|
|
21
|
+
- Execute scripts in a headless Blender environment
|
|
22
|
+
- View execution results and errors
|
|
23
|
+
- Track script metadata (creation date, last modified, execution count)
|
|
24
|
+
|
|
25
|
+
## Requirements
|
|
26
|
+
|
|
27
|
+
- Python 3.7+
|
|
28
|
+
- Blender installed and accessible
|
|
29
|
+
- MCP library (`pip install mcp`)
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
1. Start the server:
|
|
34
|
+
```
|
|
35
|
+
python server.py
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
2. Connect to the server using an MCP client (like Claude Desktop)
|
|
39
|
+
|
|
40
|
+
3. Use the provided tools to manage scripts:
|
|
41
|
+
- `add_script(name, content)` - Add a new script
|
|
42
|
+
- `edit_script(name, content)` - Edit an existing script
|
|
43
|
+
- `execute_script(name, blend_file=None)` - Execute a script in Blender, optionally specifying a .blend file
|
|
44
|
+
- `remove_script(name)` - Remove a script
|
|
45
|
+
|
|
46
|
+
4. Access resources to get information:
|
|
47
|
+
- `scripts://list` - Get list of available scripts
|
|
48
|
+
- `script://{name}` - Get content of a specific script
|
|
49
|
+
- `result://{name}` - Get execution result of a script
|
|
50
|
+
|
|
51
|
+
## Examples
|
|
52
|
+
|
|
53
|
+
### Basic Example
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
# Add a simple script
|
|
57
|
+
add_script("hello_cube", '''
|
|
58
|
+
import bpy
|
|
59
|
+
|
|
60
|
+
# Clear existing objects
|
|
61
|
+
bpy.ops.object.select_all(action='SELECT')
|
|
62
|
+
bpy.ops.object.delete()
|
|
63
|
+
|
|
64
|
+
# Create a cube
|
|
65
|
+
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
|
66
|
+
print("Cube created!")
|
|
67
|
+
''')
|
|
68
|
+
|
|
69
|
+
# Execute the script
|
|
70
|
+
execute_script("hello_cube")
|
|
71
|
+
|
|
72
|
+
# Get the result
|
|
73
|
+
# Access using: result://hello_cube
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Working with Blend Files
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
# Add a script that works with a blend file
|
|
80
|
+
add_script("analyze_scene", '''
|
|
81
|
+
import bpy
|
|
82
|
+
|
|
83
|
+
# Print information about the current scene
|
|
84
|
+
print(f"Current Blender version: {bpy.app.version_string}")
|
|
85
|
+
print(f"Current file: {bpy.data.filepath}")
|
|
86
|
+
|
|
87
|
+
# List all objects in the scene
|
|
88
|
+
print("\\nObjects in the scene:")
|
|
89
|
+
for obj in bpy.data.objects:
|
|
90
|
+
print(f" - {obj.name} ({obj.type})")
|
|
91
|
+
''')
|
|
92
|
+
|
|
93
|
+
# Execute with a specific blend file
|
|
94
|
+
execute_script("analyze_scene", blend_file="/path/to/your/project.blend")
|
|
95
|
+
|
|
96
|
+
# Get the result
|
|
97
|
+
# Access using: result://analyze_scene
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## How It Works
|
|
101
|
+
|
|
102
|
+
1. When a script is added, it's stored in the `script_files/scripts` directory
|
|
103
|
+
2. When executed, the script is run in a headless Blender instance
|
|
104
|
+
- If a blend file is specified, Blender will open that file before running the script
|
|
105
|
+
- Otherwise, a default empty Blender scene is used
|
|
106
|
+
3. Output and errors are captured and stored in the `script_files/results` directory
|
|
107
|
+
4. Metadata about scripts is tracked in `script_files/metadata.json`
|
|
108
|
+
|
|
109
|
+
## Installation
|
|
110
|
+
|
|
111
|
+
1. Clone this repository
|
|
112
|
+
2. Install the MCP library: `pip install mcp`
|
|
113
|
+
3. Ensure Blender is installed and accessible from your PATH
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
blender/__init__.py,sha256=-nr6dwd6f_6xfJEy_nRV7D5unQMFxXvEEvYdk_gHjyo,32
|
|
2
|
+
blender/executor.py,sha256=UbrmYIXjNloWEkUMtsgOoeVqvrtnLtztcbWtvUxD9tU,5464
|
|
3
|
+
blender/uv_tools.py,sha256=anfOL3hW1fZdPTi1LDA2nMC123hCx95rvQfKer_nBrY,11658
|
|
4
|
+
scripts/__init__.py,sha256=x78bZpSWEjokJ24WqB-bvGAx3PnvixRCZ4xzWgmORBo,32
|
|
5
|
+
scripts/manager.py,sha256=06f5O8rCex-jDBMdxuEHEaq5SHFwPojOYnrIPBItVT0,6122
|
|
6
|
+
utils/__init__.py,sha256=XkLKwxjypuJhs2A-RPst7CZ1G_tWfvdHwFFFtZTjAQI,30
|
|
7
|
+
utils/helpers.py,sha256=EZkvfxRBFdr5Jm8Niwzikqv5QmR3WkIE2_MFvusRNqk,5452
|
|
8
|
+
utils/uv_integration.py,sha256=FQuhOTPPRxeUPZWYwGwLEQbkj10gKx-sg0ushKfRPt4,8630
|
|
9
|
+
utils/uv_manager.py,sha256=OCj8r3g6SPj-jHh-zti6H6EKubRkF8OLiCBAlzuARuY,1964
|
|
10
|
+
iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/METADATA,sha256=VHchiyQQOtDbh2d2O_H1e6BIrXQO_-kpG_MC23VPJnk,3124
|
|
11
|
+
iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
12
|
+
iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/entry_points.txt,sha256=QnyWp9XwL2dm3qqSZH1WdCgLZQ0nkmTBgsRJ1wRM3Xc,43
|
|
13
|
+
iflow_mcp_cwahlfeldt_blender_mcp-0.1.0.dist-info/RECORD,,
|
scripts/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# scripts module initialization
|
scripts/manager.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import datetime
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
class ScriptManager:
|
|
8
|
+
"""Class to manage Blender scripts"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, scripts_dir=None):
|
|
11
|
+
"""
|
|
12
|
+
Initialize the ScriptManager
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
scripts_dir (str, optional): Directory to store scripts. If None, will use ./scripts
|
|
16
|
+
"""
|
|
17
|
+
self.scripts_dir = Path(scripts_dir or Path.cwd() / "script_files")
|
|
18
|
+
self.scripts_dir.mkdir(exist_ok=True, parents=True)
|
|
19
|
+
|
|
20
|
+
# Create subdirectories
|
|
21
|
+
self.scripts_path = self.scripts_dir / "scripts"
|
|
22
|
+
self.results_path = self.scripts_dir / "results"
|
|
23
|
+
self.metadata_path = self.scripts_dir / "metadata.json"
|
|
24
|
+
|
|
25
|
+
self.scripts_path.mkdir(exist_ok=True)
|
|
26
|
+
self.results_path.mkdir(exist_ok=True)
|
|
27
|
+
|
|
28
|
+
# Initialize metadata
|
|
29
|
+
self._init_metadata()
|
|
30
|
+
|
|
31
|
+
def _init_metadata(self):
|
|
32
|
+
"""Initialize or load metadata file"""
|
|
33
|
+
if not self.metadata_path.exists():
|
|
34
|
+
self.metadata = {}
|
|
35
|
+
self._save_metadata()
|
|
36
|
+
else:
|
|
37
|
+
try:
|
|
38
|
+
with open(self.metadata_path, "r") as f:
|
|
39
|
+
self.metadata = json.load(f)
|
|
40
|
+
except json.JSONDecodeError:
|
|
41
|
+
# If the file is corrupted, initialize new metadata
|
|
42
|
+
self.metadata = {}
|
|
43
|
+
self._save_metadata()
|
|
44
|
+
|
|
45
|
+
def _save_metadata(self):
|
|
46
|
+
"""Save metadata to file"""
|
|
47
|
+
with open(self.metadata_path, "w") as f:
|
|
48
|
+
json.dump(self.metadata, f, indent=2)
|
|
49
|
+
|
|
50
|
+
def _validate_script_name(self, name):
|
|
51
|
+
"""Validate script name"""
|
|
52
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', name):
|
|
53
|
+
raise ValueError("Script name can only contain letters, numbers, underscores, and hyphens")
|
|
54
|
+
return name
|
|
55
|
+
|
|
56
|
+
def list_scripts(self):
|
|
57
|
+
"""List all available scripts with metadata"""
|
|
58
|
+
scripts = []
|
|
59
|
+
for script_name, metadata in self.metadata.items():
|
|
60
|
+
script_info = metadata.copy()
|
|
61
|
+
script_info["name"] = script_name
|
|
62
|
+
scripts.append(script_info)
|
|
63
|
+
return scripts
|
|
64
|
+
|
|
65
|
+
def add_script(self, name, content):
|
|
66
|
+
"""
|
|
67
|
+
Add a new script
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
name (str): Script name
|
|
71
|
+
content (str): Script content
|
|
72
|
+
"""
|
|
73
|
+
name = self._validate_script_name(name)
|
|
74
|
+
script_path = self.scripts_path / f"{name}.py"
|
|
75
|
+
|
|
76
|
+
if script_path.exists():
|
|
77
|
+
raise ValueError(f"Script '{name}' already exists")
|
|
78
|
+
|
|
79
|
+
# Save script content
|
|
80
|
+
with open(script_path, "w") as f:
|
|
81
|
+
f.write(content)
|
|
82
|
+
|
|
83
|
+
# Update metadata
|
|
84
|
+
now = datetime.datetime.now().isoformat()
|
|
85
|
+
self.metadata[name] = {
|
|
86
|
+
"created": now,
|
|
87
|
+
"last_modified": now,
|
|
88
|
+
"last_executed": None,
|
|
89
|
+
"execution_count": 0
|
|
90
|
+
}
|
|
91
|
+
self._save_metadata()
|
|
92
|
+
|
|
93
|
+
def edit_script(self, name, content):
|
|
94
|
+
"""
|
|
95
|
+
Edit an existing script
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name (str): Script name
|
|
99
|
+
content (str): New script content
|
|
100
|
+
"""
|
|
101
|
+
name = self._validate_script_name(name)
|
|
102
|
+
script_path = self.scripts_path / f"{name}.py"
|
|
103
|
+
|
|
104
|
+
if not script_path.exists():
|
|
105
|
+
raise ValueError(f"Script '{name}' not found")
|
|
106
|
+
|
|
107
|
+
# Save script content
|
|
108
|
+
with open(script_path, "w") as f:
|
|
109
|
+
f.write(content)
|
|
110
|
+
|
|
111
|
+
# Update metadata
|
|
112
|
+
now = datetime.datetime.now().isoformat()
|
|
113
|
+
self.metadata[name]["last_modified"] = now
|
|
114
|
+
self._save_metadata()
|
|
115
|
+
|
|
116
|
+
def get_script(self, name):
|
|
117
|
+
"""
|
|
118
|
+
Get script content
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
name (str): Script name
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
str: Script content
|
|
125
|
+
"""
|
|
126
|
+
name = self._validate_script_name(name)
|
|
127
|
+
script_path = self.scripts_path / f"{name}.py"
|
|
128
|
+
|
|
129
|
+
if not script_path.exists():
|
|
130
|
+
raise ValueError(f"Script '{name}' not found")
|
|
131
|
+
|
|
132
|
+
with open(script_path, "r") as f:
|
|
133
|
+
return f.read()
|
|
134
|
+
|
|
135
|
+
def remove_script(self, name):
|
|
136
|
+
"""
|
|
137
|
+
Remove a script
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
name (str): Script name
|
|
141
|
+
"""
|
|
142
|
+
name = self._validate_script_name(name)
|
|
143
|
+
script_path = self.scripts_path / f"{name}.py"
|
|
144
|
+
result_path = self.results_path / f"{name}.txt"
|
|
145
|
+
|
|
146
|
+
if not script_path.exists():
|
|
147
|
+
raise ValueError(f"Script '{name}' not found")
|
|
148
|
+
|
|
149
|
+
# Remove script file
|
|
150
|
+
script_path.unlink()
|
|
151
|
+
|
|
152
|
+
# Remove result file if it exists
|
|
153
|
+
if result_path.exists():
|
|
154
|
+
result_path.unlink()
|
|
155
|
+
|
|
156
|
+
# Update metadata
|
|
157
|
+
if name in self.metadata:
|
|
158
|
+
del self.metadata[name]
|
|
159
|
+
self._save_metadata()
|
|
160
|
+
|
|
161
|
+
def save_result(self, name, result):
|
|
162
|
+
"""
|
|
163
|
+
Save script execution result
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
name (str): Script name
|
|
167
|
+
result (str): Execution result
|
|
168
|
+
"""
|
|
169
|
+
name = self._validate_script_name(name)
|
|
170
|
+
result_path = self.results_path / f"{name}.txt"
|
|
171
|
+
|
|
172
|
+
# Save result
|
|
173
|
+
with open(result_path, "w") as f:
|
|
174
|
+
f.write(result)
|
|
175
|
+
|
|
176
|
+
# Update metadata
|
|
177
|
+
if name in self.metadata:
|
|
178
|
+
now = datetime.datetime.now().isoformat()
|
|
179
|
+
self.metadata[name]["last_executed"] = now
|
|
180
|
+
self.metadata[name]["execution_count"] = self.metadata[name].get("execution_count", 0) + 1
|
|
181
|
+
self._save_metadata()
|
|
182
|
+
|
|
183
|
+
def get_result(self, name):
|
|
184
|
+
"""
|
|
185
|
+
Get script execution result
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
name (str): Script name
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
str: Execution result
|
|
192
|
+
"""
|
|
193
|
+
name = self._validate_script_name(name)
|
|
194
|
+
result_path = self.results_path / f"{name}.txt"
|
|
195
|
+
|
|
196
|
+
if not result_path.exists():
|
|
197
|
+
return f"No execution results found for script '{name}'"
|
|
198
|
+
|
|
199
|
+
with open(result_path, "r") as f:
|
|
200
|
+
return f.read()
|
utils/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# utils module initialization
|
utils/helpers.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
import logging
|
|
6
|
+
|
|
7
|
+
def setup_logger(name, level=logging.INFO):
|
|
8
|
+
"""Set up and return a logger"""
|
|
9
|
+
logger = logging.getLogger(name)
|
|
10
|
+
logger.setLevel(level)
|
|
11
|
+
|
|
12
|
+
# Create console handler
|
|
13
|
+
ch = logging.StreamHandler()
|
|
14
|
+
ch.setLevel(level)
|
|
15
|
+
|
|
16
|
+
# Create formatter
|
|
17
|
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
18
|
+
ch.setFormatter(formatter)
|
|
19
|
+
|
|
20
|
+
# Add handler to logger
|
|
21
|
+
logger.addHandler(ch)
|
|
22
|
+
|
|
23
|
+
return logger
|
|
24
|
+
|
|
25
|
+
def verify_blender_installation():
|
|
26
|
+
"""
|
|
27
|
+
Verify Blender is installed and return its version
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
tuple: (bool, str) - (is_installed, version_or_error_message)
|
|
31
|
+
"""
|
|
32
|
+
common_paths = [
|
|
33
|
+
"blender", # If it's in PATH
|
|
34
|
+
"/usr/bin/blender",
|
|
35
|
+
"/usr/local/bin/blender",
|
|
36
|
+
"/Applications/Blender.app/Contents/MacOS/Blender",
|
|
37
|
+
"C:\\Program Files\\Blender Foundation\\Blender\\blender.exe",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
for path in common_paths:
|
|
41
|
+
try:
|
|
42
|
+
result = subprocess.run(
|
|
43
|
+
[path, "--version"],
|
|
44
|
+
stdout=subprocess.PIPE,
|
|
45
|
+
stderr=subprocess.PIPE,
|
|
46
|
+
timeout=5,
|
|
47
|
+
text=True
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
# Extract version from output
|
|
51
|
+
version_output = result.stdout
|
|
52
|
+
return True, version_output.strip()
|
|
53
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
return False, "Blender not found. Please install Blender or set the correct path."
|
|
57
|
+
|
|
58
|
+
def generate_example_script():
|
|
59
|
+
"""Generate an example Blender script"""
|
|
60
|
+
return """
|
|
61
|
+
import bpy
|
|
62
|
+
|
|
63
|
+
# Clear existing objects
|
|
64
|
+
bpy.ops.object.select_all(action='SELECT')
|
|
65
|
+
bpy.ops.object.delete()
|
|
66
|
+
|
|
67
|
+
# Create a cube
|
|
68
|
+
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
|
69
|
+
|
|
70
|
+
# Create a material
|
|
71
|
+
mat = bpy.data.materials.new(name="Red Material")
|
|
72
|
+
mat.diffuse_color = (1, 0, 0, 1) # Red
|
|
73
|
+
|
|
74
|
+
# Assign material to the cube
|
|
75
|
+
cube = bpy.context.active_object
|
|
76
|
+
if cube.data.materials:
|
|
77
|
+
cube.data.materials[0] = mat
|
|
78
|
+
else:
|
|
79
|
+
cube.data.materials.append(mat)
|
|
80
|
+
|
|
81
|
+
# Print confirmation
|
|
82
|
+
print("Created a red cube at the origin!")
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def generate_blend_file_example_script():
|
|
86
|
+
"""Generate an example script that works with a blend file"""
|
|
87
|
+
return """
|
|
88
|
+
import bpy
|
|
89
|
+
import os
|
|
90
|
+
|
|
91
|
+
# Print information about the current scene
|
|
92
|
+
print(f"Current Blender version: {bpy.app.version_string}")
|
|
93
|
+
print(f"Current file: {bpy.data.filepath}")
|
|
94
|
+
|
|
95
|
+
# List all objects in the scene
|
|
96
|
+
print("\\nObjects in the scene:")
|
|
97
|
+
for obj in bpy.data.objects:
|
|
98
|
+
print(f" - {obj.name} ({obj.type})")
|
|
99
|
+
|
|
100
|
+
# List all materials
|
|
101
|
+
print("\\nMaterials in the scene:")
|
|
102
|
+
for mat in bpy.data.materials:
|
|
103
|
+
print(f" - {mat.name}")
|
|
104
|
+
|
|
105
|
+
# List all scenes
|
|
106
|
+
print("\\nScenes in the file:")
|
|
107
|
+
for scene in bpy.data.scenes:
|
|
108
|
+
print(f" - {scene.name}")
|
|
109
|
+
|
|
110
|
+
# Make a small change to demonstrate saving
|
|
111
|
+
if bpy.data.objects:
|
|
112
|
+
# Move the first object up by 1 unit
|
|
113
|
+
obj = bpy.data.objects[0]
|
|
114
|
+
original_loc = obj.location.copy()
|
|
115
|
+
obj.location.z += 1
|
|
116
|
+
print(f"\\nMoved {obj.name} up by 1 unit (from {original_loc.z} to {obj.location.z})")
|
|
117
|
+
|
|
118
|
+
# Note about saving
|
|
119
|
+
print("\\nNote: To save changes to the blend file, uncomment the save line below.")
|
|
120
|
+
# bpy.ops.wm.save_mainfile()
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
def generate_readme():
|
|
124
|
+
"""Generate README content for the project"""
|
|
125
|
+
return """# Blender MCP Server
|
|
126
|
+
|
|
127
|
+
A Model Context Protocol (MCP) server for managing and executing Blender scripts.
|
|
128
|
+
|
|
129
|
+
## Features
|
|
130
|
+
|
|
131
|
+
- Add, edit, execute, and remove Blender Python scripts
|
|
132
|
+
- Execute scripts in a headless Blender environment
|
|
133
|
+
- View execution results and errors
|
|
134
|
+
- Track script metadata (creation date, last modified, execution count)
|
|
135
|
+
|
|
136
|
+
## Requirements
|
|
137
|
+
|
|
138
|
+
- Python 3.7+
|
|
139
|
+
- Blender installed and accessible
|
|
140
|
+
- MCP library (`pip install mcp`)
|
|
141
|
+
|
|
142
|
+
## Usage
|
|
143
|
+
|
|
144
|
+
1. Start the server:
|
|
145
|
+
```
|
|
146
|
+
python server.py
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
2. Connect to the server using an MCP client (like Claude Desktop)
|
|
150
|
+
|
|
151
|
+
3. Use the provided tools to manage scripts:
|
|
152
|
+
- `add_script(name, content)` - Add a new script
|
|
153
|
+
- `edit_script(name, content)` - Edit an existing script
|
|
154
|
+
- `execute_script(name)` - Execute a script in Blender
|
|
155
|
+
- `remove_script(name)` - Remove a script
|
|
156
|
+
|
|
157
|
+
4. Access resources to get information:
|
|
158
|
+
- `scripts://list` - Get list of available scripts
|
|
159
|
+
- `script://{name}` - Get content of a specific script
|
|
160
|
+
- `result://{name}` - Get execution result of a script
|
|
161
|
+
|
|
162
|
+
## Example
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
# Add a simple script
|
|
166
|
+
add_script("hello_cube", '''
|
|
167
|
+
import bpy
|
|
168
|
+
|
|
169
|
+
# Clear existing objects
|
|
170
|
+
bpy.ops.object.select_all(action='SELECT')
|
|
171
|
+
bpy.ops.object.delete()
|
|
172
|
+
|
|
173
|
+
# Create a cube
|
|
174
|
+
bpy.ops.mesh.primitive_cube_add(size=2, location=(0, 0, 0))
|
|
175
|
+
print("Cube created!")
|
|
176
|
+
''')
|
|
177
|
+
|
|
178
|
+
# Execute the script
|
|
179
|
+
execute_script("hello_cube")
|
|
180
|
+
|
|
181
|
+
# Get the result
|
|
182
|
+
result = get_resource("result://hello_cube")
|
|
183
|
+
print(result)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## License
|
|
187
|
+
|
|
188
|
+
MIT
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
def create_empty_init_files(root_dir):
|
|
192
|
+
"""
|
|
193
|
+
Create empty __init__.py files in all subdirectories
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
root_dir (str): Root directory path
|
|
197
|
+
"""
|
|
198
|
+
for dirpath, dirnames, filenames in os.walk(root_dir):
|
|
199
|
+
# Skip hidden directories
|
|
200
|
+
dirnames[:] = [d for d in dirnames if not d.startswith('.')]
|
|
201
|
+
|
|
202
|
+
# Create __init__.py if it doesn't exist
|
|
203
|
+
init_file = os.path.join(dirpath, "__init__.py")
|
|
204
|
+
if not os.path.exists(init_file):
|
|
205
|
+
with open(init_file, 'w') as f:
|
|
206
|
+
pass # Create empty file
|
utils/uv_integration.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class UVIntegration:
|
|
11
|
+
"""Integration with UV Python for package management"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
"""Initialize UV integration utils"""
|
|
15
|
+
self.uv_path = self._find_uv_executable()
|
|
16
|
+
self.has_uv = self.uv_path is not None
|
|
17
|
+
|
|
18
|
+
def _find_uv_executable(self):
|
|
19
|
+
"""Find the UV executable in the system"""
|
|
20
|
+
# Try common locations
|
|
21
|
+
common_names = ["uv", "uv.exe"]
|
|
22
|
+
|
|
23
|
+
for name in common_names:
|
|
24
|
+
try:
|
|
25
|
+
# Just check if we can run it with --version
|
|
26
|
+
subprocess.run([name, "--version"],
|
|
27
|
+
stdout=subprocess.PIPE,
|
|
28
|
+
stderr=subprocess.PIPE,
|
|
29
|
+
timeout=5)
|
|
30
|
+
return name
|
|
31
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def is_available(self):
|
|
37
|
+
"""Check if UV is available on the system"""
|
|
38
|
+
return self.has_uv
|
|
39
|
+
|
|
40
|
+
def install_dependencies(self, dependencies, venv_path=None):
|
|
41
|
+
"""
|
|
42
|
+
Install dependencies using UV
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
dependencies (list): List of dependencies to install
|
|
46
|
+
venv_path (str, optional): Path to virtual environment
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
dict: Result of the operation
|
|
50
|
+
"""
|
|
51
|
+
if not self.has_uv:
|
|
52
|
+
return {
|
|
53
|
+
"success": False,
|
|
54
|
+
"message": "UV Python is not available. Please install it first."
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
cmd = [self.uv_path, "pip", "install"]
|
|
58
|
+
cmd.extend(dependencies)
|
|
59
|
+
|
|
60
|
+
env = os.environ.copy()
|
|
61
|
+
if venv_path:
|
|
62
|
+
# Set virtual environment path
|
|
63
|
+
if sys.platform == "win32":
|
|
64
|
+
env["VIRTUAL_ENV"] = venv_path
|
|
65
|
+
env["PATH"] = os.path.join(venv_path, "Scripts") + os.pathsep + env["PATH"]
|
|
66
|
+
else:
|
|
67
|
+
env["VIRTUAL_ENV"] = venv_path
|
|
68
|
+
env["PATH"] = os.path.join(venv_path, "bin") + os.pathsep + env["PATH"]
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
result = subprocess.run(
|
|
72
|
+
cmd,
|
|
73
|
+
stdout=subprocess.PIPE,
|
|
74
|
+
stderr=subprocess.PIPE,
|
|
75
|
+
env=env,
|
|
76
|
+
text=True
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
if result.returncode == 0:
|
|
80
|
+
return {
|
|
81
|
+
"success": True,
|
|
82
|
+
"message": f"Successfully installed dependencies: {', '.join(dependencies)}",
|
|
83
|
+
"output": result.stdout
|
|
84
|
+
}
|
|
85
|
+
else:
|
|
86
|
+
return {
|
|
87
|
+
"success": False,
|
|
88
|
+
"message": "Failed to install dependencies",
|
|
89
|
+
"error": result.stderr,
|
|
90
|
+
"output": result.stdout
|
|
91
|
+
}
|
|
92
|
+
except Exception as e:
|
|
93
|
+
return {
|
|
94
|
+
"success": False,
|
|
95
|
+
"message": f"Error during installation: {str(e)}"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
def create_venv(self, path):
|
|
99
|
+
"""
|
|
100
|
+
Create a virtual environment using UV
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
path (str): Path for the virtual environment
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
dict: Result of the operation
|
|
107
|
+
"""
|
|
108
|
+
if not self.has_uv:
|
|
109
|
+
return {
|
|
110
|
+
"success": False,
|
|
111
|
+
"message": "UV Python is not available. Please install it first."
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
path = os.path.abspath(path)
|
|
115
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
116
|
+
|
|
117
|
+
cmd = [self.uv_path, "venv", path]
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
result = subprocess.run(
|
|
121
|
+
cmd,
|
|
122
|
+
stdout=subprocess.PIPE,
|
|
123
|
+
stderr=subprocess.PIPE,
|
|
124
|
+
text=True
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if result.returncode == 0:
|
|
128
|
+
return {
|
|
129
|
+
"success": True,
|
|
130
|
+
"message": f"Successfully created virtual environment at {path}",
|
|
131
|
+
"path": path,
|
|
132
|
+
"output": result.stdout
|
|
133
|
+
}
|
|
134
|
+
else:
|
|
135
|
+
return {
|
|
136
|
+
"success": False,
|
|
137
|
+
"message": "Failed to create virtual environment",
|
|
138
|
+
"error": result.stderr,
|
|
139
|
+
"output": result.stdout
|
|
140
|
+
}
|
|
141
|
+
except Exception as e:
|
|
142
|
+
return {
|
|
143
|
+
"success": False,
|
|
144
|
+
"message": f"Error creating virtual environment: {str(e)}"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def get_installed_packages(self, venv_path=None):
|
|
148
|
+
"""
|
|
149
|
+
Get list of installed packages
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
venv_path (str, optional): Path to virtual environment
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
dict: Result of the operation with list of packages
|
|
156
|
+
"""
|
|
157
|
+
if not self.has_uv:
|
|
158
|
+
return {
|
|
159
|
+
"success": False,
|
|
160
|
+
"message": "UV Python is not available. Please install it first."
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
cmd = [self.uv_path, "pip", "list", "--format=json"]
|
|
164
|
+
|
|
165
|
+
env = os.environ.copy()
|
|
166
|
+
if venv_path:
|
|
167
|
+
# Set virtual environment path
|
|
168
|
+
if sys.platform == "win32":
|
|
169
|
+
env["VIRTUAL_ENV"] = venv_path
|
|
170
|
+
env["PATH"] = os.path.join(venv_path, "Scripts") + os.pathsep + env["PATH"]
|
|
171
|
+
else:
|
|
172
|
+
env["VIRTUAL_ENV"] = venv_path
|
|
173
|
+
env["PATH"] = os.path.join(venv_path, "bin") + os.pathsep + env["PATH"]
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
result = subprocess.run(
|
|
177
|
+
cmd,
|
|
178
|
+
stdout=subprocess.PIPE,
|
|
179
|
+
stderr=subprocess.PIPE,
|
|
180
|
+
env=env,
|
|
181
|
+
text=True
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if result.returncode == 0:
|
|
185
|
+
try:
|
|
186
|
+
packages = json.loads(result.stdout)
|
|
187
|
+
return {
|
|
188
|
+
"success": True,
|
|
189
|
+
"packages": packages,
|
|
190
|
+
"count": len(packages)
|
|
191
|
+
}
|
|
192
|
+
except json.JSONDecodeError:
|
|
193
|
+
return {
|
|
194
|
+
"success": False,
|
|
195
|
+
"message": "Failed to parse package list",
|
|
196
|
+
"output": result.stdout
|
|
197
|
+
}
|
|
198
|
+
else:
|
|
199
|
+
return {
|
|
200
|
+
"success": False,
|
|
201
|
+
"message": "Failed to get list of packages",
|
|
202
|
+
"error": result.stderr,
|
|
203
|
+
"output": result.stdout
|
|
204
|
+
}
|
|
205
|
+
except Exception as e:
|
|
206
|
+
return {
|
|
207
|
+
"success": False,
|
|
208
|
+
"message": f"Error getting packages: {str(e)}"
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
def run_script_in_venv(self, script_path, venv_path):
|
|
212
|
+
"""
|
|
213
|
+
Run a Python script in a virtual environment
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
script_path (str): Path to the script
|
|
217
|
+
venv_path (str): Path to virtual environment
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
dict: Result of the operation
|
|
221
|
+
"""
|
|
222
|
+
if not os.path.exists(script_path):
|
|
223
|
+
return {
|
|
224
|
+
"success": False,
|
|
225
|
+
"message": f"Script not found: {script_path}"
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if not os.path.exists(venv_path):
|
|
229
|
+
return {
|
|
230
|
+
"success": False,
|
|
231
|
+
"message": f"Virtual environment not found: {venv_path}"
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
# Get path to Python in the virtual environment
|
|
235
|
+
if sys.platform == "win32":
|
|
236
|
+
python_path = os.path.join(venv_path, "Scripts", "python.exe")
|
|
237
|
+
else:
|
|
238
|
+
python_path = os.path.join(venv_path, "bin", "python")
|
|
239
|
+
|
|
240
|
+
if not os.path.exists(python_path):
|
|
241
|
+
return {
|
|
242
|
+
"success": False,
|
|
243
|
+
"message": f"Python not found in virtual environment: {python_path}"
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
result = subprocess.run(
|
|
248
|
+
[python_path, script_path],
|
|
249
|
+
stdout=subprocess.PIPE,
|
|
250
|
+
stderr=subprocess.PIPE,
|
|
251
|
+
text=True
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"success": result.returncode == 0,
|
|
256
|
+
"exit_code": result.returncode,
|
|
257
|
+
"stdout": result.stdout,
|
|
258
|
+
"stderr": result.stderr
|
|
259
|
+
}
|
|
260
|
+
except Exception as e:
|
|
261
|
+
return {
|
|
262
|
+
"success": False,
|
|
263
|
+
"message": f"Error running script: {str(e)}"
|
|
264
|
+
}
|
utils/uv_manager.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
class UVManager:
|
|
11
|
+
"""Manager for UV Python package installer and environment manager"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, env_dir=None):
|
|
14
|
+
"""
|
|
15
|
+
Initialize UV manager
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
env_dir (str, optional): Directory to store environments
|
|
19
|
+
"""
|
|
20
|
+
self.uv_bin = self._find_uv_executable()
|
|
21
|
+
self.env_dir = Path(env_dir or Path.cwd() / "python_envs")
|
|
22
|
+
self.env_dir.mkdir(exist_ok=True, parents=True)
|
|
23
|
+
|
|
24
|
+
def _find_uv_executable(self):
|
|
25
|
+
"""Find the UV executable in the system"""
|
|
26
|
+
# Check common names and locations
|
|
27
|
+
common_names = ["uv", "uv.exe"]
|
|
28
|
+
|
|
29
|
+
for name in common_names:
|
|
30
|
+
try:
|
|
31
|
+
# Check if we can run it with --version
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
[name, "--version"],
|
|
34
|
+
stdout=subprocess.PIPE,
|
|
35
|
+
stderr=subprocess.PIPE,
|
|
36
|
+
timeout=5,
|
|
37
|
+
text=True
|
|
38
|
+
)
|
|
39
|
+
if result.returncode == 0:
|
|
40
|
+
return name
|
|
41
|
+
except (subprocess.SubprocessError, FileNotFoundError):
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
# If not found, try to install it using pip
|
|
45
|
+
try:
|
|
46
|
+
subprocess.run(
|
|
47
|
+
[sys.executable, "-m", "pip", "install", "uv"],
|
|
48
|
+
stdout=subprocess.PIPE,
|
|
49
|
+
stderr=subprocess.PIPE,
|
|
50
|
+
check=True
|
|
51
|
+
)
|
|
52
|
+
return "uv" # Try the command directly after installation
|
|
53
|
+
except Exception as e:
|
|
54
|
+
logger.warning(f"Failed to install UV: {e}")
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
def is_available(self):
|
|
59
|
+
"""Check if UV is available"""
|
|
60
|
+
return self.uv_bin is not None
|
|
61
|
+
|
|
62
|
+
def create_environment(self, name, python_version=None):
|
|
63
|
+
"""
|
|
64
|
+
|