From 2cd82ad93dd9d590f769f42e348a30e7963ec80a Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 14:11:06 +0530 Subject: [PATCH 1/8] hdri and moidel import working --- addon.py | 350 +++++++++++++++++++++++++++++++++++++- src/blender_mcp/server.py | 174 ++++++++++++++----- uv.lock | 2 +- 3 files changed, 478 insertions(+), 48 deletions(-) diff --git a/addon.py b/addon.py index 4c74102..2be9421 100644 --- a/addon.py +++ b/addon.py @@ -3,7 +3,12 @@ import json import threading import socket import time +import requests # Add this import for HTTP requests +import tempfile # Add this import for temporary directories from bpy.props import StringProperty, IntProperty +import traceback +import os +import shutil bl_info = { "name": "Blender MCP", @@ -134,7 +139,7 @@ class BlenderMCPServer: except Exception as e: print(f"Error executing command: {str(e)}") - import traceback + traceback.print_exc() return {"status": "error", "message": str(e)} @@ -159,6 +164,10 @@ class BlenderMCPServer: "execute_code": self.execute_code, "set_material": self.set_material, "render_scene": self.render_scene, + # Add Polyhaven handlers + "get_polyhaven_categories": self.get_polyhaven_categories, + "search_polyhaven_assets": self.search_polyhaven_assets, + "download_polyhaven_asset": self.download_polyhaven_asset, } handler = handlers.get(cmd_type) @@ -170,7 +179,6 @@ class BlenderMCPServer: return {"status": "success", "result": result} except Exception as e: print(f"Error in handler: {str(e)}") - import traceback traceback.print_exc() return {"status": "error", "message": str(e)} else: @@ -216,7 +224,6 @@ class BlenderMCPServer: return scene_info except Exception as e: print(f"Error in get_scene_info: {str(e)}") - import traceback traceback.print_exc() return {"error": str(e)} @@ -427,7 +434,6 @@ class BlenderMCPServer: except Exception as e: print(f"Error in set_material: {str(e)}") - import traceback traceback.print_exc() return { "status": "error", @@ -456,6 +462,342 @@ class BlenderMCPServer: "resolution": [bpy.context.scene.render.resolution_x, bpy.context.scene.render.resolution_y], } + def get_polyhaven_categories(self, asset_type): + """Get categories for a specific asset type from Polyhaven""" + try: + if asset_type not in ["hdris", "textures", "models", "all"]: + return {"error": f"Invalid asset type: {asset_type}. Must be one of: hdris, textures, models, all"} + + response = requests.get(f"https://api.polyhaven.com/categories/{asset_type}") + if response.status_code == 200: + return {"categories": response.json()} + else: + return {"error": f"API request failed with status code {response.status_code}"} + except Exception as e: + return {"error": str(e)} + + def search_polyhaven_assets(self, asset_type=None, categories=None): + """Search for assets from Polyhaven with optional filtering""" + try: + url = "https://api.polyhaven.com/assets" + params = {} + + if asset_type and asset_type != "all": + if asset_type not in ["hdris", "textures", "models"]: + return {"error": f"Invalid asset type: {asset_type}. Must be one of: hdris, textures, models, all"} + params["type"] = asset_type + + if categories: + params["categories"] = categories + + response = requests.get(url, params=params) + if response.status_code == 200: + # Limit the response size to avoid overwhelming Blender + assets = response.json() + # Return only the first 20 assets to keep response size manageable + limited_assets = {} + for i, (key, value) in enumerate(assets.items()): + if i >= 20: # Limit to 20 assets + break + limited_assets[key] = value + + return {"assets": limited_assets, "total_count": len(assets), "returned_count": len(limited_assets)} + else: + return {"error": f"API request failed with status code {response.status_code}"} + except Exception as e: + return {"error": str(e)} + + def download_polyhaven_asset(self, asset_id, asset_type, resolution="1k", file_format=None): + """Download an asset from Polyhaven and import it into Blender""" + + try: + # First get the files information + files_response = requests.get(f"https://api.polyhaven.com/files/{asset_id}") + if files_response.status_code != 200: + return {"error": f"Failed to get asset files: {files_response.status_code}"} + + files_data = files_response.json() + + # Handle different asset types + if asset_type == "hdris": + # For HDRIs, download the .hdr or .exr file + if not file_format: + file_format = "hdr" # Default format for HDRIs + + if "hdri" in files_data and resolution in files_data["hdri"] and file_format in files_data["hdri"][resolution]: + file_info = files_data["hdri"][resolution][file_format] + file_url = file_info["url"] + + # For HDRIs, we need to save to a temporary file first + # since Blender can't properly load HDR data directly from memory + with tempfile.NamedTemporaryFile(suffix=f".{file_format}", delete=False) as tmp_file: + # Download the file + response = requests.get(file_url) + if response.status_code != 200: + return {"error": f"Failed to download HDRI: {response.status_code}"} + + tmp_file.write(response.content) + tmp_path = tmp_file.name + + try: + # Create a new world if none exists + if not bpy.data.worlds: + bpy.data.worlds.new("World") + + world = bpy.data.worlds[0] + world.use_nodes = True + node_tree = world.node_tree + + # Clear existing nodes + for node in node_tree.nodes: + node_tree.nodes.remove(node) + + # Create nodes + tex_coord = node_tree.nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = node_tree.nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + + # Load the image from the temporary file + env_tex = node_tree.nodes.new(type='ShaderNodeTexEnvironment') + env_tex.location = (-400, 0) + env_tex.image = bpy.data.images.load(tmp_path) + + # FIXED: Use a color space that exists in all Blender versions + if file_format.lower() == 'exr': + # Try to use Linear color space for EXR files + try: + env_tex.image.colorspace_settings.name = 'Linear' + except: + # Fallback to Non-Color if Linear isn't available + env_tex.image.colorspace_settings.name = 'Non-Color' + else: # hdr + # For HDR files, try these options in order + for color_space in ['Linear', 'Linear Rec.709', 'Non-Color']: + try: + env_tex.image.colorspace_settings.name = color_space + break # Stop if we successfully set a color space + except: + continue + + background = node_tree.nodes.new(type='ShaderNodeBackground') + background.location = (-200, 0) + + output = node_tree.nodes.new(type='ShaderNodeOutputWorld') + output.location = (0, 0) + + # Connect nodes + node_tree.links.new(tex_coord.outputs['Generated'], mapping.inputs['Vector']) + node_tree.links.new(mapping.outputs['Vector'], env_tex.inputs['Vector']) + node_tree.links.new(env_tex.outputs['Color'], background.inputs['Color']) + node_tree.links.new(background.outputs['Background'], output.inputs['Surface']) + + # Set as active world + bpy.context.scene.world = world + + # Clean up temporary file + try: + tempfile._cleanup() # This will clean up all temporary files + except: + pass + + return { + "success": True, + "message": f"HDRI {asset_id} imported successfully", + "image_name": env_tex.image.name + } + except Exception as e: + return {"error": f"Failed to set up HDRI in Blender: {str(e)}"} + else: + return {"error": f"Requested resolution or format not available for this HDRI"} + + elif asset_type == "textures": + # For textures, download available maps + if not file_format: + file_format = "jpg" # Default format for textures + + # Find available maps (diffuse, normal, etc.) + downloaded_maps = {} + for map_type in files_data: + if map_type not in ["blend", "gltf"]: # Skip non-texture files + if resolution in files_data[map_type] and file_format in files_data[map_type][resolution]: + file_info = files_data[map_type][resolution][file_format] + file_url = file_info["url"] + + # Download the file directly into Blender's memory + response = requests.get(file_url) + if response.status_code == 200: + # Create a new image in Blender's memory + image_name = f"{asset_id}_{map_type}.{file_format}" + image = bpy.data.images.new(name=image_name, width=1, height=1) + + # Save the downloaded data + image.file_format = file_format.upper() + image.filepath_raw = f"/tmp/{image_name}" # This is just for reference + image.pack(data=response.content) + + downloaded_maps[map_type] = image + + if not downloaded_maps: + return {"error": f"No texture maps found for the requested resolution and format"} + + # Create a new material with the downloaded textures + mat = bpy.data.materials.new(name=asset_id) + mat.use_nodes = True + nodes = mat.node_tree.nodes + links = mat.node_tree.links + + # Clear default nodes + for node in nodes: + nodes.remove(node) + + # Create output node + output = nodes.new(type='ShaderNodeOutputMaterial') + output.location = (300, 0) + + # Create principled BSDF node + principled = nodes.new(type='ShaderNodeBsdfPrincipled') + principled.location = (0, 0) + links.new(principled.outputs[0], output.inputs[0]) + + # Add texture nodes based on available maps + tex_coord = nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) + + # Position offset for texture nodes + x_pos = -400 + y_pos = 300 + + # Connect different texture maps + for map_type, image in downloaded_maps.items(): + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.location = (x_pos, y_pos) + tex_node.image = image + tex_node.image.colorspace_settings.name = 'sRGB' if map_type in ['color', 'diffuse', 'albedo'] else 'Non-Color' + + links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + + # Connect to appropriate input on Principled BSDF + if map_type in ['color', 'diffuse', 'albedo']: + links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) + elif map_type in ['roughness', 'rough']: + links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) + elif map_type in ['metallic', 'metalness', 'metal']: + links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) + elif map_type in ['normal', 'nor']: + # Add normal map node + normal_map = nodes.new(type='ShaderNodeNormalMap') + normal_map.location = (x_pos + 200, y_pos) + links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) + links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) + elif map_type in ['displacement', 'disp', 'height']: + # Add displacement node + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (x_pos + 200, y_pos - 200) + links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + + y_pos -= 250 + + return { + "success": True, + "message": f"Texture {asset_id} imported as material", + "material": mat.name, + "maps": list(downloaded_maps.keys()) + } + + elif asset_type == "models": + # For models, prefer glTF format if available + if not file_format: + file_format = "gltf" # Default format for models + + if file_format in files_data and resolution in files_data[file_format]: + file_info = files_data[file_format][resolution][file_format] + file_url = file_info["url"] + + # Create a temporary directory to store the model and its dependencies + temp_dir = tempfile.mkdtemp() + main_file_path = "" + + try: + # Download the main model file + main_file_name = file_url.split("/")[-1] + main_file_path = os.path.join(temp_dir, main_file_name) + + response = requests.get(file_url) + if response.status_code != 200: + return {"error": f"Failed to download model: {response.status_code}"} + + with open(main_file_path, "wb") as f: + f.write(response.content) + + # Check for included files and download them + if "include" in file_info and file_info["include"]: + for include_path, include_info in file_info["include"].items(): + # Get the URL for the included file - this is the fix + include_url = include_info["url"] + + # Create the directory structure for the included file + include_file_path = os.path.join(temp_dir, include_path) + os.makedirs(os.path.dirname(include_file_path), exist_ok=True) + + # Download the included file + include_response = requests.get(include_url) + if include_response.status_code == 200: + with open(include_file_path, "wb") as f: + f.write(include_response.content) + else: + print(f"Failed to download included file: {include_path}") + + # Import the model into Blender + if file_format == "gltf" or file_format == "glb": + bpy.ops.import_scene.gltf(filepath=main_file_path) + elif file_format == "fbx": + bpy.ops.import_scene.fbx(filepath=main_file_path) + elif file_format == "obj": + bpy.ops.import_scene.obj(filepath=main_file_path) + elif file_format == "blend": + # For blend files, we need to append or link + with bpy.data.libraries.load(main_file_path, link=False) as (data_from, data_to): + data_to.objects = data_from.objects + + # Link the objects to the scene + for obj in data_to.objects: + if obj is not None: + bpy.context.collection.objects.link(obj) + else: + return {"error": f"Unsupported model format: {file_format}"} + + # Get the names of imported objects + imported_objects = [obj.name for obj in bpy.context.selected_objects] + + return { + "success": True, + "message": f"Model {asset_id} imported successfully", + "imported_objects": imported_objects + } + except Exception as e: + return {"error": f"Failed to import model: {str(e)}"} + finally: + # Clean up temporary directory + try: + shutil.rmtree(temp_dir) + except: + print(f"Failed to clean up temporary directory: {temp_dir}") + else: + return {"error": f"Requested format or resolution not available for this model"} + + else: + return {"error": f"Unsupported asset type: {asset_type}"} + + except Exception as e: + return {"error": f"Failed to download asset: {str(e)}"} + # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Blender MCP" diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index a8d2b69..2bbb783 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -263,41 +263,7 @@ def get_object_info(ctx: Context, object_name: str) -> str: # Tool endpoints @mcp.tool() -def create_primitive( - ctx: Context, - type: str = "CUBE", - location: List[float] = None, - color: List[float] = None -) -> str: - """ - Create a basic primitive object in Blender. - - Parameters: - - type: Object type (CUBE, SPHERE, CYLINDER, PLANE) - - location: Optional [x, y, z] location coordinates - - color: Optional [R, G, B] color values (0.0-1.0) - """ - try: - blender = get_blender_connection() - loc = location or [0, 0, 0] - - # First create the object - params = { - "type": type, - "location": loc - } - result = blender.send_command("create_object", params) - - # If color specified, set the material - if color: - blender.send_command("set_material", { - "object_name": result["name"], - "color": color - }) - - return f"Created {type} at location {loc}" - except Exception as e: - return f"Error creating primitive: {str(e)}" + @mcp.tool() def set_object_property( @@ -474,15 +440,137 @@ def execute_blender_code(ctx: Context, code: str) -> str: logger.error(f"Error executing code: {str(e)}") return f"Error executing code: {str(e)}" -@mcp.prompt() -def create_basic_object() -> str: - """Create a single object with basic properties""" - return """Create a blue cube at position [0, 1, 0]""" +@mcp.tool() +def get_polyhaven_categories(ctx: Context, asset_type: str = "hdris") -> str: + """ + Get a list of categories for a specific asset type on Polyhaven. + + Parameters: + - asset_type: The type of asset to get categories for (hdris, textures, models, all) + + Returns a list of categories with the count of assets in each category. + """ + try: + blender = get_blender_connection() + result = blender.send_command("get_polyhaven_categories", {"asset_type": asset_type}) + + if "error" in result: + return f"Error: {result['error']}" + + # Format the categories in a more readable way + categories = result["categories"] + formatted_output = f"Categories for {asset_type}:\n\n" + + # Sort categories by count (descending) + sorted_categories = sorted(categories.items(), key=lambda x: x[1], reverse=True) + + for category, count in sorted_categories: + formatted_output += f"- {category}: {count} assets\n" + + return formatted_output + except Exception as e: + logger.error(f"Error getting Polyhaven categories: {str(e)}") + return f"Error getting Polyhaven categories: {str(e)}" -@mcp.prompt() -def modify_basic_object() -> str: - """Modify a single property of an object""" - return """Make the cube red""" +@mcp.tool() +def search_polyhaven_assets( + ctx: Context, + asset_type: str = "all", + categories: str = None +) -> str: + """ + Search for assets on Polyhaven with optional filtering. + + Parameters: + - asset_type: Type of assets to search for (hdris, textures, models, all) + - categories: Optional comma-separated list of categories to filter by + + Returns a list of matching assets with basic information. + """ + try: + blender = get_blender_connection() + result = blender.send_command("search_polyhaven_assets", { + "asset_type": asset_type, + "categories": categories + }) + + if "error" in result: + return f"Error: {result['error']}" + + # Format the assets in a more readable way + assets = result["assets"] + total_count = result["total_count"] + returned_count = result["returned_count"] + + formatted_output = f"Found {total_count} assets" + if categories: + formatted_output += f" in categories: {categories}" + formatted_output += f"\nShowing {returned_count} assets:\n\n" + + # Sort assets by download count (popularity) + sorted_assets = sorted(assets.items(), key=lambda x: x[1].get("download_count", 0), reverse=True) + + for asset_id, asset_data in sorted_assets: + formatted_output += f"- {asset_data.get('name', asset_id)} (ID: {asset_id})\n" + formatted_output += f" Type: {['HDRI', 'Texture', 'Model'][asset_data.get('type', 0)]}\n" + formatted_output += f" Categories: {', '.join(asset_data.get('categories', []))}\n" + formatted_output += f" Downloads: {asset_data.get('download_count', 'Unknown')}\n\n" + + return formatted_output + except Exception as e: + logger.error(f"Error searching Polyhaven assets: {str(e)}") + return f"Error searching Polyhaven assets: {str(e)}" + +@mcp.tool() +def download_polyhaven_asset( + ctx: Context, + asset_id: str, + asset_type: str, + resolution: str = "1k", + file_format: str = None +) -> str: + """ + Download and import a Polyhaven asset into Blender. + + Parameters: + - asset_id: The ID of the asset to download + - asset_type: The type of asset (hdris, textures, models) + - resolution: The resolution to download (e.g., 1k, 2k, 4k) + - file_format: Optional file format (e.g., hdr, exr for HDRIs; jpg, png for textures; gltf, fbx for models) + + Returns a message indicating success or failure. + """ + try: + blender = get_blender_connection() + result = blender.send_command("download_polyhaven_asset", { + "asset_id": asset_id, + "asset_type": asset_type, + "resolution": resolution, + "file_format": file_format + }) + + if "error" in result: + return f"Error: {result['error']}" + + if result.get("success"): + message = result.get("message", "Asset downloaded and imported successfully") + + # Add additional information based on asset type + if asset_type == "hdris": + return f"{message}. The HDRI has been set as the world environment." + elif asset_type == "textures": + material_name = result.get("material", "") + maps = ", ".join(result.get("maps", [])) + return f"{message}. Created material '{material_name}' with maps: {maps}." + elif asset_type == "models": + return f"{message}. The model has been imported into the current scene." + else: + return message + else: + return f"Failed to download asset: {result.get('message', 'Unknown error')}" + except Exception as e: + logger.error(f"Error downloading Polyhaven asset: {str(e)}") + return f"Error downloading Polyhaven asset: {str(e)}" # Main execution diff --git a/uv.lock b/uv.lock index 41cde50..6939f94 100644 --- a/uv.lock +++ b/uv.lock @@ -28,7 +28,7 @@ wheels = [ [[package]] name = "blender-mcp" -version = "1.0.0" +version = "1.0.2" source = { editable = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, From 4113f62b0a830c48b29b3f83eaeb9d49abc07cd2 Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 15:37:07 +0530 Subject: [PATCH 2/8] textures getting added but flat --- addon.py | 518 +++++++++++++++++++++++++++++++------- src/blender_mcp/server.py | 61 +++++ 2 files changed, 490 insertions(+), 89 deletions(-) diff --git a/addon.py b/addon.py index 2be9421..461f7a2 100644 --- a/addon.py +++ b/addon.py @@ -168,6 +168,7 @@ class BlenderMCPServer: "get_polyhaven_categories": self.get_polyhaven_categories, "search_polyhaven_assets": self.search_polyhaven_assets, "download_polyhaven_asset": self.download_polyhaven_asset, + "set_texture": self.set_texture, } handler = handlers.get(cmd_type) @@ -508,8 +509,6 @@ class BlenderMCPServer: return {"error": str(e)} def download_polyhaven_asset(self, asset_id, asset_type, resolution="1k", file_format=None): - """Download an asset from Polyhaven and import it into Blender""" - try: # First get the files information files_response = requests.get(f"https://api.polyhaven.com/files/{asset_id}") @@ -613,103 +612,138 @@ class BlenderMCPServer: return {"error": f"Requested resolution or format not available for this HDRI"} elif asset_type == "textures": - # For textures, download available maps if not file_format: file_format = "jpg" # Default format for textures - # Find available maps (diffuse, normal, etc.) downloaded_maps = {} - for map_type in files_data: - if map_type not in ["blend", "gltf"]: # Skip non-texture files - if resolution in files_data[map_type] and file_format in files_data[map_type][resolution]: - file_info = files_data[map_type][resolution][file_format] - file_url = file_info["url"] - - # Download the file directly into Blender's memory - response = requests.get(file_url) - if response.status_code == 200: - # Create a new image in Blender's memory - image_name = f"{asset_id}_{map_type}.{file_format}" - image = bpy.data.images.new(name=image_name, width=1, height=1) + + try: + for map_type in files_data: + if map_type not in ["blend", "gltf"]: # Skip non-texture files + if resolution in files_data[map_type] and file_format in files_data[map_type][resolution]: + file_info = files_data[map_type][resolution][file_format] + file_url = file_info["url"] - # Save the downloaded data - image.file_format = file_format.upper() - image.filepath_raw = f"/tmp/{image_name}" # This is just for reference - image.pack(data=response.content) - - downloaded_maps[map_type] = image + # Use NamedTemporaryFile like we do for HDRIs + with tempfile.NamedTemporaryFile(suffix=f".{file_format}", delete=False) as tmp_file: + # Download the file + response = requests.get(file_url) + if response.status_code == 200: + tmp_file.write(response.content) + tmp_path = tmp_file.name + + # Load image from temporary file + image = bpy.data.images.load(tmp_path) + image.name = f"{asset_id}_{map_type}.{file_format}" + + # Pack the image into .blend file + image.pack() + + # Set color space based on map type + if map_type in ['color', 'diffuse', 'albedo']: + try: + image.colorspace_settings.name = 'sRGB' + except: + pass + else: + try: + image.colorspace_settings.name = 'Non-Color' + except: + pass + + downloaded_maps[map_type] = image + + # Clean up temporary file + try: + os.unlink(tmp_path) + except: + pass - if not downloaded_maps: - return {"error": f"No texture maps found for the requested resolution and format"} - - # Create a new material with the downloaded textures - mat = bpy.data.materials.new(name=asset_id) - mat.use_nodes = True - nodes = mat.node_tree.nodes - links = mat.node_tree.links - - # Clear default nodes - for node in nodes: - nodes.remove(node) - - # Create output node - output = nodes.new(type='ShaderNodeOutputMaterial') - output.location = (300, 0) - - # Create principled BSDF node - principled = nodes.new(type='ShaderNodeBsdfPrincipled') - principled.location = (0, 0) - links.new(principled.outputs[0], output.inputs[0]) - - # Add texture nodes based on available maps - tex_coord = nodes.new(type='ShaderNodeTexCoord') - tex_coord.location = (-800, 0) - - mapping = nodes.new(type='ShaderNodeMapping') - mapping.location = (-600, 0) - links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) - - # Position offset for texture nodes - x_pos = -400 - y_pos = 300 - - # Connect different texture maps - for map_type, image in downloaded_maps.items(): - tex_node = nodes.new(type='ShaderNodeTexImage') - tex_node.location = (x_pos, y_pos) - tex_node.image = image - tex_node.image.colorspace_settings.name = 'sRGB' if map_type in ['color', 'diffuse', 'albedo'] else 'Non-Color' + if not downloaded_maps: + return {"error": f"No texture maps found for the requested resolution and format"} - links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + # Create a new material with the downloaded textures + mat = bpy.data.materials.new(name=asset_id) + mat.use_nodes = True + nodes = mat.node_tree.nodes + links = mat.node_tree.links - # Connect to appropriate input on Principled BSDF - if map_type in ['color', 'diffuse', 'albedo']: - links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) - elif map_type in ['roughness', 'rough']: - links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) - elif map_type in ['metallic', 'metalness', 'metal']: - links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) - elif map_type in ['normal', 'nor']: - # Add normal map node - normal_map = nodes.new(type='ShaderNodeNormalMap') - normal_map.location = (x_pos + 200, y_pos) - links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) - links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) - elif map_type in ['displacement', 'disp', 'height']: - # Add displacement node - disp_node = nodes.new(type='ShaderNodeDisplacement') - disp_node.location = (x_pos + 200, y_pos - 200) - links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) - links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + # Clear default nodes + for node in nodes: + nodes.remove(node) - y_pos -= 250 + # Create output node + output = nodes.new(type='ShaderNodeOutputMaterial') + output.location = (300, 0) + + # Create principled BSDF node + principled = nodes.new(type='ShaderNodeBsdfPrincipled') + principled.location = (0, 0) + links.new(principled.outputs[0], output.inputs[0]) + + # Add texture nodes based on available maps + tex_coord = nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) + + # Position offset for texture nodes + x_pos = -400 + y_pos = 300 + + # Connect different texture maps + for map_type, image in downloaded_maps.items(): + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.location = (x_pos, y_pos) + tex_node.image = image + + # Set color space based on map type + if map_type.lower() in ['color', 'diffuse', 'albedo']: + try: + tex_node.image.colorspace_settings.name = 'sRGB' + except: + pass # Use default if sRGB not available + else: + try: + tex_node.image.colorspace_settings.name = 'Non-Color' + except: + pass # Use default if Non-Color not available + + links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + + # Connect to appropriate input on Principled BSDF + if map_type.lower() in ['color', 'diffuse', 'albedo']: + links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) + elif map_type.lower() in ['roughness', 'rough']: + links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) + elif map_type.lower() in ['metallic', 'metalness', 'metal']: + links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) + elif map_type.lower() in ['normal', 'nor']: + # Add normal map node + normal_map = nodes.new(type='ShaderNodeNormalMap') + normal_map.location = (x_pos + 200, y_pos) + links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) + links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) + elif map_type in ['displacement', 'disp', 'height']: + # Add displacement node + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (x_pos + 200, y_pos - 200) + links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + + y_pos -= 250 + + return { + "success": True, + "message": f"Texture {asset_id} imported as material", + "material": mat.name, + "maps": list(downloaded_maps.keys()) + } - return { - "success": True, - "message": f"Texture {asset_id} imported as material", - "material": mat.name, - "maps": list(downloaded_maps.keys()) - } + except Exception as e: + return {"error": f"Failed to process textures: {str(e)}"} elif asset_type == "models": # For models, prefer glTF format if available @@ -798,6 +832,312 @@ class BlenderMCPServer: except Exception as e: return {"error": f"Failed to download asset: {str(e)}"} + def set_texture(self, object_name, texture_id): + """Apply a previously downloaded Polyhaven texture to an object by creating a new material""" + try: + # Get the object + obj = bpy.data.objects.get(object_name) + if not obj: + return {"error": f"Object not found: {object_name}"} + + # Make sure object can accept materials + if not hasattr(obj, 'data') or not hasattr(obj.data, 'materials'): + return {"error": f"Object {object_name} cannot accept materials"} + + # Find all images related to this texture and ensure they're properly loaded + texture_images = {} + for img in bpy.data.images: + if img.name.startswith(texture_id + "_"): + # Extract the map type from the image name + map_type = img.name.split('_')[-1].split('.')[0] + + # Force a reload of the image + img.reload() + + # Ensure proper color space + if map_type.lower() in ['color', 'diffuse', 'albedo']: + try: + img.colorspace_settings.name = 'sRGB' + except: + pass + else: + try: + img.colorspace_settings.name = 'Non-Color' + except: + pass + + # Ensure the image is packed + if not img.packed_file: + img.pack() + + texture_images[map_type] = img + print(f"Loaded texture map: {map_type} - {img.name}") + + # Debug info + print(f"Image size: {img.size[0]}x{img.size[1]}") + print(f"Color space: {img.colorspace_settings.name}") + print(f"File format: {img.file_format}") + print(f"Is packed: {bool(img.packed_file)}") + + if not texture_images: + return {"error": f"No texture images found for: {texture_id}. Please download the texture first."} + + # Create a new material + new_mat_name = f"{texture_id}_material_{object_name}" + + # Remove any existing material with this name to avoid conflicts + existing_mat = bpy.data.materials.get(new_mat_name) + if existing_mat: + bpy.data.materials.remove(existing_mat) + + new_mat = bpy.data.materials.new(name=new_mat_name) + new_mat.use_nodes = True + + # Set up the material nodes + nodes = new_mat.node_tree.nodes + links = new_mat.node_tree.links + + # Clear default nodes + nodes.clear() + + # Create output node + output = nodes.new(type='ShaderNodeOutputMaterial') + output.location = (600, 0) + + # Create principled BSDF node + principled = nodes.new(type='ShaderNodeBsdfPrincipled') + principled.location = (300, 0) + links.new(principled.outputs[0], output.inputs[0]) + + # Add texture nodes based on available maps + tex_coord = nodes.new(type='ShaderNodeTexCoord') + tex_coord.location = (-800, 0) + + mapping = nodes.new(type='ShaderNodeMapping') + mapping.location = (-600, 0) + # Set a smaller scale to make the texture more visible + mapping.inputs['Scale'].default_value = (0.5, 0.5, 0.5) + links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) + + # Position offset for texture nodes + x_pos = -400 + y_pos = 300 + + # Connect different texture maps + for map_type, image in texture_images.items(): + tex_node = nodes.new(type='ShaderNodeTexImage') + tex_node.location = (x_pos, y_pos) + tex_node.image = image + + # Set color space based on map type + if map_type.lower() in ['color', 'diffuse', 'albedo']: + try: + tex_node.image.colorspace_settings.name = 'sRGB' + except: + pass # Use default if sRGB not available + else: + try: + tex_node.image.colorspace_settings.name = 'Non-Color' + except: + pass # Use default if Non-Color not available + + links.new(mapping.outputs['Vector'], tex_node.inputs['Vector']) + + # Connect to appropriate input on Principled BSDF + if map_type.lower() in ['color', 'diffuse', 'albedo']: + links.new(tex_node.outputs['Color'], principled.inputs['Base Color']) + elif map_type.lower() in ['roughness', 'rough']: + links.new(tex_node.outputs['Color'], principled.inputs['Roughness']) + elif map_type.lower() in ['metallic', 'metalness', 'metal']: + links.new(tex_node.outputs['Color'], principled.inputs['Metallic']) + elif map_type.lower() in ['normal', 'nor', 'dx', 'gl']: + # Add normal map node + normal_map = nodes.new(type='ShaderNodeNormalMap') + normal_map.location = (x_pos + 200, y_pos) + links.new(tex_node.outputs['Color'], normal_map.inputs['Color']) + links.new(normal_map.outputs['Normal'], principled.inputs['Normal']) + elif map_type.lower() in ['displacement', 'disp', 'height']: + # Add displacement node + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (x_pos + 200, y_pos - 200) + disp_node.inputs['Scale'].default_value = 0.1 # Reduce displacement strength + links.new(tex_node.outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + + y_pos -= 250 + + # Second pass: Connect nodes with proper handling for special cases + texture_nodes = {} + + # First find all texture nodes and store them by map type + for node in nodes: + if node.type == 'TEX_IMAGE' and node.image: + for map_type, image in texture_images.items(): + if node.image == image: + texture_nodes[map_type] = node + break + + # Now connect everything using the nodes instead of images + # Handle base color (diffuse) + for map_name in ['color', 'diffuse', 'albedo']: + if map_name in texture_nodes: + links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Base Color']) + print(f"Connected {map_name} to Base Color") + break + + # Handle roughness + for map_name in ['roughness', 'rough']: + if map_name in texture_nodes: + links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Roughness']) + print(f"Connected {map_name} to Roughness") + break + + # Handle metallic + for map_name in ['metallic', 'metalness', 'metal']: + if map_name in texture_nodes: + links.new(texture_nodes[map_name].outputs['Color'], principled.inputs['Metallic']) + print(f"Connected {map_name} to Metallic") + break + + # Handle normal maps + for map_name in ['gl', 'dx', 'nor']: + if map_name in texture_nodes: + normal_map_node = nodes.new(type='ShaderNodeNormalMap') + normal_map_node.location = (100, 100) + links.new(texture_nodes[map_name].outputs['Color'], normal_map_node.inputs['Color']) + links.new(normal_map_node.outputs['Normal'], principled.inputs['Normal']) + print(f"Connected {map_name} to Normal") + break + + # Handle displacement + for map_name in ['displacement', 'disp', 'height']: + if map_name in texture_nodes: + disp_node = nodes.new(type='ShaderNodeDisplacement') + disp_node.location = (300, -200) + disp_node.inputs['Scale'].default_value = 0.1 # Reduce displacement strength + links.new(texture_nodes[map_name].outputs['Color'], disp_node.inputs['Height']) + links.new(disp_node.outputs['Displacement'], output.inputs['Displacement']) + print(f"Connected {map_name} to Displacement") + break + + # Handle ARM texture (Ambient Occlusion, Roughness, Metallic) + if 'arm' in texture_nodes: + separate_rgb = nodes.new(type='ShaderNodeSeparateRGB') + separate_rgb.location = (-200, -100) + links.new(texture_nodes['arm'].outputs['Color'], separate_rgb.inputs['Image']) + + # Connect Roughness (G) if no dedicated roughness map + if not any(map_name in texture_nodes for map_name in ['roughness', 'rough']): + links.new(separate_rgb.outputs['G'], principled.inputs['Roughness']) + print("Connected ARM.G to Roughness") + + # Connect Metallic (B) if no dedicated metallic map + if not any(map_name in texture_nodes for map_name in ['metallic', 'metalness', 'metal']): + links.new(separate_rgb.outputs['B'], principled.inputs['Metallic']) + print("Connected ARM.B to Metallic") + + # For AO (R channel), multiply with base color if we have one + base_color_node = None + for map_name in ['color', 'diffuse', 'albedo']: + if map_name in texture_nodes: + base_color_node = texture_nodes[map_name] + break + + if base_color_node: + mix_node = nodes.new(type='ShaderNodeMixRGB') + mix_node.location = (100, 200) + mix_node.blend_type = 'MULTIPLY' + mix_node.inputs['Fac'].default_value = 0.8 # 80% influence + + # Disconnect direct connection to base color + for link in base_color_node.outputs['Color'].links: + if link.to_socket == principled.inputs['Base Color']: + links.remove(link) + + # Connect through the mix node + links.new(base_color_node.outputs['Color'], mix_node.inputs[1]) + links.new(separate_rgb.outputs['R'], mix_node.inputs[2]) + links.new(mix_node.outputs['Color'], principled.inputs['Base Color']) + print("Connected ARM.R to AO mix with Base Color") + + # Handle AO (Ambient Occlusion) if separate + if 'ao' in texture_nodes: + base_color_node = None + for map_name in ['color', 'diffuse', 'albedo']: + if map_name in texture_nodes: + base_color_node = texture_nodes[map_name] + break + + if base_color_node: + mix_node = nodes.new(type='ShaderNodeMixRGB') + mix_node.location = (100, 200) + mix_node.blend_type = 'MULTIPLY' + mix_node.inputs['Fac'].default_value = 0.8 # 80% influence + + # Disconnect direct connection to base color + for link in base_color_node.outputs['Color'].links: + if link.to_socket == principled.inputs['Base Color']: + links.remove(link) + + # Connect through the mix node + links.new(base_color_node.outputs['Color'], mix_node.inputs[1]) + links.new(texture_nodes['ao'].outputs['Color'], mix_node.inputs[2]) + links.new(mix_node.outputs['Color'], principled.inputs['Base Color']) + print("Connected AO to mix with Base Color") + + # CRITICAL: Make sure to clear all existing materials from the object + while len(obj.data.materials) > 0: + obj.data.materials.pop(index=0) + + # Assign the new material to the object + obj.data.materials.append(new_mat) + + # CRITICAL: Make the object active and select it + bpy.context.view_layer.objects.active = obj + obj.select_set(True) + + # CRITICAL: Force Blender to update the material + bpy.context.view_layer.update() + + # Get the list of texture maps + texture_maps = list(texture_images.keys()) + + # Get info about texture nodes for debugging + material_info = { + "name": new_mat.name, + "has_nodes": new_mat.use_nodes, + "node_count": len(new_mat.node_tree.nodes), + "texture_nodes": [] + } + + for node in new_mat.node_tree.nodes: + if node.type == 'TEX_IMAGE' and node.image: + connections = [] + for output in node.outputs: + for link in output.links: + connections.append(f"{output.name} → {link.to_node.name}.{link.to_socket.name}") + + material_info["texture_nodes"].append({ + "name": node.name, + "image": node.image.name, + "colorspace": node.image.colorspace_settings.name, + "connections": connections + }) + + return { + "success": True, + "message": f"Created new material and applied texture {texture_id} to {object_name}", + "material": new_mat.name, + "maps": texture_maps, + "material_info": material_info + } + + except Exception as e: + print(f"Error in set_texture: {str(e)}") + traceback.print_exc() + return {"error": f"Failed to apply texture: {str(e)}"} + + # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Blender MCP" diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index 2bbb783..46be803 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -572,6 +572,67 @@ def download_polyhaven_asset( logger.error(f"Error downloading Polyhaven asset: {str(e)}") return f"Error downloading Polyhaven asset: {str(e)}" +@mcp.tool() +def set_texture( + ctx: Context, + object_name: str, + texture_id: str +) -> str: + """ + Apply a previously downloaded Polyhaven texture to an object. + + Parameters: + - object_name: Name of the object to apply the texture to + - texture_id: ID of the Polyhaven texture to apply (must be downloaded first) + + Returns a message indicating success or failure. + """ + try: + # Get the global connection + blender = get_blender_connection() + + result = blender.send_command("set_texture", { + "object_name": object_name, + "texture_id": texture_id + }) + + if "error" in result: + return f"Error: {result['error']}" + + if result.get("success"): + material_name = result.get("material", "") + maps = ", ".join(result.get("maps", [])) + + # Add detailed material info + material_info = result.get("material_info", {}) + node_count = material_info.get("node_count", 0) + has_nodes = material_info.get("has_nodes", False) + texture_nodes = material_info.get("texture_nodes", []) + + output = f"Successfully applied texture '{texture_id}' to {object_name}.\n" + output += f"Using material '{material_name}' with maps: {maps}.\n\n" + output += f"Material has nodes: {has_nodes}\n" + output += f"Total node count: {node_count}\n\n" + + if texture_nodes: + output += "Texture nodes:\n" + for node in texture_nodes: + output += f"- {node['name']} using image: {node['image']}\n" + if node['connections']: + output += " Connections:\n" + for conn in node['connections']: + output += f" {conn}\n" + else: + output += "No texture nodes found in the material.\n" + + return output + else: + return f"Failed to apply texture: {result.get('message', 'Unknown error')}" + except Exception as e: + logger.error(f"Error applying texture: {str(e)}") + return f"Error applying texture: {str(e)}" + + # Main execution def main(): From 6599b225d3bb1a0ef1b6426d5e28e663e7068c1b Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 15:54:38 +0530 Subject: [PATCH 3/8] flat textures are getting added --- addon.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/addon.py b/addon.py index 461f7a2..039a8ba 100644 --- a/addon.py +++ b/addon.py @@ -687,6 +687,7 @@ class BlenderMCPServer: mapping = nodes.new(type='ShaderNodeMapping') mapping.location = (-600, 0) + mapping.vector_type = 'TEXTURE' # Changed from default 'POINT' to 'TEXTURE' links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) # Position offset for texture nodes @@ -915,8 +916,7 @@ class BlenderMCPServer: mapping = nodes.new(type='ShaderNodeMapping') mapping.location = (-600, 0) - # Set a smaller scale to make the texture more visible - mapping.inputs['Scale'].default_value = (0.5, 0.5, 0.5) + mapping.vector_type = 'TEXTURE' # Changed from default 'POINT' to 'TEXTURE' links.new(tex_coord.outputs['UV'], mapping.inputs['Vector']) # Position offset for texture nodes From 40a8334b9718ce208af6b9a497d8f122571e1df1 Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 16:39:59 +0530 Subject: [PATCH 4/8] updated README --- README.md | 9 ++++++++- src/blender_mcp/server.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5bfe684..be1c05d 100644 --- a/README.md +++ b/README.md @@ -101,12 +101,16 @@ Once the config file has been set on Claude, and the addon is running on Blender - `delete_object` - Remove an object from the scene - `set_material` - Apply or create materials for objects - `execute_blender_code` - Run any Python code in Blender +- `get_polyhaven_categories` - Get a list of categories for PolyHaven assets (HDRIs, textures, models) +- `search_polyhaven_assets` - Search for assets on PolyHaven with optional category filtering +- `download_polyhaven_asset` - Download and import a PolyHaven asset into Blender ### Example Commands Here are some examples of what you can ask Claude to do: - "Create a low poly scene in a dungeon, with a dragon guarding a pot of gold" [Demo](https://www.youtube.com/watch?v=DqgKuLYUv00) +- "Create a beach vibe using HDRIs, textures, and models like rocks and vegatation from Poly Haven" - Give a reference image, and create a Blender scene out of it [Demo](https://www.youtube.com/watch?v=FDRb03XPiRo) - "Get information about the current scene, and make a threejs sketch from it" [Demo](https://www.youtube.com/watch?v=jxbNI5L7AH8) - "Make this car red and metallic" @@ -116,10 +120,12 @@ Here are some examples of what you can ask Claude to do: ## Troubleshooting -- **Connection issues**: Make sure the Blender addon server is running, and the MCP server is configured on Claude, DO NOT run the uvx command in the terminal +- **Connection issues**: Make sure the Blender addon server is running, and the MCP server is configured on Claude, DO NOT run the uvx command in the terminal. Sometimes, the first command won't go through but after that it starts working. - **Timeout errors**: Try simplifying your requests or breaking them into smaller steps +- **Poly Haven integration**: Claude is sometimes erratic with its behaviour - **Have you tried turning it off and on again?**: If you're still having connection errors, try restarting both Claude and the Blender server + ## Technical Details ### Communication Protocol @@ -132,6 +138,7 @@ The system uses a simple JSON-based protocol over TCP sockets: ## Limitations & Security Considerations - The `execute_blender_code` tool allows running arbitrary Python code in Blender, which can be powerful but potentially dangerous. Use with caution in production environments. ALWAYS save your work before using it. +- Poly Haven requires downloading models, textures, and HDRI images. If you do not want to use it, please turn it off in the checkbox in Blender. - Complex operations might need to be broken down into smaller steps diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index 46be803..8504b71 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -632,6 +632,26 @@ def set_texture( logger.error(f"Error applying texture: {str(e)}") return f"Error applying texture: {str(e)}" +@mcp.prompt() +def asset_creation_strategy() -> str: + """Defines the preferred strategy for creating assets in Blender""" + return """When creating 3D content in Blender, follow these priorities: + + 1. First, try to find and use appropriate PolyHaven assets: + - For objects/models: Use download_polyhaven_asset() with asset_type="models" + - For materials/textures: Use download_polyhaven_asset() with asset_type="textures" + - For environment lighting: Use download_polyhaven_asset() with asset_type="hdris" + + 2. Only fall back to basic creation tools when: + - A simple primitive is explicitly requested + - No suitable PolyHaven asset exists + - The task specifically requires a basic material/color + - Time or resource constraints make downloading assets impractical + + Basic creation tools to use as fallback: + - create_object() for basic primitives + - set_material() for basic colors and materials + """ # Main execution From 36c05e7196046ee29dc611cac317662de3342623 Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 17:02:04 +0530 Subject: [PATCH 5/8] added polyhaven checkbox --- README.md | 4 +- addon.py | 56 +++++++++++++++++++-------- src/blender_mcp/server.py | 80 +++++++++++++++++++-------------------- 3 files changed, 80 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index be1c05d..e274fa1 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,8 @@ uvx blender-mcp 1. In Blender, go to the 3D View sidebar (press N if not visible) 2. Find the "BlenderMCP" tab -3. Set the port number (default: 9876) -4. Click "Start MCP Server" +3. Turn on the Poly Haven checkbox if you want assets from their API (optional) +4. Click "Connect to Claude" 5. Make sure the MCP server is running in your terminal ### Using with Claude diff --git a/addon.py b/addon.py index 039a8ba..3e73095 100644 --- a/addon.py +++ b/addon.py @@ -148,14 +148,12 @@ class BlenderMCPServer: cmd_type = command.get("type") params = command.get("params", {}) - # Add a simple ping handler - if cmd_type == "ping": - print("Handling ping command") - return {"status": "success", "result": {"pong": True}} + # Add a handler for checking PolyHaven status + if cmd_type == "get_polyhaven_status": + return {"status": "success", "result": self.get_polyhaven_status()} + # Base handlers that are always available handlers = { - "ping": lambda **kwargs: {"pong": True}, - "get_simple_info": self.get_simple_info, "get_scene_info": self.get_scene_info, "create_object": self.create_object, "modify_object": self.modify_object, @@ -163,14 +161,19 @@ class BlenderMCPServer: "get_object_info": self.get_object_info, "execute_code": self.execute_code, "set_material": self.set_material, - "render_scene": self.render_scene, - # Add Polyhaven handlers - "get_polyhaven_categories": self.get_polyhaven_categories, - "search_polyhaven_assets": self.search_polyhaven_assets, - "download_polyhaven_asset": self.download_polyhaven_asset, - "set_texture": self.set_texture, + "get_polyhaven_status": self.get_polyhaven_status, } + # Add Polyhaven handlers only if enabled + if bpy.context.scene.blendermcp_use_polyhaven: + polyhaven_handlers = { + "get_polyhaven_categories": self.get_polyhaven_categories, + "search_polyhaven_assets": self.search_polyhaven_assets, + "download_polyhaven_asset": self.download_polyhaven_asset, + "set_texture": self.set_texture, + } + handlers.update(polyhaven_handlers) + handler = handlers.get(cmd_type) if handler: try: @@ -1137,7 +1140,20 @@ class BlenderMCPServer: traceback.print_exc() return {"error": f"Failed to apply texture: {str(e)}"} - + def get_polyhaven_status(self): + """Get the current status of PolyHaven integration""" + enabled = bpy.context.scene.blendermcp_use_polyhaven + if enabled: + return {"enabled": True, "message": "PolyHaven integration is enabled and ready to use."} + else: + return { + "enabled": False, + "message": """PolyHaven integration is currently disabled. To enable it: + 1. In the 3D Viewport, find the BlenderMCP panel in the sidebar (press N if hidden) + 2. Check the 'Use assets from Poly Haven' checkbox + 3. Restart the connection to Claude""" + } + # Blender UI Panel class BLENDERMCP_PT_Panel(bpy.types.Panel): bl_label = "Blender MCP" @@ -1151,6 +1167,7 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): scene = context.scene layout.prop(scene, "blendermcp_port") + layout.prop(scene, "blendermcp_use_polyhaven", text="Use assets from Poly Haven") if not scene.blendermcp_server_running: layout.operator("blendermcp.start_server", text="Start MCP Server") @@ -1161,7 +1178,7 @@ class BLENDERMCP_PT_Panel(bpy.types.Panel): # Operator to start the server class BLENDERMCP_OT_StartServer(bpy.types.Operator): bl_idname = "blendermcp.start_server" - bl_label = "Start BlenderMCP Server" + bl_label = "Connect to Claude" bl_description = "Start the BlenderMCP server to connect with Claude" def execute(self, context): @@ -1180,8 +1197,8 @@ class BLENDERMCP_OT_StartServer(bpy.types.Operator): # Operator to stop the server class BLENDERMCP_OT_StopServer(bpy.types.Operator): bl_idname = "blendermcp.stop_server" - bl_label = "Stop BlenderMCP Server" - bl_description = "Stop the BlenderMCP server" + bl_label = "Stop the connection to Claude" + bl_description = "Stop the connection to Claude" def execute(self, context): scene = context.scene @@ -1210,6 +1227,12 @@ def register(): default=False ) + bpy.types.Scene.blendermcp_use_polyhaven = bpy.props.BoolProperty( + name="Use Poly Haven", + description="Enable Poly Haven asset integration", + default=False + ) + bpy.utils.register_class(BLENDERMCP_PT_Panel) bpy.utils.register_class(BLENDERMCP_OT_StartServer) bpy.utils.register_class(BLENDERMCP_OT_StopServer) @@ -1228,6 +1251,7 @@ def unregister(): del bpy.types.Scene.blendermcp_port del bpy.types.Scene.blendermcp_server_running + del bpy.types.Scene.blendermcp_use_polyhaven print("BlenderMCP addon unregistered") diff --git a/src/blender_mcp/server.py b/src/blender_mcp/server.py index 8504b71..9828220 100644 --- a/src/blender_mcp/server.py +++ b/src/blender_mcp/server.py @@ -196,17 +196,19 @@ mcp = FastMCP( # Global connection for resources (since resources can't access context) _blender_connection = None +_polyhaven_enabled = False # Add this global variable def get_blender_connection(): """Get or create a persistent Blender connection""" - global _blender_connection + global _blender_connection, _polyhaven_enabled # Add _polyhaven_enabled to globals # If we have an existing connection, check if it's still valid if _blender_connection is not None: - # Test if the connection is still alive with a simple ping try: - # Just try to send a small message to check if the socket is still connected - _blender_connection.sock.sendall(b'') + # First check if PolyHaven is enabled by sending a ping command + result = _blender_connection.send_command("get_polyhaven_status") + # Store the PolyHaven status globally + _polyhaven_enabled = result.get("enabled", False) return _blender_connection except Exception as e: # Connection is dead, close it and create a new one @@ -260,33 +262,7 @@ def get_object_info(ctx: Context, object_name: str) -> str: logger.error(f"Error getting object info from Blender: {str(e)}") return f"Error getting object info: {str(e)}" -# Tool endpoints -@mcp.tool() - - -@mcp.tool() -def set_object_property( - ctx: Context, - name: str, - property: str, - value: Any -) -> str: - """ - Set a single property of an object. - - Parameters: - - name: Name of the object - - property: Property to set (location, rotation, scale, color, visible) - - value: New value for the property - """ - try: - blender = get_blender_connection() - params = {"name": name, property: value} - result = blender.send_command("modify_object", params) - return f"Set {property} of {name} to {value}" - except Exception as e: - return f"Error setting property: {str(e)}" @mcp.tool() def create_object( @@ -447,11 +423,11 @@ def get_polyhaven_categories(ctx: Context, asset_type: str = "hdris") -> str: Parameters: - asset_type: The type of asset to get categories for (hdris, textures, models, all) - - Returns a list of categories with the count of assets in each category. """ try: blender = get_blender_connection() + if not _polyhaven_enabled: + return "PolyHaven integration is disabled. Select it in the sidebar in BlenderMCP, then run it again." result = blender.send_command("get_polyhaven_categories", {"asset_type": asset_type}) if "error" in result: @@ -632,25 +608,45 @@ def set_texture( logger.error(f"Error applying texture: {str(e)}") return f"Error applying texture: {str(e)}" +@mcp.tool() +def get_polyhaven_status(ctx: Context) -> str: + """ + Check if PolyHaven integration is enabled in Blender. + Returns a message indicating whether PolyHaven features are available. + """ + try: + blender = get_blender_connection() + result = blender.send_command("get_polyhaven_status") + enabled = result.get("enabled", False) + message = result.get("message", "") + + return message + except Exception as e: + logger.error(f"Error checking PolyHaven status: {str(e)}") + return f"Error checking PolyHaven status: {str(e)}" + @mcp.prompt() def asset_creation_strategy() -> str: """Defines the preferred strategy for creating assets in Blender""" - return """When creating 3D content in Blender, follow these priorities: + return """When creating 3D content in Blender, always start by checking if PolyHaven is available: - 1. First, try to find and use appropriate PolyHaven assets: + 0. Before anything, always check the scene from get_scene_info() + 1. First use get_polyhaven_status() to verify if PolyHaven integration is enabled. + + 2. If PolyHaven is enabled: - For objects/models: Use download_polyhaven_asset() with asset_type="models" - For materials/textures: Use download_polyhaven_asset() with asset_type="textures" - For environment lighting: Use download_polyhaven_asset() with asset_type="hdris" - 2. Only fall back to basic creation tools when: - - A simple primitive is explicitly requested - - No suitable PolyHaven asset exists - - The task specifically requires a basic material/color - - Time or resource constraints make downloading assets impractical + 3. If PolyHaven is disabled or when falling back to basic tools: + - create_object() for basic primitives (CUBE, SPHERE, CYLINDER, etc.) + - set_material() for basic colors and materials - Basic creation tools to use as fallback: - - create_object() for basic primitives - - set_material() for basic colors and materials + Only fall back to basic creation tools when: + - PolyHaven is disabled + - A simple primitive is explicitly requested + - No suitable PolyHaven asset exists + - The task specifically requires a basic material/color """ # Main execution From d430a6b21549669ac0d3404b3d54defa1a9b2623 Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 17:22:05 +0530 Subject: [PATCH 6/8] updated README --- README.md | 2 ++ pyproject.toml | 2 +- uv.lock | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e274fa1..c85b0b9 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,8 @@ Once the config file has been set on Claude, and the addon is running on Blender - `search_polyhaven_assets` - Search for assets on PolyHaven with optional category filtering - `download_polyhaven_asset` - Download and import a PolyHaven asset into Blender +To see everything in Poly Haven, [see here](https://polyhaven.com/) + ### Example Commands Here are some examples of what you can ask Claude to do: diff --git a/pyproject.toml b/pyproject.toml index b38827a..baa95c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "blender-mcp" -version = "1.0.2" +version = "1.1.0" description = "Blender integration through the Model Context Protocol" readme = "README.md" requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 6939f94..33799c1 100644 --- a/uv.lock +++ b/uv.lock @@ -28,7 +28,7 @@ wheels = [ [[package]] name = "blender-mcp" -version = "1.0.2" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "mcp", extra = ["cli"] }, From a939c7d2e5b2d961f7a6c9cde771ebf7030b0b7e Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 17:26:58 +0530 Subject: [PATCH 7/8] updated README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c85b0b9..da69039 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ To see everything in Poly Haven, [see here](https://polyhaven.com/) Here are some examples of what you can ask Claude to do: - "Create a low poly scene in a dungeon, with a dragon guarding a pot of gold" [Demo](https://www.youtube.com/watch?v=DqgKuLYUv00) -- "Create a beach vibe using HDRIs, textures, and models like rocks and vegatation from Poly Haven" +- "Create a beach vibe using HDRIs, textures, and models like rocks and vegatation from Poly Haven" [Demo](https://www.youtube.com/watch?v=Clm1CIbBhPw) - Give a reference image, and create a Blender scene out of it [Demo](https://www.youtube.com/watch?v=FDRb03XPiRo) - "Get information about the current scene, and make a threejs sketch from it" [Demo](https://www.youtube.com/watch?v=jxbNI5L7AH8) - "Make this car red and metallic" From f9da5281d8bf97aaf9855aa1eff802cbe81957fe Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 17:39:43 +0530 Subject: [PATCH 8/8] added release notes to README --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index da69039..e4064d6 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,13 @@ BlenderMCP connects Blender to Claude AI through the Model Context Protocol (MCP), allowing Claude to directly interact with and control Blender. This integration enables prompt assisted 3D modeling, scene creation, and manipulation. +## Release notes (1.2.0) + +- Added support for Poly Haven assets through their API +- For newcomers, you can go straight to Installation. For existing users, see the points below +- Download the latest addon.py file and replace the older one, then add it to Blender +- Delete the MCP server from Claude and add it back again, and you should be good to go! + ## Features - **Two-way communication**: Connect Claude AI to Blender through a socket-based server