From 2cd82ad93dd9d590f769f42e348a30e7963ec80a Mon Sep 17 00:00:00 2001 From: ahujasid Date: Thu, 13 Mar 2025 14:11:06 +0530 Subject: [PATCH] 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"] },